All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.jexxa.drivingadapter.rest.RESTfulRPCAdapter Maven / Gradle / Ivy

The newest version!
package io.jexxa.drivingadapter.rest;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.javalin.Javalin;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
import io.javalin.http.staticfiles.Location;
import io.javalin.json.JsonMapper;
import io.javalin.plugin.bundled.CorsPluginConfig;
import io.javalin.util.JavalinLogger;
import io.jexxa.adapterapi.drivingadapter.IDrivingAdapter;
import io.jexxa.adapterapi.invocation.InvocationManager;
import io.jexxa.adapterapi.invocation.InvocationTargetRuntimeException;

import io.jexxa.common.facade.json.JSONConverter;
import io.jexxa.common.facade.logger.ApplicationBanner;
import io.jexxa.common.facade.logger.SLF4jLogger;
import io.jexxa.common.facade.utils.properties.Secret;
import io.jexxa.drivingadapter.rest.openapi.OpenAPIConvention;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.jetbrains.annotations.NotNull;

import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;

import static io.jexxa.common.facade.json.JSONManager.getJSONConverter;
import static io.jexxa.common.facade.logger.SLF4jLogger.getLogger;
import static io.jexxa.drivingadapter.rest.RESTfulRPCConvention.createRPCConvention;


public final class RESTfulRPCAdapter implements IDrivingAdapter
{
    private final JSONConverter jsonConverter = getJSONConverter();
    private final Properties properties;
    private Javalin javalin;
    private ServerConnector sslConnector;
    private ServerConnector httpConnector;
    private OpenAPIConvention openAPIConvention;

    private static final Map RPC_ADAPTER_MAP = new HashMap<>();

    private RESTfulRPCAdapter(Properties properties)
    {
        this.properties = properties;

        validateIsTrue(isHTTPEnabled() || isHTTPSEnabled(), "Neither HTTP (" + JexxaWebProperties.JEXXA_REST_PORT + ") nor HTTPS (" + JexxaWebProperties.JEXXA_REST_HTTPS_PORT + ") is enabled! -> Check your jexxa-application.properties");

        if ( isHTTPSEnabled() )
        {
            validateIsTrue( properties.containsKey( JexxaWebProperties.JEXXA_REST_KEYSTORE), "You need to define a location for keystore ("+ JexxaWebProperties.JEXXA_REST_KEYSTORE + ")");
            validateIsTrue( properties.containsKey( JexxaWebProperties.JEXXA_REST_KEYSTORE_PASSWORD) || properties.containsKey( JexxaWebProperties.JEXXA_REST_FILE_KEYSTORE_PASSWORD)
                    , "You need to define a location for keystore-password ("+ JexxaWebProperties.JEXXA_REST_KEYSTORE_PASSWORD + "or" + JexxaWebProperties.JEXXA_REST_FILE_KEYSTORE_PASSWORD + ")");
        }

        openAPIConvention = new OpenAPIConvention(properties);

        setupJavalin();

        registerExceptionHandler();

        ApplicationBanner.addAccessBanner(this::bannerInformation);
    }

    public static RESTfulRPCAdapter createAdapter(Properties properties)
    {
        JavalinLogger.startupInfo = false;

        if ( RPC_ADAPTER_MAP.containsKey(properties) )
        {
            getLogger(RESTfulRPCAdapter.class).warn("Tried to create an RESTfulRPCAdapter with same properties twice! Return already instantiated adapter.");
        } else {
            RPC_ADAPTER_MAP.put(properties, new RESTfulRPCAdapter(properties));
        }

        return RPC_ADAPTER_MAP.get(properties);
    }

    @Override
    public void register(Object object)
    {
        Objects.requireNonNull(object);
        registerGETMethods(object);
        registerPOSTMethods(object);
    }


    @Override
    public void start()
    {
        try
        {
            javalin.start();
        } catch (RuntimeException e)
        {
            if (e.getMessage().contains("Port already in use.")) // Javalin states its default port of the server. Therefore, we correct the error message here.
            {
                throw new IllegalStateException(
                        RESTfulRPCAdapter.class.getSimpleName()
                        + ": "
                        + e.getCause().getMessage()
                        + ". Please check that IP address is correct and port is not in use.", e
                );
            }
            throw e;
        }
    }

    @Override
    public void stop()
    {
        RPC_ADAPTER_MAP.remove(properties);

        javalin.stop();
        Optional.ofNullable(httpConnector).ifPresent(ServerConnector::close);
        Optional.ofNullable(sslConnector).ifPresent(ServerConnector::close);
    }

    @SuppressWarnings("unused")
    public int getHTTPSPort()
    {
        if (sslConnector != null)
        {
            if (sslConnector.getPort() != 0 )
            {
                return sslConnector.getPort();
            }
            return sslConnector.getLocalPort();
        }

        return getHTTPSPortFromProperties();
    }

    public int getHTTPPort()
    {
        if (httpConnector != null)
        {
            if (httpConnector.getPort() != 0 )
            {
                return httpConnector.getPort();
            }
            return httpConnector.getLocalPort();
        }

        return getHTTPPortFromProperties();
    }

    boolean isHTTPEnabled()
    {
        return properties.containsKey(JexxaWebProperties.JEXXA_REST_PORT);
    }

    boolean isHTTPSEnabled()
    {
        return properties.containsKey(JexxaWebProperties.JEXXA_REST_HTTPS_PORT);
    }

    String getHostname()
    {
        return properties.getProperty(JexxaWebProperties.JEXXA_REST_HOST, "0.0.0.0");
    }

    String getKeystore()
    {
        return properties.getProperty(JexxaWebProperties.JEXXA_REST_KEYSTORE, "");
    }

    String getKeystorePassword()
    {
        return new Secret(properties, JexxaWebProperties.JEXXA_REST_KEYSTORE_PASSWORD, JexxaWebProperties.JEXXA_REST_FILE_KEYSTORE_PASSWORD)
                .getSecret();
    }


    private int getHTTPPortFromProperties()
    {
        try {
            return Integer.parseInt(properties.getProperty(JexxaWebProperties.JEXXA_REST_PORT, "0"));
        } catch (NumberFormatException e)  {
            SLF4jLogger.getLogger(RESTfulRPCAdapter.class).error("Invalid integer format used for http-port");
            return 0;
        }
    }

    private int getHTTPSPortFromProperties()
    {
        try {
        return Integer.parseInt(properties.getProperty(JexxaWebProperties.JEXXA_REST_HTTPS_PORT, "0"));
        } catch (NumberFormatException e)  {
            SLF4jLogger.getLogger(RESTfulRPCAdapter.class).error("Invalid integer format used for http-port");
            return 0;
        }
    }


    @SuppressWarnings("HttpUrlsUsage") //http is used for showing access possibilities
    public void bannerInformation(@SuppressWarnings("unused") Properties properties)
    {
        // Print Listening ports
        if (isHTTPEnabled() ) {
            getLogger(ApplicationBanner.class).info("Listening on: {}", "http://" + getHostname() + ":" + getHTTPPort()  );
        }

        if (isHTTPSEnabled() ) {
            getLogger(ApplicationBanner.class).info("Listening on: {}", "https://" + getHostname() + ":" + getHTTPSPort() );
        }

        // Print OPENAPI links
        if (isHTTPEnabled()) {
            openAPIConvention.getPath().ifPresent(path -> getLogger(ApplicationBanner.class).info("OpenAPI available at: {}"
                    , "http://" + getHostname() + ":" + getHTTPPort() +  path ) );
        }
        if (isHTTPSEnabled()) {
            openAPIConvention.getPath().ifPresent(path -> getLogger(ApplicationBanner.class).info("OpenAPI available at: {}"
                    , "https://" + getHostname() + ":" + getHTTPSPort() + path ) );
        }
    }

    /**
     * Mapping of exception is done as follows
     * 
     * {@code
     *   {
     *     "Exception": "",
     *     "ExceptionType": "",
     *     "ApplicationType": "application/json"
     *   }
     * }
     * 
* */ private void registerExceptionHandler() { //Exception Handler for thrown Exception from methods javalin.exception(InvocationTargetException.class, (e, ctx) -> handleTargetException(e.getTargetException(), ctx)); javalin.exception(InvocationTargetRuntimeException.class, (e, ctx) -> handleTargetException(e.getTargetException(), ctx)); javalin.exception(IllegalArgumentException.class, this::handleTargetException); javalin.exception(RuntimeException.class, this::handleRuntimeException); javalin.error(404, this::handleResourceNotFound); } private void handleResourceNotFound(Context ctx) { if (openAPIConvention != null && !openAPIConvention.isDisabled() ) { var openAPIPath = openAPIConvention.getPath().orElse(""); var response = "Resource " + ctx.path() + " is not available. " + "Check OpenAPI specification: " + getProtocolByPort(ctx.port()) + "://" + ctx.host() + openAPIPath + " "; ctx.status(404); ctx.result(response); } else { var response = "Resource " + ctx.path() + " is not available." ; ctx.status(404); ctx.result(response); } } private String getProtocolByPort(int port) { if (port == getHTTPPort()) { return "http"; } else { return "https"; } } private void handleTargetException(Throwable targetException, Context ctx ) { internalHandleException(targetException, ctx); ctx.status(400); } private void handleRuntimeException(RuntimeException runtimeException, Context ctx ) { internalHandleException(runtimeException, ctx); ctx.status(500); } private void internalHandleException(Throwable targetException, Context ctx) { if ( targetException != null ) { targetException.getStackTrace(); // Ensures that stack trace is filled in var ctxMethod = ctx.method(); var ctxBody = ctx.body(); var ctxPath = ctx.path(); getLogger(RESTfulRPCAdapter.class).error("{} occurred when processing {} request {}", targetException.getClass().getSimpleName(), ctxMethod, ctxPath); getLogger(RESTfulRPCAdapter.class).error("Content of Body: {}", ctxBody); getLogger(RESTfulRPCAdapter.class).error("Exception message: {}", targetException.getMessage()); var exceptionWrapper = new JsonObject(); exceptionWrapper.addProperty("ExceptionType", targetException.getClass().getName()); exceptionWrapper.addProperty("Exception", toJson(targetException)); exceptionWrapper.addProperty("ApplicationType", jsonConverter.toJson("application/json")); ctx.result(exceptionWrapper.toString()); } } private String toJson(Throwable e) { try { return jsonConverter.toJson(e); } catch (RuntimeException re){ return jsonConverter.toJson(new IllegalArgumentException(e.getMessage())); } } private void registerGETMethods(Object object) { var getCommands = createRPCConvention(object).getGETCommands(); getCommands.forEach( method -> javalin.get( method.resourcePath(), httpCtx -> invokeMethod(object, method, httpCtx) ) ); getCommands.forEach( method -> openAPIConvention.documentGET(method.method(), method.resourcePath())); } private void registerPOSTMethods(Object object) { var postCommands = createRPCConvention(object).getPOSTCommands(); postCommands.forEach( method -> javalin.post( method.resourcePath(), httpCtx -> invokeMethod(object, method, httpCtx) ) ); postCommands.forEach( method -> openAPIConvention.documentPOST(method.method(), method.resourcePath())); } private void invokeMethod(Object object, RESTfulRPCConvention.RESTfulRPCMethod method, Context httpContext ) { Object[] methodParameters = deserializeParameters(httpContext.body(), method.method()); var invocationHandler = InvocationManager.getInvocationHandler(object); var result = Optional.ofNullable( invocationHandler.invoke(method.method(), object, methodParameters) ); //At the moment, we do not handle any credentials httpContext.header("Access-Control-Allow-Origin", "*"); httpContext.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); result.ifPresent(httpContext::json); } boolean methodIsNonParameterized(String jsonString, Method method) { return jsonString == null || jsonString.isEmpty() || method.getParameterCount() == 0; } boolean methodHasSingleParameter(Method method) { return method.getParameterCount() == 1; } boolean methodHasParameterInArrayStyle(JsonElement jsonElement, Method method) { return method.getParameterCount() > 1 && jsonElement.isJsonArray(); } boolean methodHasParameterInArgStyle(JsonElement jsonElement, Method method) { return method.getParameterCount() > 1 && jsonElement.isJsonObject(); } private Object[] deserializeParameters(String jsonString, Method method) { try { if ( methodIsNonParameterized(jsonString, method) ) { return new Object[]{}; } var jsonElement = JsonParser.parseString(jsonString); // Handle 1 parameter if ( methodHasSingleParameter(method) ) { return new Object[]{jsonConverter.fromJson(jsonString, method.getGenericParameterTypes()[0])}; } // In case we have more than one attribute, check if we have JSonArray representation if (methodHasParameterInArrayStyle(jsonElement, method)) { return readArray(jsonElement.getAsJsonArray(), method); } // ... or Arg-representation if (methodHasParameterInArgStyle(jsonElement, method)) { return readArgParameter(jsonElement.getAsJsonObject(), method); } } catch (RuntimeException e) { if (e.getCause() != null && e.getCause().getMessage() != null) { throw new IllegalArgumentException("Could not deserialize attributes for method " + method.getName() + " Reason: " + e.getCause().getMessage(), e ); } else { throw new IllegalArgumentException("Could not deserialize attributes for method " + method.getName() + " Reason: " + e.getMessage(), e ); } } throw new IllegalArgumentException("Invalid JSON request for method " + method.getName() + ". JSON request: " + jsonString); } private Object[] readArgParameter(JsonObject jsonObject, Method method) { var result = new Object[method.getParameterCount()]; for (int i = 0; i < method.getParameterCount(); ++i) { result[i] = getJSONConverter().fromJson(jsonObject.get("arg"+i).toString(), method.getGenericParameterTypes()[i]); } return result; } private Object[] readArray(JsonElement jsonElement, Method method) { if (!jsonElement.isJsonArray()) { throw new IllegalArgumentException("Multiple method attributes musst be passed inside a JSonArray"); } var jsonArray = jsonElement.getAsJsonArray(); if (jsonArray.size() != method.getParameterCount()) { throw new IllegalArgumentException("Invalid Number of parameters for method " + method.getName()); } var parameterTypes = method.getGenericParameterTypes(); var paramArray = new Object[parameterTypes.length]; for (var i = 0; i < parameterTypes.length; ++i) { paramArray[i] = jsonConverter.fromJson(jsonArray.get(i).toString(), parameterTypes[i]); } return paramArray; } private void setupJavalin() { this.javalin = Javalin.create(this::getJavalinConfig); var openAPIPath = properties.getProperty(JexxaWebProperties.JEXXA_REST_OPEN_API_PATH); if (openAPIPath != null && !openAPIPath.isEmpty()) { openAPIConvention = new OpenAPIConvention(properties); javalin.get(openAPIPath, httpCtx -> httpCtx.result(openAPIConvention.getOpenAPI())); } } private void getJavalinConfig(JavalinConfig javalinConfig) { javalinConfig.jetty.modifyServer(this::configureServer); javalinConfig.showJavalinBanner = false; javalinConfig.jsonMapper(new JexxaJSONMapper()); Location location = Location.CLASSPATH; if (properties.getProperty(JexxaWebProperties.JEXXA_REST_STATIC_FILES_EXTERNAL, "false").equalsIgnoreCase("true")) { location = Location.EXTERNAL; } if (properties.containsKey(JexxaWebProperties.JEXXA_REST_STATIC_FILES_ROOT)) { javalinConfig.staticFiles.add(properties.getProperty(JexxaWebProperties.JEXXA_REST_STATIC_FILES_ROOT), location); } javalinConfig.bundledPlugins.enableCors(cors -> cors.addRule(CorsPluginConfig.CorsRule::anyHost)); } void configureServer(Server server) { if ( server != null ) { if (isHTTPEnabled()) { httpConnector = new ServerConnector(server); httpConnector.setHost(getHostname()); httpConnector.setPort(getHTTPPortFromProperties()); server.addConnector(httpConnector); } if (isHTTPSEnabled()) { HttpConfiguration httpsConfig = new HttpConfiguration(); httpsConfig.setSendServerVersion(false); httpsConfig.setRequestHeaderSize(512 * 1024); httpsConfig.setResponseHeaderSize(512 * 1024); SecureRequestCustomizer src = new SecureRequestCustomizer(); src.setSniHostCheck(false); httpsConfig.addCustomizer(src); sslConnector = new ServerConnector(server, getSslContextFactory(), new HttpConnectionFactory(httpsConfig)); sslConnector.setHost(getHostname()); sslConnector.setPort(getHTTPSPortFromProperties()); server.addConnector(sslConnector); } } } private SslContextFactory.Server getSslContextFactory() { URL keystoreURL = RESTfulRPCAdapter.class.getResource("/" + getKeystore()); if ( keystoreURL == null ) { File file = new File(getKeystore()); if(file.exists() && !file.isDirectory()) { try { keystoreURL =file.toURI().toURL(); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); } } else { throw new IllegalArgumentException("File Keystore " + getKeystore() + " is not available! Please check the setting " + JexxaWebProperties.JEXXA_REST_KEYSTORE); } } var sslContextFactory = new SslContextFactory.Server(); sslContextFactory.setKeyStorePath(keystoreURL.toExternalForm()); sslContextFactory.setKeyStorePassword(getKeystorePassword()); return sslContextFactory; } void validateIsTrue( boolean expression, String message) { if (!expression) { throw new IllegalArgumentException(message); } } private static class JexxaJSONMapper implements JsonMapper { @NotNull @Override public String toJsonString(@NotNull Object obj, @NotNull Type targetClass) { return getJSONConverter().toJson(obj); } @NotNull @Override public T fromJsonString(@NotNull String json, @NotNull Type targetClass) { return getJSONConverter().fromJson(json, targetClass); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy