/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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.apache.jena.riot.writer ; import static org.apache.jena.graph.Triple.ANY; import static org.apache.jena.rdf.model.impl.Util.isLangString; import static org.apache.jena.rdf.model.impl.Util.isSimpleString; import java.io.IOException ; import java.io.OutputStream ; import java.io.OutputStreamWriter ; import java.io.Writer ; import java.util.LinkedHashMap; import java.util.Map; import java.util.Map.Entry; import java.util.function.Consumer; import org.apache.jena.atlas.io.IO ; import org.apache.jena.atlas.lib.Chars ; import org.apache.jena.atlas.lib.Pair; import org.apache.jena.graph.Graph ; import org.apache.jena.graph.Node ; import org.apache.jena.graph.Triple ; import org.apache.jena.iri.IRI ; import org.apache.jena.riot.Lang ; import org.apache.jena.riot.RDFFormat ; import org.apache.jena.riot.RiotException ; import org.apache.jena.riot.WriterDatasetRIOT ; import org.apache.jena.riot.system.PrefixMap ; import org.apache.jena.riot.system.PrefixMapFactory; import org.apache.jena.sparql.core.DatasetGraph ; import org.apache.jena.sparql.util.Context ; import org.apache.jena.sparql.util.Symbol; import org.apache.jena.vocabulary.RDF ; import com.fasterxml.jackson.core.JsonGenerationException ; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonMappingException ; import com.github.jsonldjava.core.JsonLdApi; import com.github.jsonldjava.core.JsonLdError ; import com.github.jsonldjava.core.JsonLdOptions ; import com.github.jsonldjava.core.JsonLdProcessor ; import com.github.jsonldjava.core.RDFDataset; import com.github.jsonldjava.utils.JsonUtils ; /** * Writer that prints out JSON-LD. * * By default, the output is "compact" (in JSON-LD terminology), and the JSON is "pretty" (using line breaks). * One can choose another form using one of the dedicated RDFFormats (JSONLD_EXPAND_PRETTY, etc.). * * For formats using a context (that is, which have an "@context" node), (compact and expand), * this automatically generates a default one. * * One can pass a jsonld context using the (jena) Context mechanism, defining a (jena) Context * (sorry for this clash of "contexts"), (cf. last argument in * {@link WriterDatasetRIOT#write(OutputStream out, DatasetGraph datasetGraph, PrefixMap prefixMap, String baseURI, Context context)}) * with: * <pre> * Context jenaContext = new Context() * jenaCtx.set(JsonLDWriter.JSONLD_CONTEXT, contextAsJsonString); * </pre> * where contextAsJsonString is a JSON string containing the value of the "@context". * * It is possible to change the content of the "@context" node in the output using the {@link #JSONLD_CONTEXT_SUBSTITUTION} Symbol. * * For a frame output, one must pass a frame in the jenaContext using the {@link #JSONLD_FRAME} Symbol. * * It is also possible to define the different options supported * by JSONLD-java using the {@link #JSONLD_OPTIONS} Symbol * * The {@link org.apache.jena.riot.JsonLDWriteContext} is a convenience class that extends Context and * provides methods to set the values of these different Symbols that are used in controlling the writing of JSON-LD. */ public class JsonLDWriter extends WriterDatasetRIOTBase { private static final String SYMBOLS_NS = "http://jena.apache.org/riot/jsonld#" ; private static Symbol createSymbol(String localName) { return Symbol.create(SYMBOLS_NS + localName); } /** * Expected value: the value of the "@context" * (a JSON String, or the object expected by the JSONLD-java API) */ public static final Symbol JSONLD_CONTEXT = createSymbol("JSONLD_CONTEXT"); /** * Expected value: the value of the "@context" to be put in final output (a JSON String) * This is NOT the context used to produce the output (given by JSONLD_CONTEXT, * or computed from the input RDF. It is something that will replace the @context content. * This is useful<ol><li>for the cases you want to have a URI as value of @context, * without having JSON-LD java to download it and</li><li>as a trick to * change the URIs in your result.</li></ol> * * Only for compact and flatten formats. * * Note that it is supposed to be a JSON String: to set the value of @context to a URI, * the String must be quoted.*/ public static final Symbol JSONLD_CONTEXT_SUBSTITUTION = createSymbol("JSONLD_CONTEXT_SUBSTITUTION"); /** value: a JSON String, or the frame object expected by JsonLdProcessor.frame */ public static final Symbol JSONLD_FRAME = createSymbol("JSONLD_FRAME"); /** value: the option object expected by JsonLdProcessor (instance of JsonLdOptions) */ public static final Symbol JSONLD_OPTIONS = createSymbol("JSONLD_OPTIONS"); /** * if creating a (jsonld) context from dataset, should we include all the prefixes defined in graph's prefix mappings * value: a Boolean (default: true) */ public static final Symbol JSONLD_ADD_ALL_PREFIXES_TO_CONTEXT = createSymbol("JSONLD_ADD_ALL_PREFIXES_TO_CONTEXT"); private final RDFFormat format ; public JsonLDWriter(RDFFormat syntaxForm) { format = syntaxForm ; } @Override public Lang getLang() { return format.getLang() ; } @Override public void write(Writer out, DatasetGraph dataset, PrefixMap prefixMap, String baseURI, Context context) { serialize(out, dataset, prefixMap, baseURI, context) ; } @Override public void write(OutputStream out, DatasetGraph dataset, PrefixMap prefixMap, String baseURI, Context context) { Writer w = new OutputStreamWriter(out, Chars.charsetUTF8) ; write(w, dataset, prefixMap, baseURI, context) ; IO.flush(w) ; } private RDFFormat.JSONLDVariant getVariant() { return (RDFFormat.JSONLDVariant) format.getVariant(); } private JsonLdOptions getJsonLdOptions(String baseURI, Context jenaContext) { JsonLdOptions opts = null; if (jenaContext != null) { opts = (JsonLdOptions) jenaContext.get(JSONLD_OPTIONS); } if (opts == null) { opts = defaultJsonLdOptions(baseURI); } return opts; } // jena is not using same default as JSONLD-java // maybe we should have, but it's too late now: // changing it now would imply some unexpected changes in current users' outputs static private JsonLdOptions defaultJsonLdOptions(String baseURI) { JsonLdOptions opts = new JsonLdOptions(baseURI); opts.useNamespaces = true ; // this is NOT jsonld-java's default // opts.setUseRdfType(true); // false -> use "@type" opts.setUseNativeTypes(true); // this is NOT jsonld-java's default opts.setCompactArrays(true); // this is jsonld-java's default return opts; } private void serialize(Writer writer, DatasetGraph dataset, PrefixMap prefixMap, String baseURI, Context jenaContext) { try { JsonLdOptions opts = getJsonLdOptions(baseURI, jenaContext) ; // we can benefit from the fact we know that there are no duplicates in the jsonld RDFDataset that we create // (optimization in jsonld-java 0.8.3) // see https://github.com/jsonld-java/jsonld-java/pull/173 // with this, we cannot call the json-ld fromRDF method that assumes no duplicates in RDFDataset // Object obj = JsonLdProcessor.fromRDF(dataset, opts, new JenaRDF2JSONLD()) ; final RDFDataset jsonldDataset = (new JenaRDF2JSONLD()).parse(dataset); @SuppressWarnings("deprecation") // JsonLdApi.fromRDF(RDFDataset, boolean) is "experimental" rather than "deprecated" Object obj = (new JsonLdApi(opts)).fromRDF(jsonldDataset, true); // true because we know that we don't have any duplicate in jsonldDataset RDFFormat.JSONLDVariant variant = getVariant(); if (variant.isExpand()) { // nothing more to do } else if (variant.isFrame()) { Object frame = null; if (jenaContext != null) { frame = jenaContext.get(JSONLD_FRAME); } if (frame == null) { throw new IllegalArgumentException("No frame object found in jena Context"); } if (frame instanceof String) { frame = JsonUtils.fromString((String) frame); } obj = JsonLdProcessor.frame(obj, frame, opts); } else { // compact or flatten // we need a (jsonld) context. Get it from jenaContext, or create one: Object ctx = getJsonldContext(dataset, prefixMap, jenaContext); if (variant.isCompact()) { obj = JsonLdProcessor.compact(obj, ctx, opts); } else if (variant.isFlatten()) { obj = JsonLdProcessor.flatten(obj, ctx, opts); } else { throw new IllegalArgumentException("Unexpected " + RDFFormat.JSONLDVariant.class.getName() + ": " + variant); } // replace @context in output? if (jenaContext != null) { Object ctxReplacement = jenaContext.get(JSONLD_CONTEXT_SUBSTITUTION); if (ctxReplacement != null) { if (obj instanceof Map) { @SuppressWarnings("unchecked") Map<String, Object> map = (Map<String, Object>) obj; if (map.containsKey("@context")) { map.put("@context", JsonUtils.fromString(ctxReplacement.toString())); } } } } } if (variant.isPretty()) { JsonUtils.writePrettyPrint(writer, obj) ; } else { JsonUtils.write(writer, obj) ; } writer.write("\n") ; } catch (JsonLdError | JsonMappingException | JsonGenerationException e) { throw new RiotException(e) ; } catch (IOException e) { IO.exception(e) ; } } // // getting / creating a (jsonld) context // /** Get the (jsonld) context from the jena context, or create one */ private static Object getJsonldContext(DatasetGraph dataset, PrefixMap prefixMap, Context jenaContext) throws JsonParseException, IOException { Object ctx = null; boolean isCtxDefined = false; // to allow jenaContext to set ctx to null. Useful? if (jenaContext != null) { if (jenaContext.isDefined(JSONLD_CONTEXT)) { isCtxDefined = true; Object o = jenaContext.get(JSONLD_CONTEXT); if (o != null) { if (o instanceof String) { // supposed to be a json string String jsonString = (String) o; ctx = JsonUtils.fromString(jsonString); } else { ctx = o; } } } } if (!isCtxDefined) { // if no ctx passed via jenaContext, create one in order to have localnames as keys for properties ctx = createJsonldContext(dataset.getDefaultGraph(), prefixMap, addAllPrefixesToContextFlag(jenaContext)) ; // I don't think this should be done: the JsonLdProcessor begins // by looking whether the argument passed is a map with key "@context" and if so, takes corresponding value // Then, better not to do this: we create a map for nothing, and worse, // if the context object has been created by a user and passed through the (jena) context // in case he got the same idea, we would end up with 2 levels of maps and it would not work // Map<String, Object> localCtx = new HashMap<>() ; // localCtx.put("@context", ctx) ; } return ctx; } static Object createJsonldContext(Graph g) { return createJsonldContext(g, PrefixMapFactory.create(g.getPrefixMapping()), true); } private static Object createJsonldContext(Graph g, PrefixMap prefixMap, boolean addAllPrefixesToContext) { final Map<String, Object> ctx = new LinkedHashMap<>() ; // Add properties (in order to get: "localname": ....) addProperties(ctx, g); // Add prefixes addPrefixes(ctx, g, prefixMap, addAllPrefixesToContext); return ctx ; } /** Add properties to jsonld context. */ static void addProperties(Map<String, Object> ctx, Graph g) { Consumer<Triple> x = new Consumer<Triple>() { @Override public void accept(Triple item) { Node p = item.getPredicate() ; Node o = item.getObject() ; if ( p.equals(RDF.type.asNode()) ) return ; String x = p.getLocalName() ; if ( ctx.containsKey(x) ) { } else if ( o.isBlank() || o.isURI() ) { // add property as a property (the object is an IRI) Map<String, Object> x2 = new LinkedHashMap<>() ; x2.put("@id", p.getURI()) ; x2.put("@type", "@id") ; ctx.put(x, x2) ; } else if ( o.isLiteral() ) { String literalDatatypeURI = o.getLiteralDatatypeURI() ; if ( literalDatatypeURI != null ) { // add property as a typed attribute (the object is a // typed literal) Map<String, Object> x2 = new LinkedHashMap<>() ; x2.put("@id", p.getURI()) ; if (! isLangString(o) && ! isSimpleString(o) ) // RDF 1.1 : Skip if rdf:langString or xsd:string. x2.put("@type", literalDatatypeURI) ; ctx.put(x, x2) ; } else { // add property as an untyped attribute (the object is // an untyped literal) ctx.put(x, p.getURI()) ; } } } } ; g.find(ANY).forEachRemaining(x); } /** * Add the prefixes to jsonld context. * * @param ctx * @param g * @param prefixMap * @param addAllPrefixesToContext true to add all prefixes in prefixMap to the jsonld context, * false to only add those which are actually used in g (false is useful for instance * when downloading schema.org: we get a very long list of prefixes. */ // if adding all the prefixes in PrefixMap to ctx // one pb is, many of the prefixes may be actually unused in the graph. // This happens for instance when downloading schema.org: a very long list of prefixes // hence the addAllPrefixesToContext param private static void addPrefixes(Map<String, Object> ctx, Graph g, PrefixMap prefixMap, boolean addAllPrefixesToContext) { if (prefixMap != null) { Map<String, IRI> mapping = prefixMap.getMapping(); if (addAllPrefixesToContext) { for ( Entry<String, IRI> e : mapping.entrySet() ) { addOnePrefix(ctx, e.getKey(), e.getValue().toString()); } } else { // only add those that are actually used Consumer<Triple> x = new Consumer<Triple>() { @Override public void accept(Triple item) { Node node = item.getSubject(); if (node.isURI()) addPrefix2Ctx(node.getURI()); node = item.getPredicate() ; addPrefix2Ctx(node.getURI()); node = item.getObject() ; if (node.isURI()) addPrefix2Ctx(node.getURI()); } private void addPrefix2Ctx(String resUri) { Pair<String, String> pair = prefixMap.abbrev(resUri); if (pair != null) { String prefix = pair.getLeft(); addOnePrefix(ctx, prefix, mapping.get(prefix).toString()); } } } ; g.find(ANY).forEachRemaining(x); } } } /** Add one prefix to jsonld context */ static void addOnePrefix(Map<String, Object> ctx, String prefix, String value) { if (!prefix.isEmpty()) { // Prefix "" is not allowed in JSON-LD -- could probably be replaced by "@vocab" ctx.put(prefix, value); } } private static boolean addAllPrefixesToContextFlag(Context jenaContext) { if (jenaContext != null) { Object o = jenaContext.get(JSONLD_ADD_ALL_PREFIXES_TO_CONTEXT); if (o != null) { if (o instanceof Boolean) { return ((Boolean) o).booleanValue(); } else { throw new IllegalArgumentException("Value attached to JSONLD_ADD_ALL_PREFIXES_TO_CONTEXT shoud be a Boolean"); } } } // default return true; } }