EntryController.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 io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.PrincipalManager.AccessProperty;
import org.entrystore.rest.springboot.model.api.GetEntryNameResponse;
import org.entrystore.rest.springboot.model.api.GetEntryResponse;
import org.entrystore.rest.springboot.model.api.ListFilter;
import org.entrystore.rest.springboot.model.api.SetEntryNameRequestBody;
import org.entrystore.rest.springboot.model.exception.DataConflictException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.service.EntryService;
import org.entrystore.rest.springboot.util.EntryMediaTypeResolver;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;

import java.util.Map;

import static org.entrystore.rest.springboot.util.HttpUtil.determineMediaType;

@Slf4j
@RestController
@RequiredArgsConstructor
public class EntryController {

	private static final String REDUCED_INFO_DESCRIPTION = "Callers who may read neither the entry's metadata nor " +
			"its resource get only the types, the URIs of the entry's parts, the dates and the home context.";

	private final EntryService entryService;
	private final ObjectMapper objectMapper;

	@Operation(
			summary = "Returns the entry information.",
			description = "Returns an RDF graph unless application/json is requested in which case the JSON-structure " +
					"as specified in the response body is used. " + REDUCED_INFO_DESCRIPTION)
	@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetEntryResponse.class)))
	@GetMapping(path = "/{context-id}/entry/{entry-id}", produces = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<byte[]> getEntryInJsonFormat(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) MediaType rdfFormat,
			@RequestParam(required = false) String includeAll,
			@ModelAttribute ListFilter listFilter,
			@Parameter(hidden = true) HttpServletResponse response
	) {
		String mediaType = rdfFormat != null ? GraphUtil.validateRdfMediaType(rdfFormat.toString()) : null;
		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		boolean mayReadNothing = entryService.mayReadNothing(entry);
		GetEntryResponse body = entryService.getEntryInJsonFormat(entry, mediaType, includeAll != null, listFilter,
				mayReadNothing);
		// Serialized here rather than by the message converter, so that the ETag can be computed from the bytes sent
		byte[] json = objectMapper.writeValueAsBytes(body);
		return ResponseEntity.ok()
				.contentType(MediaType.APPLICATION_JSON)
				.headers(headers -> {
					if (jsonEmbedsOtherData(entry, includeAll != null)) {
						HttpUtil.setContentRevalidationHeaders(headers, response, entry.getModifiedDate(), json, true);
					} else {
						setDateRevalidationHeaders(headers, response, entry, mayReadNothing);
					}
				})
				.body(json);
	}

	/**
	 * Sets the revalidation headers with the ETag from the entry's modification date, which differs for a caller who
	 * gets the reduced entry information, see {@link HttpUtil#setReducedRevalidationHeaders}.
	 */
	private static void setDateRevalidationHeaders(HttpHeaders headers, HttpServletResponse response, Entry entry,
												   boolean mayReadNothing) {
		if (mayReadNothing) {
			HttpUtil.setReducedRevalidationHeaders(headers, response, entry.getModifiedDate(), true);
		} else {
			HttpUtil.setRevalidationHeaders(headers, response, entry.getModifiedDate(), true);
		}
	}

	/**
	 * Whether the entry JSON carries data that can change while the entry's modification date does not: with
	 * {@code includeAll} the caller's rights, the relations and the members of a list, group or user, and for a
	 * context its quota fill level. Its ETag is then computed from the JSON instead of that date.
	 */
	private static boolean jsonEmbedsOtherData(Entry entry, boolean includeAll) {
		GraphType graphType = entry.getGraphType();
		return includeAll || graphType == GraphType.Context || graphType == GraphType.SystemContext;
	}

	@Operation(
			summary = "Returns the entry information.",
			description = "Returns an RDF graph unless application/json is requested in which case the JSON-structure " +
					"as specified in the response body is used. The 'format' parameter takes precedence over the " +
					"Accept header; without either, or for a wildcard Accept header, the graph is RDF/XML. " +
					REDUCED_INFO_DESCRIPTION)
	@GetMapping(path = "/{context-id}/entry/{entry-id}", produces = {"application/rdf+xml", "text/n3", "text/rdf+n3",
			"text/turtle", "application/trix", "application/n-triples", "application/trig", "application/ld+json",
			"application/rdf+json"})
	public ResponseEntity<String> getEntryInRdfFormat(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@Parameter(hidden = true) HttpServletRequest request,
			@Parameter(hidden = true) HttpServletResponse response
	) throws HttpMediaTypeNotAcceptableException {
		// Return ResponseEntity instead of String to control the response Content-Type. Spring MVC would otherwise
		// echo back the client's Accept type (text/rdf+n3) as the response Content-Type, but we respond with
		// the normalized form (text/n3) since text/rdf+n3 is a non-standard legacy N3 MIME type.
		String mediaType = GraphUtil.validateRdfMediaType(EntryMediaTypeResolver.resolve(request).toString());
		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		boolean mayReadNothing = entryService.mayReadNothing(entry);
		String body = entryService.getEntryInRdfFormat(entry, mediaType, mayReadNothing);
		return ResponseEntity.ok()
				.contentType(MediaType.parseMediaType(mediaType))
				.headers(headers -> setDateRevalidationHeaders(headers, response, entry, mayReadNothing))
				.body(body);
	}

	@Operation(
			summary = "Returns the headers of the entry information.",
			description = "Answers with the headers a GET would send, including Last-Modified, without serializing " +
					"the entry. The ETag is omitted where the GET computes it from the JSON: with includeAll, and " +
					"for contexts.")
	@RequestMapping(path = "/{context-id}/entry/{entry-id}", method = RequestMethod.HEAD)
	public ResponseEntity<Void> headEntry(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@Parameter(hidden = true) HttpServletRequest request,
			@Parameter(hidden = true) HttpServletResponse response
	) throws HttpMediaTypeNotAcceptableException {
		// The GET is served by the JSON handler for any application/json, parameters included, else by the RDF one.
		MediaType negotiated = EntryMediaTypeResolver.resolve(request);
		boolean json = MediaType.APPLICATION_JSON.equalsTypeAndSubtype(negotiated);
		MediaType contentType = json
				? MediaType.APPLICATION_JSON
				: MediaType.parseMediaType(GraphUtil.validateRdfMediaType(negotiated.toString()));
		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		return ResponseEntity.ok()
				.contentType(contentType)
				.headers(headers -> {
					if (json && jsonEmbedsOtherData(entry, request.getParameter("includeAll") != null)) {
						HttpUtil.setRevalidationHeaders(headers, response, entry.getModifiedDate(), true);
						// the GET's ETag is computed from the JSON, which HEAD does not build
						headers.remove(HttpHeaders.ETAG);
					} else {
						setDateRevalidationHeaders(headers, response, entry, entryService.mayReadNothing(entry));
					}
				})
				.build();
	}

	@Operation(
			summary = "Sets the entry information.",
			description = "Overrides entry data with data in the request body.")
	@PutMapping(path = "/{context-id}/entry/{entry-id}")
	public ResponseEntity<Void> modifyEntry(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) MediaType format,
			@RequestParam(required = false) String applyACLtoChildren,
			@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);
		Entry modifiedEntry = entryService.modifyEntry(entry, body, mediaType, applyACLtoChildren != null);

		return HttpUtil.updateResponseWithModificationDateAndETag(
						ResponseEntity.noContent(),
						modifiedEntry.getModifiedDate())
				.build();
	}

	@Operation(
			summary = "Deletes the entry.",
			description = "Deletes given entry. If parameter 'recursive' is set then also deletes all its children.")
	@DeleteMapping(path = "/{context-id}/entry/{entry-id}", produces = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<Void> deleteEntry(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) String recursive
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		entryService.deleteEntry(entry, recursive != null);

		return ResponseEntity
				.noContent()
				.build();
	}

	@Operation(summary = "Returns the entry's name (alias).")
	@GetMapping(path = "/{context-id}/entry/{entry-id}/name", produces = MediaType.APPLICATION_JSON_VALUE)
	public GetEntryNameResponse getEntryName(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		String name = entryService.getEntryName(entry);

		if (name == null) {
			throw new EntityNotFoundException("Entry with id '" + entry.getId() + "' has no name set");
		}

		return new GetEntryNameResponse(name);
	}

	@Operation(summary = "Sets the entry's name (alias).")
	@PutMapping(path = "/{context-id}/entry/{entry-id}/name", produces = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<Void> setEntryName(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestBody SetEntryNameRequestBody body
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		boolean success = entryService.setEntryName(entry, body.name());

		if (!success) {
			throw new DataConflictException("Unable to set new name for Entry with id '" + entry.getId() + "'");
		}

		return HttpUtil.updateResponseWithModificationDateAndETag(
						ResponseEntity.noContent(),
						entry.getModifiedDate())
				.build();
	}

	@Operation(summary = "Returns entry's index")
	@GetMapping(path = "/{context-id}/entry/{entry-id}/index", produces = MediaType.APPLICATION_JSON_VALUE)
	public Map<String, Object> getEntryIndex(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		entryService.checkEntryUserAccess(entry, AccessProperty.Administer);

		return entryService.getEntryIndex(entry);
	}
}