ResourceService.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.service;

import lombok.RequiredArgsConstructor;
import org.apache.commons.io.function.IOSupplier;
import org.eclipse.rdf4j.model.Model;
import org.entrystore.Entry;
import org.entrystore.EntryType;
import org.entrystore.GraphType;
import org.entrystore.PrincipalManager;
import org.entrystore.Resource;
import org.entrystore.ResourceType;
import org.entrystore.impl.RDFResource;
import org.entrystore.impl.StringResource;
import org.entrystore.repository.RepositoryException;
import org.entrystore.rest.springboot.model.api.ListFilter;
import org.entrystore.rest.springboot.model.api.ResourceQuery;
import org.entrystore.rest.springboot.model.dto.CompletionState;
import org.entrystore.rest.springboot.model.dto.RenderedFeed;
import org.entrystore.rest.springboot.model.dto.ResourceRepresentation;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.model.exception.RedirectSeeOtherException;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
 * Entry point for the {@code /{context-id}/resource/{entry-id}} operations. Dispatches on the entry's graph type
 * to {@link ListResourceService}, {@link FileResourceService}, {@link UserService} and {@link ProxyService}, and
 * keeps the String and Graph/Pipeline handling that is small enough not to need a home of its own.
 */
@Service
@RequiredArgsConstructor
public class ResourceService {

	private static final String EMPTY_REPRESENTATION = "";

	private final ResourceSerializationService resourceSerializationService;
	private final PrincipalManager principalManager;
	private final SyndicationService syndicationService;
	private final ListResourceService listResourceService;
	private final FileResourceService fileResourceService;
	private final UserService userService;
	private final ProxyService proxyService;
	private final ContextService contextService;

	/**
	 * Chooses the representation a GET on the resource URI answers with. A syndication request is served before
	 * the entry's own type is considered. May throw {@code RedirectSeeOtherException} for named resources.
	 * A context's entry IDs are listed to admins only, as by {@code GET /{context-id}}.
	 */
	public ResourceRepresentation getResourceRepresentation(Entry entry, ResourceQuery query) {
		if (query.syndication() != null) {
			RenderedFeed feed = syndicationService.renderFeed(entry, query.syndication(), query.language(),
					query.feedSize(), query.urlTemplate(), query.feedTitle());
			return new ResourceRepresentation.TextBody(feed.xml(), feed.mediaType());
		}

		EntryType entryType = entry.getEntryType();
		GraphType graphType = entry.getGraphType();

		if (entryType == EntryType.Local && graphType == GraphType.None) {
			File file = fileResourceService.dataFileForDownload(entry);
			if (file == null) {
				return new ResourceRepresentation.Empty();
			}
			String filename = Objects.requireNonNullElse(entry.getFilename(), entry.getId());
			return new ResourceRepresentation.FileDownload(file, fileResourceService.mediaTypeForDownload(entry),
					filename, resourceSerializationService.readDigest(entry));
		}

		if (entryType == EntryType.Local && graphType == GraphType.String) {
			// A String resource without a rdf:value has no text; the answer is an empty body, not an error.
			String text = Objects.requireNonNullElse(
					resourceSerializationService.serializeResourceString(entry.getResource()), "");
			return new ResourceRepresentation.TextBody(text, MediaType.TEXT_PLAIN);
		}

		if (graphType == GraphType.Graph || graphType == GraphType.List) {
			MediaType requested = query.format() != null ? query.format() : query.rdfFormat();
			String rdfMediaType = GraphUtil.resolveRdfMediaType(requested, query.acceptHeader());
			return new ResourceRepresentation.TextBody(serializeResourceAsJson(entry, rdfMediaType, query.listFilter()),
					MediaType.parseMediaType(rdfMediaType));
		}

		String mediaType = query.rdfFormat() != null ? query.rdfFormat().toString() : query.acceptHeader();
		return new ResourceRepresentation.TextBody(serializeResourceAsJson(entry, mediaType, query.listFilter()),
				MediaType.APPLICATION_JSON);
	}

	String serializeResourceAsJson(Entry entry, String mediaType, ListFilter listFilter) {

		EntryType entryType = entry.getEntryType();
		GraphType graphType = entry.getGraphType();
		ResourceType resourceType = entry.getResourceType();

		// Resource missing
		if (graphType == null) {
			throw new EntityNotFoundException("No resource available for entry: " + entry.getResourceURI());
		}

		// Graph and List resource

		if (graphType == GraphType.Graph || graphType == GraphType.List) {
			boolean isList = (entry.getGraphType() == GraphType.List);
			Model graph;

			if (isList) {
				graph = ((org.entrystore.List) entry.getResource()).getGraph();
			} else {
				graph = ((RDFResource) entry.getResource()).getGraph();
			}

			if (graph != null) {
				if (isList && MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) {
					return listResourceService.serializeChildrenIds(entry, listFilter);
				}
				// serializeGraph routes application/json and application/rdf+json through RDFJSON itself
				return GraphUtil.serializeGraph(graph, mediaType);
			}
		}

		// Remote Document (GraphType == None) Resource

		if (entryType == EntryType.Link || entryType == EntryType.LinkReference || entryType == EntryType.Reference) {
			if (graphType == GraphType.None) {
				throw new RedirectSeeOtherException(entry.getResourceURI());
			}
		}

		/*
		 * Local Resource
		 */
		if (entryType == EntryType.Local) {

			// Named Local Resource
			if (resourceType == ResourceType.NamedResource) {
				throw new RedirectSeeOtherException(entry.getLocalMetadataURI());
			}

			try {
				Resource resource = entry.getResource();
				return switch (graphType) {
					case User -> resourceSerializationService.serializeResourceUser(resource).toString();
					case Group -> resourceSerializationService.serializeResourceGroup(resource, mediaType).toString();
					case Context -> {
						contextService.checkMayListEntries();
						yield resourceSerializationService.serializeResourceContext(resource).toString();
					}
					case SystemContext -> {
						contextService.checkMayListEntries();
						yield resourceSerializationService.serializeResourceSystemContext(resource).toString();
					}
					case Pipeline -> {
						if (resource instanceof RDFResource pipeline) {
							if (pipeline.getGraph() == null) {
								throw new EntityNotFoundException("The pipeline has not been set");
							}
							yield resourceSerializationService.serializeResourcePipeline(pipeline, mediaType)
									.toString();
						}
						yield EMPTY_REPRESENTATION;
					}
					case ResultList, PipelineResult -> EMPTY_REPRESENTATION;
					default -> EMPTY_REPRESENTATION;
				};
			} catch (IllegalArgumentException e) {
				throw new IllegalStateException(e);
			}
		}
		return EMPTY_REPRESENTATION;
	}

	/**
	 * Routes a PUT body to the service owning the entry's graph type and bumps the entry's modification date.
	 * A binary upload is reported as {@link CompletionState#CREATED}, every other update as
	 * {@link CompletionState#UPDATED}; an unsupported graph type answers {@link CompletionState#ERROR} and leaves
	 * the entry untouched. A binary (graph type None) body is passed on unopened once the caller is found to have
	 * write access, and is streamed to disk; every other body is small and read into memory first.
	 */
	public CompletionState setEntryResource(Entry entry, IOSupplier<InputStream> body, long contentLength,
											String mediaType, String mimeType, String filename,
											String currentSessionId) {
		if (entry.getGraphType() == GraphType.None) {
			principalManager.checkAuthenticatedUserAuthorized(entry, PrincipalManager.AccessProperty.WriteResource);
			fileResourceService.setData(entry, body, contentLength, mediaType, mimeType, filename);
			entry.updateModificationDate();
			return CompletionState.CREATED;
		}
		byte[] requestBody = readBody(body);
		CompletionState state = switch (entry.getGraphType()) {
			case List, Group -> {
				if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) {
					listResourceService.setChildrenFromJson(entry, requestBody);
				} else {
					listResourceService.setGraph(entry, requestBody, mediaType);
				}
				yield CompletionState.UPDATED;
			}
			case String -> {
				setString(entry, requestBody);
				yield CompletionState.UPDATED;
			}
			case Graph, Pipeline -> {
				setGraph(entry, requestBody, mediaType);
				yield CompletionState.UPDATED;
			}
			case User -> {
				userService.updateSettings(entry, requestBody, currentSessionId);
				yield CompletionState.UPDATED;
			}
			case null, default -> CompletionState.ERROR;
		};
		if (state != CompletionState.ERROR) {
			entry.updateModificationDate();
		}
		return state;
	}

	private static byte[] readBody(IOSupplier<InputStream> body) {
		try {
			return body.get().readAllBytes();
		} catch (IOException e) {
			throw new BadRequestException("Failed to read the request body", e);
		}
	}

	private static void setString(Entry entry, byte[] requestBody) {
		try {
			StringResource stringResource = (StringResource) entry.getResource();
			stringResource.setString(new String(requestBody, StandardCharsets.UTF_8));
		} catch (RepositoryException e) {
			throw new InternalServerErrorException("Failed to store string resource for entry " + entry.getEntryURI(), e);
		} catch (IllegalArgumentException e) {
			throw new BadRequestException("Invalid string resource payload for entry " + entry.getEntryURI(), e);
		} catch (ClassCastException e) {
			throw new InternalServerErrorException("Resource of entry " + entry.getEntryURI() + " is not a StringResource", e);
		}
	}

	private static void setGraph(Entry entry, byte[] requestBody, String mediaType) {
		if (!(entry.getResource() instanceof RDFResource graphResource)) {
			throw new InternalServerErrorException("No RDF resource found for entry with ResourceType Graph");
		}
		Model graph = GraphUtil.deserializeGraph(new String(requestBody, StandardCharsets.UTF_8), mediaType);
		graphResource.setGraph(graph);
	}

	/**
	 * Stores an uploaded file via {@link FileResourceService#setDataMultipart} and bumps the entry's modification
	 * date.
	 */
	public CompletionState setEntryResourceMultipart(Entry entry, MultipartFile file, String mimeType) {
		fileResourceService.setDataMultipart(entry, file, mimeType);
		entry.updateModificationDate();
		return CompletionState.CREATED;
	}

	/**
	 * See {@link ListResourceService#importFromZip}.
	 */
	public void importEntryResource(Entry entry, byte[] requestBody) {
		listResourceService.importFromZip(entry, requestBody);
	}

	/**
	 * See {@link ListResourceService#moveEntry}.
	 */
	public Entry modifyListEntryResource(Entry entry, String moveEntry, String fromList, boolean removeAll) {
		return listResourceService.moveEntry(entry, moveEntry, fromList, removeAll);
	}

	/**
	 * Checks write access, then deletes the remote document of a link-type entry when {@code proxy} is
	 * {@code "true"} and otherwise routes to the service owning the entry's graph type.
	 */
	public void deleteResource(Entry entry, String proxy, boolean isRecursive) {
		principalManager.checkAuthenticatedUserAuthorized(entry, PrincipalManager.AccessProperty.WriteResource);

		EntryType entryType = entry.getEntryType();

		if ((entryType == EntryType.Link || entryType == EntryType.Reference || entryType == EntryType.LinkReference)
				&& "true".equalsIgnoreCase(proxy)) {
			proxyService.deleteUrl(entry.getResourceURI().toString());
			return;
		}
		switch (entry.getGraphType()) {
			case List -> listResourceService.deleteChildren(entry, isRecursive);
			case None -> fileResourceService.deleteData(entry);
			case null, default -> {
				// Nothing to delete for the remaining graph types.
			}
		}
	}
}