MetadataController.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.controller;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.entrystore.Entry;
import org.entrystore.rest.springboot.model.api.MetadataType;
import org.entrystore.rest.springboot.model.dto.MetadataResult;
import org.entrystore.rest.springboot.service.EntryService;
import org.entrystore.rest.springboot.service.MetadataService;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static org.entrystore.rest.springboot.util.HttpUtil.determineMediaType;
@Slf4j
@RestController
@RequiredArgsConstructor
public class MetadataController {
private static final RDFFormat RDFJSON_WITH_APPLICATION_JSON
= new RDFFormat("RDF/JSON", List.of("application/json"), StandardCharsets.UTF_8, List.of("json"),
SimpleValueFactory.getInstance().createIRI("http://www.w3.org/ns/formats/RDF_JSON"), false, true, false);
private final MetadataService metadataService;
private final EntryService entryService;
@Operation(
summary = "Returns an entry's metadata graph.",
description = "The {type} path segment selects the entry's local metadata, its cached external metadata " +
"or the merged graph of both. The RDF format follows the 'format' parameter, then the Accept " +
"header. 'graphQuery' filters the graph; 'recursive' lists traversal profiles or predicates to " +
"follow up to 'depth' levels within 'scope' (when given, {type} and 'rev' are ignored and the " +
"traversal merges local and cached external metadata); 'rev' selects a revision of the local " +
"metadata; 'download' answers with Content-Disposition: attachment.")
@GetMapping(path = "/{context-id}/{type:metadata|cached-external-metadata|merged-metadata}/{entry-id}")
public ResponseEntity<String> getMetadata(
@PathVariable("context-id") String contextId,
@PathVariable("type") MetadataType metadataType,
@PathVariable("entry-id") String entryId,
@RequestParam(required = false) MediaType format,
@RequestParam(required = false) String graphQuery,
@RequestParam(required = false, defaultValue = "10") Integer depth,
@RequestParam(required = false) String recursive,
@RequestParam(required = false) String scope,
@RequestParam(name = "rev", required = false) String revision,
@RequestParam(required = false) String download,
@RequestHeader(value = "Accept", required = false, defaultValue = GraphUtil.DEFAULT_RDF_MEDIA_TYPE) String acceptHeader
) {
String mediaType = GraphUtil.resolveRdfMediaType(format, acceptHeader);
Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
MetadataResult metadataResult = metadataService.getMetadata(entry, metadataType, mediaType, graphQuery, depth, recursive, scope, revision);
Date modificationDate = metadataResult.lastModified() != null
? metadataResult.lastModified()
: getModificationDate(entry, metadataType);
HttpHeaders headers = buildResponseHeaders(entry, mediaType, download != null);
HttpUtil.setLastModifiedAndETag(headers, modificationDate);
return new ResponseEntity<>(metadataResult.serializedGraph(), headers, HttpStatus.OK);
}
@Operation(
summary = "Sets an entry's metadata graph.",
description = "Replaces the entry's local or cached external metadata with the graph in the request " +
"body; the merged graph is read-only. 'rev' is not supported on writes: with provenance enabled " +
"the request fails, without it the parameter is ignored and the current graph is replaced.")
@PutMapping(path = "/{context-id}/{type:metadata|cached-external-metadata|merged-metadata}/{entry-id}")
public ResponseEntity<Void> setMetadata(
@PathVariable("context-id") String contextId,
@PathVariable("type") MetadataType metadataType,
@PathVariable("entry-id") String entryId,
@RequestParam(required = false) MediaType format,
@RequestParam(name = "rev", required = false) String revision,
@RequestHeader("Content-Type") String contentType,
@RequestBody String body
) {
String mediaType = GraphUtil.validateRdfMediaType(
determineMediaType(format, contentType), HttpStatus.UNSUPPORTED_MEDIA_TYPE);
Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
Model deserializedGraph = GraphUtil.deserializeGraph(body, mediaType);
metadataService.setEntryMetadata(entry, metadataType, deserializedGraph, revision);
Date modificationDate = getModificationDate(entry, metadataType);
return HttpUtil.updateResponseWithModificationDateAndETag(
ResponseEntity.noContent(),
modificationDate)
.build();
}
@Operation(
summary = "Deletes an entry's metadata graph.",
description = "Clears the entry's local or cached external metadata graph; the merged graph cannot be " +
"deleted. 'rev' is not supported on writes: with provenance enabled the request fails, without " +
"it the parameter is ignored and the current graph is replaced.")
@DeleteMapping(path = "/{context-id}/{type:metadata|cached-external-metadata|merged-metadata}/{entry-id}")
public ResponseEntity<Void> deleteMetadata(
@PathVariable("context-id") String contextId,
@PathVariable("type") MetadataType metadataType,
@PathVariable("entry-id") String entryId,
@RequestParam(name = "rev", required = false) String revision) {
Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
metadataService.setEntryMetadata(entry, metadataType, new LinkedHashModel(), revision);
return ResponseEntity.noContent().build();
}
private static HttpHeaders buildResponseHeaders(Entry entry, String mediaType, boolean isDownload) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(mediaType));
// set file name
String fileName = entry.getFilename();
if (fileName == null) {
fileName = entry.getId();
}
fileName += "." + getFileExtensionForMediaType(mediaType);
ContentDisposition contentDisposition;
if (isDownload) {
contentDisposition = ContentDisposition.attachment().filename(fileName).build();
} else {
contentDisposition = ContentDisposition.inline().filename(fileName).build();
}
headers.setContentDisposition(contentDisposition);
return headers;
}
private static String getFileExtensionForMediaType(String mt) {
Optional<RDFFormat> rdfFormat = RDFFormat.matchMIMEType(mt, Arrays.asList(
RDFFormat.RDFXML,
RDFFormat.NTRIPLES,
RDFFormat.TURTLE,
RDFFormat.N3,
RDFFormat.TRIX,
RDFFormat.TRIG,
RDFFormat.BINARY,
RDFFormat.NQUADS,
RDFFormat.JSONLD,
RDFFormat.RDFJSON,
RDFFormat.RDFA,
RDFJSON_WITH_APPLICATION_JSON)
);
if (rdfFormat.isPresent() && rdfFormat.get().getDefaultFileExtension() != null) {
return rdfFormat.get().getDefaultFileExtension();
}
return "rdf";
}
private static Date getModificationDate(Entry entry, MetadataType metadataType) {
return switch (metadataType) {
case LOCAL_METADATA -> entry.getModifiedDate();
case CACHED_EXTERNAL_METADATA -> entry.getExternalMetadataCacheDate();
case MERGED_METADATA -> latest(entry.getExternalMetadataCacheDate(), entry.getModifiedDate());
};
}
private static Date latest(Date... dates) {
return Arrays.stream(dates)
.filter(Objects::nonNull)
.max(Date::compareTo)
.orElse(null);
}
}