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