MetadataService.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 com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.rdf4j.common.iteration.Iterations;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.query.GraphQuery;
import org.eclipse.rdf4j.query.MalformedQueryException;
import org.eclipse.rdf4j.query.QueryEvaluationException;
import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.repository.sail.SailRepository;
import org.eclipse.rdf4j.sail.memory.MemoryStore;
import org.entrystore.Entity;
import org.entrystore.Entry;
import org.entrystore.EntryType;
import org.entrystore.GraphEntity;
import org.entrystore.Metadata;
import org.entrystore.Provenance;
import org.entrystore.ProvenanceType;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.util.EntryUtil;
import org.entrystore.repository.util.NS;
import org.entrystore.rest.springboot.configuration.TraversalProperties;
import org.entrystore.rest.springboot.model.api.MetadataType;
import org.entrystore.rest.springboot.model.dto.MetadataResult;
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.MethodNotAllowedException;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

@Slf4j
@Service
@RequiredArgsConstructor
public class MetadataService {

	private static final int DEFAULT_TRAVERSAL_DEPTH = 10;

	private final RepositoryManagerImpl repositoryManager;
	private final TraversalProperties traversalProperties;

	public MetadataResult getMetadata(Entry entry, MetadataType metadataType, String format, String graphQuery, Integer depth, String recursive, String scope, String revision) {

		if (recursive != null) {
			Set<URI> predicatesToFollow = resolvePredicates(recursive);
			if (predicatesToFollow.isEmpty()) {
				throw new BadRequestException("Unable to find predicates for parameter 'recursive' value: " + recursive);
			}
			return getRecursiveMetadata(entry, format, graphQuery, depth, recursive, scope, predicatesToFollow);
		}

		Model metadataGraph = switch (metadataType) {

			case LOCAL_METADATA -> {
				Metadata metadata = getEntryLocalMetadata(entry, revision);
				if (metadata == null) {
					throw new EntityNotFoundException("Local Metadata not found");
				}
				yield metadata.getGraph();
			}

			case CACHED_EXTERNAL_METADATA -> {
				Metadata metadata = getEntryCachedExternalMetadata(entry);
				if (metadata == null) {
					throw new EntityNotFoundException("External Metadata not found");
				}
				yield metadata.getGraph();
			}

			case MERGED_METADATA -> entry.getMetadataGraph();

		};

		if (metadataGraph == null) {
			throw new EntityNotFoundException("Metadata graph not found");
		}

		Model graphToSerialize = graphQuery != null ? applyGraphQuery(graphQuery, metadataGraph) : metadataGraph;
		return new MetadataResult(GraphUtil.serializeGraph(graphToSerialize, format), null);
	}

	/**
	 * Sets the metadata of entry to graphString.
	 *
	 * @param entry            Entry
	 * @param metadataType     Metadata type
	 * @param newMetadataGraph New metadata to be set
	 * @param revision         Revision
	 */
	public void setEntryMetadata(Entry entry, MetadataType metadataType, Model newMetadataGraph, String revision) {

		Metadata metadata = switch (metadataType) {
			case LOCAL_METADATA -> getEntryLocalMetadata(entry, revision);
			case CACHED_EXTERNAL_METADATA -> getEntryCachedExternalMetadata(entry);
			case MERGED_METADATA ->
					throw new BadRequestException("Unable to set Merged Metadata on entry: " + entry.getEntryURI());
		};

		if (metadata != null && newMetadataGraph != null) {
			metadata.setGraph(newMetadataGraph);
			return;
		}
		throw new MethodNotAllowedException("Metadata is empty for entry: " + entry.getEntryURI());
	}

	private Metadata getEntryLocalMetadata(Entry entry, String revision) {

		EntryType et = entry.getEntryType();
		if (EntryType.Local.equals(et) || EntryType.Link.equals(et) || EntryType.LinkReference.equals(et)) {
			Provenance provenance = entry.getProvenance();
			if (revision != null && provenance != null) {
				Entity entity = provenance.getEntityFor(revision, ProvenanceType.Metadata);
				if (entity instanceof GraphEntity graphEntity) {
					return graphEntity;
				}
				return null;
			}
			return entry.getLocalMetadata();
		}
		return null;
	}

	private Metadata getEntryCachedExternalMetadata(Entry entry) {
		EntryType et = entry.getEntryType();
		if (EntryType.Reference.equals(et) || EntryType.LinkReference.equals(et)) {
			return entry.getCachedExternalMetadata();
		}
		return null;
	}

	private MetadataResult getRecursiveMetadata(Entry entry, String format, String graphQuery, Integer depth, String recursive,
										String scope, Set<URI> predicatesToFollow) {

		String firstDetectedProfile = getFirstProfile(recursive);

		Map<String, String> blacklist = loadBlacklist(recursive);

		int depthMax = DEFAULT_TRAVERSAL_DEPTH;
		int limit = 1000;
		boolean repositoryScope = true;
		if (firstDetectedProfile != null) {
			depthMax = traversalProperties.maxDepth(firstDetectedProfile).orElse(depthMax);
			limit = traversalProperties.limit(firstDetectedProfile).orElse(limit);
			repositoryScope = traversalProperties.repositoryScope(firstDetectedProfile).orElse(repositoryScope);
		}

		depth = Math.min(depth > 0 ? depth : DEFAULT_TRAVERSAL_DEPTH, depthMax);

		if (StringUtils.isNotEmpty(scope)) {
			// we allow an override by parameter
			repositoryScope = !"context".equalsIgnoreCase(scope);
		}

		EntryUtil.TraversalResult travResult = traverse(entry, predicatesToFollow, blacklist, repositoryScope, depth, limit);
		Date latestModified = travResult.getLatestModified();
		Model graphToSerialize = graphQuery != null
				? applyGraphQuery(graphQuery, travResult.getGraph())
				: travResult.getGraph();
		return new MetadataResult(GraphUtil.serializeGraph(graphToSerialize, format), latestModified);
	}

	private String getFirstProfile(String predCSV) {
		for (String s : predCSV.split(",")) {
			if (!loadTraversalProfile(s).isEmpty()) {
				return s;
			}
		}
		return null;
	}

	/**
	 * Performs a traversal of the metadata graphs of the entries to
	 * which the first entry links.
	 * A maximum (default) of 10 levels is traversed. Levels can be set with depth parameter.
	 *
	 * @param entry        Starting point for traversal.
	 * @param predToFollow Which predicates should be followed for
	 *                     fetching entries further down the traversal path.
	 * @param blacklist    blacklist A map containing key/value pairs of predicate/object combinations that,
	 *                     if contained in the graph of the currently processed entry,
	 *                     trigger a stop of the traversal excluding the matching entry.
	 * @param repository   Ignore context boundaries.
	 * @param depth        Levels traversed.
	 * @param limit        Max number of results.
	 * @return Returns a Graph consisting of merged metadata graphs. Contains all metadata, including e.g. cached external.
	 */
	private EntryUtil.TraversalResult traverse(Entry entry, Set<URI> predToFollow, Map<String, String> blacklist, boolean repository, int depth, int limit) {
		return EntryUtil.traverseAndLoadEntryMetadata(
				ImmutableSet.of(repositoryManager.getValueFactory().createIRI(entry.getEntryURI().toString())),
				predToFollow,
				blacklist,
				0,
				depth,
				limit,
				HashMultimap.create(),
				repository ? null : entry.getContext(),
				repositoryManager
		);
	}

	private Model applyGraphQuery(String query, Model graph) {
		Date before = new Date();
		MemoryStore ms = new MemoryStore();
		Repository sr = new SailRepository(ms);
		RepositoryConnection rc = null;
		try {
			sr.init();
			rc = sr.getConnection();
			rc.add(graph);
			GraphQuery gq = rc.prepareGraphQuery(QueryLanguage.SPARQL, query);
			gq.setMaxExecutionTime(10); // 10 seconds, TODO: make this configurable
			Model result = Iterations.addAll(gq.evaluate(), new LinkedHashModel());
			log.info("Graph query took {} ms", new Date().getTime() - before.getTime());
			return result;
		} catch (RepositoryException | QueryEvaluationException e) {
			throw new InternalServerErrorException("Graph query evaluation failed", e);
		} catch (MalformedQueryException mfqe) {
			log.warn("Malformed SPARQL graph query: {}", mfqe.getMessage(), mfqe);
			throw new BadRequestException("Malformed SPARQL query");
		} finally {
			if (rc != null) {
				try {
					rc.close();
				} catch (RepositoryException e) {
					log.error("Failed to close repository connection: {}", e.getMessage(), e);
				}
			}
			try {
				sr.shutDown();
			} catch (RepositoryException e) {
				log.error("Failed to shut down repository: {}", e.getMessage(), e);
			}
		}
	}

	/**
	 * Builds a set of predicate URIs.
	 *
	 * @param predCSV A comma-separated list of predicates and/or
	 *                traversal profiles (need to be defined in configuration).
	 * @return Returns a set of URIs. Traversal profile names are resolved
	 * in their member URIs, and namespaces' URIs are expanded.
	 */
	private Set<URI> resolvePredicates(String predCSV) {
		Set<URI> result = new HashSet<>();
		for (String s : predCSV.split(",")) {
			Set<URI> pSet = loadTraversalProfile(s);
			if (pSet.isEmpty()) {
				try {
					URI expanded = NS.expand(s);
					// we add it to the result if it could be expanded
					if (expanded != null && !s.equals(expanded.toString())) {
						result.add(expanded);
					}
				} catch (IllegalArgumentException iae) {
					log.warn("Unable to expand namespace: {}", iae.getMessage());
				}
			} else {
				result.addAll(pSet);
			}
		}
		return result;
	}

	/**
	 * Loads a traversal profile from configuration.
	 *
	 * @param profileName The name of the traversal profile.
	 * @return A set of URIs.
	 */
	private Set<URI> loadTraversalProfile(String profileName) {
		Set<URI> result = new HashSet<>();
		for (String s : traversalProperties.predicates(profileName)) {
			result.add(URI.create(s));
		}
		return result;
	}

	private Map<String, String> loadBlacklist(String traversalParam) {
		Map<String, String> result = new HashMap<>();
		for (String s : traversalParam.split(",")) {
			result.putAll(loadTraversalBlacklistForProfile(s));
		}
		return result;
	}

	/**
	 * Loads a blacklist for a traversal profile from configuration.
	 *
	 * @param profileName The name of the traversal profile.
	 * @return A map containing the tuples of the blacklist.
	 */
	private Map<String, String> loadTraversalBlacklistForProfile(String profileName) {
		Map<String, String> result = new HashMap<>();
		for (String tuple : traversalProperties.blacklistTuples(profileName)) {
			String[] tupleArr = tuple.split(",");
			if (tupleArr.length != 2) {
				log.warn("Invalid blacklist configuration in traversal profile {}: {}", profileName, tuple);
				continue;
			}
			result.put(NS.expand(tupleArr[0]).toString(), NS.expand(tupleArr[1]).toString());
		}
		return result;
	}

}