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" =&gt; "info:fedora/object1/another"</li>
186     *     <li>"info:fedora/object1/another/fcr:metadata" =&gt; "info:fedora/object1/another/fcr:metadata"</li>
187     *     <li>"info:fedora/object1/another" =&gt; "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" =&gt; "info:fedora/object1/another"</li>
216     *     <li>"info:fedora/object1/another/fcr:metadata" =&gt; "info:fedora/object1/another"</li>
217     *     <li>"info:fedora/object1/another" =&gt; "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}