SoapRequestParser.java

/*
** Module   : SoapRequestParser.java
** Abstract : APIs to parse the SOAP request arguments.
**
** Copyright (c) 2020-2021, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20200518 First version.
**     CA  20200528 Fixes for extent parameters, BEFORE-TABLE and error management.
** 002 CA  20210304 Fixed date, datetime and datetimetz literal parsing. 
**     CA  20211013 DATASET parameters have the table table indexes, too, at the WSDL schema.
**     CA  20211112 Refactored to allow transfer of the DATASET or TABLE via XML.  Added support for 
**                  DATASET-HANDLE and TABLE-HANDLE.
** 003 RNC 20241128 Implemented logic to recognize 'nil' attribute.
*/

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

import java.io.*;
import java.lang.reflect.*;
import java.math.*;
import java.util.*;

import javax.servlet.http.*;
import javax.wsdl.*;
import javax.xml.bind.*;
import javax.xml.namespace.*;
import javax.xml.stream.*;

import org.apache.axiom.om.*;
import org.apache.axiom.om.util.*;
import org.apache.axis2.databinding.utils.*;

import com.goldencode.p2j.directory.Base64;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.rest.*;
import com.goldencode.p2j.soap.WsdlConfig.*;
import com.goldencode.p2j.util.*;

/**
 * Parse the service input arguments from the SOAP envelope (body or header).
 */
public class SoapRequestParser
extends ServiceArgumentsParser
{
   /** The SOAP body element which encodes the request. */
   private final OMElement soapRequest;
   
   /** The map of service input parameters, (FWD-style) as defined at the WSDL operation. */
   private final Map<String, LegacyServiceParameter> params = new HashMap<>();

   /** The schema definition for the service input parameters, as defined at the schema. */
   private final Map<String, BaseSchemaType> soapParams;

   /** The list of service input parameters (FWD-style) as defined at the WSDL operation. */
   private final LegacyServiceParameter[] serviceParameters;

   /**
    * Initialize this instance, to parse the input parameters for the SOAP operation.
    * 
    * @param    params
    *           The output parameters for the SOAP operation.
    * @param    soapRequest
    *           The SOAP request body.
    * @param    soapOperation
    *           The SOAP operation as defined in the WSDL.
    * @param    wsdlConfig
    *           The WSDL configuration.
    */
   public SoapRequestParser(LegacyServiceParameter[] params, 
                            OMElement                soapRequest,
                            Operation                soapOperation,
                            WsdlConfig               wsdlConfig)
   {
      this.soapRequest = soapRequest;
      this.serviceParameters = params;
      
      for (LegacyServiceParameter lsp : params)
      {
         this.params.put(lsp.name().toLowerCase(), lsp);
      }

      soapParams = wsdlConfig.resolveParameters(soapOperation);
   }
   
   /**
    * Parse an extent argument.
    * 
    * @param    body
    *           The HTTP body.
    * @param    request
    *           The request payload.
    * @param    idx
    *           The argument's index.
    * @param    source
    *           The arguments's source.
    * @param    type
    *           The arguments's type.
    * @param    extent
    *           The arguments's length.
    * 
    * @return   An array with the argument's values.
    */
   @Override
   protected Object[] parseExtentArgument(String             body, 
                                          HttpServletRequest request,
                                          int                idx,
                                          String             source,
                                          String             type,
                                          int                extent)
   throws RequestArgumentError
   {
      LegacyServiceParameter lsp = serviceParameters[idx];
      BaseSchemaType bst = soapParams.get(lsp.name());
      
      Class<?> cls = BaseDataType.fromTypeName(type);
      
      List<BaseDataType> vals = new ArrayList<>();
      Iterator<OMElement> iter = getSoapParameters(source);
      while (iter.hasNext())
      {
         OMElement el = iter.next();
         
         BaseDataType bdt = BaseDataType.generateUnknown(cls);
         bdt.assign(createArgument(bst.xsdType, cls, el.getText()));
         
         vals.add(bdt);
      }
      
      int length = extent == SourceNameMapper.DYNAMIC_EXTENT ? vals.size() : extent;
      if (extent != SourceNameMapper.DYNAMIC_EXTENT && length != vals.size() && !vals.isEmpty())
      {
         // must match
         throw new RequestArgumentError("extent not match");
      }
      Object arg = Array.newInstance(cls, length);
      for (int i = 0; i < vals.size(); i++)
      {
         Array.set(arg, i, vals.get(i));
      }
      for (int i = vals.size(); i < length; i++)
      {
         Array.set(arg, i, BaseDataType.generateUnknown(cls));
      }
      
      return (Object[]) arg;
   }
   
   /**
    * Assign the given argument to the specified value.
    * <p>
    * This will assign the <code>bdt</code> instance, depending on the argument's configured XSD type.
    * 
    * @param    idx
    *           The argument's index.
    * @param    bdt
    *           The argument's {@link BaseDataType} instance (may be <code>null</code>).
    * @param    sval
    *           The string representation of this argument.
    */
   @Override
   protected void assignArgument(int idx, BaseDataType bdt, String sval)
   throws RequestArgumentError
   {
      LegacyServiceParameter lsp = serviceParameters[idx];
      BaseSchemaType bst = soapParams.get(lsp.name());

      QName nilQName = new QName("http://www.w3.org/2001/XMLSchema-instance", "nil");
      String nilValue = getSoapParameter(lsp.source()).getAttributeValue(nilQName);

      if (nilValue == null || nilValue.equalsIgnoreCase("false"))
      {
         bdt.assign(createArgument(bst.xsdType, bdt.getClass(), sval));
      }
      else if (nilValue.equalsIgnoreCase("true"))
      {
         bdt.setUnknown();
      }
      else
      {
         throw new RequestArgumentError("Invalid boolean value: " + nilValue + " (10915)", true);
      }
   }
   
   /**
    * Parse the argument, by interpreting the request.
    * 
    * @param    body
    *           The request body.
    * @param    source
    *           The parameter's encoded source.
    * @param    request
    *           The request payload.
    *           
    * @return   The resolved argument.
    */
   @Override
   protected String parseArgumentInt(String body, String source, HttpServletRequest request)
   throws IOException
   {
      OMElement parEl = getSoapParameter(source);
      LegacyServiceParameter lsp = getLegacyParameter(source);
      
      switch (lsp.type().toUpperCase())
      {
         case "DATASET":
         case "DATASET-HANDLE":
         case "TABLE":
         case "TABLE-HANDLE":
            return source;

         default:
            return parEl == null ? null : parEl.getText();
      }
   }

   /**
    * Load the specified table.
    * 
    * @param    content
    *           The table definition.
    *           
    * @return   A {@link TableWrapper} instance to be passed as argument to the remote call.
    */
   @Override
   protected TableWrapper loadTable(String content) 
   throws IOException,
          RequestArgumentError
   {
      OMElement parEl = getSoapParameter(content);
      LegacyServiceParameter lsp = getLegacyParameter(content);

      if (parEl == null)
      {
         // the table is not specified, create an 'empty' node
         try
         {
            parEl = AXIOMUtil.stringToOM("<" + lsp.name() + "/>");
         }
         catch (XMLStreamException e)
         {
            throw new RuntimeException(e);
         }
      }
      else if ("TABLE-HANDLE".equalsIgnoreCase(lsp.type()))
      {
         // in this case, the XML looks like this:
         // root element: the TABLE-HANDLE parameter name
         //    > first child: -> TempTable node
         //       > first child: the schema
         //       > second child: the table with its rows (the schema node must be moved here)
         // we need to move the schema node to the proper child and use that XML
         
         OMElement schemaEl = null;
         OMElement tableEl = null;
         Iterator<OMElement> iter1 = parEl.getChildrenWithLocalName("TempTable");
         if (iter1.hasNext())
         {
            Iterator<OMElement> iter = iter1.next().getChildElements();
            while (iter.hasNext())
            {
               OMElement child = iter.next();
               if (child.getNamespace() != null && 
                   child.getNamespace().getNamespaceURI().equals("http://www.w3.org/2001/XMLSchema"))
               {
                  schemaEl = child;
               }
               else
               {
                  if (tableEl != null)
                  {
                     tableEl = null;
                     break;
                  }
                  tableEl = child;
               }
            }
         }
         
         if (schemaEl != null && tableEl != null)
         {
            schemaEl.detach();
            Iterator<OMElement> iter = tableEl.getChildElements();
            List<OMElement> rows = new ArrayList<>();
            while (iter.hasNext())
            {
               OMElement row = iter.next();
               rows.add(row);
            }
            for (OMElement row : rows)
            {
               row.detach();
            }
            
            // schema must be first!
            tableEl.addChild(schemaEl);
            for (OMElement row : rows)
            {
               tableEl.addChild(row);
            }
            parEl = tableEl;
         }
         else
         {
            // TODO: error ?
         }
      }
      
      SoapHandler.removeNamespaces(parEl);
      String xmlTable = parEl.toString();

      TableWrapper wrapper = new TableWrapper(lsp.input(), lsp.output(), false);
      wrapper.setXmlTable(xmlTable);
      wrapper.setAsXml(true);
      
      return wrapper;
   }

   /**
    * Create a new {@link DatasetWrapper}, so that the transfer is made via XML.
    * 
    * @param    name
    *           The dataset name.
    * @param    input
    *           The INPUT mode.
    * @param    output
    *           The OUTPUT mode.
    * @param    asHandle
    *           Flag indicating if this is a DATASET-HANDLE parameter.
    *           
    * @return   A new {@link DatasetWrapper} for XML transfer.
    */
   @Override
   protected DatasetWrapper createDataset(String name, boolean input, boolean output, boolean asHandle)
   {
      DataSetSchemaType dst = name == null || name.isEmpty() ? null : (DataSetSchemaType) soapParams.get(name);
      
      DatasetWrapper wrapper = new DatasetWrapper(input, output, asHandle);
      wrapper.setAsXml(true);
      wrapper.setUseBeforeImage(dst != null && dst.useBeforeImage);
      
      return wrapper;
   }
   
   /**
    * Create a new {@link TableWrapper}, so that the transfer is made via XML.
    * 
    * @param    name
    *           The table name.
    * @param    input
    *           The INPUT mode.
    * @param    output
    *           The OUTPUT mode.
    * @param    asHandle
    *           Flag indicating if this is a DATASET-HANDLE parameter.
    *           
    * @return   A new {@link TableWrapper} for XML transfer.
    */
   @Override
   protected TableWrapper createTable(String name, boolean input, boolean output, boolean asHandle)
   {
      TableSchemaType tst = name == null || name.isEmpty() ? null : (TableSchemaType) soapParams.get(name);
      
      TableWrapper wrapper = new TableWrapper(input, output, asHandle);
      wrapper.setAsXml(true);

      return wrapper;
   }
   
   /**
    * Load the specified dataset.
    * 
    * @param    content
    *           The dataset definition.
    *           
    * @return   A {@link DatasetWrapper} instance to be passed as argument to the remote call.
    */
   @Override
   protected DatasetWrapper loadDataSet(String content) 
   throws IOException,
          RequestArgumentError
   {
      OMElement parEl = getSoapParameter(content);
      LegacyServiceParameter lsp = getLegacyParameter(content);

      if (parEl == null)
      {
         // the dataset is not specified, create an 'empty' node
         try
         {
            parEl = AXIOMUtil.stringToOM("<" + lsp.name() + "/>");
         }
         catch (XMLStreamException e)
         {
            throw new RuntimeException(e);
         }
      }
      else if ("DATASET-HANDLE".equalsIgnoreCase(lsp.type()))
      {
         // in this case, the XML looks like this:
         // root element: the DATASET-HANDLE parameter name
         // first child: the schema
         // second child: the dataset
         // we need to move the schema node to the second child and use that XML
         
         OMElement schemaEl = null;
         OMElement dsEl = null;
         Iterator<OMElement> iter = parEl.getChildElements();
         while (iter.hasNext())
         {
            OMElement child = iter.next();
            if (child.getNamespace() != null && 
                child.getNamespace().getNamespaceURI().equals("http://www.w3.org/2001/XMLSchema"))
            {
               schemaEl = child;
            }
            else
            {
               if (dsEl != null)
               {
                  dsEl = null;
                  break;
               }
               dsEl = child;
            }
         }
         
         if (schemaEl != null && dsEl != null)
         {
            schemaEl.detach();
            iter = dsEl.getChildElements();
            List<OMElement> rows = new ArrayList<>();
            while (iter.hasNext())
            {
               OMElement row = iter.next();
               rows.add(row);
            }
            for (OMElement row : rows)
            {
               row.detach();
            }
            
            // schema must be first!
            dsEl.addChild(schemaEl);
            for (OMElement row : rows)
            {
               dsEl.addChild(row);
            }
            parEl = dsEl;
         }
         else
         {
            // TODO: error ?
         }
      }
      
      SoapHandler.removeNamespaces(parEl);
      String xmlDs = parEl.toString();

      DatasetWrapper wrapper = new DatasetWrapper(lsp.input(), lsp.output(), false);
      wrapper.setXmlDataset(xmlDs);
      wrapper.setAsXml(true);
      
      return wrapper;
   }
   
   /**
    * Raises an error so that the request stops with a 5xx internal server error.
    * 
    * @param    bdt
    *           The BDT for which the value could not be parsed.
    * @param    val
    *           The attempted value to assign.
    */
   @Override
   protected void notAValue(BaseDataType bdt, String val)
   throws RequestArgumentError
   {
      if (val == null)
      {
         val = "?";
      }

      String type = bdt.getTypeName().toLowerCase();
      boolean quote = true;
      switch (bdt.getClass().getSimpleName())
      {
         case "integer":
            type = "integer";
            break;
         case "recid":
         case "int64":
            type = "long";
            break;
         case "decimal":
            type = "decimal";
            break;
         case "logical":
            type = "boolean";
            break;
         case "date":
            type = "date";
            break;
         case "datetimetz":
            quote = false;
            if (val.isEmpty())
            {
               throw new RequestArgumentError("String index out of range: 0 (10914)");
            }
            type = "dateTime";
            break;
         case "datetime":
            if (val.isEmpty())
            {
               throw new RequestArgumentError("String index out of range: 0 (10914)");
            }
            if (val.equals("?"))
            {
               val = "";
            }
            type = "dateTime";
            break;
      }
      
      if (quote)
      {
         val = "''''" + val + "''''";
      }
      throw new RequestArgumentError("Invalid " + type + " value " + val + " sent to deserializer (10914)");
   }

   /**
    * Get the service parameter with the given name.
    * 
    * @param    source
    *           The parameter's name.
    *           
    * @return   The {@link LegacyServiceParameter} from the {@link #params} map.
    */
   private LegacyServiceParameter getLegacyParameter(String source)
   {
      if (!source.startsWith("SOAP:"))
      {
         throw new RuntimeException("Invalid SOAP source: " + source);
      }
      
      source = source.substring("SOAP:".length());
      source = source.toLowerCase();
      
      return params.get(source);
   }
   
   /**
    * Get the SOAP element from the envelope's body, for the specified parameter.
    * 
    * @param    source
    *           The parameter name.
    *           
    * @return   The SOAP element for this parameter, from the envelope's body.
    */
   private OMElement getSoapParameter(String source)
   {
      if (!source.startsWith("SOAP:"))
      {
         throw new RuntimeException("Invalid SOAP source: " + source);
      }
      
      source = source.substring("SOAP:".length());
      // source = source.toLowerCase();
      
      BaseSchemaType bst = soapParams.get(source);
      String xmlName = bst.xmlName;
      
      QName parqn = new QName(soapRequest.getQName().getNamespaceURI(), xmlName);
      OMElement parEl = soapRequest.getFirstChildWithName(parqn);

      return parEl == null ? soapRequest.getFirstChildWithName(new QName(xmlName)) : parEl;
   }
   
   /**
    * Get the SOAP elements from the envelope's body, for the specified parameter.
    * 
    * @param    source
    *           The parameter name.
    *           
    * @return   The SOAP elements for this parameter, from the envelope's body.
    */
   private Iterator<OMElement> getSoapParameters(String source)
   {
      if (!source.startsWith("SOAP:"))
      {
         throw new RuntimeException("Invalid SOAP source: " + source);
      }
      
      source = source.substring("SOAP:".length());
      // source = source.toLowerCase();
      
      QName parqn = new QName(soapRequest.getQName().getNamespaceURI(), source);
      return soapRequest.getChildrenWithName(parqn);
   }

   /**
    * Create a new {@link BaseDataType} instance and restore its value from the given string,
    * based on the XSD type.
    *  
    * @param    xsdType
    *           The parameter's XSD type.
    * @param    type
    *           The BDT sub-class.
    * @param    sval
    *           The string representation of this argument.
    *           
    * @return   The new {@link BaseDataType} or {@link MemoryBuffer} instance for this argument.
    */
   private Object createArgument(String xsdType, Class<?> type, String sval)
   throws RequestArgumentError
   {
      BaseDataType bdt = BaseDataType.generateUnknown(type);

      if (type == handle.class)
      {
         notAValue(bdt, sval);
         
         return bdt;
      }

      if (sval == null)
      {
         return bdt;
      }
      switch (xsdType)
      {
         case "xsd:string":
            bdt.assign(DatatypeConverter.parseString(sval));
            break;
            
         case "xsd:int":
            bdt.assign(new BigInteger(Long.toString(DatatypeConverter.parseLong(sval))));
            break;
            
         case "xsd:long":
            bdt.assign(new BigInteger(Long.toString(DatatypeConverter.parseLong(sval))));
            break;
            
         case "xsd:decimal":
            if (sval.isEmpty())
            {
               notAValue(bdt, "");
            }
            bdt.assign(DatatypeConverter.parseDecimal(sval));
            break;
            
         case "xsd:dateTime":
         case "xsd:date":
            if (sval.isEmpty())
            {
               notAValue(bdt, "");
            }
            else if (sval.equals("?"))
            {
               notAValue(bdt, "?");
            }
            if (bdt.getClass() == date.class)
            {
               // we need only the date part
               if (sval.indexOf("T") > 0)
               {
                  sval = sval.substring(0, sval.indexOf("T"));
               }
            }
            else if (bdt.getClass() == datetime.class)
            {
               // TODO: we need only the datetime part
            }

            date d = null;
            try
            {
               d = date.parseIso8601(sval);
               
               if (d == null)
               {
                  notAValue(bdt, sval);
               }
            }
            catch (Throwable t)
            {
               notAValue(bdt, sval);
            }
            
            if (bdt instanceof datetimetz)
            {
               if (!(d instanceof datetimetz))
               {
                  notAValue(bdt, sval);
               }
               ((datetimetz) bdt).assign(d);
            }
            else if (bdt instanceof datetime)
            {
               if (!(d instanceof datetime))
               {
                  notAValue(bdt, sval);
               }

               if (d instanceof datetimetz)
               {
                  Calendar c = ((datetimetz) d).calendarValue();
                  ((datetime) bdt).assign(new datetime(c.getTime(), c.getTimeZone()));
               }
               else
               {
                  ((datetime) bdt).assign(d);
               }
            }
            else 
            {
               if (d instanceof datetimetz)
               {
                  Calendar c = ((datetimetz) d).calendarValue();
                  ((date) bdt).assign(new date(c.getTime(), c.getTimeZone()));
               }
               else
               {
                  ((date) bdt).assign(d);
               }
            }
            break;
            
         case "xsd:boolean":
            if (sval.isEmpty())
            {
               notAValue(bdt, "");
            }
            
            bdt.assign(ConverterUtil.convertToBoolean(sval));
            break;

         case "xsd:base64Binary":
            byte[] bytes = Base64.safeBase64ToByteArray(sval);
            if (bdt instanceof memptr || bdt instanceof blob)
            {
               MemoryBuffer mb = new MemoryBuffer(bytes);
               return mb;
            }
            
            if (bdt instanceof rowid)
            {
               if (bytes.length > 0)
               {
                  long val = bytes[0] & 0xffL;
                  for (int i = 1; i < Math.min(bytes.length, 8); i++)
                  {
                     val += (bytes[i] & 0xffL) << (8 * i);
                  }
                  ((rowid) bdt).assign(new rowid(val));
               }
            }
            else
            {
               ((BinaryData) bdt).assign(bytes);
            }
            break;
      }
      
      return bdt;
   }
}