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