001/**
002 * Copyright 2014 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.Iterables.concat;
020import static com.google.common.collect.Lists.newArrayList;
021import static com.hp.hpl.jena.rdf.model.ResourceFactory.createResource;
022import static org.apache.commons.lang.StringUtils.EMPTY;
023import static org.apache.commons.lang.StringUtils.replaceOnce;
024import static org.fcrepo.kernel.FedoraJcrTypes.FCR_METADATA;
025import static org.fcrepo.kernel.FedoraJcrTypes.FCR_VERSIONS;
026import static org.fcrepo.kernel.impl.identifiers.NodeResourceConverter.nodeConverter;
027import static org.fcrepo.kernel.impl.services.TransactionServiceImpl.getCurrentTransactionId;
028import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.getClosestExistingAncestor;
029import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.isFrozenNode;
030import static org.fcrepo.kernel.utils.NamespaceTools.validatePath;
031import static org.modeshape.jcr.api.JcrConstants.JCR_CONTENT;
032import static org.slf4j.LoggerFactory.getLogger;
033import static org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext;
034
035import java.io.UnsupportedEncodingException;
036import java.net.URLDecoder;
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.models.NonRdfSourceDescription;
051import org.fcrepo.kernel.models.FedoraResource;
052import org.fcrepo.kernel.exception.IdentifierConversionException;
053import org.fcrepo.kernel.exception.RepositoryRuntimeException;
054import org.fcrepo.kernel.exception.TombstoneException;
055import org.fcrepo.kernel.identifiers.IdentifierConverter;
056import org.fcrepo.kernel.impl.TombstoneImpl;
057import org.fcrepo.kernel.impl.identifiers.HashConverter;
058import org.fcrepo.kernel.impl.identifiers.NamespaceConverter;
059import org.glassfish.jersey.uri.UriTemplate;
060import org.slf4j.Logger;
061import org.springframework.context.ApplicationContext;
062
063import com.google.common.base.Converter;
064import com.google.common.collect.ImmutableList;
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
092     * @param uriBuilder
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 HashMap<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            try {
131                if ( e instanceof PathNotFoundException ) {
132                    final Node preexistingNode = getClosestExistingAncestor(session, path);
133                    if (TombstoneImpl.hasMixin(preexistingNode)) {
134                        throw new TombstoneException(new TombstoneImpl(preexistingNode));
135                    }
136                }
137            } catch (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 HashMap<String, String> values = new HashMap<>();
153        return uriTemplate.match(resource.getURI(), values) && values.containsKey("path");
154    }
155
156    @Override
157    public Resource toDomain(final String path) {
158
159        final String realPath;
160        if (path == null) {
161            realPath = "";
162        } else if (path.startsWith("/")) {
163            realPath = path.substring(1);
164        } else {
165            realPath = path;
166        }
167
168        final UriBuilder uri = uriBuilder();
169
170        if (realPath.contains("#")) {
171
172            final String[] split = realPath.split("#", 2);
173
174            uri.resolveTemplate("path", split[0], false);
175            uri.fragment(split[1]);
176        } else {
177            uri.resolveTemplate("path", realPath, false);
178
179        }
180        return createResource(uri.build().toString());
181    }
182
183    @Override
184    public String asString(final Resource resource) {
185        final HashMap<String, String> values = new HashMap<>();
186
187        return asString(resource, values);
188    }
189
190    /**
191     * Convert the incoming Resource to a JCR path (but don't attempt to load the node).
192     *
193     * @param resource Jena Resource to convert
194     * @param values a map that will receive the matching URI template variables for future use.
195     * @return
196     */
197    private String asString(final Resource resource, final Map<String, String> values) {
198        if (uriTemplate.match(resource.getURI(), values) && values.containsKey("path")) {
199            String path = "/" + values.get("path");
200
201            final boolean metadata = path.endsWith("/" + FCR_METADATA);
202
203            if (metadata) {
204                path = replaceOnce(path, "/" + FCR_METADATA, EMPTY);
205            }
206
207            path = forward.convert(path);
208
209            if (path == null) {
210                return null;
211            }
212
213            try {
214                path = URLDecoder.decode(path, "UTF-8");
215            } catch (UnsupportedEncodingException e) {
216                LOGGER.debug("Unable to URL-decode path " + e + " as UTF-8", e);
217            }
218
219            if (path.isEmpty()) {
220                return "/";
221            }
222            return path;
223        }
224        return null;
225    }
226
227
228    private Node getNode(final String path) throws RepositoryException {
229        if (path.contains(FCR_VERSIONS)) {
230            final String[] split = path.split("/" + FCR_VERSIONS + "/", 2);
231            final String versionedPath = split[0];
232            final String versionAndPathIntoVersioned = split[1];
233            final String[] split1 = versionAndPathIntoVersioned.split("/", 2);
234            final String version = split1[0];
235
236            final String pathIntoVersioned;
237            if (split1.length > 1) {
238                pathIntoVersioned = split1[1];
239            } else {
240                pathIntoVersioned = "";
241            }
242
243            final Node node = getFrozenNodeByLabel(versionedPath, version);
244
245            if (pathIntoVersioned.isEmpty()) {
246                return node;
247            } else if (node != null) {
248                return node.getNode(pathIntoVersioned);
249            } else {
250                throw new PathNotFoundException("Unable to find versioned resource at " + path);
251            }
252        }
253        return session.getNode(path);
254    }
255
256    /**
257     * A private helper method that tries to look up frozen node for the given subject
258     * by a label.  That label may either be one that was assigned at creation time
259     * (and is a version label in the JCR sense) or a system assigned identifier that
260     * was used for versions created without a label.  The current implementation
261     * uses the JCR UUID for the frozen node as the system-assigned label.
262     */
263    private Node getFrozenNodeByLabel(final String baseResourcePath, final String label) {
264        try {
265            try {
266                final Node frozenNode = session.getNodeByIdentifier(label);
267
268            /*
269             * We found a node whose identifier is the "label" for the version.  Now
270             * we must do due dilligence to make sure it's a frozen node representing
271             * a version of the subject node.
272             */
273                final Property p = frozenNode.getProperty("jcr:frozenUuid");
274                if (p != null) {
275                    final Node subjectNode = session.getNode(baseResourcePath);
276                    if (p.getString().equals(subjectNode.getIdentifier())) {
277                        return frozenNode;
278                    }
279                }
280            /*
281             * Though a node with an id of the label was found, it wasn't the
282             * node we were looking for, so fall through and look for a labeled
283             * node.
284             */
285            } catch (final ItemNotFoundException ex) {
286            /*
287             * the label wasn't a uuid of a frozen node but
288             * instead possibly a version label.
289             */
290            }
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 String getPath(final FedoraResource resource) {
307        if (isFrozenNode.apply(resource)) {
308            try {
309
310                // the versioned resource we're in
311                final FedoraResource versionableFrozenResource = resource.getVersionedAncestor();
312
313                // the unfrozen equivalent for the versioned resource
314                final FedoraResource unfrozenVersionableResource = versionableFrozenResource.getUnfrozenResource();
315
316                // the identifier for this version (by default, the UUID for the versioned resource)
317                final String versionIdentifier = versionableFrozenResource.getNode().getIdentifier();
318
319                // the path to this resource within the versioning tree
320                final String pathWithinVersionable;
321
322                if (!resource.equals(versionableFrozenResource)) {
323                    pathWithinVersionable = getRelativePath(resource, versionableFrozenResource);
324                } else {
325                    pathWithinVersionable = "";
326                }
327
328                // and, finally, the path we want to expose in the URI
329                final String path = unfrozenVersionableResource.getPath()
330                        + "/" + FCR_VERSIONS
331                        + "/" + versionIdentifier
332                        + pathWithinVersionable;
333                return path.startsWith("/") ? path : "/" + path;
334            } catch (final RepositoryException e) {
335                throw new RepositoryRuntimeException(e);
336            }
337        }
338        return resource.getPath();
339    }
340
341    private static String getRelativePath(final FedoraResource child, final FedoraResource ancestor) {
342        return child.getPath().substring(ancestor.getPath().length());
343    }
344
345    /**
346     * Get only the resource path to this resource, before embedding it in a full URI
347     * @param resource
348     * @return
349     */
350    private String doBackwardPathOnly(final FedoraResource resource) {
351        String path = reverse.convert(getPath(resource));
352        if (path != null) {
353
354            if (resource instanceof NonRdfSourceDescription) {
355                path = path + "/" + FCR_METADATA;
356            }
357
358            if (path.endsWith(JCR_CONTENT)) {
359                path = replaceOnce(path, "/" + JCR_CONTENT, EMPTY);
360            }
361            return path;
362        }
363        throw new RuntimeException("Unable to process reverse chain for resource " + resource);
364    }
365
366
367    protected void resetTranslationChain() {
368        if (translationChain == null) {
369            translationChain = getTranslationChain();
370
371            final Converter<String,String> transactionIdentifierConverter = new TransactionIdentifierConverter(session);
372
373            @SuppressWarnings("unchecked")
374            final ImmutableList<Converter<String, String>> chain = copyOf(
375                    concat(newArrayList(transactionIdentifierConverter),
376                            translationChain));
377            setTranslationChain(chain);
378        }
379    }
380
381    private void setTranslationChain(final List<Converter<String, String>> chained) {
382
383        translationChain = chained;
384
385        for (final Converter<String, String> t : translationChain) {
386            forward = forward.andThen(t);
387        }
388        for (final Converter<String, String> t : Lists.reverse(translationChain)) {
389            reverse = reverse.andThen(t.reverse());
390        }
391    }
392
393
394    @SuppressWarnings("unchecked")
395    private static final List<Converter<String, String>> minimalTranslationChain =
396            newArrayList(
397                    (Converter<String, String>) new NamespaceConverter(),
398                    (Converter<String, String>) new HashConverter()
399            );
400
401    protected List<Converter<String,String>> getTranslationChain() {
402        final ApplicationContext context = getApplicationContext();
403        if (context != null) {
404            final List<Converter<String,String>> tchain =
405                    getApplicationContext().getBean("translationChain", List.class);
406            return tchain;
407        }
408        return minimalTranslationChain;
409    }
410
411    protected ApplicationContext getApplicationContext() {
412        return getCurrentWebApplicationContext();
413    }
414
415    /**
416     * Translate the current transaction into the identifier
417     */
418    class TransactionIdentifierConverter extends Converter<String, String> {
419        public static final String TX_PREFIX = "tx:";
420
421        private final Session session;
422
423        public TransactionIdentifierConverter(final Session session) {
424            this.session = session;
425        }
426
427        @Override
428        protected String doForward(final String path) {
429
430            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
431                throw new RepositoryRuntimeException("Path " + path
432                        + " is not in current transaction " +  getCurrentTransactionId(session));
433            }
434
435            return replaceOnce(path, txSegment(), EMPTY);
436        }
437
438        @Override
439        protected String doBackward(final String path) {
440            return txSegment() + path;
441        }
442
443        private String txSegment() {
444
445            final String txId = getCurrentTransactionId(session);
446
447            if (txId != null) {
448                return "/" + TX_PREFIX + txId;
449            }
450            return EMPTY;
451        }
452    }
453}