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.copyOf; 019import static com.google.common.collect.ImmutableList.of; 020import static com.google.common.collect.Iterables.concat; 021import static com.google.common.collect.Lists.newArrayList; 022import static com.hp.hpl.jena.rdf.model.ResourceFactory.createResource; 023import static org.apache.commons.lang.StringUtils.EMPTY; 024import static org.apache.commons.lang.StringUtils.replaceOnce; 025import static org.fcrepo.kernel.FedoraJcrTypes.FCR_METADATA; 026import static org.fcrepo.kernel.FedoraJcrTypes.FCR_VERSIONS; 027import static org.fcrepo.kernel.impl.identifiers.NodeResourceConverter.nodeConverter; 028import static org.fcrepo.kernel.impl.services.TransactionServiceImpl.getCurrentTransactionId; 029import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.getClosestExistingAncestor; 030import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.isFrozenNode; 031import static org.fcrepo.kernel.utils.NamespaceTools.validatePath; 032import static org.modeshape.jcr.api.JcrConstants.JCR_CONTENT; 033import static org.slf4j.LoggerFactory.getLogger; 034import static org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext; 035 036import java.io.UnsupportedEncodingException; 037import java.net.URLDecoder; 038import java.util.HashMap; 039import java.util.List; 040import java.util.Map; 041 042import javax.jcr.ItemNotFoundException; 043import javax.jcr.Node; 044import javax.jcr.PathNotFoundException; 045import javax.jcr.Property; 046import javax.jcr.RepositoryException; 047import javax.jcr.Session; 048import javax.jcr.version.VersionHistory; 049import javax.ws.rs.core.UriBuilder; 050 051import org.fcrepo.kernel.models.NonRdfSourceDescription; 052import org.fcrepo.kernel.models.FedoraResource; 053import org.fcrepo.kernel.exception.IdentifierConversionException; 054import org.fcrepo.kernel.exception.RepositoryRuntimeException; 055import org.fcrepo.kernel.exception.TombstoneException; 056import org.fcrepo.kernel.identifiers.IdentifierConverter; 057import org.fcrepo.kernel.impl.TombstoneImpl; 058import org.fcrepo.kernel.impl.identifiers.HashConverter; 059import org.fcrepo.kernel.impl.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.ImmutableList; 067import com.google.common.collect.Lists; 068import com.hp.hpl.jena.rdf.model.Resource; 069 070/** 071 * Convert between Jena Resources and JCR Nodes using a JAX-RS UriBuilder to mediate the 072 * URI translation. 073 * 074 * @author cabeer 075 * @since 10/5/14 076 */ 077public class HttpResourceConverter extends IdentifierConverter<Resource,FedoraResource> { 078 079 private static final Logger LOGGER = getLogger(HttpResourceConverter.class); 080 081 protected List<Converter<String, String>> translationChain; 082 083 private final Session session; 084 private final UriBuilder uriBuilder; 085 086 protected Converter<String, String> forward = identity(); 087 protected Converter<String, String> reverse = identity(); 088 089 private final UriTemplate uriTemplate; 090 091 /** 092 * Create a new identifier converter within the given session with the given URI template 093 * @param session the session 094 * @param uriBuilder the uri builder 095 */ 096 public HttpResourceConverter(final Session session, 097 final UriBuilder uriBuilder) { 098 099 this.session = session; 100 this.uriBuilder = uriBuilder; 101 this.uriTemplate = new UriTemplate(uriBuilder.toTemplate()); 102 103 resetTranslationChain(); 104 } 105 106 private UriBuilder uriBuilder() { 107 return UriBuilder.fromUri(uriBuilder.toTemplate()); 108 } 109 110 @Override 111 protected FedoraResource doForward(final Resource resource) { 112 final Map<String, String> values = new HashMap<>(); 113 final String path = asString(resource, values); 114 try { 115 if (path != null) { 116 final Node node = getNode(path); 117 118 final boolean metadata = values.containsKey("path") 119 && values.get("path").endsWith("/" + FCR_METADATA); 120 121 final FedoraResource fedoraResource = nodeConverter.convert(node); 122 123 if (!metadata && fedoraResource instanceof NonRdfSourceDescription) { 124 return ((NonRdfSourceDescription)fedoraResource).getDescribedResource(); 125 } 126 return fedoraResource; 127 } 128 throw new IdentifierConversionException("Asked to translate a resource " + resource 129 + " that doesn't match the URI template"); 130 } catch (final RepositoryException e) { 131 validatePath(session, path); 132 133 if ( e instanceof PathNotFoundException ) { 134 try { 135 final Node preexistingNode = getClosestExistingAncestor(session, path); 136 if (TombstoneImpl.hasMixin(preexistingNode)) { 137 throw new TombstoneException(new TombstoneImpl(preexistingNode)); 138 } 139 } catch (RepositoryException inner) { 140 LOGGER.debug("Error checking for parent tombstones", inner); 141 } 142 } 143 throw new RepositoryRuntimeException(e); 144 } 145 } 146 147 @Override 148 protected Resource doBackward(final FedoraResource resource) { 149 return toDomain(doBackwardPathOnly(resource)); 150 } 151 152 @Override 153 public boolean inDomain(final Resource resource) { 154 final Map<String, String> values = new HashMap<>(); 155 return uriTemplate.match(resource.getURI(), values) && values.containsKey("path"); 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 return path; 225 } 226 return null; 227 } 228 229 230 private Node getNode(final String path) throws RepositoryException { 231 if (path.contains(FCR_VERSIONS)) { 232 final String[] split = path.split("/" + FCR_VERSIONS + "/", 2); 233 final String versionedPath = split[0]; 234 final String versionAndPathIntoVersioned = split[1]; 235 final String[] split1 = versionAndPathIntoVersioned.split("/", 2); 236 final String version = split1[0]; 237 238 final String pathIntoVersioned; 239 if (split1.length > 1) { 240 pathIntoVersioned = split1[1]; 241 } else { 242 pathIntoVersioned = ""; 243 } 244 245 final Node node = getFrozenNodeByLabel(versionedPath, version); 246 247 if (pathIntoVersioned.isEmpty()) { 248 return node; 249 } else if (node != null) { 250 return node.getNode(pathIntoVersioned); 251 } else { 252 throw new PathNotFoundException("Unable to find versioned resource at " + path); 253 } 254 } 255 return session.getNode(path); 256 } 257 258 /** 259 * A private helper method that tries to look up frozen node for the given subject 260 * by a label. That label may either be one that was assigned at creation time 261 * (and is a version label in the JCR sense) or a system assigned identifier that 262 * was used for versions created without a label. The current implementation 263 * uses the JCR UUID for the frozen node as the system-assigned label. 264 */ 265 private Node getFrozenNodeByLabel(final String baseResourcePath, final String label) { 266 try { 267 final Node n = getNode(baseResourcePath, label); 268 269 if (n != null) { 270 return n; 271 } 272 273 /* 274 * Though a node with an id of the label was found, it wasn't the 275 * node we were looking for, so fall through and look for a labeled 276 * node. 277 */ 278 final VersionHistory hist = 279 session.getWorkspace().getVersionManager().getVersionHistory(baseResourcePath); 280 if (hist.hasVersionLabel(label)) { 281 LOGGER.debug("Found version for {} by label {}.", baseResourcePath, label); 282 return hist.getVersionByLabel(label).getFrozenNode(); 283 } 284 LOGGER.warn("Unknown version {} with label or uuid {}!", baseResourcePath, label); 285 throw new PathNotFoundException("Unknown version " + baseResourcePath 286 + " with label or uuid " + label); 287 } catch (final RepositoryException e) { 288 throw new RepositoryRuntimeException(e); 289 } 290 } 291 292 private Node getNode(final String baseResourcePath, final String label) throws RepositoryException { 293 try { 294 final Node frozenNode = session.getNodeByIdentifier(label); 295 296 /* 297 * We found a node whose identifier is the "label" for the version. Now 298 * we must do due diligence to make sure it's a frozen node representing 299 * a version of the subject node. 300 */ 301 final Property p = frozenNode.getProperty("jcr:frozenUuid"); 302 if (p != null) { 303 final Node subjectNode = session.getNode(baseResourcePath); 304 if (p.getString().equals(subjectNode.getIdentifier())) { 305 return frozenNode; 306 } 307 } 308 309 } catch (final ItemNotFoundException ex) { 310 /* 311 * the label wasn't a uuid of a frozen node but 312 * instead possibly a version label. 313 */ 314 } 315 return null; 316 } 317 318 private static String getPath(final FedoraResource resource) { 319 if (isFrozenNode.apply(resource)) { 320 try { 321 322 // the versioned resource we're in 323 final FedoraResource versionableFrozenResource = resource.getVersionedAncestor(); 324 325 // the unfrozen equivalent for the versioned resource 326 final FedoraResource unfrozenVersionableResource = versionableFrozenResource.getUnfrozenResource(); 327 328 // the identifier for this version (by default, the UUID for the versioned resource) 329 final String versionIdentifier = versionableFrozenResource.getNode().getIdentifier(); 330 331 // the path to this resource within the versioning tree 332 final String pathWithinVersionable; 333 334 if (!resource.equals(versionableFrozenResource)) { 335 pathWithinVersionable = getRelativePath(resource, versionableFrozenResource); 336 } else { 337 pathWithinVersionable = ""; 338 } 339 340 // and, finally, the path we want to expose in the URI 341 final String path = unfrozenVersionableResource.getPath() 342 + "/" + FCR_VERSIONS 343 + "/" + versionIdentifier 344 + pathWithinVersionable; 345 return path.startsWith("/") ? path : "/" + path; 346 } catch (final RepositoryException e) { 347 throw new RepositoryRuntimeException(e); 348 } 349 } 350 return resource.getPath(); 351 } 352 353 private static String getRelativePath(final FedoraResource child, final FedoraResource ancestor) { 354 return child.getPath().substring(ancestor.getPath().length()); 355 } 356 357 /** 358 * Get only the resource path to this resource, before embedding it in a full URI 359 * @param resource 360 * @return 361 */ 362 private String doBackwardPathOnly(final FedoraResource resource) { 363 String path = reverse.convert(getPath(resource)); 364 if (path != null) { 365 366 if (resource instanceof NonRdfSourceDescription) { 367 path = path + "/" + FCR_METADATA; 368 } 369 370 if (path.endsWith(JCR_CONTENT)) { 371 path = replaceOnce(path, "/" + JCR_CONTENT, EMPTY); 372 } 373 return path; 374 } 375 throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource); 376 } 377 378 379 protected void resetTranslationChain() { 380 if (translationChain == null) { 381 translationChain = getTranslationChain(); 382 383 final Converter<String,String> transactionIdentifierConverter = new TransactionIdentifierConverter(session); 384 385 @SuppressWarnings("unchecked") 386 final ImmutableList<Converter<String, String>> chain = copyOf( 387 concat(newArrayList(transactionIdentifierConverter), 388 translationChain)); 389 setTranslationChain(chain); 390 } 391 } 392 393 private void setTranslationChain(final List<Converter<String, String>> chained) { 394 395 translationChain = chained; 396 397 for (final Converter<String, String> t : translationChain) { 398 forward = forward.andThen(t); 399 } 400 for (final Converter<String, String> t : Lists.reverse(translationChain)) { 401 reverse = reverse.andThen(t.reverse()); 402 } 403 } 404 405 406 private static final List<Converter<String, String>> minimalTranslationChain = of( 407 new NamespaceConverter(), (Converter<String, String>) new HashConverter() 408 ); 409 410 protected List<Converter<String,String>> getTranslationChain() { 411 final ApplicationContext context = getApplicationContext(); 412 if (context != null) { 413 final List<Converter<String,String>> tchain = 414 getApplicationContext().getBean("translationChain", List.class); 415 return tchain; 416 } 417 return minimalTranslationChain; 418 } 419 420 protected ApplicationContext getApplicationContext() { 421 return getCurrentWebApplicationContext(); 422 } 423 424 /** 425 * Translate the current transaction into the identifier 426 */ 427 static class TransactionIdentifierConverter extends Converter<String, String> { 428 public static final String TX_PREFIX = "tx:"; 429 430 private final Session session; 431 432 public TransactionIdentifierConverter(final Session session) { 433 this.session = session; 434 } 435 436 @Override 437 protected String doForward(final String path) { 438 439 if (path.contains(TX_PREFIX) && !path.contains(txSegment())) { 440 throw new RepositoryRuntimeException("Path " + path 441 + " is not in current transaction " + getCurrentTransactionId(session)); 442 } 443 444 return replaceOnce(path, txSegment(), EMPTY); 445 } 446 447 @Override 448 protected String doBackward(final String path) { 449 return txSegment() + path; 450 } 451 452 private String txSegment() { 453 454 final String txId = getCurrentTransactionId(session); 455 456 if (txId != null) { 457 return "/" + TX_PREFIX + txId; 458 } 459 return EMPTY; 460 } 461 } 462}