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.http.commons.api.rdf;
019
020import static com.google.common.collect.ImmutableList.of;
021import static com.hp.hpl.jena.rdf.model.ResourceFactory.createResource;
022import static java.util.Collections.singleton;
023import static org.apache.commons.lang3.StringUtils.EMPTY;
024import static org.apache.commons.lang3.StringUtils.replaceOnce;
025import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
026import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS;
027import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
028import static org.fcrepo.kernel.modeshape.services.TransactionServiceImpl.getCurrentTransactionId;
029import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getClosestExistingAncestor;
030import static org.fcrepo.kernel.modeshape.utils.NamespaceTools.validatePath;
031import static org.slf4j.LoggerFactory.getLogger;
032import static org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext;
033
034import java.io.UnsupportedEncodingException;
035import java.net.URLDecoder;
036import java.util.ArrayList;
037import java.util.HashMap;
038import java.util.List;
039import java.util.Map;
040
041import javax.jcr.ItemNotFoundException;
042import javax.jcr.Node;
043import javax.jcr.PathNotFoundException;
044import javax.jcr.Property;
045import javax.jcr.RepositoryException;
046import javax.jcr.Session;
047import javax.jcr.version.VersionHistory;
048import javax.ws.rs.core.UriBuilder;
049
050import org.fcrepo.kernel.api.exception.IdentifierConversionException;
051import org.fcrepo.kernel.api.exception.InvalidResourceIdentifierException;
052import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
053import org.fcrepo.kernel.api.exception.TombstoneException;
054import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
055import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
056import org.fcrepo.kernel.api.models.FedoraResource;
057import org.fcrepo.kernel.modeshape.TombstoneImpl;
058import org.fcrepo.kernel.modeshape.identifiers.HashConverter;
059import org.fcrepo.kernel.modeshape.identifiers.NamespaceConverter;
060
061import org.glassfish.jersey.uri.UriTemplate;
062import org.slf4j.Logger;
063import org.springframework.context.ApplicationContext;
064
065import com.google.common.base.Converter;
066import com.google.common.collect.Lists;
067import com.hp.hpl.jena.rdf.model.Resource;
068
069/**
070 * Convert between Jena Resources and JCR Nodes using a JAX-RS UriBuilder to mediate the
071 * URI translation.
072 *
073 * @author cabeer
074 * @since 10/5/14
075 */
076public class HttpResourceConverter extends IdentifierConverter<Resource,FedoraResource> {
077
078    private static final Logger LOGGER = getLogger(HttpResourceConverter.class);
079
080    protected List<Converter<String, String>> translationChain;
081
082    private final Session session;
083    private final UriBuilder uriBuilder;
084
085    protected Converter<String, String> forward = identity();
086    protected Converter<String, String> reverse = identity();
087
088    private final UriTemplate uriTemplate;
089
090    /**
091     * Create a new identifier converter within the given session with the given URI template
092     * @param session the session
093     * @param uriBuilder the uri builder
094     */
095    public HttpResourceConverter(final Session session,
096                                 final UriBuilder uriBuilder) {
097
098        this.session = session;
099        this.uriBuilder = uriBuilder;
100        this.uriTemplate = new UriTemplate(uriBuilder.toTemplate());
101
102        resetTranslationChain();
103    }
104
105    private UriBuilder uriBuilder() {
106        return UriBuilder.fromUri(uriBuilder.toTemplate());
107    }
108
109    @Override
110    protected FedoraResource doForward(final Resource resource) {
111        final Map<String, String> values = new HashMap<>();
112        final String path = asString(resource, values);
113        try {
114            if (path != null) {
115                final Node node = getNode(path);
116
117                final boolean metadata = values.containsKey("path")
118                        && values.get("path").endsWith("/" + FCR_METADATA);
119
120                final FedoraResource fedoraResource = nodeConverter.convert(node);
121
122                if (!metadata && fedoraResource instanceof NonRdfSourceDescription) {
123                    return fedoraResource.getDescribedResource();
124                }
125                return fedoraResource;
126            }
127            throw new IdentifierConversionException("Asked to translate a resource " + resource
128                    + " that doesn't match the URI template");
129        } catch (final RepositoryException e) {
130            validatePath(session, path);
131
132            if ( e instanceof PathNotFoundException ) {
133                try {
134                    final Node preexistingNode = getClosestExistingAncestor(session, path);
135                    if (TombstoneImpl.hasMixin(preexistingNode)) {
136                        throw new TombstoneException(new TombstoneImpl(preexistingNode));
137                    }
138                } catch (final RepositoryException inner) {
139                    LOGGER.debug("Error checking for parent tombstones", inner);
140                }
141            }
142            throw new RepositoryRuntimeException(e);
143        }
144    }
145
146    @Override
147    protected Resource doBackward(final FedoraResource resource) {
148        return toDomain(doBackwardPathOnly(resource));
149    }
150
151    @Override
152    public boolean inDomain(final Resource resource) {
153        final Map<String, String> values = new HashMap<>();
154
155        return uriTemplate.match(resource.getURI(), values) && values.containsKey("path") ||
156            isRootWithoutTrailingSlash(resource);
157    }
158
159    @Override
160    public Resource toDomain(final String path) {
161
162        final String realPath;
163        if (path == null) {
164            realPath = "";
165        } else if (path.startsWith("/")) {
166            realPath = path.substring(1);
167        } else {
168            realPath = path;
169        }
170
171        final UriBuilder uri = uriBuilder();
172
173        if (realPath.contains("#")) {
174
175            final String[] split = realPath.split("#", 2);
176
177            uri.resolveTemplate("path", split[0], false);
178            uri.fragment(split[1]);
179        } else {
180            uri.resolveTemplate("path", realPath, false);
181
182        }
183        return createResource(uri.build().toString());
184    }
185
186    @Override
187    public String asString(final Resource resource) {
188        final Map<String, String> values = new HashMap<>();
189
190        return asString(resource, values);
191    }
192
193    /**
194     * Convert the incoming Resource to a JCR path (but don't attempt to load the node).
195     *
196     * @param resource Jena Resource to convert
197     * @param values a map that will receive the matching URI template variables for future use.
198     * @return
199     */
200    private String asString(final Resource resource, final Map<String, String> values) {
201        if (uriTemplate.match(resource.getURI(), values) && values.containsKey("path")) {
202            String path = "/" + values.get("path");
203
204            final boolean metadata = path.endsWith("/" + FCR_METADATA);
205
206            if (metadata) {
207                path = replaceOnce(path, "/" + FCR_METADATA, EMPTY);
208            }
209
210            path = forward.convert(path);
211
212            if (path == null) {
213                return null;
214            }
215
216            try {
217                path = URLDecoder.decode(path, "UTF-8");
218            } catch (final UnsupportedEncodingException e) {
219                LOGGER.debug("Unable to URL-decode path " + e + " as UTF-8", e);
220            }
221
222            if (path.isEmpty()) {
223                return "/";
224            }
225
226            // Validate path
227            if (path.contains("//")) {
228                throw new InvalidResourceIdentifierException("Path contains empty element! " + path);
229            }
230            return path;
231        }
232
233        if (isRootWithoutTrailingSlash(resource)) {
234            return "/";
235        }
236
237        return null;
238    }
239
240
241    private Node getNode(final String path) throws RepositoryException {
242        if (path.contains(FCR_VERSIONS)) {
243            final String[] split = path.split("/" + FCR_VERSIONS + "/", 2);
244            final String versionedPath = split[0];
245            final String versionAndPathIntoVersioned = split[1];
246            final String[] split1 = versionAndPathIntoVersioned.split("/", 2);
247            final String version = split1[0];
248
249            final String pathIntoVersioned;
250            if (split1.length > 1) {
251                pathIntoVersioned = split1[1];
252            } else {
253                pathIntoVersioned = "";
254            }
255
256            final Node node = getFrozenNodeByLabel(versionedPath, version);
257
258            if (pathIntoVersioned.isEmpty()) {
259                return node;
260            } else if (node != null) {
261                return node.getNode(pathIntoVersioned);
262            } else {
263                throw new PathNotFoundException("Unable to find versioned resource at " + path);
264            }
265        }
266        try {
267            return session.getNode(path);
268        } catch (IllegalArgumentException ex) {
269            throw new InvalidResourceIdentifierException("Illegal path: " + path);
270        }
271    }
272
273    /**
274     * A private helper method that tries to look up frozen node for the given subject
275     * by a label.  That label may either be one that was assigned at creation time
276     * (and is a version label in the JCR sense) or a system assigned identifier that
277     * was used for versions created without a label.  The current implementation
278     * uses the JCR UUID for the frozen node as the system-assigned label.
279     */
280    private Node getFrozenNodeByLabel(final String baseResourcePath, final String label) {
281        try {
282            final Node n = getNode(baseResourcePath, label);
283
284            if (n != null) {
285                return n;
286            }
287
288             /*
289             * Though a node with an id of the label was found, it wasn't the
290             * node we were looking for, so fall through and look for a labeled
291             * node.
292             */
293            final VersionHistory hist =
294                    session.getWorkspace().getVersionManager().getVersionHistory(baseResourcePath);
295            if (hist.hasVersionLabel(label)) {
296                LOGGER.debug("Found version for {} by label {}.", baseResourcePath, label);
297                return hist.getVersionByLabel(label).getFrozenNode();
298            }
299            LOGGER.warn("Unknown version {} with label or uuid {}!", baseResourcePath, label);
300            throw new PathNotFoundException("Unknown version " + baseResourcePath
301                    + " with label or uuid " + label);
302        } catch (final RepositoryException e) {
303            throw new RepositoryRuntimeException(e);
304        }
305    }
306
307    private Node getNode(final String baseResourcePath, final String label) throws RepositoryException {
308        try {
309            final Node frozenNode = session.getNodeByIdentifier(label);
310
311            /*
312             * We found a node whose identifier is the "label" for the version.  Now
313             * we must do due diligence to make sure it's a frozen node representing
314             * a version of the subject node.
315             */
316            final Property p = frozenNode.getProperty("jcr:frozenUuid");
317            if (p != null) {
318                final Node subjectNode = session.getNode(baseResourcePath);
319                if (p.getString().equals(subjectNode.getIdentifier())) {
320                    return frozenNode;
321                }
322            }
323
324        } catch (final ItemNotFoundException ex) {
325            /*
326             * the label wasn't a uuid of a frozen node but
327             * instead possibly a version label.
328             */
329        }
330        return null;
331    }
332
333    private static String getPath(final FedoraResource resource) {
334        if (resource.isFrozenResource()) {
335            // the versioned resource we're in
336            final FedoraResource versionableFrozenResource = resource.getVersionedAncestor();
337
338            // the unfrozen equivalent for the versioned resource
339            final FedoraResource unfrozenVersionableResource = versionableFrozenResource.getUnfrozenResource();
340
341            // the label for this version
342            final String versionLabel = versionableFrozenResource.getVersionLabelOfFrozenResource();
343
344            // the path to this resource within the versioning tree
345            final String pathWithinVersionable;
346
347            if (!resource.equals(versionableFrozenResource)) {
348                pathWithinVersionable = getRelativePath(resource, versionableFrozenResource);
349            } else {
350                pathWithinVersionable = "";
351            }
352
353            // and, finally, the path we want to expose in the URI
354            final String path = unfrozenVersionableResource.getPath()
355                    + "/" + FCR_VERSIONS
356                    + (versionLabel != null ? "/" + versionLabel : "")
357                    + pathWithinVersionable;
358            return path.startsWith("/") ? path : "/" + path;
359        }
360        return resource.getPath();
361    }
362
363    private static String getRelativePath(final FedoraResource child, final FedoraResource ancestor) {
364        return child.getPath().substring(ancestor.getPath().length());
365    }
366
367    /**
368     * Get only the resource path to this resource, before embedding it in a full URI
369     * @param resource
370     * @return
371     */
372    private String doBackwardPathOnly(final FedoraResource resource) {
373        final String path = reverse.convert(getPath(resource));
374        if (path != null) {
375
376            if (resource instanceof NonRdfSourceDescription) {
377                return path + "/" + FCR_METADATA;
378            }
379
380            return path;
381        }
382        throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource);
383    }
384
385
386    protected void resetTranslationChain() {
387        if (translationChain == null) {
388            translationChain = getTranslationChain();
389            final List<Converter<String, String>> newChain =
390                    new ArrayList<>(singleton(new TransactionIdentifierConverter(session)));
391            newChain.addAll(translationChain);
392            setTranslationChain(newChain);
393        }
394    }
395
396    private void setTranslationChain(final List<Converter<String, String>> chained) {
397
398        translationChain = chained;
399
400        for (final Converter<String, String> t : translationChain) {
401            forward = forward.andThen(t);
402        }
403        for (final Converter<String, String> t : Lists.reverse(translationChain)) {
404            reverse = reverse.andThen(t.reverse());
405        }
406    }
407
408
409    private static final List<Converter<String, String>> minimalTranslationChain =
410            of(new NamespaceConverter(), new HashConverter());
411
412    protected List<Converter<String,String>> getTranslationChain() {
413        final ApplicationContext context = getApplicationContext();
414        if (context != null) {
415            @SuppressWarnings("unchecked")
416            final List<Converter<String,String>> tchain =
417                    getApplicationContext().getBean("translationChain", List.class);
418            return tchain;
419        }
420        return minimalTranslationChain;
421    }
422
423    protected ApplicationContext getApplicationContext() {
424        return getCurrentWebApplicationContext();
425    }
426
427    /**
428     * Translate the current transaction into the identifier
429     */
430    static class TransactionIdentifierConverter extends Converter<String, String> {
431        public static final String TX_PREFIX = "tx:";
432
433        private final Session session;
434
435        public TransactionIdentifierConverter(final Session session) {
436            this.session = session;
437        }
438
439        @Override
440        protected String doForward(final String path) {
441
442            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
443                throw new RepositoryRuntimeException("Path " + path
444                        + " is not in current transaction " +  getCurrentTransactionId(session));
445            }
446
447            return replaceOnce(path, txSegment(), EMPTY);
448        }
449
450        @Override
451        protected String doBackward(final String path) {
452            return txSegment() + path;
453        }
454
455        private String txSegment() {
456
457            final String txId = getCurrentTransactionId(session);
458
459            if (txId != null) {
460                return "/" + TX_PREFIX + txId;
461            }
462            return EMPTY;
463        }
464    }
465
466    private boolean isRootWithoutTrailingSlash(final Resource resource) {
467        final Map<String, String> values = new HashMap<>();
468
469        return uriTemplate.match(resource.getURI() + "/", values) && values.containsKey("path") &&
470            values.get("path").isEmpty();
471    }
472}