001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.kernel.api.identifiers; 019 020import com.fasterxml.jackson.annotation.JsonCreator; 021import com.fasterxml.jackson.annotation.JsonValue; 022import org.apache.commons.lang3.StringUtils; 023import org.fcrepo.kernel.api.exception.InvalidMementoPathException; 024import org.fcrepo.kernel.api.exception.InvalidResourceIdentifierException; 025 026import java.time.Instant; 027import java.time.format.DateTimeParseException; 028import java.util.Arrays; 029import java.util.Objects; 030import java.util.Set; 031import java.util.regex.Pattern; 032import java.util.stream.Collectors; 033 034import static org.fcrepo.kernel.api.FedoraTypes.FCR_ACL; 035import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA; 036import static org.fcrepo.kernel.api.FedoraTypes.FCR_TOMBSTONE; 037import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS; 038import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX; 039import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_LABEL_FORMATTER; 040 041/** 042 * Class to store contextual information about a Fedora ID. 043 * 044 * Differentiates between the original ID of the request and the actual resource we are operating on. 045 * 046 * Resource Id : the shortened ID of the base resource, mostly needed to access the correct persistence object. 047 * fullId : the full ID from the request, used in most cases. 048 * 049 * So a fullId of info:fedora/object1/another/fcr:versions/20000101121212 has an id of info:fedora/object1/another 050 * 051 * @author whikloj 052 * @since 6.0.0 053 */ 054public class FedoraId { 055 056 /** 057 * The Fedora ID with prefix and extensions. eg info:fedora/object1/another/fcr:versions/20000101121212 058 */ 059 private final String fullId; 060 061 /** 062 * The Fedora ID with prefix but without extensions. eg info:fedora/object1/another 063 */ 064 private final String baseId; 065 066 /** 067 * The Fedora ID without prefix but with extensions. eg /object1/another/fcr:versions/20000101121212 068 */ 069 private final String fullPath; 070 071 private String hashUri; 072 private boolean isRepositoryRoot = false; 073 private boolean isNonRdfSourceDescription = false; 074 private boolean isAcl = false; 075 private boolean isMemento = false; 076 private boolean isTimemap = false; 077 private boolean isTombstone = false; 078 private Instant mementoDatetime; 079 private String mementoDatetimeStr; 080 081 private final static Set<Pattern> extensions = Set.of(FCR_TOMBSTONE, FCR_METADATA, FCR_ACL, FCR_VERSIONS) 082 .stream().map(Pattern::compile).collect(Collectors.toSet()); 083 084 /** 085 * Basic constructor. 086 * @param fullId The full identifier or null if root. 087 * @throws IllegalArgumentException If ID does not start with expected prefix. 088 */ 089 private FedoraId(final String fullId) { 090 this.fullId = ensurePrefix(fullId).replaceAll("/+$", ""); 091 // Carry the path of the request for any exceptions. 092 this.fullPath = this.fullId.substring(FEDORA_ID_PREFIX.length()); 093 checkForInvalidPath(); 094 this.baseId = processIdentifier(); 095 } 096 097 /** 098 * Static create method 099 * @param additions One or more strings to build an ID. 100 * @return The FedoraId. 101 */ 102 @JsonCreator 103 public static FedoraId create(final String... additions) { 104 return new FedoraId(idBuilder(additions)); 105 } 106 107 /** 108 * Get a FedoraId for repository root. 109 * @return The FedoraId for repository root. 110 */ 111 public static FedoraId getRepositoryRootId() { 112 return new FedoraId(null); 113 } 114 115 /** 116 * Is the identifier for the repository root. 117 * @return true of id is equal to info:fedora/ 118 */ 119 public boolean isRepositoryRoot() { 120 return isRepositoryRoot; 121 } 122 123 /** 124 * Is the identifier for a Memento? 125 * @return true if the id is for the fcr:versions endpoint and has a memento datetime string after it. 126 */ 127 public boolean isMemento() { 128 return isMemento; 129 } 130 131 /** 132 * Is the identifier for an ACL? 133 * @return true if the id is for the fcr:acl endpoint. 134 */ 135 public boolean isAcl() { 136 return isAcl; 137 } 138 139 /** 140 * Is the identifier for a timemap? 141 * @return true if id for the fcr:versions endpoint and NOT a memento. 142 */ 143 public boolean isTimemap() { 144 return isTimemap; 145 } 146 147 /** 148 * Is the identifier for a nonRdfSourceDescription? 149 * @return true if id for the fcr:metadata endpoint 150 */ 151 public boolean isDescription() { 152 return isNonRdfSourceDescription; 153 } 154 155 /** 156 * Is the identifier for a tombstone 157 * @return true if id for the fcr:tombstone endpoint 158 */ 159 public boolean isTombstone() { 160 return isTombstone; 161 } 162 163 /** 164 * Is the identifier for a hash uri? 165 * @return true if full id referenced a hash uri. 166 */ 167 public boolean isHashUri() { 168 return hashUri != null; 169 } 170 171 /** 172 * Get the hash uri. 173 * @return the hash uri from the id or null if none. 174 */ 175 public String getHashUri() { 176 return hashUri; 177 } 178 179 /** 180 * Returns the ID string for the physical resource the Fedora ID describes. In most cases, this ID is the same as 181 * the full resource ID. However, if the resource is a memento, timemap, or tombstone, then the ID returned here 182 * will be for the resource that contains it. Here are some examples: 183 * 184 * <ul> 185 * <li>"info:fedora/object1/another/fcr:versions/20000101121212" => "info:fedora/object1/another"</li> 186 * <li>"info:fedora/object1/another/fcr:metadata" => "info:fedora/object1/another/fcr:metadata"</li> 187 * <li>"info:fedora/object1/another" => "info:fedora/object1/another"</li> 188 * </ul> 189 * 190 * @return the ID of the associated physical resource 191 */ 192 public String getResourceId() { 193 if (isNonRdfSourceDescription) { 194 return baseId + "/" + FCR_METADATA; 195 } else if (isAcl) { 196 return baseId + "/" + FCR_ACL; 197 } 198 return baseId; 199 } 200 201 /** 202 * Behaves the same as {@link #getResourceId()} except it returns a FedoraId rather than a String. 203 * 204 * @return the ID of the associated physical resource 205 */ 206 public FedoraId asResourceId() { 207 return FedoraId.create(getResourceId()); 208 } 209 210 /** 211 * Returns the ID string for the base ID the Fedora ID describes. This value is the equivalent of the full ID 212 * with all extensions removed. 213 * 214 * <ul> 215 * <li>"info:fedora/object1/another/fcr:versions/20000101121212" => "info:fedora/object1/another"</li> 216 * <li>"info:fedora/object1/another/fcr:metadata" => "info:fedora/object1/another"</li> 217 * <li>"info:fedora/object1/another" => "info:fedora/object1/another"</li> 218 * </ul> 219 * 220 * @return the ID of the associated base resource 221 */ 222 public String getBaseId() { 223 return baseId; 224 } 225 226 /** 227 * Behaves the same as {@link #getBaseId()} except it returns a FedoraId rather than a String. 228 * 229 * @return the ID of the associated base resource 230 */ 231 public FedoraId asBaseId() { 232 return FedoraId.create(getBaseId()); 233 } 234 235 /** 236 * Return the original full ID. 237 * @return the id. 238 */ 239 public String getFullId() { 240 return fullId; 241 } 242 243 /** 244 * Return the original full ID without the info:fedora prefix. 245 * @return the full id path part 246 */ 247 public String getFullIdPath() { 248 return fullPath; 249 } 250 251 /** 252 * Return the Memento datetime as Instant. 253 * @return The datetime or null if not a memento. 254 */ 255 public Instant getMementoInstant() { 256 return mementoDatetime; 257 } 258 259 /** 260 * Return the Memento datetime string. 261 * @return The yyyymmddhhiiss memento datetime or null if not a Memento. 262 */ 263 public String getMementoString() { 264 return mementoDatetimeStr; 265 } 266 267 /** 268 * Creates a new Fedora ID by joining the base ID of this Fedora ID with the specified string part. Any extensions 269 * that this Fedora ID contains are discarded. For example: 270 * <p> 271 * Resolving "child" against "info:fedora/object1/another/fcr:versions/20000101121212" yields 272 * "info:fedora/object1/another/child". 273 * 274 * @param child the part to join 275 * @return new Fedora ID in the form baseId/child 276 */ 277 public FedoraId resolve(final String child) { 278 if (StringUtils.isBlank(child)) { 279 throw new IllegalArgumentException("Child cannot be blank"); 280 } 281 return FedoraId.create(baseId, child); 282 } 283 284 /** 285 * Creates a new Fedora ID based on this ID that points to an ACL resource. The base ID, full ID without extensions, 286 * is always used to construct an ACL ID. If this ID is already an ACL, then it returns itself. 287 * 288 * @return ACL resource ID 289 */ 290 public FedoraId asAcl() { 291 if (isAcl()) { 292 return this; 293 } 294 295 return FedoraId.create(getBaseId(), FCR_ACL); 296 } 297 298 /** 299 * Creates a new Fedora ID based on this ID that points to a binary description resource. There is no guarantee that 300 * the binary description resource exists. If this ID is already a description, then it returns itself. Otherwise, 301 * it uses the base ID, without extensions, to construct the new ID. If this Fedora ID is a timemap or memento or 302 * a hash uri, then these extensions are applied to new description ID as well. 303 * 304 * @return description resource ID 305 */ 306 public FedoraId asDescription() { 307 if (isDescription()) { 308 return this; 309 } 310 311 if (isTimemap()) { 312 return FedoraId.create(getBaseId(), FCR_METADATA, FCR_VERSIONS); 313 } 314 315 if (isMemento()) { 316 return FedoraId.create(getBaseId(), FCR_METADATA, FCR_VERSIONS, appendHashIfPresent(getMementoString())); 317 } 318 319 return FedoraId.create(getBaseId(), appendHashIfPresent(FCR_METADATA)); 320 } 321 322 /** 323 * Creates a new Fedora ID based on this ID that points to a tombstone resource. If this ID is already a tombstone, 324 * then it returns itself. Otherwise, it uses the base ID, without extensions, to construct the new ID. 325 * 326 * @return tombstone resource ID 327 */ 328 public FedoraId asTombstone() { 329 if (isTombstone()) { 330 return this; 331 } 332 333 return FedoraId.create(getBaseId(), FCR_TOMBSTONE); 334 } 335 336 /** 337 * Creates a new Fedora ID based on this ID that points to a timemap resource. If this ID is already a timemap, 338 * then it returns itself. Otherwise, it uses the base ID, without extensions, to construct the new ID. Unless 339 * this ID is a binary description, in which case the new ID is constructed using the full ID. 340 * 341 * @return timemap resource ID 342 */ 343 public FedoraId asTimemap() { 344 if (isTimemap()) { 345 return this; 346 } 347 348 if (isDescription()) { 349 return FedoraId.create(getBaseId(), FCR_METADATA, FCR_VERSIONS); 350 } 351 352 return FedoraId.create(getBaseId(), FCR_VERSIONS); 353 } 354 355 /** 356 * Creates a new Fedora ID based on this ID that points to a memento resource. If this ID is already a memento, 357 * then it returns itself. If this ID is an ACL, tombstone, or timemap, then the new ID is constructed using this 358 * ID's base ID. Otherwise, the full ID is used. 359 * 360 * @param mementoInstant memento representation 361 * @return memento resource ID 362 */ 363 public FedoraId asMemento(final Instant mementoInstant) { 364 return asMemento(MEMENTO_LABEL_FORMATTER.format(mementoInstant)); 365 } 366 367 /** 368 * Creates a new Fedora ID based on this ID that points to a memento resource. If this ID is already a memento, 369 * then it returns itself. If this ID is an ACL, tombstone, or timemap, then the new ID is constructed using this 370 * ID's base ID. If this ID is a description, then the new ID is appended to the description ID. 371 * 372 * @param mementoString string memento representation 373 * @return memento resource ID 374 */ 375 public FedoraId asMemento(final String mementoString) { 376 if (isMemento()) { 377 return this; 378 } 379 380 if (isDescription()) { 381 return FedoraId.create(getBaseId(), FCR_METADATA, FCR_VERSIONS, appendHashIfPresent(mementoString)); 382 } 383 384 if (isAcl() || isTombstone() || isTimemap()) { 385 return FedoraId.create(getBaseId(), FCR_VERSIONS, mementoString); 386 } 387 388 return FedoraId.create(getBaseId(), FCR_VERSIONS, appendHashIfPresent(mementoString)); 389 } 390 391 @Override 392 public boolean equals(final Object obj) { 393 if (obj == this) { 394 return true; 395 } 396 397 if (!(obj instanceof FedoraId)) { 398 return false; 399 } 400 401 final var testObj = (FedoraId) obj; 402 return Objects.equals(testObj.getFullId(), this.getFullId()); 403 } 404 405 @Override 406 public int hashCode() { 407 return getFullId().hashCode(); 408 } 409 410 @JsonValue 411 @Override 412 public String toString() { 413 return getFullId(); 414 } 415 416 /** 417 * Concatenates all the parts with slashes 418 * @param parts array of strings 419 * @return the concatenated string. 420 */ 421 private static String idBuilder(final String... parts) { 422 if (parts != null && parts.length > 0) { 423 return Arrays.stream(parts).filter(Objects::nonNull) 424 .map(s -> s.startsWith("/") ? s.substring(1) : s) 425 .map(s -> s.endsWith("/") ? s.substring(0, s.length() - 1 ) : s) 426 .collect(Collectors.joining("/")); 427 } 428 return ""; 429 } 430 431 /** 432 * Ensure the ID has the info:fedora/ prefix. 433 * @param id the identifier, if null assume repository root (info:fedora/) 434 * @return the identifier with the info:fedora/ prefix. 435 */ 436 private static String ensurePrefix(final String id) { 437 if (id == null) { 438 return FEDORA_ID_PREFIX; 439 } 440 return id.startsWith(FEDORA_ID_PREFIX) ? id : FEDORA_ID_PREFIX + "/" + id; 441 } 442 443 /** 444 * Process the original ID into its parts without using a regular expression. 445 */ 446 private String processIdentifier() { 447 // Regex pattern which decomposes a http resource uri into components 448 // The first group determines if it is an fcr:metadata non-rdf source. 449 // The second group determines if the path is for a memento or timemap. 450 // The third group allows for a memento identifier. 451 // The fourth group for allows ACL. 452 // The fifth group allows for any hashed suffixes. 453 // ".*?(/" + FCR_METADATA + ")?(/" + FCR_VERSIONS + "(/\\d{14})?)?(/" + FCR_ACL + ")?(\\#\\S+)?$"); 454 if (this.fullId.contains("//")) { 455 throw new InvalidResourceIdentifierException(String.format("Path contains empty element! %s", fullPath)); 456 } 457 String processID = this.fullId; 458 if (processID.equals(FEDORA_ID_PREFIX)) { 459 this.isRepositoryRoot = true; 460 return this.fullId; 461 } 462 if (processID.contains("#")) { 463 final String[] hashSplits = StringUtils.splitPreserveAllTokens(processID, "#"); 464 if (hashSplits.length > 2) { 465 throw new InvalidResourceIdentifierException(String.format( 466 "Path <%s> is invalid. It may not contain more than one #", 467 fullPath)); 468 } 469 this.hashUri = hashSplits[1]; 470 processID = hashSplits[0]; 471 } 472 if (processID.contains(FCR_TOMBSTONE)) { 473 processID = removePart(processID, FCR_TOMBSTONE); 474 this.isTombstone = true; 475 } 476 if (processID.contains(FCR_ACL)) { 477 processID = removePart(processID, FCR_ACL); 478 this.isAcl = true; 479 } 480 if (processID.contains(FCR_VERSIONS)) { 481 final String[] versionSplits = split(processID, FCR_VERSIONS); 482 if (versionSplits.length > 2) { 483 throw new InvalidResourceIdentifierException(String.format( 484 "Path <%s> is invalid. May not contain multiple %s parts.", 485 fullPath, FCR_VERSIONS)); 486 } else if (versionSplits.length == 2 && versionSplits[1].isEmpty()) { 487 this.isTimemap = true; 488 } else { 489 final String afterVersion = versionSplits[1]; 490 if (afterVersion.matches("/\\d{14}")) { 491 this.isMemento = true; 492 this.mementoDatetimeStr = afterVersion.substring(1); 493 try { 494 this.mementoDatetime = Instant.from(MEMENTO_LABEL_FORMATTER.parse(this.mementoDatetimeStr)); 495 } catch (final DateTimeParseException e) { 496 throw new InvalidMementoPathException(String.format("Invalid request for memento at %s", 497 fullPath)); 498 } 499 } else if (afterVersion.equals("/")) { 500 // Possible trailing slash? 501 this.isTimemap = true; 502 } else { 503 throw new InvalidMementoPathException(String.format("Invalid request for memento at %s", fullPath)); 504 } 505 } 506 processID = versionSplits[0]; 507 } 508 if (processID.contains(FCR_METADATA)) { 509 processID = removePart(processID, FCR_METADATA); 510 this.isNonRdfSourceDescription = true; 511 } 512 if (processID.endsWith("/")) { 513 processID = processID.replaceAll("/+$", ""); 514 } 515 516 return processID; 517 } 518 519 private String removePart(final String original, final String part) { 520 final String[] split = split(original, part); 521 if (split.length > 2 || (split.length == 2 && !split[1].isEmpty())) { 522 throw new InvalidResourceIdentifierException("Path is invalid:" + fullPath); 523 } 524 return split[0]; 525 } 526 527 private String[] split(final String original, final String part) { 528 return StringUtils.splitByWholeSeparatorPreserveAllTokens(original, "/" + part); 529 } 530 531 /** 532 * Check for obvious path errors. 533 */ 534 private void checkForInvalidPath() { 535 // Check for combinations of endpoints not allowed. 536 if ( 537 // ID contains fcr:acl or fcr:tombstone AND fcr:metadata or fcr:versions 538 ((this.fullId.contains(FCR_ACL) || this.fullId.contains(FCR_TOMBSTONE)) && 539 (this.fullId.contains(FCR_METADATA) || this.fullId.contains(FCR_VERSIONS))) || 540 // or ID contains fcr:acl AND fcr:tombstone 541 (this.fullId.contains(FCR_TOMBSTONE) && this.fullId.contains(FCR_ACL)) 542 ) { 543 throw new InvalidResourceIdentifierException(String.format("Path is invalid: %s", fullPath)); 544 } 545 // Ensure we don't have 2 of any of the extensions, ie. info:fedora/object/fcr:acl/fcr:acl, etc. 546 for (final Pattern extension : extensions) { 547 if (extension.matcher(this.fullId).results().count() > 1) { 548 throw new InvalidResourceIdentifierException(String.format("Path is invalid: %s", fullPath)); 549 } 550 } 551 } 552 553 private String appendHashIfPresent(final String original) { 554 if (isHashUri()) { 555 return original + "#" + getHashUri(); 556 } 557 return original; 558 } 559 560}