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