OslcRdf.java

package com.taxonomy.exchange;

import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.riot.RDFDataMgr;
import org.apache.jena.riot.RDFFormat;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Small RDF graph writer for the declared read-only OSLC surface. No HTML or remote URI is executed. */
public final class OslcRdf {
    public static final String RM_PROFILE = "oslc-rm-2.1";
    public static final String RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
    public static final String DCT = "http://purl.org/dc/terms/";
    public static final String OSLC = "http://open-services.net/ns/core#";
    public static final String RM = "http://open-services.net/ns/rm#";
    public static final String TAX = "https://github.com/carstenartur/Taxonomy/ns/integration#";
    public record Triple(String subject, String predicate, String value, boolean resource) {}
    private final List<Triple> triples = new ArrayList<>();
    public OslcRdf literal(String subject, String predicate, String value) {
        if (value == null) return this;
        if (subject == null || predicate == null)
            throw new IllegalArgumentException("RDF literal subject and predicate are required");
        triples.add(new Triple(subject, predicate, value, false)); return this;
    }
    public OslcRdf link(String subject, String predicate, String value) {
        if (subject == null || predicate == null || value == null)
            throw new IllegalArgumentException("RDF link subject, predicate and value are required");
        triples.add(new Triple(subject, predicate, value, true)); return this;
    }
    public OslcRdf type(String subject, String type) { return link(subject, RDF + "type", type); }
    public List<Triple> triples() { return List.copyOf(triples); }
    public byte[] xml() {
        var model = ModelFactory.createDefaultModel();
        for (Triple triple : triples) {
            var subject = model.createResource(iri(triple.subject()));
            var predicate = model.createProperty(iri(triple.predicate()));
            if (triple.resource()) subject.addProperty(predicate, model.createResource(iri(triple.value())));
            else subject.addLiteral(predicate, triple.value());
        }
        // Let the standards writer derive XML QNames, including URN namespaces.
        ByteArrayOutputStream out = new BoundedOutput();
        RDFDataMgr.write(out, model, RDFFormat.RDFXML_PLAIN);
        return out.toByteArray();
    }
    public byte[] turtle() {
        return utf8(result -> {
            for (Triple t : triples) {
                result.append('<').append(iri(t.subject())).append("> <").append(iri(t.predicate())).append("> ");
                if (t.resource()) result.append('<').append(iri(t.value())).append('>');
                else quote(result, t.value());
                result.append(" .\n");
            }
        });
    }
    public byte[] jsonLd() {
        return utf8(result -> {
            result.append("{\"@graph\":["); boolean comma = false;
            for (Triple t : triples) {
                if (comma) result.append(','); comma = true;
                result.append("{\"@id\":"); quote(result, iri(t.subject())); result.append(',');
                quote(result, iri(t.predicate())); result.append(':');
                result.append(t.resource() ? "{\"@id\":" : "{\"@value\":");
                quote(result, t.resource() ? iri(t.value()) : t.value()); result.append("}}");
            }
            result.append("]}");
        });
    }
    @FunctionalInterface
    private interface TextOutput { void write(Writer output) throws IOException; }

    private static byte[] utf8(TextOutput action) {
        var output = new BoundedOutput();
        try (var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) {
            action.write(writer);
        } catch (IOException failure) {
            throw new IllegalStateException("Cannot serialize OSLC output", failure);
        }
        return output.toByteArray();
    }

    private static final class BoundedOutput extends ByteArrayOutputStream {
        @Override public synchronized void write(int value) {
            requireSpace(1);
            super.write(value);
        }

        @Override public synchronized void write(byte[] bytes, int offset, int length) {
            Objects.checkFromIndexSize(offset, length, bytes.length);
            requireSpace(length);
            super.write(bytes, offset, length);
        }

        private void requireSpace(int length) {
            if (length > ExchangeXml.MAX_BYTES - count)
                throw ExchangeXml.invalid("PACKAGE_SIZE", "OSLC output exceeds 16 MiB");
        }
    }
    private static String iri(String value) {
        if (value == null || value.chars().anyMatch(c -> c <= 32 || "<>\"{}|^`\\".indexOf(c) >= 0) || !java.net.URI.create(value).isAbsolute())
            throw new IllegalArgumentException("RDF resource must be an absolute safe IRI"); return value;
    }
    private static void quote(Writer result, String value) throws IOException {
        result.append('"');
        int start = 0;
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            boolean escape = c == '\\' || c == '"' || c < 32;
            if (escape || i - start == 4096) {
                result.write(value, start, i - start);
                start = i;
            }
            if (!escape) continue;
            switch (c) {
                case '\\' -> result.append("\\\\"); case '"' -> result.append("\\\"");
                case '\n' -> result.append("\\n"); case '\r' -> result.append("\\r"); case '\t' -> result.append("\\t");
                default -> result.append("\\u00")
                        .append("0123456789abcdef".charAt(c >>> 4))
                        .append("0123456789abcdef".charAt(c & 15));
            }
            start = i + 1;
        }
        result.write(value, start, value.length() - start);
        result.append('"');
    }
}