001/**
002 * Copyright 2015 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.fcrepo.http.commons.api.rdf;
017
018import static com.google.common.collect.ImmutableList.of;
019import static com.hp.hpl.jena.rdf.model.ResourceFactory.createResource;
020import static java.util.Collections.singleton;
021import static org.apache.commons.lang.StringUtils.EMPTY;
022import static org.apache.commons.lang.StringUtils.replaceOnce;
023import static org.fcrepo.kernel.api.FedoraJcrTypes.FCR_METADATA;
024import static org.fcrepo.kernel.api.FedoraJcrTypes.FCR_VERSIONS;
025import static org.fcrepo.kernel.api.utils.NamespaceTools.validatePath;
026import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
027import static org.fcrepo.kernel.modeshape.services.TransactionServiceImpl.getCurrentTransactionId;
028import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getClosestExistingAncestor;
029import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.isFrozenNode;
030import static org.modeshape.jcr.api.JcrConstants.JCR_CONTENT;
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 ((NonRdfSourceDescription)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        return uriTemplate.match(resource.getURI(), values) && values.containsKey("path");
155    }
156
157    @Override
158    public Resource toDomain(final String path) {
159
160        final String realPath;
161        if (path == null) {
162            realPath = "";
163        } else if (path.startsWith("/")) {
164            realPath = path.substring(1);
165        } else {
166            realPath = path;
167        }
168
169        final UriBuilder uri = uriBuilder();
170
171        if (realPath.contains("#")) {
172
173            final String[] split = realPath.split("#", 2);
174
175            uri.resolveTemplate("path", split[0], false);
176            uri.fragment(split[1]);
177        } else {
178            uri.resolveTemplate("path", realPath, false);
179
180        }
181        return createResource(uri.build().toString());
182    }
183
184    @Override
185    public String asString(final Resource resource) {
186        final Map<String, String> values = new HashMap<>();
187
188        return asString(resource, values);
189    }
190
191    /**
192     * Convert the incoming Resource to a JCR path (but don't attempt to load the node).
193     *
194     * @param resource Jena Resource to convert
195     * @param values a map that will receive the matching URI template variables for future use.
196     * @return
197     */
198    private String asString(final Resource resource, final Map<String, String> values) {
199        if (uriTemplate.match(resource.getURI(), values) && values.containsKey("path")) {
200            String path = "/" + values.get("path");
201
202            final boolean metadata = path.endsWith("/" + FCR_METADATA);
203
204            if (metadata) {
205                path = replaceOnce(path, "/" + FCR_METADATA, EMPTY);
206            }
207
208            path = forward.convert(path);
209
210            if (path == null) {
211                return null;
212            }
213
214            try {
215                path = URLDecoder.decode(path, "UTF-8");
216            } catch (final UnsupportedEncodingException e) {
217                LOGGER.debug("Unable to URL-decode path " + e + " as UTF-8", e);
218            }
219
220            if (path.isEmpty()) {
221                return "/";
222            }
223
224            // Validate path
225            if (path.contains("//")) {
226                throw new InvalidResourceIdentifierException("Path contains empty element! " + path);
227            }
228            return path;
229        }
230        return null;
231    }
232
233
234    private Node getNode(final String path) throws RepositoryException {
235        if (path.contains(FCR_VERSIONS)) {
236            final String[] split = path.split("/" + FCR_VERSIONS + "/", 2);
237            final String versionedPath = split[0];
238            final String versionAndPathIntoVersioned = split[1];
239            final String[] split1 = versionAndPathIntoVersioned.split("/", 2);
240            final String version = split1[0];
241
242            final String pathIntoVersioned;
243            if (split1.length > 1) {
244                pathIntoVersioned = split1[1];
245            } else {
246                pathIntoVersioned = "";
247            }
248
249            final Node node = getFrozenNodeByLabel(versionedPath, version);
250
251            if (pathIntoVersioned.isEmpty()) {
252                return node;
253            } else if (node != null) {
254                return node.getNode(pathIntoVersioned);
255            } else {
256                throw new PathNotFoundException("Unable to find versioned resource at " + path);
257            }
258        }
259        return session.getNode(path);
260    }
261
262    /**
263     * A private helper method that tries to look up frozen node for the given subject
264     * by a label.  That label may either be one that was assigned at creation time
265     * (and is a version label in the JCR sense) or a system assigned identifier that
266     * was used for versions created without a label.  The current implementation
267     * uses the JCR UUID for the frozen node as the system-assigned label.
268     */
269    private Node getFrozenNodeByLabel(final String baseResourcePath, final String label) {
270        try {
271            final Node n = getNode(baseResourcePath, label);
272
273            if (n != null) {
274                return n;
275            }
276
277             /*
278             * Though a node with an id of the label was found, it wasn't the
279             * node we were looking for, so fall through and look for a labeled
280             * node.
281             */
282            final VersionHistory hist =
283                    session.getWorkspace().getVersionManager().getVersionHistory(baseResourcePath);
284            if (hist.hasVersionLabel(label)) {
285                LOGGER.debug("Found version for {} by label {}.", baseResourcePath, label);
286                return hist.getVersionByLabel(label).getFrozenNode();
287            }
288            LOGGER.warn("Unknown version {} with label or uuid {}!", baseResourcePath, label);
289            throw new PathNotFoundException("Unknown version " + baseResourcePath
290                    + " with label or uuid " + label);
291        } catch (final RepositoryException e) {
292            throw new RepositoryRuntimeException(e);
293        }
294    }
295
296    private Node getNode(final String baseResourcePath, final String label) throws RepositoryException {
297        try {
298            final Node frozenNode = session.getNodeByIdentifier(label);
299
300            /*
301             * We found a node whose identifier is the "label" for the version.  Now
302             * we must do due diligence to make sure it's a frozen node representing
303             * a version of the subject node.
304             */
305            final Property p = frozenNode.getProperty("jcr:frozenUuid");
306            if (p != null) {
307                final Node subjectNode = session.getNode(baseResourcePath);
308                if (p.getString().equals(subjectNode.getIdentifier())) {
309                    return frozenNode;
310                }
311            }
312
313        } catch (final ItemNotFoundException ex) {
314            /*
315             * the label wasn't a uuid of a frozen node but
316             * instead possibly a version label.
317             */
318        }
319        return null;
320    }
321
322    private static String getPath(final FedoraResource resource) {
323        if (isFrozenNode.test(resource)) {
324            try {
325
326                // the versioned resource we're in
327                final FedoraResource versionableFrozenResource = resource.getVersionedAncestor();
328
329                // the unfrozen equivalent for the versioned resource
330                final FedoraResource unfrozenVersionableResource = versionableFrozenResource.getUnfrozenResource();
331
332                // the identifier for this version (by default, the UUID for the versioned resource)
333                final String versionIdentifier = versionableFrozenResource.getNode().getIdentifier();
334
335                // the path to this resource within the versioning tree
336                final String pathWithinVersionable;
337
338                if (!resource.equals(versionableFrozenResource)) {
339                    pathWithinVersionable = getRelativePath(resource, versionableFrozenResource);
340                } else {
341                    pathWithinVersionable = "";
342                }
343
344                // and, finally, the path we want to expose in the URI
345                final String path = unfrozenVersionableResource.getPath()
346                        + "/" + FCR_VERSIONS
347                        + "/" + versionIdentifier
348                        + pathWithinVersionable;
349                return path.startsWith("/") ? path : "/" + path;
350            } catch (final RepositoryException e) {
351                throw new RepositoryRuntimeException(e);
352            }
353        }
354        return resource.getPath();
355    }
356
357    private static String getRelativePath(final FedoraResource child, final FedoraResource ancestor) {
358        return child.getPath().substring(ancestor.getPath().length());
359    }
360
361    /**
362     * Get only the resource path to this resource, before embedding it in a full URI
363     * @param resource
364     * @return
365     */
366    private String doBackwardPathOnly(final FedoraResource resource) {
367        String path = reverse.convert(getPath(resource));
368        if (path != null) {
369
370            if (resource instanceof NonRdfSourceDescription) {
371                path = path + "/" + FCR_METADATA;
372            }
373
374            if (path.endsWith(JCR_CONTENT)) {
375                path = replaceOnce(path, "/" + JCR_CONTENT, EMPTY);
376            }
377            return path;
378        }
379        throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource);
380    }
381
382
383    protected void resetTranslationChain() {
384        if (translationChain == null) {
385            translationChain = getTranslationChain();
386            final List<Converter<String, String>> newChain =
387                    new ArrayList<>(singleton(new TransactionIdentifierConverter(session)));
388            newChain.addAll(translationChain);
389            setTranslationChain(newChain);
390        }
391    }
392
393    private void setTranslationChain(final List<Converter<String, String>> chained) {
394
395        translationChain = chained;
396
397        for (final Converter<String, String> t : translationChain) {
398            forward = forward.andThen(t);
399        }
400        for (final Converter<String, String> t : Lists.reverse(translationChain)) {
401            reverse = reverse.andThen(t.reverse());
402        }
403    }
404
405
406    private static final List<Converter<String, String>> minimalTranslationChain =
407            of(new NamespaceConverter(), new HashConverter());
408
409    protected List<Converter<String,String>> getTranslationChain() {
410        final ApplicationContext context = getApplicationContext();
411        if (context != null) {
412            final List<Converter<String,String>> tchain =
413                    getApplicationContext().getBean("translationChain", List.class);
414            return tchain;
415        }
416        return minimalTranslationChain;
417    }
418
419    protected ApplicationContext getApplicationContext() {
420        return getCurrentWebApplicationContext();
421    }
422
423    /**
424     * Translate the current transaction into the identifier
425     */
426    static class TransactionIdentifierConverter extends Converter<String, String> {
427        public static final String TX_PREFIX = "tx:";
428
429        private final Session session;
430
431        public TransactionIdentifierConverter(final Session session) {
432            this.session = session;
433        }
434
435        @Override
436        protected String doForward(final String path) {
437
438            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
439                throw new RepositoryRuntimeException("Path " + path
440                        + " is not in current transaction " +  getCurrentTransactionId(session));
441            }
442
443            return replaceOnce(path, txSegment(), EMPTY);
444        }
445
446        @Override
447        protected String doBackward(final String path) {
448            return txSegment() + path;
449        }
450
451        private String txSegment() {
452
453            final String txId = getCurrentTransactionId(session);
454
455            if (txId != null) {
456                return "/" + TX_PREFIX + txId;
457            }
458            return EMPTY;
459        }
460    }
461}