OslcProviderController.java
package com.taxonomy.interop.controller;
import com.taxonomy.exchange.OslcRdf;
import com.taxonomy.exchange.ExchangeFormatException;
import com.taxonomy.exchange.ReqifExchangeCodec;
import com.taxonomy.interop.IntegrationProblem;
import com.taxonomy.interop.oslc.OslcProviderService;
import com.taxonomy.workspace.service.WorkspaceResolver;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.util.*;
@RestController
@RequestMapping("/oslc/scopes/{scope}")
public class OslcProviderController {
private final OslcProviderService service;
private final WorkspaceResolver resolver;
public OslcProviderController(OslcProviderService service, WorkspaceResolver resolver) { this.service = service; this.resolver = resolver; }
@GetMapping("/catalog") public ResponseEntity<byte[]> catalog(@PathVariable String scope, HttpServletRequest request) { return response(scope, request, (c, links) -> service.catalog(c, links)); }
@GetMapping("/projects/{project}/service") public ResponseEntity<byte[]> provider(@PathVariable String scope, @PathVariable long project, HttpServletRequest request) { return response(scope, request, (c, links) -> service.service(c, project, links)); }
@GetMapping("/projects/{project}/requirements") public ResponseEntity<byte[]> query(@PathVariable String scope, @PathVariable long project,
@RequestParam(defaultValue="0") int page, @RequestParam(name="oslc.pageSize", defaultValue="50") int size, HttpServletRequest request) {
return response(scope, request, (c, links) -> service.query(c, project, page, size, links));
}
@GetMapping("/projects/{project}/requirements/{requirement}") public ResponseEntity<byte[]> requirement(@PathVariable String scope, @PathVariable long project, @PathVariable long requirement, HttpServletRequest request) {
return response(scope, request, (c, links) -> service.requirement(c, project, requirement, null, links));
}
@GetMapping("/projects/{project}/requirements/{requirement}/versions/{version}") public ResponseEntity<byte[]> version(@PathVariable String scope, @PathVariable long project, @PathVariable long requirement, @PathVariable long version, HttpServletRequest request) {
return response(scope, request, (c, links) -> service.requirement(c, project, requirement, version, links));
}
@GetMapping("/shapes/requirement") public ResponseEntity<byte[]> shape(@PathVariable String scope, HttpServletRequest request) { return response(scope, request, (c, links) -> service.shape(links)); }
@GetMapping("/configurations/current") public ResponseEntity<byte[]> configuration(@PathVariable String scope, HttpServletRequest request) {
return response(scope, request, (c, links) -> service.configuration(c, links));
}
@GetMapping("/architecture/versions/{commit}") public ResponseEntity<byte[]> architecture(@PathVariable String scope, @PathVariable String commit, HttpServletRequest request) {
return response(scope, request, (c, links) -> { try { return service.architectureVersion(c, commit, links); } catch (IOException failure) { throw new IntegrationProblem("VERSION_UNAVAILABLE", 503, "Architecture version is temporarily unavailable"); } });
}
private interface Resource { OslcRdf read(com.taxonomy.workspace.service.RepositoryContext context, OslcProviderService.Links links); }
private ResponseEntity<byte[]> response(String scope, HttpServletRequest request, Resource resource) {
var context = resolver.resolveCurrentRepositoryContext(); service.authorize(context, scope);
if (request.getHeader("OSLC-Core-Version") != null && !request.getHeader("OSLC-Core-Version").equals("3.0")) throw new IllegalArgumentException("Unsupported OSLC Core version");
// Spring Security's form token is transport metadata, never an OSLC query or resource field.
for (String name : request.getParameterMap().keySet()) if (!Set.of("repositoryId", "workspaceId", "branch", "page", "oslc.pageSize", "oslc.paging", "_csrf").contains(name))
throw new IllegalArgumentException("OSLC query option is outside the declared read-only profile");
String base = ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString() + "/oslc/scopes/" + scope;
OslcProviderService.Links links = path -> UriComponentsBuilder.fromUriString(base + path)
.queryParam("repositoryId", context.repositoryId()).queryParam("workspaceId", context.workspaceId()).queryParam("branch", context.branch()).build().encode().toUriString();
String configuration = links.uri("/configurations/current");
if (request.getHeader("Configuration-Context") != null && !configuration.equals(request.getHeader("Configuration-Context")))
throw new IntegrationProblem("CONFIGURATION_UNAVAILABLE", 404, "Requested configuration is not available in this scope");
String type = negotiate(request.getHeader(HttpHeaders.ACCEPT)); OslcRdf graph = resource.read(context, links);
byte[] body = switch (type) { case "text/turtle" -> graph.turtle(); case "application/ld+json" -> graph.jsonLd(); default -> graph.xml(); };
String etag = "\"" + ReqifExchangeCodec.digest(body) + "\"";
if (request.getHeader(HttpHeaders.IF_MATCH) != null && !matches(request.getHeader(HttpHeaders.IF_MATCH), etag, false))
throw new IntegrationProblem("RESOURCE_VERSION_CHANGED", 412, "Resource no longer has the expected version");
boolean unchanged = matches(request.getHeader(HttpHeaders.IF_NONE_MATCH), etag, true);
var response = ResponseEntity.status(unchanged ? HttpStatus.NOT_MODIFIED : HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, type).header("OSLC-Core-Version", "3.0").header(HttpHeaders.ETAG, etag)
.header("Configuration-Context", configuration)
.header(HttpHeaders.VARY, "Accept, Authorization, Configuration-Context").header(HttpHeaders.CACHE_CONTROL, "private, no-cache").header("X-Content-Type-Options", "nosniff");
return response.body(unchanged ? null : body);
}
static boolean matches(String condition, String etag, boolean weak) {
if (condition == null) return false;
for (String token : condition.split(",")) {
String value = token.strip();
if (weak && value.startsWith("W/")) value = value.substring(2);
if (value.equals("*") || value.equals(etag)) return true;
}
return false;
}
public static String negotiate(String accept) {
List<MediaType> requested = accept == null ? List.of(MediaType.ALL) : MediaType.parseMediaTypes(accept);
String best = null; double quality = 0;
for (String supported : List.of("application/rdf+xml", "text/turtle", "application/ld+json")) {
MediaType representation = MediaType.parseMediaType(supported), match = null;
int specificity = -1;
for (MediaType candidate : requested) if (candidate.isCompatibleWith(representation)) {
int rank = candidate.isWildcardType() ? 0 : candidate.isWildcardSubtype() ? 1 : 2;
if (rank > specificity || rank == specificity && match != null && candidate.getQualityValue() > match.getQualityValue()) {
specificity = rank; match = candidate;
}
}
if (match != null && match.getQualityValue() > quality) { quality = match.getQualityValue(); best = supported; }
}
if (best != null) return best;
throw new IntegrationProblem("UNSUPPORTED_REPRESENTATION", 406, "Supported representations are RDF/XML, Turtle and JSON-LD");
}
// Failures use bounded JSON regardless of the requested RDF representation.
@ExceptionHandler(IntegrationProblem.class) public ResponseEntity<Map<String, String>> problem(IntegrationProblem failure) { return ResponseEntity.status(failure.status()).contentType(MediaType.APPLICATION_JSON).body(Map.of("code", failure.code(), "message", failure.getMessage())); }
@ExceptionHandler(ExchangeFormatException.class) public ResponseEntity<Map<String, String>> format(ExchangeFormatException failure) {
return ResponseEntity.unprocessableContent().contentType(MediaType.APPLICATION_JSON)
.body(Map.of("code", failure.code(), "message", failure.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<Map<String, String>> invalid() { return ResponseEntity.badRequest().contentType(MediaType.APPLICATION_JSON).body(Map.of("code", "UNSUPPORTED_OSLC_REQUEST", "message", "Request is outside the declared OSLC profile")); }
}