OslcRequirementsCodec.java

package com.taxonomy.exchange;

import com.taxonomy.extension.api.integration.IntegrationContracts.*;
import org.apache.jena.rdf.model.*;
import org.apache.jena.riot.*;
import org.apache.jena.riot.system.ErrorHandlerFactory;
import org.apache.jena.riot.out.NodeFmtLib;
import org.apache.jena.vocabulary.RDF;

import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static com.taxonomy.exchange.OslcRdf.DCT;
import static com.taxonomy.exchange.OslcRdf.OSLC;
import static com.taxonomy.exchange.OslcRdf.RM;

/** OSLC RM RDF/XML profile, parsed as RDF instead of depending on one producer's XML spelling. */
public final class OslcRequirementsCodec {
    public static final String PROFILE = OslcRdf.RM_PROFILE;
    public ExchangeDocument read(byte[] content, URI base, String version, String configuration) {
        Model model = parse(content, base); List<Artifact> artifacts = new ArrayList<>(); List<MappingLoss> losses = new ArrayList<>();
        List<Resource> subjects = model.listResourcesWithProperty(RDF.type, model.createResource(RM + "Requirement")).toList();
        subjects.addAll(model.listResourcesWithProperty(RDF.type, model.createResource(RM + "RequirementCollection")).toList());
        for (Resource subject : subjects.stream().distinct().sorted(Comparator.comparing(Resource::toString)).toList()) {
            if (!subject.isURIResource() || subject.getURI().length() > 2048) throw ExchangeXml.invalid("INVALID_IDENTITY", "An OSLC requirement needs a bounded stable resource URI");
            Map<String, String> attributes = new TreeMap<>(), extensions = new TreeMap<>();
            Model evidence = ModelFactory.createDefaultModel();
            for (Statement statement : subject.listProperties().toList()) {
                if (statement.getObject().isAnon()) throw ExchangeXml.invalid("OSLC_BLANK_NODE_MAPPING", "This profile requires explicit mapping of structured blank-node attributes");
                evidence.add(statement);
                String value = NodeFmtLib.strNT(statement.getObject().asNode());
                attributes.put(statement.getPredicate().getURI() + ":" + ReqifExchangeCodec.digest(value.getBytes(StandardCharsets.UTF_8)), value);
            }
            String title = literal(subject, DCT + "title"), body = literal(subject, DCT + "description");
            if (title.isBlank()) losses.add(new MappingLoss(subject.getURI(), "title", "REQUIREMENT_MAPPING_REQUIRED", LossDisposition.UNSUPPORTED, "Resource has no mapped title"));
            Statement description = subject.getProperty(model.createProperty(DCT + "description"));
            if (description != null && description.getObject().isLiteral() && RDF.dtXMLLiteral.getURI().equals(description.getLiteral().getDatatypeURI()))
                body = xmlLiteralText(body);
            extensions.put("rdfXml", new String(serialize(evidence), StandardCharsets.UTF_8)); extensions.put("uri", subject.getURI());
            if (configuration != null) extensions.put("configuration", configuration);
            boolean requirement = subject.hasProperty(RDF.type, model.createResource(RM + "Requirement"));
            artifacts.add(new Artifact(subject.getURI(), requirement ? ArtifactKind.REQUIREMENT : ArtifactKind.SPECIFICATION,
                    RM + (requirement ? "Requirement" : "RequirementCollection"), title, body, attributes, extensions));
        }
        if (artifacts.size() > ExchangeXml.MAX_ARTIFACTS) throw ExchangeXml.invalid("ITEM_LIMIT", "OSLC response exceeds the object limit");
        return new ExchangeDocument(PROFILE, "1", version == null ? ReqifExchangeCodec.digest(content) : version, false,
                new String(ExchangeXml.write(ExchangeXml.parse(content)), StandardCharsets.UTF_8), artifacts, List.of(), List.of(), Map.of("resource", base.toString()), losses);
    }
    public DiscoveryResult discover(byte[] content, URI base, String etag, String configuration) {
        Model model = parse(content, base); List<DiscoveryResource> result = new ArrayList<>(); Set<String> seen = new HashSet<>();
        for (Statement statement : model.listStatements().toList()) {
            String predicate = statement.getPredicate().getURI();
            if (Set.of(OSLC + "serviceProvider", OSLC + "queryBase", OSLC + "nextPage", "http://www.w3.org/2000/01/rdf-schema#member").contains(predicate) && statement.getObject().isURIResource()) {
                String uri = statement.getResource().getURI(); if (seen.add(uri)) result.add(new DiscoveryResource(uri, predicate, "", etag, configuration));
            }
        }
        return new DiscoveryResult(result, false, etag);
    }
    public byte[] write(ExchangeDocument document) {
        Model result = ModelFactory.createDefaultModel();
        for (Artifact artifact : document.artifacts()) {
            if (artifact.extensions().containsKey("rdfXml")) result.add(parse(artifact.extensions().get("rdfXml").getBytes(StandardCharsets.UTF_8), URI.create(artifact.id())));
            Resource subject = result.createResource(artifact.id()); subject.addProperty(RDF.type, result.createResource(artifact.type()));
            replaceLiteral(subject, DCT + "title", artifact.title());
            String existing = literal(subject, DCT + "description");
            Statement original = subject.getProperty(result.createProperty(DCT + "description"));
            if (original != null && original.getObject().isLiteral() && RDF.dtXMLLiteral.getURI().equals(original.getLiteral().getDatatypeURI()))
                existing = xmlLiteralText(existing);
            if (!existing.equals(artifact.text())) replaceLiteral(subject, DCT + "description", artifact.text());
        }
        return serialize(result);
    }
    public List<MappingLoss> exportLosses(ExchangeDocument document) {
        List<MappingLoss> losses = new ArrayList<>(document.losses());
        for (Artifact artifact : document.artifacts()) if (artifact.extensions().containsKey("rdfXml")) {
            Model model = parse(artifact.extensions().get("rdfXml").getBytes(StandardCharsets.UTF_8), URI.create(artifact.id()));
            Resource subject = model.getResource(artifact.id()); Statement original = subject.getProperty(model.createProperty(DCT + "description"));
            if (original != null && original.getObject().isLiteral() && RDF.dtXMLLiteral.getURI().equals(original.getLiteral().getDatatypeURI())
                    && !xmlLiteralText(original.getString()).equals(artifact.text()))
                losses.add(new MappingLoss(artifact.id(), "description", "OSLC_RICH_TEXT_REPLACED", LossDisposition.TRANSFORMED,
                        "The edited canonical plain text replaces the original XML literal; old markup is retained only in operation evidence"));
        }
        return List.copyOf(losses);
    }
    private static String xmlLiteralText(String fragment) {
        var document = ExchangeXml.parse(("<div xmlns=\"http://www.w3.org/1999/xhtml\">" + fragment + "</div>").getBytes(StandardCharsets.UTF_8));
        if (ExchangeXml.all(document, "*", "*").stream().anyMatch(e -> !"http://www.w3.org/1999/xhtml".equals(e.getNamespaceURI())))
            throw ExchangeXml.invalid("OSLC_XML_LITERAL_MAPPING", "Mapped descriptions support passive XHTML fragments only");
        return document.getDocumentElement().getTextContent();
    }
    private static void replaceLiteral(Resource subject, String predicate, String value) {
        if (literal(subject, predicate).equals(value)) return;
        Property property = subject.getModel().createProperty(predicate); subject.removeAll(property).addLiteral(property, value);
    }
    private static String literal(Resource subject, String predicate) {
        List<Statement> values = subject.listProperties(subject.getModel().createProperty(predicate)).toList();
        if (values.isEmpty()) return "";
        if (values.stream().anyMatch(s -> !s.getObject().isLiteral())) throw ExchangeXml.invalid("OSLC_LITERAL_REQUIRED", "Mapped OSLC text must be a literal");
        return values.stream().sorted(Comparator.comparing(s -> s.getLiteral().getLanguage())).findFirst().orElseThrow().getString();
    }
    public static Model parse(byte[] content, URI base) {
        // Eliminate DTD/entities before RDF parsing. No remote context or SPARQL is evaluated.
        String safe = new String(ExchangeXml.write(ExchangeXml.parse(content)), StandardCharsets.UTF_8);
        try {
            Model model = ModelFactory.createDefaultModel();
            RDFParser.fromString(safe, Lang.RDFXML).base(base.toString()).errorHandler(ErrorHandlerFactory.errorHandlerStrictSilent()).parse(model);
            if (model.size() > 100000) throw ExchangeXml.invalid("ITEM_LIMIT", "RDF response exceeds the triple limit");
            return model;
        } catch (Exception rejected) { throw ExchangeXml.invalid("INVALID_RDF", "Response does not satisfy the declared RDF/XML profile"); }
    }
    private static byte[] serialize(Model model) {
        ByteArrayOutputStream out = new ByteArrayOutputStream(); RDFDataMgr.write(out, model, RDFFormat.RDFXML_PLAIN);
        if (out.size() > ExchangeXml.MAX_BYTES) throw ExchangeXml.invalid("PACKAGE_SIZE", "OSLC output exceeds 16 MiB"); return out.toByteArray();
    }
}