JsonResponseArguments.java

/*
** Module   : JsonResponseArguments.java
** Abstract : APIs to serialize the OUTPUT arguments in the HTTP servlet response, as JSON.
**
** Copyright (c) 2019-2022, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20190614 First version.
** 002 CA  20190628 Added TABLE output support.
** 003 CA  20190812 Changed getTableName to getStructureName.
** 004 CA  20200427 Fixes for DATASET, TABLE and error serialization.
**     CA  20200514 Refactoring to allow common code for SOAP services support.
**     CA  20200528 Added APIs for REST extent arguments (not implemented yet). 
** 005 CA  20210304 The cookies must follow RFC2965.
**     CA  20211112 Do not write an empty table.
**     CA  20220329 Added support for REST services written directly in Java.
** 006 AL2 20250325 Override reset abstract method. REST responses are cached, so reset is no-op.
*/

/*
** 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.rest;

import java.io.*;
import java.lang.reflect.*;
import java.math.*;
import java.util.*;
import javax.servlet.http.*;
import javax.xml.bind.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import com.goldencode.p2j.oo.lang.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.util.*;

/**
 * Helper class to serialize a parameter's value, JSON-style.
 */
class JsonResponseArguments
extends ResponseArguments
{
   /** The root JSON element. */
   private final ObjectNode root;
   
   /** The JSON 'response' element - here any JSON arguments will be attached. */
   private final ObjectNode jsonResponse;
   
   /** Flag identifying if any JSON arguments were written. */
   private boolean hasArgs = false;
   
   /**
    * Initialize this instance by creating the <code>{ "response": {} }</code> JSON.
    */
   public JsonResponseArguments()
   {
      root = JsonNodeFactory.instance.objectNode();
      jsonResponse = root.putObject("response");
   }
   
   /**
    * Serialize the error, to be included in the response.
    * 
    * @param    err
    *           The error.
    *           
    * @return   The serialized version of the error.
    */
   public String writeError(LegacyError err)
   {
      // format is: _retVal, _errors: [{_errorMsg, _errorNum}]
      ObjectNode root = JsonNodeFactory.instance.objectNode();
      ArrayNode errRoot = root.putArray("_errors");
      if (err instanceof AppError && ((AppError) err).isFromReturn())
      {
         root.put("_retVal", ((AppError) err).getReturnValue().toStringMessage());
         
         ObjectNode errNode = errRoot.addObject();
         errNode.put("_errorMsg", 
                     "ERROR condition: The Server application has returned an error. (7243) (7211)");
         errNode.put("_errorNum", 0);
      }
      else
      {
         int numErrors = err.getNumMessages().intValue();
         integer idx = new integer();
         for (int i = 1; i <= numErrors; i++)
         {
            idx.assign(i);
            
            ObjectNode errNode = errRoot.addObject();
            errNode.put("_errorMsg", String.format("ERROR condition: %s (7211)", 
                                                   err.getMessage(idx).toStringMessage()));
            errNode.put("_errorNum", err.getMessageNum(idx).toStringMessage());
         }
      }
      
      return root.toString();
   }
   
   /**
    * Determine if unknown values must be ignored.
    * 
    * @return    Always <code>true</code>
    */
   @Override
   protected boolean ignoreUnknown()
   {
      return true;
   }
   
   /**
    * Serialize this argument's values, considering the target format.
    * 
    * @param    stream
    *           The response stream.
    * @param    target
    *           The argument's encoded target.
    * @param    val
    *           The argument's value.
    * @param    response
    *           The HTTP response.
    */
   protected void writeExtentArgument(OutputStream        stream, 
                                      String              target,
                                      Object              val,
                                      HttpServletResponse response)
   throws IOException
   {
      // TODO: fix this
      UnimplementedFeature.unsupported("REST extent arguments");
   }

   /**
    * Serialize this argument's value, considering the target format.
    * 
    * @param    stream
    *           The response stream.
    * @param    target
    *           The argument's encoded target.
    * @param    sval
    *           The argument's string-converted value.
    * @param    val
    *           The argument's value.
    * @param    response
    *           The HTTP response.
    */
   @Override
   protected void writeArgumentInt(OutputStream        stream, 
                                   String              target,
                                   String              sval,
                                   Object              val, 
                                   HttpServletResponse response) 
   throws IOException
   {
      
      if (target.startsWith("${"))
      {
         target = target.substring(2);
      }
      if (target.endsWith("}"))
      {
         target = target.substring(0, target.length() - 1);
      }
      
      switch (target)
      {
         case "http.body":
            // TODO: what is the correct format here? especially in case of DATASET/TABLE
            // TODO: in .restoe, there is an association with a Java type, for each argument
            //       maybe those are used to build and serialize to string?
            stream.write(sval.getBytes());
            return;
            
         case "http.statuscode":
            response.setStatus(Integer.parseInt(sval));
            return;
      }
      
      if (target.startsWith("http.header["))
      {
         String name = RestHandler.extractVal(target);
         
         // TODO: is 'toString' correct?
         response.setHeader(name, sval);
         return;
      }
      
      if (target.startsWith("rest.cookieparam["))
      {
         // TODO: is 'toString' correct?
         String name = RestHandler.extractVal(target);
         Cookie cookie = new Cookie(name, sval);
         cookie.setVersion(1);
         response.addCookie(cookie);
         return;
      }
      
      if (target.startsWith("rest.cookieparam[")) // duplicate case. Was this the intent?
      {
         // TODO: is 'toString' correct?
         String name = RestHandler.extractVal(target);
         Cookie cookie = new Cookie(name, sval);
         cookie.setVersion(1);
         response.addCookie(cookie);
         return;
      }
      
      if (target.startsWith("json.object['response']."))
      {
         // TODO: unknown values? how are they serialized?
         
         String jsonMode = target.substring("json.object['response'].".length());
         String attr = RestHandler.extractVal(jsonMode);
         
         if (val instanceof JsonNode)
         {
            // already created, nothing else to do
            hasArgs = true;
            jsonResponse.set(attr, (JsonNode) val);
            return;
         }
         
         jsonMode = jsonMode.substring(0, jsonMode.indexOf('['));
         
         switch (jsonMode.toLowerCase())
         {
            case "boolean":
               jsonResponse.put(attr, Boolean.valueOf(val.toString()));
               break;
            case "integervalue":
               jsonResponse.put(attr, Integer.parseInt(val.toString()));
               break;
            case "longvalue":
               jsonResponse.put(attr, Long.parseLong(val.toString()));
               break;
            case "decimalvalue":
               jsonResponse.put(attr, new BigDecimal(val.toString()));
               break;
            case "string":
               jsonResponse.put(attr, val.toString());
               break;
            case "object":
               if (val instanceof ObjectNode) // always false, see the previous [if] statement
               {
                  jsonResponse.set(attr, (ObjectNode) val);
               }
               else
               {
                  jsonResponse.put(attr, val.toString());
               }
               break;
            default:
               return;
         }
         
         hasArgs = true;
      }
   }
   
   /**
    * Flush the arguments to the response stream (i.e. HTTP body).
    * 
    * @param    stream
    *           The stream to write the parameters.
    * @param    response
    *           The servlet response.
    */
   @Override
   protected void flushArguments(OutputStream stream, HttpServletResponse response)
   throws IOException
   {
      if (hasArgs)
      {
         stream.write(root.toString().getBytes());
      }
   }
   
   /**
    * Serialize the specified result-set.
    *  
    * @param    target
    *           The target OUTPUT parameter.
    * @param    val
    *           The table result set, as received from the remote side.
    *
    * @return   The string-representation of this table.
    */
   @Override
   protected Object writeTable(String target, TableWrapper val)
   {
      ObjectNode root = JsonNodeFactory.instance.objectNode();
      ArrayNode table = root.putArray(val.getTableName());
      
      writeTable(table, val.getProperties(), val.getRows());
      
      return root;
   }
   
   /**
    * Serialize the specified dataset.
    *  
    * @param    target
    *           The target OUTPUT parameter.
    * @param    val
    *           The dataset, as received from the remote side.
    *           
    * @return   The serialized version of this dataset.
    */
   @Override
   protected Object writeDataSet(String target, DataSetContainer val)
   {
      ObjectNode root = JsonNodeFactory.instance.objectNode();
      ObjectNode dsRoot = root.putObject(val.getStructureName());
      
      for (DsTableDefinition dsTable : val.getTableDefinitions())
      {
         if (!dsTable.getRows().isEmpty())
         {
            ArrayNode table = dsRoot.putArray(dsTable.getName());
            writeTable(table, dsTable.getProperties(), dsTable.getRows());
         }
      }
      
      return root;
   }
   
   /**
    * Reset the state of the response arguments. For REST requests, arguments
    * are cached, so there is no need to drop the serializer.
    */
   @Override
   protected void reset() 
   {
      // no-op
   }
   
   /**
    * Write the specified table to JSON.
    * 
    * @param    table
    *           The JSON structure.
    * @param    props
    *           The table properties.
    * @param    rows
    *           The table data.
    */
   private void writeTable(ArrayNode table, List<PropertyDefinition> props, List<Object[]> rows)
   {
      for (Object[] row : rows)
      {
         ObjectNode jrow = table.addObject();
         
         for (int i = 0; i < props.size(); i++)
         {
            PropertyDefinition prop = props.get(i);
            if (prop.isExtent())
            {
               int sz = Array.getLength(row[i]);
               Class<?> cls = row[i].getClass().getComponentType();
               ArrayNode jBdtArr = jrow.putArray(prop.getLegacyName());
               for (int j = 0; j < sz; j++)
               {
                  BaseDataType bdt = (BaseDataType) Array.get(row[i], i);
                  
                  if (bdt.isUnknown())
                  {
                     jBdtArr.addNull();
                  }
                  else
                  {
                     String val = toString(bdt);
                     switch (cls.getSimpleName())
                     {
                        case "integer":
                           jBdtArr.add(DatatypeConverter.parseInt(val));
                           break;
                        
                        case "int64":
                           jBdtArr.add(DatatypeConverter.parseLong(val));
                           break;
                        
                        case "decimal":
                           jBdtArr.add(DatatypeConverter.parseDecimal(val));
                           break;
                        
                        case "logical":
                           jBdtArr.add(DatatypeConverter.parseBoolean(val));
                           break;
                        
                        default:
                           jBdtArr.add(val);
                           break;
                     }
                  }
               }
            }
            else
            {
               BaseDataType bdt = (BaseDataType) row[i];
               if (bdt.isUnknown())
               {
                  if (bdt instanceof blob)
                  {
                     jrow.put(prop.getLegacyName(), "");
                  }
                  else
                  {
                     jrow.putNull(prop.getLegacyName());
                  }
               }
               else
               {
                  String val = toString(bdt);

                  switch (bdt.getClass().getSimpleName())
                  {
                     case "integer":
                        jrow.put(prop.getLegacyName(), DatatypeConverter.parseInt(val));
                        break;
                     
                     case "int64":
                        jrow.put(prop.getLegacyName(), DatatypeConverter.parseLong(val));
                        break;
                     
                     case "decimal":
                        jrow.put(prop.getLegacyName(), DatatypeConverter.parseDecimal(val));
                        break;
                     
                     case "logical":
                        jrow.put(prop.getLegacyName(), DatatypeConverter.parseBoolean(val));
                        break;
                     
                     default:
                        jrow.put(prop.getLegacyName(),  val);
                        break;
                  }
               }
            }
         }
      }
   }
}