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