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