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}