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 the session
094     * @param uriBuilder the uri builder
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 Map<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
133            if ( e instanceof PathNotFoundException ) {
134                try {
135                    final Node preexistingNode = getClosestExistingAncestor(session, path);
136                    if (TombstoneImpl.hasMixin(preexistingNode)) {
137                        throw new TombstoneException(new TombstoneImpl(preexistingNode));
138                    }
139                } catch (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 Map<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 Map<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            final Node n = getNode(baseResourcePath, label);
268
269            if (n != null) {
270                return n;
271            }
272
273             /*
274             * Though a node with an id of the label was found, it wasn't the
275             * node we were looking for, so fall through and look for a labeled
276             * node.
277             */
278            final VersionHistory hist =
279                    session.getWorkspace().getVersionManager().getVersionHistory(baseResourcePath);
280            if (hist.hasVersionLabel(label)) {
281                LOGGER.debug("Found version for {} by label {}.", baseResourcePath, label);
282                return hist.getVersionByLabel(label).getFrozenNode();
283            }
284            LOGGER.warn("Unknown version {} with label or uuid {}!", baseResourcePath, label);
285            throw new PathNotFoundException("Unknown version " + baseResourcePath
286                    + " with label or uuid " + label);
287        } catch (final RepositoryException e) {
288            throw new RepositoryRuntimeException(e);
289        }
290    }
291
292    private Node getNode(final String baseResourcePath, final String label) throws RepositoryException {
293        try {
294            final Node frozenNode = session.getNodeByIdentifier(label);
295
296            /*
297             * We found a node whose identifier is the "label" for the version.  Now
298             * we must do due diligence to make sure it's a frozen node representing
299             * a version of the subject node.
300             */
301            final Property p = frozenNode.getProperty("jcr:frozenUuid");
302            if (p != null) {
303                final Node subjectNode = session.getNode(baseResourcePath);
304                if (p.getString().equals(subjectNode.getIdentifier())) {
305                    return frozenNode;
306                }
307            }
308
309        } catch (final ItemNotFoundException ex) {
310            /*
311             * the label wasn't a uuid of a frozen node but
312             * instead possibly a version label.
313             */
314        }
315        return null;
316    }
317
318    private static String getPath(final FedoraResource resource) {
319        if (isFrozenNode.apply(resource)) {
320            try {
321
322                // the versioned resource we're in
323                final FedoraResource versionableFrozenResource = resource.getVersionedAncestor();
324
325                // the unfrozen equivalent for the versioned resource
326                final FedoraResource unfrozenVersionableResource = versionableFrozenResource.getUnfrozenResource();
327
328                // the identifier for this version (by default, the UUID for the versioned resource)
329                final String versionIdentifier = versionableFrozenResource.getNode().getIdentifier();
330
331                // the path to this resource within the versioning tree
332                final String pathWithinVersionable;
333
334                if (!resource.equals(versionableFrozenResource)) {
335                    pathWithinVersionable = getRelativePath(resource, versionableFrozenResource);
336                } else {
337                    pathWithinVersionable = "";
338                }
339
340                // and, finally, the path we want to expose in the URI
341                final String path = unfrozenVersionableResource.getPath()
342                        + "/" + FCR_VERSIONS
343                        + "/" + versionIdentifier
344                        + pathWithinVersionable;
345                return path.startsWith("/") ? path : "/" + path;
346            } catch (final RepositoryException e) {
347                throw new RepositoryRuntimeException(e);
348            }
349        }
350        return resource.getPath();
351    }
352
353    private static String getRelativePath(final FedoraResource child, final FedoraResource ancestor) {
354        return child.getPath().substring(ancestor.getPath().length());
355    }
356
357    /**
358     * Get only the resource path to this resource, before embedding it in a full URI
359     * @param resource
360     * @return
361     */
362    private String doBackwardPathOnly(final FedoraResource resource) {
363        String path = reverse.convert(getPath(resource));
364        if (path != null) {
365
366            if (resource instanceof NonRdfSourceDescription) {
367                path = path + "/" + FCR_METADATA;
368            }
369
370            if (path.endsWith(JCR_CONTENT)) {
371                path = replaceOnce(path, "/" + JCR_CONTENT, EMPTY);
372            }
373            return path;
374        }
375        throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource);
376    }
377
378
379    protected void resetTranslationChain() {
380        if (translationChain == null) {
381            translationChain = getTranslationChain();
382
383            final Converter<String,String> transactionIdentifierConverter = new TransactionIdentifierConverter(session);
384
385            @SuppressWarnings("unchecked")
386            final ImmutableList<Converter<String, String>> chain = copyOf(
387                    concat(newArrayList(transactionIdentifierConverter),
388                            translationChain));
389            setTranslationChain(chain);
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 = of(
407                    new NamespaceConverter(), (Converter<String, String>) new HashConverter()
408            );
409
410    protected List<Converter<String,String>> getTranslationChain() {
411        final ApplicationContext context = getApplicationContext();
412        if (context != null) {
413            final List<Converter<String,String>> tchain =
414                    getApplicationContext().getBean("translationChain", List.class);
415            return tchain;
416        }
417        return minimalTranslationChain;
418    }
419
420    protected ApplicationContext getApplicationContext() {
421        return getCurrentWebApplicationContext();
422    }
423
424    /**
425     * Translate the current transaction into the identifier
426     */
427    static class TransactionIdentifierConverter extends Converter<String, String> {
428        public static final String TX_PREFIX = "tx:";
429
430        private final Session session;
431
432        public TransactionIdentifierConverter(final Session session) {
433            this.session = session;
434        }
435
436        @Override
437        protected String doForward(final String path) {
438
439            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
440                throw new RepositoryRuntimeException("Path " + path
441                        + " is not in current transaction " +  getCurrentTransactionId(session));
442            }
443
444            return replaceOnce(path, txSegment(), EMPTY);
445        }
446
447        @Override
448        protected String doBackward(final String path) {
449            return txSegment() + path;
450        }
451
452        private String txSegment() {
453
454            final String txId = getCurrentTransactionId(session);
455
456            if (txId != null) {
457                return "/" + TX_PREFIX + txId;
458            }
459            return EMPTY;
460        }
461    }
462}