RDFJSON.java

/*
 * Copyright (c) 2007-2017 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.util;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import org.eclipse.rdf4j.model.BNode;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.rio.RDFParseException;
import org.entrystore.repository.util.NS;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;

import static org.eclipse.rdf4j.model.util.Values.iri;

/**
 * A utility class to help converting Sesame Graphs from and to RDF/JSON.
 * 
 * @author Hannes Ebner <hebner@csc.kth.se>
 */
public class RDFJSON {

	private static final Logger log = LoggerFactory.getLogger(RDFJSON.class);

	private static final IRI dtString = iri(NS.xsd, "string");

	private static final IRI dtLangString = iri(NS.rdf, "langString");

	private static final ValueFactory vf = SimpleValueFactory.getInstance();

	/**
	 * Implementation using the json.org API.
	 *
	 * @param json
	 *            The RDF/JSON string to be parsed and converted into a Sesame
	 *            Graph.
	 * @return A Sesame Graph if successful, otherwise null.
	 */
	public static Model rdfJsonToGraph(JSONObject json) throws RDFParseException {
		Model result = new LinkedHashModel();
		HashMap<String, BNode> id2bnode = new HashMap<>();

		try {
			Iterator<String> subjects = json.keys();
			while (subjects.hasNext()) {
				String subjStr = subjects.next();
				Resource subject;
				try {
					if (subjStr.startsWith("_:")) {
						if (id2bnode.containsKey(subjStr)) {
							subject = id2bnode.get(subjStr);
						} else {
							subject = vf.createBNode();
							id2bnode.put(subjStr, (BNode) subject);
						}
					} else {
						subject = parseAndValidateIRI(subjStr);
					}
				} catch (IllegalArgumentException iae) {
					subject = vf.createBNode();
				}
				JSONObject pObj = json.getJSONObject(subjStr);
				Iterator<String> predicates = pObj.keys();
				while (predicates.hasNext()) {
					String predStr = predicates.next();
					IRI predicate = parseAndValidateIRI(predStr);
					JSONArray predArr = pObj.getJSONArray(predStr);
					for (int i = 0; i < predArr.length(); i++) {
						Value object = null;
						JSONObject obj = predArr.getJSONObject(i);
						if (!obj.has("value")) {
							continue;
						}
						String value = obj.getString("value");
						if (!obj.has("type")) {
							continue;
						}
						String type = obj.getString("type");
						String lang = null;
						if (obj.has("lang")) {
							lang = obj.getString("lang");
							if (lang.trim().isEmpty()) {
								lang = null;
							}
						}
						IRI datatype = null;
						if (obj.has("datatype")) {
							datatype = parseAndValidateIRI(obj.getString("datatype"));
						}
						if ("literal".equals(type)) {
							if (lang != null) {
								object = vf.createLiteral(value, lang);
							} else if (datatype != null) {
								object = vf.createLiteral(value, datatype);
							} else {
								object = vf.createLiteral(value);
							}
						} else if ("bnode".equals(type)) {
							if (id2bnode.containsKey(value)) {
								object = id2bnode.get(value);
							} else {
								object = vf.createBNode();
								id2bnode.put(value, (BNode) object);
							}
						} else if ("uri".equals(type)) {
							object = parseAndValidateIRI(value);
						}
						result.add(subject, predicate, object);
					}
				}
			}
		} catch (JSONException e) {
			log.error(e.getMessage(), e);
			return null;
		}

		return result;
	}

	public static Model rdfJsonToGraph(String json) {
		try {
			return rdfJsonToGraph(new JSONObject(json));
		} catch (JSONException e) {
			log.error(e.getMessage(), e);
			return null;
		}
	}

	private static JSONObject getValue(Value v) {
		JSONObject valueObj = new JSONObject();
		if (v instanceof BNode && !v.stringValue().startsWith("_:")) {
			valueObj.put("value", "_:" + v.stringValue());
		} else {
			valueObj.put("value", v.stringValue());
		}
		if (v instanceof Literal l) {
			valueObj.put("type", "literal");
			if (l.getLanguage().isPresent()) {
				valueObj.put("lang", l.getLanguage().get());
			} else if (l.getDatatype() != null) {
				IRI dataType = l.getDatatype();
				// we ignore data types for strings (as introduced by RDF 1.1) to be compatible with RDF 1.0
				if (!dataType.equals(dtString) && !dataType.equals(dtLangString)) {
					valueObj.put("datatype", dataType.stringValue());
				}
			}
		} else if (v instanceof BNode) {
			valueObj.put("type", "bnode");
		} else if (v instanceof IRI) {
			valueObj.put("type", "uri");
		}
		return valueObj;
	}

	public static JSONObject graphToRdfJsonObject(Model graph) {
		try {
			//First build the json structure using maps to avoid iterating through the graph more than once
			HashMap<Resource, HashMap<IRI, JSONArray>> struct = new HashMap<>();
			for (Statement stmt : graph) {
				Resource subject = stmt.getSubject();
				IRI predicate = stmt.getPredicate();
				Value object = stmt.getObject();
				HashMap<IRI, JSONArray> pred2values = struct.get(subject);
				if (pred2values == null) {
					pred2values = new HashMap<>();
					struct.put(subject, pred2values);
					JSONArray values = new JSONArray();
					pred2values.put(predicate, values);
					values.put(getValue(object));
					continue;
				}
                JSONArray values = pred2values.computeIfAbsent(predicate, k -> new JSONArray());
                values.put(getValue(object));
			}

			//Now construct the JSONObject graph from the structure
			JSONObject result = new JSONObject(); //Top level object
			for (Resource subject : struct.keySet()) {
				JSONObject predicateObj = new JSONObject(); //Predicate object where each predicate is a key
				HashMap<IRI, JSONArray> pred2values = struct.get(subject);
				for (IRI predicate : pred2values.keySet()) {
					predicateObj.put(predicate.stringValue(), pred2values.get(predicate)); //The value is an array of objects
				}

				if (subject instanceof BNode && !subject.stringValue().startsWith("_:")) {
					result.put("_:"+subject.stringValue(), predicateObj);
				} else {
					result.put(subject.stringValue(), predicateObj);
				}
			}
			return result;
		} catch (JSONException e) {
			log.error(e.getMessage(), e);
		}
		return null;
	}

	/**
	 * Implementation using the org.json API.
	 *
	 * @param graph
	 *            A Sesame Graph.
	 * @return An RDF/JSON string if successful, otherwise null.
	 */
	public static String graphToRdfJson(Model graph) {
		JSONObject obj = graphToRdfJsonObject(graph);
		if (obj != null) {
			return obj.toString(2);
		} else {
			return null;
		}
	}

	/**
	 * Implementation using the Streaming API of the Jackson framework.
	 *
	 * @param graph
	 *            A Sesame Graph.
	 * @return An RDF/JSON string if successful, otherwise null.
	 */
	public static String graphToRdfJsonJackson(Model graph) {
		JsonFactory f = new JsonFactory();
		StringWriter sw = new StringWriter();
		JsonGenerator g;
		try {
			g = f.createJsonGenerator(sw);
			g.useDefaultPrettyPrinter();
		} catch (IOException e) {
			log.error(e.getMessage(), e);
			return null;
		}

		try {
			g.writeStartObject(); // root object
			Set<Resource> subjects = new HashSet<>();
			for (Statement s1 : graph) {
				subjects.add(s1.getSubject());
			}
			for (Resource subject : subjects) {
				if (subject instanceof BNode && !subject.stringValue().startsWith("_:")) {
					g.writeObjectFieldStart("_:"+subject.stringValue()); // subject
				} else {
					g.writeObjectFieldStart(subject.stringValue()); // subject
				}
				Set<IRI> predicates = new HashSet<>();
				for (Statement statement : graph.filter(subject, null, null)) {
					predicates.add(statement.getPredicate());
				}
				for (IRI predicate : predicates) {
					g.writeArrayFieldStart(predicate.stringValue()); // predicate
					for (Statement statement : graph.filter(subject, predicate, null)) {
						Value v = statement.getObject();
						g.writeStartObject(); // value
						if (v instanceof BNode && !v.stringValue().startsWith("_:")) {
							g.writeStringField("value", "_:" + v.stringValue());
						} else {
							g.writeStringField("value", v.stringValue());
						}
						if (v instanceof Literal l) {
							g.writeStringField("type", "literal");
							if (l.getLanguage().isPresent()) {
								g.writeStringField("lang", l.getLanguage().get());
							} else if (l.getDatatype() != null) {
								g.writeStringField("datatype", l.getDatatype().stringValue());
							}
						} else if (v instanceof BNode) {
							g.writeStringField("type", "bnode");
						} else if (v instanceof IRI) {
							g.writeStringField("type", "uri");
						}
						g.writeEndObject(); // value
					}
					g.writeEndArray(); // predicate
				}
				g.writeEndObject(); // subject
			}
			g.writeEndObject(); // root object
			g.close();
			return sw.toString();
		} catch (IOException e) {
			log.error(e.getMessage(), e);
		}
		return null;
	}

	private static IRI parseAndValidateIRI(String iri) {
		Objects.requireNonNull(iri);

		try {
			URI uri = new URI(iri);

			if (uri.toString().endsWith(".")) {
				String messageTemplate = "Provided string \"%s\" is not a valid URI. Error: %s";
				throw new RDFParseException(String.format(messageTemplate, iri, "String ends in a period '.'"));
			}

			return vf.createIRI(iri);
		} catch (IllegalArgumentException | URISyntaxException ex) {
			String messageTemplate = "Provided string \"%s\" is not a valid URI. Error: %s";
			throw new RDFParseException(String.format(messageTemplate, iri, ex.getMessage()));
		}
	}

}