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);
}
}
}