GraphUtil.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.util;

import lombok.extern.slf4j.Slf4j;
import org.eclipse.rdf4j.common.xml.XMLReaderFactory;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.rio.ParserConfig;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.rio.RDFHandlerException;
import org.eclipse.rdf4j.rio.RDFParseException;
import org.eclipse.rdf4j.rio.RDFParser;
import org.eclipse.rdf4j.rio.RDFWriter;
import org.eclipse.rdf4j.rio.helpers.BasicWriterSettings;
import org.eclipse.rdf4j.rio.helpers.JSONLDMode;
import org.eclipse.rdf4j.rio.helpers.JSONLDSettings;
import org.eclipse.rdf4j.rio.helpers.StatementCollector;
import org.eclipse.rdf4j.rio.helpers.XMLParserSettings;
import org.eclipse.rdf4j.rio.jsonld.JSONLDParser;
import org.eclipse.rdf4j.rio.jsonld.JSONLDWriter;
import org.eclipse.rdf4j.rio.n3.N3ParserFactory;
import org.eclipse.rdf4j.rio.n3.N3Writer;
import org.eclipse.rdf4j.rio.ntriples.NTriplesParser;
import org.eclipse.rdf4j.rio.ntriples.NTriplesWriter;
import org.eclipse.rdf4j.rio.rdfxml.RDFXMLParser;
import org.eclipse.rdf4j.rio.rdfxml.util.RDFXMLPrettyWriter;
import org.eclipse.rdf4j.rio.trig.TriGParser;
import org.eclipse.rdf4j.rio.trig.TriGWriter;
import org.eclipse.rdf4j.rio.trix.TriXParser;
import org.eclipse.rdf4j.rio.trix.TriXWriter;
import org.eclipse.rdf4j.rio.turtle.TurtleParser;
import org.eclipse.rdf4j.rio.turtle.TurtleWriter;
import org.entrystore.repository.util.NS;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;

import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.lang.reflect.Constructor;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;

/**
 * Utility methods to serialize and deserialize graphs.
 *
 * @author Hannes Ebner
 */
@Slf4j
public class GraphUtil {

	/** Default media type for RDF responses when neither a format parameter nor an Accept header selects one. */
	public static final String DEFAULT_RDF_MEDIA_TYPE = "application/rdf+xml";

	private static final String LEGACY_N3_MEDIA_TYPE = "text/rdf+n3";

	private static final Map<String, Class<? extends RDFWriter>> MEDIATYPE_TO_RDFWRITER_MAP = Map.of(
			RDFFormat.RDFXML.getDefaultMIMEType(), RDFXMLPrettyWriter.class,
			RDFFormat.N3.getDefaultMIMEType(), N3Writer.class,
			RDFFormat.TURTLE.getDefaultMIMEType(), TurtleWriter.class,
			RDFFormat.TRIX.getDefaultMIMEType(), TriXWriter.class,
			RDFFormat.NTRIPLES.getDefaultMIMEType(), NTriplesWriter.class,
			RDFFormat.TRIG.getDefaultMIMEType(), TriGWriter.class,
			RDFFormat.JSONLD.getDefaultMIMEType(), JSONLDWriter.class
	);

	/** Types an entry URI is served in, in the order that breaks ties within one Accept range. */
	private static final List<MediaType> ENTRY_MEDIA_TYPES = Stream.of(
					DEFAULT_RDF_MEDIA_TYPE,
					MediaType.APPLICATION_JSON_VALUE,
					LEGACY_N3_MEDIA_TYPE,
					RDFFormat.N3.getDefaultMIMEType(),
					RDFFormat.TURTLE.getDefaultMIMEType(),
					RDFFormat.TRIX.getDefaultMIMEType(),
					RDFFormat.NTRIPLES.getDefaultMIMEType(),
					RDFFormat.TRIG.getDefaultMIMEType(),
					RDFFormat.JSONLD.getDefaultMIMEType(),
					RDFFormat.RDFJSON.getDefaultMIMEType())
			.map(MediaType::valueOf)
			.toList();

	private static final Set<String> ALLOWED_RDF_MEDIA_TYPES;

	static {
		Set<String> types = new HashSet<>(MEDIATYPE_TO_RDFWRITER_MAP.keySet());
		types.add(MediaType.APPLICATION_JSON_VALUE);
		types.add(RDFFormat.RDFJSON.getDefaultMIMEType());
		ALLOWED_RDF_MEDIA_TYPES = Set.copyOf(types);
	}

	/**
	 * RFC 7231 Section 5.3.2 content negotiation ordering:
	 * quality value (q) descending as primary key, specificity as tiebreaker.
	 */
	static final Comparator<MediaType> QUALITY_THEN_SPECIFICITY =
			Comparator.comparingDouble(MediaType::getQualityValue).reversed()
					.thenComparing((a, b) -> {
						if (a.isMoreSpecific(b)) return -1;
						if (b.isMoreSpecific(a)) return 1;
						return 0;
					});

	/**
	 * Normalizes the legacy text/rdf+n3 MIME type to its standard equivalent
	 * (text/n3, as returned by RDF4J's {@link RDFFormat#N3}).
	 */
	static String normalizeLegacyMediaType(String mediaType) {
		if (LEGACY_N3_MEDIA_TYPE.equalsIgnoreCase(mediaType)) {
			return RDFFormat.N3.getDefaultMIMEType();
		}
		return mediaType;
	}

	/**
	 * @param graph  The Graph to be serialized.
	 * @param writer An {@link RDFWriter} subclass with a public {@code (Writer)} constructor; the writers
	 *               used by the REST layer are the values of {@code MEDIATYPE_TO_RDFWRITER_MAP}
	 * @return A String representation of the serialized Graph, never {@code null} and never partial.
	 * @throws IllegalArgumentException     if either parameter is {@code null}
	 * @throws InternalServerErrorException if the writer cannot be instantiated or the graph cannot be
	 *                                      written in full
	 */
	public static String serializeGraph(Model graph, Class<? extends RDFWriter> writer) {
		if (graph == null || writer == null) {
			throw new IllegalArgumentException("Parameters must not be null");
		}

		StringWriter stringWriter = new StringWriter();
		RDFWriter rdfWriter;
		try {
			Constructor<? extends RDFWriter> constructor = writer.getConstructor(Writer.class);
			rdfWriter = constructor.newInstance(stringWriter);
		} catch (ReflectiveOperationException e) {
			throw new InternalServerErrorException("Failed to instantiate RDF writer " + writer.getName(), e);
		}

		if (!System.getProperties().containsKey("org.eclipse.rdf4j.rio.rdf10_plain_literals")) {
			rdfWriter.getWriterConfig().set(BasicWriterSettings.XSD_STRING_TO_PLAIN_LITERAL, true);
		}
		if (!System.getProperties().containsKey("org.eclipse.rdf4j.rio.rdf10_language_literals")) {
			rdfWriter.getWriterConfig().set(BasicWriterSettings.RDF_LANGSTRING_TO_LANG_LITERAL, true);
		}
		if (!System.getProperties().containsKey("org.eclipse.rdf4j.rio.jsonld.optimize")) {
			rdfWriter.getWriterConfig().set(JSONLDSettings.OPTIMIZE, true);
		}
		if (!System.getProperties().containsKey("org.eclipse.rdf4j.rio.jsonld.use_native_types")) {
			rdfWriter.getWriterConfig().set(JSONLDSettings.USE_NATIVE_TYPES, true);
		}
		rdfWriter.getWriterConfig().set(JSONLDSettings.JSONLD_MODE, JSONLDMode.COMPACT);

		Map<String, String> namespaces = NS.getMap();
		if (rdfWriter instanceof JSONLDWriter) {
			// we optimize to include only the used namespaces as contexts in JSON-LD
			namespaces = new HashMap<>();
			for (Statement s : graph) {
				namespaces.putAll(findNS(s.getSubject()));
				namespaces.putAll(findNS(s.getPredicate()));
				namespaces.putAll(findNS(s.getObject()));
			}
		}

		writeGraph(graph, rdfWriter, namespaces);
		return stringWriter.toString();
	}

	/**
	 * Writes the graph to a caller-supplied writer.
	 * <p>
	 * A write failure aborts with an {@link InternalServerErrorException} after an arbitrary prefix of the
	 * document has already reached the writer, so the writer must never be bound to an HTTP response stream:
	 * the response commits as soon as the container's output buffer fills, and the status can then no longer
	 * be changed to 5xx. Serialize into a buffer first (see {@link #serializeGraph(Model, String)}) and write
	 * the result to the response only after this method has returned normally.
	 * <p>
	 * Deliberately package-private: that precondition is not expressible in the type system, so the overload
	 * is kept out of reach of the service layer, which lives in another package and always needs a buffered
	 * {@code String} anyway.
	 *
	 * @throws IllegalArgumentException     if either parameter is {@code null}
	 * @throws InternalServerErrorException if the graph cannot be written in full
	 */
	static void serializeGraph(Model graph, RDFWriter rdfWriter) {
		if (graph == null || rdfWriter == null) {
			throw new IllegalArgumentException("Parameters must not be null");
		}
		writeGraph(graph, rdfWriter, NS.getMap());
	}

	private static void writeGraph(Model graph, RDFWriter rdfWriter, Map<String, String> namespaces) {
		try {
			rdfWriter.startRDF();
			namespaces.forEach(rdfWriter::handleNamespace);
			for (Statement statement : graph) {
				rdfWriter.handleStatement(statement);
			}
			rdfWriter.endRDF();
		} catch (RuntimeException e) {
			// Catches RuntimeException, not just RDFHandlerException: RDF4J writers signal write failures
			// with more than their own exception type. RDFXMLPrettyWriter — the writer for the default
			// application/rdf+xml — throws StringIndexOutOfBoundsException for a predicate IRI it cannot
			// namespace-qualify (XMLUtil.findURISplitIndex returns -1 for e.g. <urn:987>), and such a
			// triple is storable through a perfectly valid Turtle PUT.
			// Must not be downgraded to a log-and-return: the caller would serve whatever was written
			// before the failure as a complete document with status 200 (ENTRYSTORE-1091).
			throw new InternalServerErrorException("Failed to serialize RDF graph", e);
		}
	}

	/**
	 * Deserializes an RDF body into an in-memory {@link Model}. JSON ({@code application/json})
	 * and RDF/JSON ({@code application/rdf+json}) bodies are routed through {@link RDFJSON};
	 * all other supported types are parsed via the matching RDF4J {@link RDFParser}. The legacy
	 * {@code text/rdf+n3} alias is normalized to {@code text/n3} before lookup.
	 *
	 * @param graphString the serialized RDF body; must not be {@code null}
	 * @param mediaType   the media type of the body (e.g. {@code text/turtle},
	 *                    {@code application/ld+json}); used to select the parser
	 * @return the parsed graph, never {@code null}
	 * @throws BadRequestException if the body cannot be parsed or the media type is unsupported
	 */
	public static Model deserializeGraph(String graphString, String mediaType) {
		String normalized = normalizeLegacyMediaType(mediaType);

		if (MediaType.APPLICATION_JSON_VALUE.equals(normalized) || RDFFormat.RDFJSON.getDefaultMIMEType().equals(normalized)) {
			try {
				Model graph = RDFJSON.rdfJsonToGraph(graphString);
				if (graph == null) {
					throw new BadRequestException("Malformed RDF/JSON in request body");
				}
				return graph;
			} catch (RDFParseException e) {
				throw new BadRequestException("Malformed RDF/JSON in request body", e);
			}
		}

		RDFParser parser = createRdfParserForMediaType(normalized);
		if (parser == null) {
			throw new BadRequestException("Unsupported RDF media type: " + mediaType);
		}
		try {
			return parseWith(parser, graphString);
		} catch (RDFParseException e) {
			throw new BadRequestException("Malformed RDF in request body", e);
		} catch (RDFHandlerException | IOException e) {
			throw new BadRequestException("Unable to process the RDF graph from the request body", e);
		}
	}

	private static Model parseWith(RDFParser parser, String graphString)
			throws RDFParseException, RDFHandlerException, IOException {
		StatementCollector collector = new StatementCollector();
		parser.setRDFHandler(collector);
		parser.parse(new StringReader(graphString), "");
		return new LinkedHashModel(collector.getStatements());
	}

	private static RDFParser createRdfParserForMediaType(String mediaType) {
		RDFParser parser = null;
		if (RDFFormat.RDFXML.getDefaultMIMEType().equals(mediaType)) {
			parser = new RDFXMLParser();
			parser.setParserConfig(constructSafeXmlParserConfig());
		} else if (RDFFormat.N3.getDefaultMIMEType().equals(mediaType)) {
			parser = new N3ParserFactory().getParser();
		} else if (RDFFormat.TURTLE.getDefaultMIMEType().equals(mediaType)) {
			parser = new TurtleParser();
		} else if (RDFFormat.TRIX.getDefaultMIMEType().equals(mediaType)) {
			parser = new TriXParser();
			parser.setParserConfig(constructSafeXmlParserConfig());
		} else if (RDFFormat.NTRIPLES.getDefaultMIMEType().equals(mediaType)) {
			parser = new NTriplesParser();
		} else if (RDFFormat.TRIG.getDefaultMIMEType().equals(mediaType)) {
			parser = new TriGParser();
		} else if (RDFFormat.JSONLD.getDefaultMIMEType().equals(mediaType)) {
			parser = new JSONLDParser();
		}
		return parser;
	}

	public static Class<? extends RDFWriter> getRDFWriterClassForMediaType(String mediaType) {
		if (mediaType == null) {
			return null;
		}
		return MEDIATYPE_TO_RDFWRITER_MAP.get(normalizeLegacyMediaType(mediaType));
	}

	public static String validateRdfMediaType(String mediaType) {
		return validateRdfMediaType(mediaType, HttpStatus.NOT_ACCEPTABLE);
	}

	public static String validateRdfMediaType(String mediaType, HttpStatus rejectStatus) {
		if (mediaType == null) {
			throw new CustomResponseException("Unsupported media type", rejectStatus);
		}
		String normalized = normalizeLegacyMediaType(mediaType).toLowerCase(Locale.ROOT);
		if (!ALLOWED_RDF_MEDIA_TYPES.contains(normalized)) {
			throw new CustomResponseException("Unsupported media type", rejectStatus);
		}
		return normalized;
	}

	public static String resolveAcceptedMediaType(String acceptHeader, String defaultMediaType) {
		if (acceptHeader == null || acceptHeader.isBlank()) {
			return defaultMediaType;
		}

		try {
			List<MediaType> acceptTypes = MediaType.parseMediaTypes(acceptHeader);
			acceptTypes.sort(QUALITY_THEN_SPECIFICITY);
			for (MediaType type : acceptTypes) {
				if (type.isWildcardType() || type.isWildcardSubtype()) {
					return defaultMediaType;
				}
				String typeStr = normalizeLegacyMediaType(type.getType() + "/" + type.getSubtype())
						.toLowerCase(Locale.ROOT);
				if (ALLOWED_RDF_MEDIA_TYPES.contains(typeStr)) {
					return typeStr;
				}
			}
		} catch (InvalidMediaTypeException e) {
			log.warn("Failed to parse Accept header '{}': {}", acceptHeader, e.getMessage());
			throw new CustomResponseException("Malformed Accept header", HttpStatus.NOT_ACCEPTABLE);
		}

		throw new CustomResponseException("Unsupported media type", HttpStatus.NOT_ACCEPTABLE);
	}

	/**
	 * Resolves the effective RDF media type for a request: an explicit format parameter wins (validated
	 * and normalized — lowercased, legacy {@code text/rdf+n3} mapped to {@code text/n3}), otherwise
	 * content negotiation over the Accept header with the RDF default.
	 *
	 * @throws CustomResponseException with status 406 (Not Acceptable) if the format parameter is not a
	 *         supported RDF media type, or if the Accept header is malformed or matches no supported type
	 */
	public static String resolveRdfMediaType(MediaType format, String acceptHeader) {
		if (format != null) {
			return validateRdfMediaType(format.toString());
		}
		return resolveAcceptedMediaType(acceptHeader, DEFAULT_RDF_MEDIA_TYPE);
	}

	/**
	 * Picks the type a GET on an entry URI answers with when it has no {@code format} parameter. Mirrors the loop of
	 * Restlet's {@code ClientInfo.getPreferredMetadata}, which 5.x used: Accept ranges in header order, and within
	 * each range the types in {@link #ENTRY_MEDIA_TYPES} order; a type is taken only if its range's quality is
	 * strictly higher than the best so far, which starts at 0. A wildcard thus gets {@code application/rdf+xml}, as
	 * does a missing or blank header, and a range with q=0 is never taken.
	 *
	 * @return empty if the header accepts none of the types
	 * @throws InvalidMediaTypeException if the header does not parse
	 */
	public static Optional<MediaType> resolveEntryMediaType(String acceptHeader) {
		if (acceptHeader == null || acceptHeader.isBlank()) {
			return Optional.of(ENTRY_MEDIA_TYPES.getFirst());
		}
		MediaType preferred = null;
		double preferredQuality = 0;
		for (MediaType range : MediaType.parseMediaTypes(acceptHeader)) {
			for (MediaType type : ENTRY_MEDIA_TYPES) {
				if (range.getQualityValue() > preferredQuality && range.includes(type)) {
					preferred = type;
					preferredQuality = range.getQualityValue();
				}
			}
		}
		return Optional.ofNullable(preferred);
	}

	/**
	 * Serializes a graph into the given media type. JSON ({@code application/json}) and RDF/JSON
	 * ({@code application/rdf+json}) are routed through {@link RDFJSON}, all other supported types through
	 * the matching RDF4J {@link RDFWriter}.
	 *
	 * @return the serialized graph, never {@code null} and never partial
	 * @throws IllegalArgumentException     if the graph is {@code null} or the media type has no known writer
	 * @throws InternalServerErrorException if the graph cannot be serialized in full
	 */
	public static String serializeGraph(Model graph, String mediaType) {
		if (graph == null) {
			throw new IllegalArgumentException("Graph must not be null");
		}
		mediaType = normalizeLegacyMediaType(mediaType);
		if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType) || RDFFormat.RDFJSON.getDefaultMIMEType().equals(mediaType)) {
			String rdfJson = RDFJSON.graphToRdfJson(graph);
			if (rdfJson == null) {
				throw new InternalServerErrorException("Failed to serialize RDF graph as RDF/JSON");
			}
			return rdfJson;
		}

		Class<? extends RDFWriter> writerClass = getRDFWriterClassForMediaType(mediaType);
		if (writerClass == null) {
			throw new IllegalArgumentException("No known RDFWriter for mediaType of '" + mediaType + "'. Allowed values: " + MEDIATYPE_TO_RDFWRITER_MAP.keySet());
		}

		return serializeGraph(graph, writerClass);
	}

	public static JSONObject serializeGraphToJson(Model graph, String rdfFormat) {
		if (rdfFormat == null || MediaType.APPLICATION_JSON_VALUE.equals(rdfFormat)) {
			// We don't use GraphUtil.serializeGraph() because we need a JSONObject here and
			// converting back and forth between String and JSONObject would not be very efficient
			return RDFJSON.graphToRdfJsonObject(graph);
		}
		rdfFormat = normalizeLegacyMediaType(rdfFormat);
		if (RDFFormat.JSONLD.getDefaultMIMEType().equals(rdfFormat)) {
			return new JSONObject(GraphUtil.serializeGraph(graph, rdfFormat));
		}
		log.warn("Model could not be serialized, returning empty JSON object");
		return new JSONObject();
	}

	/**
	 * Builds a custom and safe XML parser configuration to prevent XXE attacks. Creates a custom
	 * XML reader to be able to set features that are not supported by the reader which is initialized by Sesame.
	 *
	 * @return Returns a custom XML parser configuration including a custom XML reader.
	 */
	private static ParserConfig constructSafeXmlParserConfig() {
		ParserConfig pc = new ParserConfig();
		pc.set(XMLParserSettings.LOAD_EXTERNAL_DTD, false);
		pc.set(XMLParserSettings.SECURE_PROCESSING, true);

		XMLReader customXmlReader = null;
		try {
			customXmlReader = XMLReaderFactory.createXMLReader();
		} catch (SAXException e) {
			log.error(e.getMessage());
		}

		if (customXmlReader != null) {
			pc.set(XMLParserSettings.CUSTOM_XML_READER, customXmlReader);
			try {
				// Disallow DOCTYPE declaration
				customXmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
			} catch (SAXException se) {
				log.warn(se.getMessage());
			}
			try {
				// External text entities
				customXmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
			} catch (SAXException se) {
				log.warn(se.getMessage());
			}
			try {
				// External parameter entities
				customXmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
			} catch (SAXException se) {
				log.warn(se.getMessage());
			}
			try {
				// Disable external DTDs
				customXmlReader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
			} catch (SAXException se) {
				log.warn(se.getMessage());
			}
		}

		return pc;
	}

	private static Map<String, String> findNS(Value value) {
		Map<String, String> result = new HashMap<>();
		String dataTypeIri;
		if (value.isLiteral()) {
			// when Value is instance of Literal then .stringValue() returns e.g. "2024-11-18T17:11:59.147+01:00"^^<http://www.w3.org/2001/XMLSchema#dateTime>, so need add .getDataType()
			dataTypeIri = ((Literal) value).getDatatype().stringValue();
		} else if (value.isIRI()) {
			dataTypeIri = value.stringValue();
		} else {
			return result;
		}
		NS.getMap().forEach((prefix, ns) -> {
			if (dataTypeIri.startsWith(ns)) {
				result.put(prefix, ns);
			}
		});
		return result;
	}
}