ListResourceService.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.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.eclipse.rdf4j.model.Model;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.Group;
import org.entrystore.QuotaException;
import org.entrystore.impl.ListImpl;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.RepositoryException;
import org.entrystore.repository.util.FileOperations;
import org.entrystore.repository.util.URISplit;
import org.entrystore.rest.springboot.model.api.ListFilter;
import org.entrystore.rest.springboot.model.dto.ListParams;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.DataConflictException;
import org.entrystore.rest.springboot.model.exception.EntityTooLargeException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.model.exception.NotImplementedException;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.json.JSONArray;
import org.json.JSONException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.StringWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Vector;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
 * Resource operations on List and Group entries: their children (a list of entry URIs, or a group's members),
 * moving an entry between lists, and the not-yet-implemented ZIP import.
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ListResourceService {

	private final RepositoryManagerImpl repositoryManager;

	@Value("${entrystore.import.tmpdir:${java.io.tmpdir}}")
	@Setter(AccessLevel.PACKAGE)
	private File importTmpDir;

	/**
	 * Replaces the children of a List or Group entry with the entry ids given as a JSON array of strings, resolved
	 * in the entry's own context. Unknown ids and malformed JSON are bad requests; a child listed twice is a
	 * conflict.
	 */
	public void setChildrenFromJson(Entry entry, byte[] requestBody) {
		try {
			JSONArray childrenJSONArray = new JSONArray(new String(requestBody, StandardCharsets.UTF_8));
			ArrayList<URI> newResource = new ArrayList<>();

			// Add new entries to the list.
			for (int i = 0; i < childrenJSONArray.length(); i++) {
				String childId = childrenJSONArray.get(i).toString();
				Entry childEntry = entry.getContext().get(childId);
				if (childEntry == null) {
					throw new BadRequestException("Cannot update resource, since one of the children does not exist. ChildId: " + childId);
				} else {
					newResource.add(childEntry.getEntryURI());
				}
			}

			if (entry.getGraphType() == GraphType.List) {
				org.entrystore.List resourceList = (org.entrystore.List) entry.getResource();
				resourceList.setChildren(newResource);
			} else {
				Group resourceGroup = (Group) entry.getResource();
				resourceGroup.setChildren(newResource);
			}
		} catch (JSONException e) {
			throw new BadRequestException("Cannot parse given body resource as JSONArray.");
		} catch (IllegalArgumentException iae) {
			throw new BadRequestException(iae.getMessage(), iae); // Core exception — message is safe to return
		} catch (RepositoryException re) {
			throw new DataConflictException("An entry cannot be added multiple times", re);
		}
	}

	/**
	 * Replaces the graph of a List or Group entry with the RDF in the request body, serialized as
	 * {@code mediaType}. Any other graph type is a bad request; a member listed twice is a conflict.
	 */
	public void setGraph(Entry entry, byte[] requestBody, String mediaType) {
		Model graph = GraphUtil.deserializeGraph(new String(requestBody, StandardCharsets.UTF_8), mediaType);
		try {
			if (entry.getGraphType() == GraphType.List) {
				((org.entrystore.List) entry.getResource()).setGraph(graph);
			} else if (entry.getGraphType() == GraphType.Group) {
				((Group) entry.getResource()).setGraph(graph);
			} else {
				throw new BadRequestException("Unsupported graph type for RDF graph update: " + entry.getGraphType());
			}
		} catch (IllegalArgumentException iae) {
			throw new BadRequestException(iae.getMessage(), iae); // Core exception — message is safe to return
		} catch (RepositoryException e) {
			throw new DataConflictException("An entry cannot be added multiple times", e);
		}
	}

	/**
	 * Moves an entry from the list {@code fromList} into the list {@code entry}. Both arguments may be absolute
	 * URIs or paths relative to the repository base URL. Returns the moved entry.
	 */
	public Entry moveEntry(Entry entry, String moveEntry, String fromList, boolean removeAll) {

		GraphType graphType = entry.getGraphType();

		if (graphType == GraphType.List
				&& moveEntry != null
				&& fromList != null) {

			// POST 3/resource/45?moveEntry=2/entry/34&fromList=2/resource/67
			ListImpl dest = (ListImpl) entry.getResource();

			String baseURI = repositoryManager.getRepositoryURL().toString();
			if (!baseURI.endsWith("/")) {
				baseURI += "/";
			}

			// Entry URI of the Entry to be moved
			URI movableEntry = resolveAgainstBase(moveEntry, baseURI);
			// Resource URI of the source List
			URI movableEntrySource = resolveAgainstBase(fromList, baseURI);

			Entry movedEntry;
			try {
				movedEntry = dest.moveEntryHere(movableEntry, movableEntrySource, removeAll);
			} catch (QuotaException qe) {
				throw new EntityTooLargeException(qe.getMessage(), qe);
			}

			return movedEntry;
		} else {
			throw new BadRequestException("Bad request: supports only Entry graphType of List (given: " + graphType + ") and moving Entry with 'moveEntry' and 'fromList' parameters.");
		}
	}

	private static URI resolveAgainstBase(String value, String baseURI) {
		URI candidate = URI.create(value);
		return candidate.isAbsolute() ? candidate : URI.create(baseURI + value);
	}

	/**
	 * Imports the RDF files of a ZIP archive into a List entry. Not implemented yet: a readable archive always ends
	 * in {@link NotImplementedException}, an unreadable one in {@link InternalServerErrorException}; other graph
	 * types are a bad request.
	 */
	public void importFromZip(Entry entry, byte[] requestBody) {

		GraphType graphType = entry.getGraphType();

		if (graphType == GraphType.List) {
			// Reads the ZIP; a .rdf entry throws from importRDFResource, otherwise the throw below signals not-implemented.
			extractRdfEntries(requestBody);
			throw new NotImplementedException("Resource ZIP import is not yet implemented");
		} else {
			throw new BadRequestException("Bad request: ZIP import supports only Entry graphType of List (given: " + graphType + ") with 'application/zip' format");
		}
	}

	/**
	 * Empties a List entry, or removes the whole tree below it when {@code recursive} is set.
	 */
	public void deleteChildren(Entry entry, boolean recursive) {
		ListImpl l = (ListImpl) entry.getResource();
		if (recursive) {
			l.removeTree();
		} else {
			l.setChildren(new Vector<>());
		}
	}

	/**
	 * Serializes the children of a List entry as a JSON array of entry ids. Sorting is applied only when the
	 * filter asks for it and the list has at most 500 children; children that no longer resolve are dropped from
	 * the sorted output.
	 */
	public String serializeChildrenIds(Entry entry, ListFilter listFilter) {
		JSONArray array = new JSONArray();
		org.entrystore.List l = (org.entrystore.List) entry.getResource();
		List<URI> uris = l.getChildren();
		Set<String> IDs = new HashSet<>();
		for (URI u : uris) {
			String id = URISplit.getLastSegment(u.toASCIIString());
			IDs.add(id);
		}

		if (listFilter.sort() != null && (IDs.size() < 501)) {
			List<Entry> childrenEntries = new ArrayList<>();
			for (String id : IDs) {
				Entry childEntry = entry.getContext().get(id);
				if (childEntry != null) {
					childrenEntries.add(childEntry);
				} else {
					log.warn("Child entry {} in context {} does not exist, but is referenced by a list.", id, entry.getContext().getURI());
				}
			}

			ResourceSerializationService.sortChildrenEntries(childrenEntries,
					ListParams.withoutPagination(listFilter));

			for (Entry childEntry : childrenEntries) {
				URI childURI = childEntry.getEntryURI();
				String id = URISplit.getLastSegment(childURI.toASCIIString());
				array.put(id);
			}
		} else {
			if (IDs.size() > 500) {
				log.warn("No sorting performed because of list size bigger than 500 children");
			}
			for (String id : IDs) {
				array.put(id);
			}
		}
		return array.toString();
	}

	private void extractRdfEntries(byte[] requestBody) {
		File tmpFile = null;
		try {
			tmpFile = writeStreamToTmpFile(new ByteArrayInputStream(requestBody));
			importZipFile(tmpFile);
		} catch (IOException ioe) {
			throw new InternalServerErrorException("Failed to process ZIP import", ioe);
		} finally {
			if (tmpFile != null) {
				try {
					Files.deleteIfExists(tmpFile.toPath());
				} catch (IOException e) {
					log.warn("[IMPORT] Failed to delete temporary ZIP file: {}", tmpFile, e);
				}
			}
		}
	}

	private void importZipFile(File tmpFile) throws IOException {
		try (ZipFile zipFile = new ZipFile(tmpFile)) {
			Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
			while (zipEntries.hasMoreElements()) {
				ZipEntry entry = zipEntries.nextElement();
				String name = entry.getName();
				if (entry.isDirectory() || !name.endsWith(".rdf")) {
					continue;
				}
				String fileString;
				try (InputStream fileIS = zipFile.getInputStream(entry)) {
					StringWriter writer = new StringWriter();
					IOUtils.copy(fileIS, writer, StandardCharsets.UTF_8);
					fileString = writer.toString();
				}
				importRDFResource(fileString);
			}
		}
	}

	private File writeStreamToTmpFile(InputStream is) throws IOException {
		File tmpFile = File.createTempFile("entrystore_res_import", ".zip", importTmpDir);
		log.info("[IMPORT] Created temporary file: {}", tmpFile);
		try (OutputStream fos = Files.newOutputStream(tmpFile.toPath())) {
			FileOperations.copyFile(is, fos);
		}
		return tmpFile;
	}

	private void importRDFResource(String rdfString) {
		throw new NotImplementedException("RDF resource import is not yet implemented");
	}
}