BaseAdminRestHandler.java

/*
** Module   : AdminRestHandler.java
** Abstract : Class that handles requests for administration of 4GL resources purposes.
**
** Copyright (c) 2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ----------------------------------------Description---------------------------------------
** 001 OM  20250310 First revision, extracted common REST code from Tenant/AdminHandler.
*/

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

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import com.goldencode.p2j.admin.*;
import com.goldencode.p2j.main.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.security.SecurityManager;
import com.goldencode.p2j.util.logging.*;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.*;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.util.*;
import java.util.logging.*;
import java.util.stream.*;

/**
 * Base class for all administration REST handlers.
 * <br>
 * The derived classes will declare their {@code type} and {@code contextPath} when they are constructed and
 * will receive the notifications for processing requests by implementing the four specific methods
 * corresponding to HTTP verbs: {@code handleGet()}, {@code handlePut()}, {@code handlePost()}, and
 * {@code handleDelete()}.
 */
public abstract class BaseAdminRestHandler
extends AbstractHandler
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(BaseAdminRestHandler.class);
   
   /** The URL path it will handle.*/
   protected final String contextPath;
   
   /** The handler used for authentication and authorization. */
   protected final WebAuthHandler authenticationHandler;
   
   /** Instance used for executing requests in a context. */
   protected final LegacyWebSecurityManager wsm;
   
   /** Used to parse the HTTP body as a JSON object. */
   protected final ThreadLocal<ObjectMapper> mapper = ThreadLocal.withInitial(ObjectMapper::new);
   
   /**
    * Constructs a new handler for administration of some resource using REST APIs.
    * 
    * @param   contextPath
    *          The URL path it will handle. 
    * @param   authenticationHandler
    *          The WebAuthHandler used for authorisation.
    */
   protected BaseAdminRestHandler(String contextPath, WebAuthHandler authenticationHandler)
   {
      this.wsm = SecurityManager.getInstance().legacyWebSm;
      this.authenticationHandler = authenticationHandler; 
      this.contextPath = contextPath;
   }
   
   /**
    * Method for handling the requests. Will perform checking, authentication and authorization before
    * dispatching the execution to proper method/verb.
    *
    * @param   target
    *          The target of the request - either a URI or a name.
    * @param   baseRequest
    *          The original unwrapped request object.
    * @param   request
    *          The request either as the {@link Request} object or a wrapper of that request.
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    */
   @Override
   public void handle(String target,
                      Request baseRequest,
                      HttpServletRequest request,
                      HttpServletResponse response)
   throws IOException, ServletException
   {
      // quick validation
      if (baseRequest.isHandled()) // already handled by other handler?
      {
         return;
      }
      
      if (target == null) // should not be possible
      {
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return;
      }
      else if (!target.startsWith(contextPath))
      {
         return; // not my business
      }
      
      baseRequest.setHandled(true);
      response.setContentType("application/json");
      
      // check authorization:
      String token = authenticationHandler.authorize(target, request, response);
      if (token == null)
      {
         response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
         return;
      }
      
      String body = request.getReader().lines().collect(Collectors.joining());
      target = trimContextPrefix(target);
      
      switch (baseRequest.getMethod())
      {
         case "GET":
            handleGet(target, body, response, token);
            break;
         case "POST":
            handlePost(target, body, response, token);
            break;
         case "PUT":
            handlePut(target, body, response, token);
            break;
         case "DELETE":
            handleDelete(target, body, response, token);
            break;
         default:
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      }
   }
   
   /**
    * Implement this method for handling the GET requests. The result should be written directly to
    * {@code response}'s writer. The default implementation will simply return error 400 / Bad request.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   protected void handleGet(String target, String body, HttpServletResponse response, String token)
   {
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
   
   /**
    * Implement this method for handling the POST requests. The result should be written directly to
    * {@code response}'s writer. The default implementation will simply return error 400 / Bad request.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   protected void handlePost(String target, String body, HttpServletResponse response, String token)
   {
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
   
   /**
    * Implement this method for handling the PUT requests. The result should be written directly to
    * {@code response}'s writer. The default implementation will simply return error 400 / Bad request.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   protected void handlePut(String target, String body, HttpServletResponse response, String token)
   {
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
   
   /**
    * Implement this method for handling the DELETE requests. The result should be written directly to
    * {@code response}'s writer. The default implementation will simply return error 400 / Bad request.
    *
    * @param   target
    *          The relative target of the request. From the absolute URL, the context prefix was eliminated.
    * @param   body
    *          The payload of the request (optional).
    * @param   response
    *          The response as the {@link Response} object or a wrapper of that request.
    * @param   token
    *          The authentication token.
    */
   protected void handleDelete(String target, String body, HttpServletResponse response, String token)
   {
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
   
   /**
    * Execute the specified task in the context associated with the web request identified via the specified
    * authentication token.
    *
    * @param    token
    *           The authentication token.
    * @param    task
    *           The task to be performed.
    */
   protected final void executeInContext(String token, Runnable task)
   {
      wsm.executeInContext(token, () -> {
         AdminServerImpl.setTargetLive(true, false, true);
         task.run();
      }, true);
   }
   
   /**
    * Execute the given task and assign a message to the HTTP response.
    *
    * @param   token
    *          The token used in the request.
    * @param   response
    *          The HTTP response.
    * @param   task
    *          The task to be executed.
    * @param   result
    *          The field that will contain the result after the execution is performed.
    * @param   errorSink
    *          A sink for errors encountered. 
    *
    * @return  {@code true} if the operations finish successfully, {@code false} otherwise.
    */
   protected final boolean executeAndWriteResponse(String token,
                                                   HttpServletResponse response,
                                                   Runnable task,
                                                   Boolean[] result,
                                                   List<String> errorSink)
   {
      return executeAndWriteResponse(token, response, task, "success", result, errorSink);
   }
   
   /**
    * Execute the given task and assign a message to the HTTP response.
    *
    * @param   token
    *          The token used in the request.
    * @param   response
    *          The HTTP response.
    * @param   task
    *          The task to be executed.
    * @param   message
    *          The key value that will be written in the JSON response.
    * @param   result
    *          The field that will contain the result after the execution is performed.
    * @param   errorSink
    *          A sink for errors encountered. 
    *
    * @return  {@code true} if the operations finish successfully, {@code false} otherwise.
    */
   protected final boolean executeAndWriteResponse(String token,
                                                   HttpServletResponse response,
                                                   Runnable task,
                                                   String message,
                                                   Boolean[] result,
                                                   List<String> errorSink)
   {
      wsm.executeInContext(token, task, true);
      
      try
      {
         if (errorSink.isEmpty())
         {
            response.getWriter().write("{\n \"" + message + "\": " + result[0] + " \n}");
            return true;
         }
         
         ObjectMapper objectMapper = mapper.get();
         ObjectNode outerNode = objectMapper.createObjectNode();
         outerNode.put(message, result[0]);
         
         ArrayNode errorArray = objectMapper.createArrayNode();
         for (String errMsg : errorSink)
         {
            errorArray.add(errMsg);
         }
         
         outerNode.set("errors", errorArray);
         response.getWriter().write(outerNode.toString());
         return true;
      }
      catch (IOException e)
      {
         LOG.log(Level.WARNING, "Could not write response due to " + e.getMessage());
         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
         return false;
      }
   }
   
   /**
    * Cleaning up the target by removing the context path. This information is not useful since all requests
    * dispatched to this handler are filtered to match this prefix.
    */
   private String trimContextPrefix(String originalTarget)
   {
      if (!originalTarget.startsWith(contextPath))
      {
         LOG.warning(
               "Invalid context received '" + originalTarget + "'. Was expecting '" + contextPath + "'.");
         return originalTarget;
      }
      
      String newTarget = originalTarget.substring(contextPath.length());
      
      // drop the starting "/" if any:
      if (newTarget.startsWith("/"))
      {
         newTarget = newTarget.substring(1);
      }
      return newTarget;
   }
   
   /**
    * Parse a JSON and extract the value (String, int or boolean for the moment) of a given field.
    *
    * @param   body
    *          The JSON data.
    * @param   fieldName
    *          The field for which the value is extracted.
    * @param   cls
    *          The expected type of value that is retrieved.
    *
    * @return  The value of the given field.
    */
   protected Object extractFirstLevelValue(String body, String fieldName, Class<?> cls)
   {
      try
      {
         JsonNode rootNode = mapper.get().readTree(body);
         if (rootNode.has(fieldName))
         {
            if (cls == String.class)
            {
               return rootNode.get(fieldName).asText();
            }
            else if (cls == boolean.class || cls == Boolean.class)
            {
               return rootNode.get(fieldName).asBoolean();
            }
            else if (cls == int.class || cls == Integer.class)
            {
               return rootNode.get(fieldName).asInt();
            }
         }
      }
      catch (JsonProcessingException e)
      {
         LOG.log(Level.WARNING, "Unable to parse JSON due to " + e.getMessage());
      }
      
      return null;
   }
}