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("  ");
         }
      }
   }
}