WebServiceHandler.java

/*
** Module   : WebServiceHandler.java
** Abstract : Handler for WebHandler calls.
**
** Copyright (c) 2019-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20190703 First version.
** 002 CA  20190710 Fixed a problem in addService, wrong WebHandler class was used.
**     CA  20190723 The path matching is done case-insensitive.
** 003 CA  20200116 Javadoc fixes.
**     GES 20200213 NPE protection in termination code.
** 004 CA  20200427 Fixed to allow session context details.
** 005 CA  20200514 Fixed error handling for handleWebService.
** 006 CA  20220405 Added authentication and authorization for web requests.  When this is enabled, the target
**                  API call will be executed under the authenticated FWD context, and not the agent's context.
** 007 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 008 GBB 20230825 WebHandler renamed to WebDriverHandler.
** 009 CA  20240219 Target web handler resolution will use the explicit path match if it exists together with
**                  a path token match like '/Test' vs '/{Other}'.
** 010 GBB 20240610 LegacyAppServerWork replaced by Consumer<LegacyServiceWorker>. 
** 011 CA  20240925 When matching paths do determine the web handler, first configuration matching this 
**                  request's target will be resolved. 
**                  Ensure the configurations are sorted.
** 012 GBB 20250403 Replace service type with enum constant.
** 013 RB2 20250530 The handle method now calls the handle method with an extra parameter, the request. This
**                  was done in order to handle multiple URLs in the Access-Control-Allow-Origin field
*/

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

import java.io.*;
import java.util.*;

import javax.servlet.http.*;

import com.goldencode.p2j.util.logging.*;
import org.eclipse.jetty.http.*;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.*;

import com.goldencode.p2j.util.*;

/**
 * Specialized handler to process {@link com.goldencode.p2j.oo.web.WebHandler} paths.
 * <p>
 * <p>
 * Service registration is being performed via {@link SourceNameMapper}, which loads the
 * programs marked with <code>with-services</code>, via {@link #addService} API.
 * <p>
 * Any converted Java code mapped to a service must have a class-level {@link LegacyService} 
 * annotation.  This annotation specifies one or more {@link LegacyWebPath paths} to be served by
 * this handler.
 * <p>
 * The implicit basepath is <code>/web</code>, and the implicit timeout is 10 seconds (set to 0
 * to disable it).  This timeout is read from the appserver's timeout.
 * <p>
 * The values which need to be configured in the directory are:
 * <ul>
 *    <li><code>WebHandler/basepath</code>, to specify the basepath.  
 *        Defaults to <code>/web</code>.</li>
 *    <li><code>WebHandler/alias</code>, a process certificate alias associated with the workers 
 *        which will process the WebHandler requests; these will delegate the call to the 
 *        appserver.  This context is automatically marked as HEADLESS.</li>
 *    <li><code>WebHandler/keyStore</code>, the key store with the certificate to authenticate 
 *        this process.  If is missing, the store will be built from the directory.</li>
 *    <li><code>WebHandler/storePassword</code>, the store password, only if 
 *        <code>WebHandler/keyStore</code> is set.</li>
 *    <li><code>WebHandler/poolSize</code> - the pool size of workers.  Defaults to 10.</li>
 *    <li><code>WebHandler/appserver</code> - the name of the appserver handling the requests.
 *        This appserver must be in the same FWD server as the handler (can't use a remote one).
 *    </li>
 * </ul>
 * <p>
 * Each queue of workers is ordered by the number of pending tasks to be executed - for each
 * incoming request, the worker with the smallest number of pending tasks will be chosen.
 */
public class WebServiceHandler
extends LegacyServiceHandler
{
   /** The singleton instance for this handler. */
   private static WebServiceHandler instance;
   
   /** The context handler. */
   private static ContextHandler handler;
   
   /** The exposed WebHandler services. */
   private Map<String, List<WebHandlerConfig>> services = new HashMap<>();

   /** Logger. */
   private static final CentralLogger LOG = CentralLogger.get(WebServiceHandler.class);
   
   /**
    * Initialize this handler with the 'WebHandler' type and '/web' default basepath.
    */
   public WebServiceHandler()
   {
      super(ServiceType.WEB, "/web");
   }

   /**
    * Initialize the WebHandler worker(s) and start the workers.
    * 
    * @return   The handler for WebHandler services.
    */
   public static AbstractHandler initialize()
   {
      synchronized (WebServiceHandler.class)
      {
         if (handler != null)
         {
            return handler;
         }
         
         instance = new WebServiceHandler();
         instance.initializeHandler();

         handler = new ContextHandler();
         handler.setContextPath(instance.getBasePath());
         handler.setHandler(instance);
         
         return handler;
      }
   }

   /**
    * Determine if this web service is configured to authenticate the APIs.
    * 
    * @return   <code>true</code> if the authentication is enabled for this web service, in the directory.
    */
   public static boolean useWebServiceAuthentication()
   {
      synchronized (WebServiceHandler.class)
      {
         return instance != null && instance.useAuthorization();
      }
   }

   /**
    * Terminate the rest worker threads.
    */
   public static void terminate()
   {
      synchronized (WebServiceHandler.class)
      {
         if (instance != null)
         {
            instance.terminateWorkers();
         }
      }
   }

   /**
    * Register a legacy service associated with a {@link com.goldencode.p2j.oo.web.WebHandler}
    * implementation.
    * 
    * @param    pls
    *           The annotation describing the service.
    * @param    cls
    *           The WebHandler implementation.
    */
   public static void addService(LegacyService pls, Class<?> cls)
   {
      synchronized (WebServiceHandler.class)
      {
         if (instance == null)
         {
            LOG.warning("Trying to register WebHandler service " + pls.name() +
                           ", but WebHandler service is not enabled.");
            return;
         }
      }

      if (!com.goldencode.p2j.oo.web.WebHandler.class.isAssignableFrom(cls))
      {
         throw new IllegalArgumentException(cls.toString() + " is not a sub-class of WebHandler!");
      }
      
      String section = pls.name().toLowerCase();
      List<WebHandlerConfig> cfgs = instance.services.get(section);
      if (cfgs == null)
      {
         instance.services.put(section, cfgs = new ArrayList<>());
      }
      for (LegacyWebPath path : pls.paths())
      {
         cfgs.add(new WebHandlerConfig(path.path(), 
                                       path.order(),
                                       (Class<? extends WebDriverHandler>) cls));
      }

      // ensure they are sorted, 'initialize' is called to early, and no need for a 'postInitialize' as the 
      // size of the configs is small
      Collections.sort(cfgs);
   }
   
   /**
    * Code to process web requests.
    */
   @Override
   public void handle(String              target, 
                      Request             baseRequest,
                      HttpServletRequest  request,
                      HttpServletResponse response) 
   throws IOException
   {
      if (baseRequest.isHandled())
      {
         return;
      }

      // first check the authentication
      super.handle(target, baseRequest, request, response);

      if (baseRequest.isHandled())
      {
         // authentication failed, nothing else to do
         return;
      }
      
      baseRequest.setHandled(true);
      
      String[] targetPaths = getPaths(target);
      WebHandlerConfig found = null;
      
      outer: for (String section : services.keySet())
      {
         List<WebHandlerConfig> cfgs = services.get(section);
         
         for (WebHandlerConfig cfg : cfgs)
         {
            if (cfg.matches(targetPaths))
            {
               found = cfg;
               break outer; 
            }
         }
      }

      if (found == null) 
      {
         response.setStatus(HttpStatus.NOT_FOUND_404);
         response.getOutputStream().write(("Can't find service for path " + target).getBytes());
         return;
      }

      WebHandlerConfig cfg = found;
      LegacyAppServerWork work = (worker) -> 
      {
         // now do the authorization - must be in the worker thread
         String token;
         if (useAuthorization())
         {
            token = authorize(target, request, response);
            if (token == null)
            {
               return;
            }
         }
         else
         {
            token = null;
         }

         try
         {
            AppServerInvocationResult result = worker.getHelper().handleWebService(instance.getTimeout(), 
                                                                                   cfg.handler, 
                                                                                   instance.getBasePath(),
                                                                                   cfg.paths,
                                                                                   token,
                                                                                   target, 
                                                                                   request, 
                                                                                   response);
            if (result.getError() != null)
            {
               response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR_500);
               try
               {
                  response.getOutputStream().write(result.getError().getMessage().getBytes());
               }
               catch (IOException e)
               {
                  // ignore
               }
            }
         }
         finally
         {
            postHandle(token);
         }
      };
      
      handle(instance, work, target, response, request);
   }
   
   /**
    * A configuration for a WebHandler, as it was defined in <code>openedge.properties</code>. 
    * This will sort them based on the original <code>handler#</code> key.
    */
   private static class WebHandlerConfig
   implements Comparable<WebHandlerConfig>
   {
      /** The split path served by this handler.*/
      private final String[] paths;
      
      /** The order. */
      private final int order;
      
      /** The handler. */
      private final Class<? extends WebDriverHandler> handler;
      
      /**
       * Configure this webhandler to service the given path.
       * 
       * @param    path
       *           The served path.
       * @param    order
       *           The path order.
       * @param    handler
       *           The handler class.
       */
      public WebHandlerConfig(String path, int order, Class<? extends WebDriverHandler> handler)
      {
         this.paths = getPaths(path);
         this.order = order;
         this.handler = handler;
      }

      /**
       * Compare this to another instance, by their {@link #order}.
       */
      @Override
      public int compareTo(WebHandlerConfig o)
      {
         return this.order - o.order;
      }
      
      /**
       * Check if this configured web handler's paths match the given target.  The match doesn't take into
       * consideration if the configuration ends with a {@code /*} or not, this is always assumed - so, if
       * the configuration is a prefix for this target (considering path parameters), then this will be a 
       * match.
       *  
       * @param    target
       *           The target path to match.
       *           
       * @return   See above.
       */
      public boolean matches(String[] target)
      {
         int plen = paths.length;

         if (plen == 0)
         {
            // here we match on anything
            return true;
         }
         
         String last = paths[plen - 1];
         if ("*".equals(last) || "**".equals(last))
         {
            plen = plen - 1;
         }

         if (plen == 0)
         {
            // match on anything
            return true;
         }
         
         if (plen > target.length)
         {
            // if more configured paths then this target, we can't match, as configuration can't be a prefix
            // for this configuration
            return false;
         }
         

         // target can have more parts as in this configuration, so walk only the configured parts 
         for (int i = 0; i < plen; i++)
         {
            if (paths[i].startsWith("{"))
            {
               // this is a path parameter, we can match
               continue;
            }
            
            if (!paths[i].equalsIgnoreCase(target[i]))
            {
               // this part doesn't match
               return false;
            }
            
            // the current part matches, we can continue
         }
         
         // we reached here; it means all tokens in the configured path match, and if there is or not a '*'
         // suffix doesn't matter, we always match
         return true;
      }
   }
}