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 com.google.common.collect.ImmutableList.of;
021import static java.util.Collections.singleton;
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.modeshape.FedoraResourceImpl.CONTAINER_WEBAC_ACL;
026import static org.fcrepo.kernel.modeshape.FedoraResourceImpl.LDPCV_TIME_MAP;
027import static org.fcrepo.kernel.api.FedoraTypes.FCR_ACL;
028import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
029import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS;
030import static org.fcrepo.kernel.api.RdfLexicon.FEDORA_DESCRIPTION;
031import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.getJcrSession;
032import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
033import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getClosestExistingAncestor;
034import static org.fcrepo.kernel.modeshape.utils.NamespaceTools.validatePath;
035import static org.slf4j.LoggerFactory.getLogger;
036import static org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext;
037
038import java.io.UnsupportedEncodingException;
039import java.net.URLDecoder;
040import java.util.ArrayList;
041import java.util.HashMap;
042import java.util.List;
043import java.util.Map;
044import java.util.regex.Matcher;
045import java.util.regex.Pattern;
046
047import javax.jcr.Node;
048import javax.jcr.PathNotFoundException;
049import javax.jcr.RepositoryException;
050import javax.jcr.Session;
051import javax.ws.rs.core.UriBuilder;
052
053import org.fcrepo.http.commons.session.HttpSession;
054import org.fcrepo.kernel.api.FedoraSession;
055import org.fcrepo.kernel.api.exception.IdentifierConversionException;
056import org.fcrepo.kernel.api.exception.InvalidResourceIdentifierException;
057import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
058import org.fcrepo.kernel.api.exception.TombstoneException;
059import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
060import org.fcrepo.kernel.api.models.FedoraResource;
061import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
062import org.fcrepo.kernel.modeshape.TombstoneImpl;
063import org.fcrepo.kernel.modeshape.identifiers.HashConverter;
064import org.fcrepo.kernel.modeshape.identifiers.NamespaceConverter;
065
066import org.apache.jena.rdf.model.Resource;
067import org.glassfish.jersey.uri.UriTemplate;
068import org.slf4j.Logger;
069import org.springframework.context.ApplicationContext;
070
071import com.google.common.base.Converter;
072import com.google.common.collect.Lists;
073
074/**
075 * Convert between Jena Resources and JCR Nodes using a JAX-RS UriBuilder to mediate the
076 * URI translation.
077 *
078 * @author cabeer
079 * @since 10/5/14
080 */
081public class HttpResourceConverter extends IdentifierConverter<Resource,FedoraResource> {
082
083    private static final Logger LOGGER = getLogger(HttpResourceConverter.class);
084
085    // Regex pattern which decomposes a http resource uri into components
086    // The first group determines if it is an fcr:metadata non-rdf source.
087    // The second group determines if the path is for a memento or timemap.
088    // The third group allows for a memento identifier.
089    // The fourth group for allows ACL.
090    // The fifth group allows for any hashed suffixes.
091    private final static Pattern FORWARD_COMPONENT_PATTERN = Pattern.compile(
092            ".*?(/" + FCR_METADATA + ")?(/" + FCR_VERSIONS + "(/\\d{14})?)?(/" + FCR_ACL + ")?(\\#\\w+)?$");
093
094    protected List<Converter<String, String>> translationChain;
095
096    private final FedoraSession session;
097    private final UriBuilder uriBuilder;
098
099    protected Converter<String, String> forward = identity();
100    protected Converter<String, String> reverse = identity();
101
102    private final UriTemplate uriTemplate;
103    private final boolean batch;
104
105    /**
106     * Create a new identifier converter within the given session with the given URI template
107     * @param session the session
108     * @param uriBuilder the uri builder
109     */
110    public HttpResourceConverter(final HttpSession session,
111                                 final UriBuilder uriBuilder) {
112
113        this.session = session.getFedoraSession();
114        this.uriBuilder = uriBuilder;
115        this.batch = session.isBatchSession();
116        this.uriTemplate = new UriTemplate(uriBuilder.toTemplate());
117
118        resetTranslationChain();
119    }
120
121    private UriBuilder uriBuilder() {
122        return UriBuilder.fromUri(uriBuilder.toTemplate());
123    }
124
125    @Override
126    protected FedoraResource doForward(final Resource resource) {
127        final Map<String, String> values = new HashMap<>();
128        final String path = asString(resource, values);
129        final Session jcrSession = getJcrSession(session);
130        try {
131            if (path != null) {
132                final Node node = getNode(path);
133
134                final boolean metadata = values.containsKey("path")
135                        && values.get("path").contains("/" + FCR_METADATA);
136
137                final FedoraResource fedoraResource = nodeConverter.convert(node);
138
139                if (!metadata && fedoraResource instanceof NonRdfSourceDescription) {
140                    return fedoraResource.getDescribedResource();
141                }
142                return fedoraResource;
143            }
144            throw new IdentifierConversionException("Asked to translate a resource " + resource
145                    + " that doesn't match the URI template");
146        } catch (final RepositoryException e) {
147            validatePath(jcrSession, path);
148
149            if ( e instanceof PathNotFoundException ) {
150                try {
151                    final Node preexistingNode = getClosestExistingAncestor(jcrSession, path);
152                    if (TombstoneImpl.hasMixin(preexistingNode)) {
153                        throw new TombstoneException(new TombstoneImpl(preexistingNode));
154                    }
155                } catch (final RepositoryException inner) {
156                    LOGGER.debug("Error checking for parent tombstones", inner);
157                }
158            }
159            throw new RepositoryRuntimeException(e);
160        }
161    }
162
163    @Override
164    protected Resource doBackward(final FedoraResource resource) {
165        return toDomain(doBackwardPathOnly(resource));
166    }
167
168    @Override
169    public boolean inDomain(final Resource resource) {
170        final Map<String, String> values = new HashMap<>();
171
172        return uriTemplate.match(resource.getURI(), values) && values.containsKey("path") ||
173            isRootWithoutTrailingSlash(resource);
174    }
175
176    @Override
177    public Resource toDomain(final String path) {
178
179        final String realPath;
180        if (path == null) {
181            realPath = "";
182        } else if (path.startsWith("/")) {
183            realPath = path.substring(1);
184        } else {
185            realPath = path;
186        }
187
188        final UriBuilder uri = uriBuilder();
189
190        if (realPath.contains("#")) {
191
192            final String[] split = realPath.split("#", 2);
193
194            uri.resolveTemplate("path", split[0], false);
195            uri.fragment(split[1]);
196        } else {
197            uri.resolveTemplate("path", realPath, false);
198
199        }
200        return createResource(uri.build().toString());
201    }
202
203    @Override
204    public String asString(final Resource resource) {
205        final Map<String, String> values = new HashMap<>();
206
207        return asString(resource, values);
208    }
209
210    /**
211     * Convert the incoming Resource to a JCR path (but don't attempt to load the node).
212     *
213     * @param resource Jena Resource to convert
214     * @param values a map that will receive the matching URI template variables for future use.
215     * @return String of JCR path
216     */
217    private String asString(final Resource resource, final Map<String, String> values) {
218        if (uriTemplate.match(resource.getURI(), values) && values.containsKey("path")) {
219            String path = "/" + values.get("path");
220
221            final Matcher matcher = FORWARD_COMPONENT_PATTERN.matcher(path);
222
223            if (matcher.matches()) {
224                final boolean metadata = matcher.group(1) != null;
225                final boolean versioning = matcher.group(2) != null;
226                final boolean webacAcl = matcher.group(4) != null;
227
228                if (versioning) {
229                    path = replaceOnce(path, "/" + FCR_VERSIONS, "/" + LDPCV_TIME_MAP);
230                }
231
232                if (metadata) {
233                    path = replaceOnce(path, "/" + FCR_METADATA, "/" + FEDORA_DESCRIPTION);
234                }
235
236                if (webacAcl) {
237                    path = replaceOnce(path, "/" + FCR_ACL, "/" + CONTAINER_WEBAC_ACL);
238                }
239            }
240
241            path = forward.convert(path);
242
243            if (path == null) {
244                return null;
245            }
246
247            try {
248                path = URLDecoder.decode(path, "UTF-8");
249            } catch (final UnsupportedEncodingException e) {
250                LOGGER.debug("Unable to URL-decode path " + e + " as UTF-8", e);
251            }
252
253            if (path.isEmpty()) {
254                return "/";
255            }
256
257            // Validate path
258            if (path.contains("//")) {
259                throw new InvalidResourceIdentifierException("Path contains empty element! " + path);
260            }
261            return path;
262        }
263
264        if (isRootWithoutTrailingSlash(resource)) {
265            return "/";
266        }
267
268        return null;
269    }
270
271
272    private Node getNode(final String path) throws RepositoryException {
273        try {
274            return getJcrSession(session).getNode(path);
275        } catch (final IllegalArgumentException ex) {
276            throw new InvalidResourceIdentifierException("Illegal path: " + path);
277        }
278    }
279
280    /**
281     * Get only the resource path to this resource, before embedding it in a full URI
282     * @param resource with desired path
283     * @return path
284     */
285    private String doBackwardPathOnly(final FedoraResource resource) {
286
287        final String path = reverse.convert(resource.getPath());
288        if (path == null) {
289            throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource);
290        }
291
292        return convertToExternalPath(path);
293    }
294
295    /**
296     * Converts internal path segments to their external formats.
297     * @param path the internal path
298     * @return the external path
299     */
300    public static  String convertToExternalPath(final String path) {
301        String newPath = replaceOnce(path, "/" + CONTAINER_WEBAC_ACL, "/" + FCR_ACL);
302
303        newPath = replaceOnce(newPath, "/" + LDPCV_TIME_MAP, "/" + FCR_VERSIONS);
304
305        newPath = replaceOnce(newPath, "/" + FEDORA_DESCRIPTION, "/" + FCR_METADATA);
306
307        return newPath;
308    }
309
310    protected void resetTranslationChain() {
311        if (translationChain == null) {
312            translationChain = getTranslationChain();
313            final List<Converter<String, String>> newChain =
314                    new ArrayList<>(singleton(new TransactionIdentifierConverter(session, batch)));
315            newChain.addAll(translationChain);
316            setTranslationChain(newChain);
317        }
318    }
319
320    private void setTranslationChain(final List<Converter<String, String>> chained) {
321
322        translationChain = chained;
323
324        for (final Converter<String, String> t : translationChain) {
325            forward = forward.andThen(t);
326        }
327        for (final Converter<String, String> t : Lists.reverse(translationChain)) {
328            reverse = reverse.andThen(t.reverse());
329        }
330    }
331
332
333    private static final List<Converter<String, String>> minimalTranslationChain =
334            of(new NamespaceConverter(), new HashConverter());
335
336    protected List<Converter<String,String>> getTranslationChain() {
337        final ApplicationContext context = getApplicationContext();
338        if (context != null) {
339            @SuppressWarnings("unchecked")
340            final List<Converter<String,String>> tchain =
341                    getApplicationContext().getBean("translationChain", List.class);
342            return tchain;
343        }
344        return minimalTranslationChain;
345    }
346
347    protected ApplicationContext getApplicationContext() {
348        return getCurrentWebApplicationContext();
349    }
350
351    /**
352     * Translate the current transaction into the identifier
353     */
354    static class TransactionIdentifierConverter extends Converter<String, String> {
355        public static final String TX_PREFIX = "tx:";
356
357        private final FedoraSession session;
358        private final boolean batch;
359
360        public TransactionIdentifierConverter(final FedoraSession session, final boolean batch) {
361            this.session = session;
362            this.batch = batch;
363        }
364
365        @Override
366        protected String doForward(final String path) {
367
368            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
369                throw new RepositoryRuntimeException("Path " + path
370                        + " is not in current transaction " +  session.getId());
371            }
372
373            return replaceOnce(path, txSegment(), EMPTY);
374        }
375
376        @Override
377        protected String doBackward(final String path) {
378            return txSegment() + path;
379        }
380
381        private String txSegment() {
382            return batch ? "/" + TX_PREFIX + session.getId() : EMPTY;
383        }
384    }
385
386    private boolean isRootWithoutTrailingSlash(final Resource resource) {
387        final Map<String, String> values = new HashMap<>();
388
389        return uriTemplate.match(resource.getURI() + "/", values) && values.containsKey("path") &&
390            values.get("path").isEmpty();
391    }
392}