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