package org.exist.xquery.functions.fn;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import org.exist.dom.QName;
import org.exist.security.PermissionDeniedException;
import org.exist.source.Source;
import org.exist.source.SourceFactory;
import org.exist.xmldb.XmldbURI;
import org.exist.xquery.*;
import org.exist.xquery.functions.array.ArrayType;
import org.exist.xquery.functions.map.MapType;
import org.exist.xquery.value.*;
import java.io.IOException;
import java.io.InputStream;
/**
* Functions related to JSON parsing.
*
* @author Wolf
*/
public class JSON extends BasicFunction {
public static final FunctionSignature[] signatures = {
new FunctionSignature(
new QName("parse-json", Function.BUILTIN_FUNCTION_NS),
"Parses a string supplied in the form of a JSON text, returning the results typically in the form of a map or array.",
new SequenceType[]{
new FunctionParameterSequenceType("json-text", Type.STRING, Cardinality.ZERO_OR_ONE, "JSON string")
},
new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "The parsed data, typically a map, array or atomic value")
),
new FunctionSignature(
new QName("parse-json", Function.BUILTIN_FUNCTION_NS),
"Parses a string supplied in the form of a JSON text, returning the results typically in the form of a map or array.",
new SequenceType[]{
new FunctionParameterSequenceType("json-text", Type.STRING, Cardinality.ZERO_OR_ONE, "JSON string"),
new FunctionParameterSequenceType("options", Type.MAP, Cardinality.EXACTLY_ONE, "Parsing options")
},
new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "The parsed data, typically a map, array or atomic value")
),
new FunctionSignature(
new QName("json-doc", Function.BUILTIN_FUNCTION_NS),
"Reads an external (or database) resource containing JSON, and returns the results of parsing the resource as JSON. An URL parameter " +
"without scheme or scheme 'xmldb:' is considered to point to a database resource.",
new SequenceType[]{
new FunctionParameterSequenceType("href", Type.STRING, Cardinality.ZERO_OR_ONE, "URL pointing to a JSON resource")
},
new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "The parsed data, typically a map, array or atomic value")
),
new FunctionSignature(
new QName("json-doc", Function.BUILTIN_FUNCTION_NS),
"Reads an external (or database) resource containing JSON, and returns the results of parsing the resource as JSON. An URL parameter " +
"without scheme or scheme 'xmldb:' is considered to point to a database resource.",
new SequenceType[]{
new FunctionParameterSequenceType("href", Type.STRING, Cardinality.ZERO_OR_ONE, "URL pointing to a JSON resource"),
new FunctionParameterSequenceType("options", Type.MAP, Cardinality.EXACTLY_ONE, "Parsing options")
},
new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "The parsed data, typically a map, array or atomic value")
)
};
public final static String OPTION_DUPLICATES = "duplicates";
public final static String OPTION_DUPLICATES_REJECT = "reject";
public final static String OPTION_DUPLICATES_USE_FIRST = "use-first";
public final static String OPTION_DUPLICATES_USE_LAST = "use-last";
public final static String OPTION_LIBERAL = "liberal";
public final static String OPTION_UNESCAPE = "unescape";
public JSON(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
@Override
public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathException {
if (context.getXQueryVersion() < 31) {
throw new XPathException(this, ErrorCodes.EXXQDY0004, "json functions only available in XQuery 3.1, but version declaration states " +
context.getXQueryVersion());
}
// process options if present
// TODO: jackson does not allow access to raw string, so option "unescape" is not supported
boolean liberal = false;
String handleDuplicates = OPTION_DUPLICATES_USE_LAST;
if (getArgumentCount() == 2) {
final MapType options = (MapType)args[1].itemAt(0);
final Sequence liberalOpt = options.get(new StringValue(OPTION_LIBERAL));
if (liberalOpt.hasOne()) {
liberal = liberalOpt.itemAt(0).convertTo(Type.BOOLEAN).effectiveBooleanValue();
}
final Sequence duplicateOpt = options.get(new StringValue(OPTION_DUPLICATES));
if (duplicateOpt.hasOne()) {
handleDuplicates = duplicateOpt.itemAt(0).getStringValue();
}
}
JsonFactory factory = new JsonFactory();
factory.configure(JsonParser.Feature.ALLOW_NON_NUMERIC_NUMBERS, true);
// duplicates are handled in readValue
factory.configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, false);
if (liberal) {
factory.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
factory.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
factory.configure(JsonParser.Feature.ALLOW_NUMERIC_LEADING_ZEROS, true);
factory.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
factory.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true);
}
if (isCalledAs("parse-json")) {
return parse(args[0], handleDuplicates, factory);
} else {
return parseResource(args[0], handleDuplicates, factory);
}
}
private Sequence parse(Sequence json, String handleDuplicates, JsonFactory factory) throws XPathException {
if (json.isEmpty()) {
return Sequence.EMPTY_SEQUENCE;
}
try {
final JsonParser parser = factory.createParser(json.itemAt(0).getStringValue());
final Item result = readValue(context, parser, handleDuplicates);
return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
} catch (IOException e) {
throw new XPathException(this, ErrorCodes.FOJS0001, e.getMessage());
} catch (XPathException e) {
e.setLocation(getLine(), getColumn(), getSource());
throw e;
}
}
private Sequence parseResource(Sequence href, String handleDuplicates, JsonFactory factory) throws XPathException {
if (href.isEmpty()) {
return Sequence.EMPTY_SEQUENCE;
}
try {
String url = href.getStringValue();
if (url.indexOf(':') == Constants.STRING_NOT_FOUND) {
url = XmldbURI.EMBEDDED_SERVER_URI_PREFIX + url;
}
final Source source = SourceFactory.getSource(context.getBroker(), "", url, false);
if (source == null) {
throw new XPathException(this, ErrorCodes.FOUT1170, "failed to load json doc from URI " + url);
}
final InputStream is = source.getInputStream();
final JsonParser parser = factory.createParser(is);
final Item result = readValue(context, parser, handleDuplicates);
return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
} catch (IOException | PermissionDeniedException e) {
throw new XPathException(this, ErrorCodes.FOUT1170, e.getMessage());
}
}
/**
* Generate an XDM from the tokens delivered by the JSON parser.
*
* @param context the XQueryContext
* @param parser parser to use
* @param handleDuplicates string indicating how to handle duplicate property names
* @return the top item read
* @throws IOException
* @throws XPathException
*/
public static Item readValue(XQueryContext context, JsonParser parser, String handleDuplicates) throws IOException, XPathException {
return readValue(context, parser, null, handleDuplicates);
}
private static Item readValue(XQueryContext context, JsonParser parser, Item parent, String handleDuplicates) throws IOException, XPathException {
JsonToken token;
Item next = null;
while ((token = parser.nextValue()) != null) {
if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) {
return parent;
}
switch (token) {
case START_OBJECT:
next = new MapType(context, null);
readValue(context, parser, next, handleDuplicates);
break;
case START_ARRAY:
next = new ArrayType(context, Sequence.EMPTY_SEQUENCE);
readValue(context, parser, next, handleDuplicates);
break;
case VALUE_FALSE:
next = BooleanValue.FALSE;
break;
case VALUE_TRUE:
next = BooleanValue.TRUE;
break;
case VALUE_NUMBER_FLOAT:
case VALUE_NUMBER_INT:
// according to spec, all numbers are converted to double
next = new StringValue(parser.getText()).convertTo(Type.DOUBLE);
break;
case VALUE_NULL:
next = null;
break;
default:
next = new StringValue(parser.getText());
break;
}
if (parent != null) {
switch (parent.getType()) {
case Type.ARRAY:
((ArrayType)parent).add(next == null ? Sequence.EMPTY_SEQUENCE : next.toSequence());
break;
case Type.MAP:
final String currentName = parser.getCurrentName();
if (currentName == null) {
throw new XPathException(ErrorCodes.FOJS0001, "Invalid JSON object");
}
final StringValue name = new StringValue(currentName);
final MapType map = (MapType) parent;
if (map.contains(name)) {
// handle duplicate keys
if (handleDuplicates.equals(OPTION_DUPLICATES_REJECT)) {
throw new XPathException(ErrorCodes.FOJS0003, "Duplicate key: " + currentName);
}
if (handleDuplicates.equals(OPTION_DUPLICATES_USE_LAST)) {
map.add(name, next == null ? Sequence.EMPTY_SEQUENCE : next.toSequence());
}
} else {
map.add(name, next == null ? Sequence.EMPTY_SEQUENCE : next.toSequence());
}
break;
}
}
}
return next;
}
}