ResourceController.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.media.SchemaProperty;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.entrystore.Entry;
import org.entrystore.PrincipalManager;
import org.entrystore.rest.springboot.model.api.ListFilter;
import org.entrystore.rest.springboot.model.api.ModifyListResourceResponse;
import org.entrystore.rest.springboot.model.api.ResourceQuery;
import org.entrystore.rest.springboot.model.dto.CompletionState;
import org.entrystore.rest.springboot.model.dto.ResourceRepresentation;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityTooLargeException;
import org.entrystore.rest.springboot.service.EntryService;
import org.entrystore.rest.springboot.service.ResourceService;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.entrystore.rest.springboot.util.MultipartUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.InputStreamResource;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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 org.springframework.web.context.request.WebRequest;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Date;

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

@RestController
@RequiredArgsConstructor
public class ResourceController {

	private final EntryService entryService;
	private final ResourceService resourceService;

	@Value("${entrystore.http.allow-content-disposition-inline:true}")
	private boolean allowContentDispositionInline;

	@Operation(
			summary = "Returns a resource.",
			description = "Depending on the entry’s character the resource may be binary, JSON or RDF. See Knowledge Base for details.")
	@GetMapping(path = "/{context-id}/resource/{entry-id}")
	public ResponseEntity<Object> getResource(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) MediaType format,
			@RequestParam(required = false) MediaType rdfFormat,
			@RequestParam(required = false) String syndication,
			@RequestParam(required = false, defaultValue = "50") int feedSize,
			@RequestParam(name = "urltemplate", required = false) String urlTemplate,
			@RequestParam(name = "feedtitle", required = false) String feedTitle,
			@RequestParam(name = "lang", required = false, defaultValue = "en") String language,
			@RequestParam(required = false) String download,
			@ModelAttribute ListFilter listFilter,
			@RequestHeader(value = "Accept", required = false, defaultValue = GraphUtil.DEFAULT_RDF_MEDIA_TYPE) String acceptHeader,
			@RequestHeader(value = HttpHeaders.IF_RANGE, required = false) String ifRange,
			@Parameter(hidden = true) HttpServletResponse response,
			@Parameter(hidden = true) WebRequest webRequest
	) {
		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		ResourceQuery query = new ResourceQuery(format, rdfFormat, acceptHeader, syndication, language, feedSize,
				urlTemplate, feedTitle, listFilter);
		Date modified = entry.getModifiedDate();

		return switch (resourceService.getResourceRepresentation(entry, query)) {
			// Spring evaluates conditional requests only for 200; 5.x answered 304 for the empty representation too.
			case ResourceRepresentation.Empty _ -> ResponseEntity
					.status(conditionalStatus(webRequest, response, modified, HttpStatus.NO_CONTENT.value()))
					.headers(headers -> HttpUtil.setRevalidationHeaders(headers, response, modified, false))
					.build();
			case ResourceRepresentation.FileDownload file -> ResponseEntity
					.ok()
					.headers(buildFileDownloadResponseHeaders(file, download != null))
					.headers(headers -> HttpUtil.setRevalidationHeaders(headers, response, modified, false))
					.contentLength(file.file().length())
					// Spring answers Range for any Resource but InputStreamResource, without checking If-Range.
					.body(HttpUtil.ifRangeMatches(ifRange, modified)
							? new FileSystemResource(file.file())
							: new InputStreamResource(() -> new FileInputStream(file.file())));
			case ResourceRepresentation.TextBody text -> ResponseEntity
					.ok()
					.contentType(text.mediaType())
					// Graph and List resources follow the Accept header. The ETag is computed from the body, since
					// lists, groups, users, contexts and feeds embed other entries, which change without this one.
					.headers(headers -> HttpUtil.setContentRevalidationHeaders(headers, response, modified,
							text.body().getBytes(StandardCharsets.UTF_8), true))
					.body(text.body());
		};
	}

	@Operation(
			summary = "Sets a resource.",
			description = "Resource should be sent in the request body. Depending on the entry’s character the resource may be binary, " +
					"JSON or RDF. See Knowledge Base for details.")
	@io.swagger.v3.oas.annotations.parameters.RequestBody(
			content = @Content(
					mediaType = MediaType.ALL_VALUE,
					schema = @Schema(type = "string", format = "binary")))
	@PutMapping(
			path = "/{context-id}/resource/{entry-id}",
			consumes = "!multipart/form-data")
	public ResponseEntity<Void> setResource(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) String mimeType,
			@RequestParam(required = false) String textarea,
			@RequestHeader(value = "Content-Type", required = false) String contentType,
			@RequestHeader(value = HttpHeaders.CONTENT_DISPOSITION, required = false) String contentDisposition,
			@Parameter(hidden = true) HttpServletRequest request,
			HttpSession session
	) {

		String mediaType = normalizeMediaType(contentType);

		String filename = null;
		if (contentDisposition != null) {
			ContentDisposition disposition = ContentDisposition.parse(contentDisposition);
			filename = disposition.getFilename();
		}

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		CompletionState result;
		try {
			// Not request.getInputStream() here: Jetty answers Expect: 100-continue as soon as it is called.
			result = resourceService.setEntryResource(entry, request::getInputStream, request.getContentLengthLong(),
					mediaType, mimeType, filename, session.getId());
		} catch (EntityTooLargeException e) {
			HttpUtil.discardRejectedBody(request);
			throw e;
		}

		return buildSetResourceResponse(entry, result);
	}

	@Operation(
			summary = "Sets a resource.",
			description = "Resource should be sent as a file part of a multipart/form-data request. It may carry any " +
					"part name: a part named \"file\" is used when it carries a filename, otherwise the first part " +
					"carrying a filename, otherwise the first part carrying content. Depending on the entry’s character " +
					"the resource may be binary, JSON or RDF. See Knowledge Base for details.")
	@io.swagger.v3.oas.annotations.parameters.RequestBody(
			content = @Content(
					mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
					schema = @Schema(type = "object"),
					schemaProperties = {
							@SchemaProperty(
									name = "file",
									schema = @Schema(type = "string", format = "binary")),
							@SchemaProperty(
									name = "mimeType",
									schema = @Schema(type = "string"))}))
	@AcceptsMultipart
	@PutMapping(
			path = "/{context-id}/resource/{entry-id}",
			consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	public ResponseEntity<Void> setResourceMultipart(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@Parameter(hidden = true) MultipartHttpServletRequest request
	) {

		// Multipart resolution is lazy, so checking access first keeps unauthorized uploads from being parsed.
		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		entryService.checkEntryUserAccess(entry, PrincipalManager.AccessProperty.WriteResource);

		MultipartFile file = MultipartUtil.firstFilePart(request)
				.orElseThrow(() -> new BadRequestException("Multipart request contains no file part"));
		String mimeType = request.getParameter("mimeType");
		CompletionState result = resourceService.setEntryResourceMultipart(entry, file, mimeType);

		return buildSetResourceResponse(entry, result);
	}

	@Operation(
			summary = "Modifies a list resource.",
			// The description from current swagger state seems outdated/invalid
			description = "Among other functionality, fallback if PUT and DELETE cannot be used by the client.")
	@PostMapping(path = "/{context-id}/resource/{entry-id}")
	public ModifyListResourceResponse modifyListResource(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam String moveEntry,
			@RequestParam String fromList,
			@RequestParam(required = false) String removeAll
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		Entry movedEntry = resourceService.modifyListEntryResource(entry, moveEntry, fromList, removeAll != null);

		return new ModifyListResourceResponse(movedEntry.getEntryURI().toString());
	}

	@Operation(
			summary = "Imports a ZIP file resource (not yet implemented).")
	@PostMapping(
			path = "/{context-id}/resource/{entry-id}",
			consumes = "application/zip")
	public void importListResource(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestBody byte[] body
	) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		resourceService.importEntryResource(entry, body);
	}

	@Operation(
			summary = "Deletes a resource.")
	@DeleteMapping("/{context-id}/resource/{entry-id}")
	public ResponseEntity<Void> deleteResource(
			@PathVariable("context-id") String contextId,
			@PathVariable("entry-id") String entryId,
			@RequestParam(required = false) String proxy,
			@RequestParam(required = false) String recursive) {

		Entry entry = entryService.getEntryByContextIdAndEntryId(contextId, entryId);
		resourceService.deleteResource(entry, proxy, recursive != null);

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

	private HttpHeaders buildFileDownloadResponseHeaders(ResourceRepresentation.FileDownload file, boolean isDownload) {
		HttpHeaders headers = new HttpHeaders();
		headers.setContentType(file.mediaType());
		// Spring adds it only when it handles Range itself, not on the full answer to a mismatched If-Range.
		headers.set(HttpHeaders.ACCEPT_RANGES, "bytes");

		ContentDisposition.Builder disposition = !allowContentDispositionInline || isDownload
				? ContentDisposition.attachment()
				: ContentDisposition.inline();
		headers.setContentDisposition(disposition.filename(file.filename()).build());

		if (file.sha256Digest() != null) {
			headers.set("Digest", "sha-256=" + file.sha256Digest());
		}
		return headers;
	}

	/**
	 * Evaluates the conditional headers as Spring does for a 200, and returns the status it chose (304, or 412 for
	 * a failed If-Unmodified-Since), else {@code status}.
	 */
	private static int conditionalStatus(WebRequest webRequest, HttpServletResponse response, Date modified,
										 int status) {
		if (modified != null && webRequest.checkNotModified(
				HttpUtil.createStrongETag(Long.toString(modified.getTime())), modified.getTime())) {
			return response.getStatus();
		}
		return status;
	}

	private static ResponseEntity<Void> buildSetResourceResponse(Entry entry, CompletionState result) {
		ResponseEntity.BodyBuilder responseBuilder = ResponseEntity
				.status(mapCompletionStateToHttpStatus(result));

		if (result == CompletionState.CREATED) {
			responseBuilder
					.location(entry.getResourceURI());
		}
		if (result != CompletionState.ERROR) {
			responseBuilder
					.lastModified(entry.getModifiedDate().getTime());
		}

		return responseBuilder.build();
	}

	private static HttpStatus mapCompletionStateToHttpStatus(CompletionState state) {
		return switch (state) {
			case CREATED -> HttpStatus.CREATED;
			case UPDATED -> HttpStatus.NO_CONTENT;
			case OK -> HttpStatus.OK;
			default -> HttpStatus.INTERNAL_SERVER_ERROR;
		};
	}
}