JsonBackend.java
/*
** Module : JsonBackend.java
** Abstract : Json backend.
**
** Copyright (c) 2019-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 HC 20190701 Initial version.
** 002 HC 20190714 Added more serializable number types.
** 003 ME 20210128 Escape forward slash as in 4GL, json data type names.
** 20210215 Extend pretty printer to match the 4GL formatted output.
** 20210319 Flush output stream on write, replace BigDecimal use for integer/int64.
** 004 CA 20210709 JSON parse must use a pristine byte stream and use the reader to decode it.
** OM 20210809 Added write/convert support for more object types. Fixed javadoc errors.
** OM 20210825 Added public constants for numbered JSON types. Added missing javadoc.
** CA 20220127 Don't use scientific notation for decimal values.
** CA 20220218 Force scale 1 for decimal values with trailing zeroes.
** ME 20220427 Check for invalid json construct object while parsing tokens. See #6048.
** VVT 20220427 Missing Javadocs comments added, some methods made static as appropriate.
** TJD 20240508 Fixed compiler warnings related with dependencies updates
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.oo.json;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.core.JsonGenerator.Feature;
import com.fasterxml.jackson.core.util.*;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter.Indenter;
import com.goldencode.p2j.oo.json.objectmodel.*;
import com.goldencode.p2j.oo.lang.*;
import com.goldencode.p2j.util.*;
import java.io.*;
import java.math.*;
import java.nio.charset.Charset;
import java.util.*;
/**
* Json backend implementation providing methods for the json legacy classes and functions. The idea is to
* hide the specific json implementation behind this facade.
*/
public class JsonBackend
{
/** Numeric value for JSON String type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_STRING = 1;
/** Numeric value for JSON Number type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_NUMBER = 2;
/** Numeric value for JSON Logical type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_BOOLEAN = 3;
/** Numeric value for JSON Object type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_OBJECT = 4;
/** Numeric value for JSON Array type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_ARRAY = 5;
/** Numeric value for JSON null type. Returned by {@code getType()} / {@code getJsonDataType()}. */
public static final int JSON_NULL = 6;
/**
* The singleton instance.
*/
private static final JsonBackend instance = new JsonBackend();
/**
* Thread-safe factory instance.
*/
private final JsonFactory factory = new JsonFactory().configure(Feature.WRITE_BIGDECIMAL_AS_PLAIN, true);
/**
* Default constructor.
*/
private JsonBackend()
{
}
/**
* Returns the singleton instance.
*
* @return see above.
*/
public static JsonBackend instance()
{
return instance;
}
/**
* Serializes the supplied json construct instance to the target output stream.
*
* @param target
* Target output stream.
* @param construct
* The json construct instance to serialize.
* @param prettyPrint
* If {@code true} the output will be formatted.
* @param legacyEnc
* The character encoding name to use.
*
* @return the Java encoding name resolved from the legacy encoding name.
*
* @throws IOException
* in case of an IO error.
*/
public String write(OutputStream target, JsonConstruct construct, boolean prettyPrint, String legacyEnc)
throws IOException
{
JsonEncoding enc = getEncoding(legacyEnc);
JsonGenerator gen = factory.createGenerator(target, enc);
if (prettyPrint)
{
gen.setPrettyPrinter(ProPrettyPrinter.instance());
}
// 4GL escapes forward slash
gen.setCharacterEscapes(ProCharacterEscapes.instance());
writeConstruct(gen, construct);
gen.flush();
// skip this as it will close the stream and not always what we need gen.close();
// 4GL add a new line at the end of document
if (prettyPrint)
{
target.write("\n".getBytes(Charset.forName(enc.getJavaName())));
}
// make sure we write everything, do not close the stream though
target.flush();
return enc.getJavaName();
}
/**
* Deserializes the source text into a json object tree.
*
* @param source
* The source text to parse.
*
* @return json construct instance.
*
* @throws IOException
* @throws JsonParseException
*/
public object<? extends JsonConstruct> parse(String source) throws JsonParseException, IOException
{
JsonParser parser = factory.createParser(source);
return parseImpl(parser);
}
/**
* Deserializes the supplied input stream into a json object tree.
*
* @param source
* The input reader to parse.
*
* @return json construct instance.
*
* @throws IOException
* @throws JsonParseException
*/
public object<? extends JsonConstruct> parse(Reader source) throws JsonParseException, IOException
{
JsonParser parser = factory.createParser(source);
return parseImpl(parser);
}
/**
* Converts the legacy value to a native Java value suitable for json serialization.
*
* @param value
* The legacy value to convert.
*
* @return the converted value.
*/
public static Object convertLegacyValueToJson(BaseDataType value)
{
if (value == null)
{
throw new IllegalArgumentException();
}
if (value.isUnknown())
{
return null;
}
Object targetValue = null;
// convert the value to json-compatible data type
if (value instanceof integer)
{
targetValue = ((integer) value).toJavaIntegerType();
}
else if (value instanceof int64)
{
targetValue = ((int64) value).toJavaLongType();
}
else if (value instanceof decimal)
{
decimal d = (decimal) value;
BigDecimal bd = d.toBigDecimal().stripTrailingZeros();
targetValue = bd;
if (d.getPrecision() != 0 && bd.toPlainString().indexOf('.') < 0)
{
targetValue = bd.setScale(1);
}
}
else if (value instanceof NumberType)
{
targetValue = ((NumberType) value).toBigDecimal().stripTrailingZeros();
}
else if (value instanceof Text)
{
targetValue = ((Text) value).getValue();
}
else if (value instanceof logical)
{
targetValue = ((logical) value).booleanValue() ? Boolean.TRUE : Boolean.FALSE;
}
else if (value instanceof date)
{
targetValue = date.isoDate((date) value).toStringMessage();
}
else if (value instanceof handle)
{
targetValue = BigInteger.valueOf(((handle) value).getResourceId());
}
else if (value instanceof comhandle)
{
Long id = comhandle.resourceId(((comhandle) value).getResource());
targetValue = BigInteger.valueOf(id);
}
else if (value instanceof BinaryData)
{
Base64.Encoder enc = Base64.getEncoder();
targetValue = enc.encodeToString(((BinaryData) value).getByteArray());
}
else if (value instanceof object)
{
// do the type cast, we want to see the class cast exception eventually
targetValue = ((object) value).ref();
}
else if (value instanceof rowid)
{
Base64.Encoder enc = Base64.getEncoder();
targetValue = enc.encodeToString(Utils.longToBytesLE(((rowid) value).getValue()));
}
return targetValue;
}
/**
* Implementation of {@code getType} legacy method of {@linkplain JsonArray} and
* {@linkplain JsonConstruct}.
*
* @param value
* Json value.
*
* @return the numeric value representing the value type.
*/
public static int getJsonDataType(Object value)
{
if (value == null)
{
return JSON_NULL;
}
else if (value instanceof String)
{
return JSON_STRING;
}
else if (value instanceof Number)
{
return JSON_NUMBER;
}
else if (value instanceof Boolean)
{
return JSON_BOOLEAN;
}
else if (value instanceof JsonObject)
{
return JSON_OBJECT;
}
else if (value instanceof JsonArray)
{
return JSON_ARRAY;
}
else
{
throw new RuntimeException("Unexpected json value data type " + value.getClass());
}
}
/**
* Returns the string representation of object's corresponding JSON data type.
*
* @param type
* Json data type.
*
* @return the string value representing the JSON data type.
*/
public static String getJsonDataTypeName(int type)
{
switch (type)
{
case JSON_ARRAY: return "array";
case JSON_OBJECT: return "object";
case JSON_BOOLEAN: return "boolean";
case JSON_NUMBER: return "number";
case JSON_STRING: return "string";
default: return "null";
}
}
/**
* Implements serialization of the supplied json construct instance using the supplied json generator.
*
* @param jsonGen
* Json generator.
* @param construct
* The json construct instance to serialize.
*
* @throws IOException
* in case of an IO error.
*/
private void writeConstruct(JsonGenerator jsonGen, JsonConstruct construct)
throws IOException
{
if (construct instanceof JsonArray)
{
jsonGen.writeStartArray();
for (Object element : ((JsonArray) construct).getElements())
{
writeValue(jsonGen, element);
}
jsonGen.writeEndArray();
}
else
{
jsonGen.writeStartObject();
for (Map.Entry<String, Object> prop : ((JsonObject) construct).getProperties())
{
jsonGen.writeFieldName(prop.getKey());
writeValue(jsonGen, prop.getValue());
}
jsonGen.writeEndObject();
}
}
/**
* Implements serialization of the supplied json construct instance using the supplied json generator.
*
* @param jsonGen
* Json generator.
* @param value
* The value to serialize.
*
* @throws IOException
* in case of an IO error.
*/
private void writeValue(JsonGenerator jsonGen, Object value)
throws IOException
{
if (value == null)
{
jsonGen.writeNull();
}
else if (value instanceof JsonConstruct)
{
writeConstruct(jsonGen, (JsonConstruct) value);
}
else if (value instanceof BaseObject)
{
BaseObject bo = (BaseObject) value;
jsonGen.writeStartObject();
jsonGen.writeFieldName("prods:objId");
jsonGen.writeNumber(object.resourceId(new object<>(bo)));
jsonGen.writeFieldName(bo.getLegacyClass().ref().getTypeName().toStringMessage());
jsonGen.writeStartObject();
// what here?
jsonGen.writeEndObject();
jsonGen.writeEndObject();
}
else if (value instanceof Boolean)
{
jsonGen.writeBoolean((Boolean) value);
}
else if (value instanceof BigInteger)
{
jsonGen.writeNumber((BigInteger) value);
}
else if (value instanceof BigDecimal)
{
BigDecimal bd = (BigDecimal) value;
if (bd.scale() == 0)
{
jsonGen.writeNumber(bd.doubleValue());
}
else
{
jsonGen.writeNumber(bd);
}
}
else if (value instanceof String)
{
jsonGen.writeString((String) value);
}
else if (value instanceof Integer)
{
jsonGen.writeNumber((Integer) value);
}
else if (value instanceof Long)
{
jsonGen.writeNumber((Long) value);
}
else if (value instanceof Float)
{
jsonGen.writeNumber((Float) value);
}
else if (value instanceof Short)
{
jsonGen.writeNumber((Short) value);
}
else if (value instanceof Double)
{
jsonGen.writeNumber((Double) value);
}
else
{
throw new RuntimeException("Unexpected primitive value type " + value.getClass());
}
}
/**
* Deserializes json object tree using the supplied parser as the input of json tokens.
*
* @param parser
* Setup json parser.
*
* @return json construct instance.
*
* @throws JsonParseException
*/
private static object<? extends JsonConstruct> parseImpl(JsonParser parser) throws JsonParseException
{
try
{
Stack<object<? extends JsonConstruct>> constructStack = new Stack<>();
JsonToken tok;
object<? extends JsonConstruct> result = null;
while ((tok = parser.nextToken()) != null)
{
JsonConstruct con = constructStack.isEmpty() ? null : constructStack.peek().ref();
String name = parser.currentName();
switch (tok)
{
case START_OBJECT:
case START_ARRAY:
object<? extends JsonConstruct> newCon =
tok == JsonToken.START_OBJECT ? ObjectOps.newInstance(JsonObject.class) :
ObjectOps.newInstance(JsonArray.class);
if (con != null)
{
con.addElement(name, newCon);
}
constructStack.push(newCon);
if (result == null)
{
result = newCon;
}
break;
case END_ARRAY:
case END_OBJECT:
constructStack.pop();
break;
case VALUE_TRUE:
checkValidConstruct(con, parser);
con.addElement(name, true);
break;
case VALUE_FALSE:
checkValidConstruct(con, parser);
con.addElement(name, false);
break;
case VALUE_NUMBER_FLOAT:
checkValidConstruct(con, parser);
con.addElement(name, parser.getDecimalValue());
break;
case VALUE_NUMBER_INT:
checkValidConstruct(con, parser);
con.addElement(name, parser.getBigIntegerValue());
break;
case VALUE_STRING:
checkValidConstruct(con, parser);
con.addElement(name, parser.getValueAsString());
break;
case VALUE_NULL:
checkValidConstruct(con, parser);
con.addElementNull(name);
break;
case FIELD_NAME:
// do nothing, the name is retrieved from getCurrentName
break;
default:
throwParserException(parser);
}
}
if (result == null)
{
throwParserException(parser);
}
return result;
}
catch (IOException ex)
{
throw new JsonParseException(parser, ex.getMessage(), ex);
}
}
/**
* Throw a {@link JsonParseException} if the the argument is not a valid construct.
*
* @param con
* the construct to check.
* @param parser
* the parser to use to create the exception
*
* @throws JsonParseException
* if the argument is {@code null}
*/
private static void checkValidConstruct(JsonConstruct con, JsonParser parser) throws JsonParseException {
if (con == null)
{
throwParserException(parser);
}
}
/**
* Convenience function to throw {@link JsonParseException}.
*
* @param parser
* the parser to use to create the exception
*
* @throws JsonParseException
*/
private static void throwParserException(JsonParser parser) throws JsonParseException {
throw new JsonParseException(parser, "Invalid JSON");
}
/**
* Converts the legacy encoding name to JsonEncoding.
*
* @param legacyEncoding
* Legacy encoding.
*
* @return see above.
*/
private static JsonEncoding getEncoding(String legacyEncoding)
{
if (legacyEncoding == null)
{
return JsonEncoding.UTF8;
}
switch (legacyEncoding.toUpperCase())
{
case "":
case "UTF-8":
return JsonEncoding.UTF8;
case "UTF-16":
case "UTF-16LE":
return JsonEncoding.UTF16_LE;
case "UTF-16BE":
return JsonEncoding.UTF16_BE;
case "UTF-32":
case "UTF-32LE":
return JsonEncoding.UTF32_LE;
case "UTF-32BE":
return JsonEncoding.UTF32_BE;
default:
throw new RuntimeException("Unsupported encoding " + legacyEncoding);
}
}
/**
* A singleton class used for escaping characters while writing JSON strings. Takes special care for the
* '/' character.
*/
private static class ProCharacterEscapes
extends CharacterEscapes
{
/** The singleton instance. */
private static final ProCharacterEscapes _escapeInstance = new ProCharacterEscapes();
/**
* Getter for the singleton instance.
*
* @return the singleton instance.
*/
public static ProCharacterEscapes instance() { return _escapeInstance; }
/** The special character '/'. */
private final SerializedString fwdSlash;
/** Codes of all escaped characters. */
private final int[] asciiEscapes;
/** The private constructor of the singleton. */
private ProCharacterEscapes()
{
fwdSlash = new SerializedString("\\/");
asciiEscapes = standardAsciiEscapesForJSON();
asciiEscapes['/'] = CharacterEscapes.ESCAPE_CUSTOM;
}
/**
* Obtain the full set of escaped ASCII codes.
*
* @return the full set of escaped ASCII codes.
*/
@Override
public int[] getEscapeCodesForAscii()
{
return asciiEscapes;
}
/**
* Get the escape string for a character {@code ch}.
*
* @param ch
* The character to be escaped.
*
* @return The escape string {@code ch} character.
*/
@Override
public SerializableString getEscapeSequence(int ch)
{
if (ch == '/')
{
return fwdSlash;
}
return null;
}
}
/** Singleton used for overwriting the separator between the field name and its value in a JSON string. */
private static class ProPrettyPrinter
extends DefaultPrettyPrinter
{
/** The singleton instance. */
private static final ProPrettyPrinter _printerInstance = new ProPrettyPrinter();
/**
* Getter for the singleton instance.
*
* @return The singleton instance.
*/
public static ProPrettyPrinter instance() { return _printerInstance; }
/** The private constructor */
private ProPrettyPrinter()
{
indentArraysWith(TwoSpacesIndenter.instance());
indentObjectsWith(TwoSpacesIndenter.instance());
}
/**
* Core method of the class. Overwrites the separator to match 4GL output.
* @param g
* The {@code JsonGenerator} in use.
*
* @throws IOException
* if {@code g.writeRaw} fails.
*/
@Override
public void writeObjectFieldValueSeparator(JsonGenerator g)
throws IOException
{
// no space between name and colon
g.writeRaw(": ");
}
}
/** Singleton used for matching the indentation of the output JSON string with 4GL.*/
private static class TwoSpacesIndenter
implements Indenter
{
/** The singleton instance. */
private static final TwoSpacesIndenter _indenterInstance = new TwoSpacesIndenter();
/**
* The singleton getter.
*
* @return The singleton instance.
*/
public static TwoSpacesIndenter instance() { return _indenterInstance; }
/**
* Check whether the JSON string is inlined.
*
* @return always {@code false}.
*/
@Override
public boolean isInline()
{
return false;
}
/**
* Core method of the utility class. Prepares an indented new line.
*
* @param g
* The {@code JsonGenerator} in use.
* @param level
* The indentation level.
* @throws IOException
* if {@code g.writeRaw} fails.
*/
@Override
public void writeIndentation(JsonGenerator g, int level)
throws IOException
{
g.writeRaw('\n');
for (int i = 0; i < level; i++)
{
g.writeRaw(" ");
}
}
}
}