Parcourir la source

Added HTTP Server

Lukas Cerny il y a 1 an
Parent
commit
199283427f

+ 2 - 0
build.gradle

@@ -54,6 +54,8 @@ dependencies {
 
     implementation 'io.vertx:vertx-core:4.5.7'
     implementation 'io.vertx:vertx-web:4.5.7'
+    implementation 'io.vertx:vertx-web-openapi:4.5.7'
+    implementation 'io.vertx:vertx-auth-jwt:4.5.7'
     implementation 'io.vertx:vertx-pg-client:4.5.7'
     implementation 'org.postgresql:postgresql:42.7.3'
     implementation 'com.ongres.scram:client:2.1'

+ 2 - 1
src/main/java/cz/senslog/analytics/app/Application.java

@@ -1,6 +1,7 @@
 package cz.senslog.analytics.app;
 
 import cz.senslog.analytics.module.api.Module;
+import cz.senslog.analytics.server.HttpVertxServer;
 import io.vertx.core.AbstractVerticle;
 import io.vertx.core.DeploymentOptions;
 import io.vertx.core.Vertx;
@@ -85,7 +86,7 @@ public final class Application {
 
 
         AbstractVerticle[] modules = Module.createModules(dbPool);
-        Vertx.vertx().deployVerticle(VertxDeployer.deploy(modules), options, res -> {
+        Vertx.vertx().deployVerticle(VertxDeployer.deploy(new HttpVertxServer(), modules), options, res -> {
             if(res.succeeded()) {
                 logger.info("Deployment id is: {}", res.result());
                 logger.info("Started in {} second.", uptime() / 1000.0);

+ 10 - 0
src/main/java/cz/senslog/analytics/app/VertxDeployer.java

@@ -25,6 +25,16 @@ public class VertxDeployer extends AbstractVerticle {
         return new VertxDeployer(verticles);
     }
 
+    public static VertxDeployer deploy(AbstractVerticle verticle, AbstractVerticle... verticles) {
+        AbstractVerticle[] res = new AbstractVerticle[verticles.length+1];
+        int i = 0;
+        for (; i < verticles.length; i++) {
+            res[i] = verticles[i];
+        }
+        res[i] = verticle;
+        return deploy(res);
+    }
+
     @Override
     public void start(Promise<Void> startPromise) {
         vertx.eventBus().registerDefaultCodec(Observation.class, new IdentityCodec<>(Observation.class));

+ 1 - 1
src/main/java/cz/senslog/analytics/module/api/Module.java

@@ -3,7 +3,7 @@ package cz.senslog.analytics.module.api;
 import cz.senslog.analytics.domain.CollectorType;
 import cz.senslog.analytics.module.*;
 import cz.senslog.analytics.repository.*;
-import io.vertx.pgclient.PgPool;
+import cz.senslog.analytics.server.HttpVertxServer;
 import io.vertx.sqlclient.Pool;
 
 import java.util.HashMap;

+ 104 - 0
src/main/java/cz/senslog/analytics/server/HttpVertxServer.java

@@ -0,0 +1,104 @@
+package cz.senslog.analytics.server;
+
+import cz.senslog.analytics.server.ws.AuthorizationType;
+import cz.senslog.analytics.server.ws.DisableAuthorizationHandler;
+import cz.senslog.analytics.server.ws.ExceptionHandler;
+import cz.senslog.analytics.server.ws.OpenAPIHandler;
+import cz.senslog.analytics.utils.ResourcesUtils;
+import io.vertx.core.AbstractVerticle;
+import io.vertx.core.Promise;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.auth.KeyStoreOptions;
+import io.vertx.ext.auth.jwt.JWTAuth;
+import io.vertx.ext.auth.jwt.JWTAuthOptions;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.handler.*;
+import io.vertx.ext.web.openapi.RouterBuilder;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.nio.file.Path;
+
+import static cz.senslog.analytics.server.ws.AuthorizationType.BEARER;
+import static java.util.Objects.requireNonNull;
+
+public final class HttpVertxServer extends AbstractVerticle {
+    private static final Logger logger = LogManager.getLogger(HttpVertxServer.class);
+
+
+    public HttpVertxServer() {
+
+    }
+
+    @Override
+    public void start(Promise<Void> startPromise) {
+        final AuthorizationType openAPIAuthType = BEARER;
+
+        Path openApiUrl = ResourcesUtils.getPath("openAPISpec.yaml");
+        logger.info("Loading the OpenAPI spec from '{}'", openApiUrl);
+
+        RouterBuilder.create(vertx, requireNonNull(openApiUrl, "Open API Specification was not found as a resource.").toString())
+                .onSuccess(openAPIRouterBuilder -> {
+                    logger.info("The OpenAPI specification was loaded successfully.");
+
+                    openAPIRouterBuilder.rootHandler(LoggerHandler.create(false, LoggerFormat.SHORT));
+
+                    openAPIRouterBuilder.rootHandler(CorsHandler.create()
+                            .allowedMethod(HttpMethod.GET)
+                            .allowedMethod(HttpMethod.POST)
+                            .allowedMethod(HttpMethod.PUT)
+                            .allowedMethod(HttpMethod.DELETE)
+
+                            .allowedHeader("x-requested-with")
+                            .allowedHeader("Access-Control-Allow-Origin")
+                            .allowedHeader("Origin")
+                            .allowedHeader("Content-Type")
+                            .allowedHeader("Accept")
+                    );
+
+                    JsonObject authConfig = config().getJsonObject("auth");
+                    if (authConfig.getBoolean("disabled")) {
+                        logger.info("Security schema for all endpoints is disabled.");
+                        openAPIRouterBuilder.securityHandler(openAPIAuthType.getSecuritySchemaKey(), DisableAuthorizationHandler.create());
+                    } else {
+                        logger.info("Setting security schema for the type '{}' with the schema key '{}'.", openAPIAuthType.name(), openAPIAuthType.getSecuritySchemaKey());
+                        openAPIRouterBuilder.securityHandler(openAPIAuthType.getSecuritySchemaKey(), JWTAuthHandler.create(JWTAuth.create(vertx, new JWTAuthOptions()
+                                .setKeyStore(new KeyStoreOptions()
+                                        .setPath(authConfig.getString("keystore.path"))
+                                        .setType(authConfig.getString("keystore.type"))
+                                        .setPassword(authConfig.getString("keystore.password"))))));
+                    }
+
+
+                    // The order matters, so adding the body handler should happen after any PLATFORM or SECURITY_POLICY handler(s).
+                    openAPIRouterBuilder.rootHandler(BodyHandler.create());
+
+                    OpenAPIHandler apiHandler = OpenAPIHandler.create();
+
+                    openAPIRouterBuilder.operation("infoGET").handler(apiHandler::info);
+
+                    Router mainRouter = openAPIRouterBuilder.createRouter();
+
+                    mainRouter.route().failureHandler(ExceptionHandler.createAsJSON());
+
+                    mainRouter.errorHandler(404, h -> h.fail(404,
+                            new Throwable(String.format("Resource '%s' not found.", h.request().uri()))
+                    ));
+
+                    JsonObject serverConfig = config().getJsonObject("server");
+                    vertx.createHttpServer()
+                            .requestHandler(mainRouter)
+                            .listen(serverConfig.getInteger("http.port"))
+                            .onSuccess(server -> {
+                                logger.info("HTTP server running on port {}.", server.actualPort());
+                                startPromise.complete();
+                            })
+                            .onFailure(fail -> {
+                                logger.error(fail);
+                                startPromise.fail(fail);
+                            });
+                })
+                .onFailure(startPromise::fail);
+    }
+}

+ 17 - 0
src/main/java/cz/senslog/analytics/server/ws/AuthorizationType.java

@@ -0,0 +1,17 @@
+package cz.senslog.analytics.server.ws;
+
+public enum AuthorizationType {
+    NONE(null),
+    BEARER("bearerAuth")
+
+    ;
+    private final String securitySchemaKey;
+
+    AuthorizationType(String securitySchemaKey) {
+        this.securitySchemaKey = securitySchemaKey;
+    }
+
+    public String getSecuritySchemaKey() {
+        return securitySchemaKey;
+    }
+}

+ 42 - 0
src/main/java/cz/senslog/analytics/server/ws/ContentType.java

@@ -0,0 +1,42 @@
+package cz.senslog.analytics.server.ws;
+
+public enum ContentType {
+    JSON    ("application/json"),
+    GEOJSON ("application/geo+json")
+    ;
+
+    private final String contentType;
+
+    ContentType(String contentType) {
+        this.contentType = contentType;
+    }
+
+    public static ContentType of(String format) {
+        return valueOf(format.toUpperCase());
+    }
+
+    public static ContentType ofType(String contentType) {
+        for (ContentType value : values()) {
+            if (value.contentType.equalsIgnoreCase(contentType)) {
+                return value;
+            }
+        }
+        throw new IllegalArgumentException(String.format("No enum constant %s for the type '%s'.", ContentType.class.getName(), contentType));
+    }
+
+    public String contentType() {
+        return contentType;
+    }
+
+    public static String[] contentTypes() {
+        return contentTypes(values());
+    }
+
+    public static String[] contentTypes(ContentType... types) {
+        String[] values = new String[types.length];
+        for (ContentType value : types) {
+            values[value.ordinal()] = value.contentType;
+        }
+        return values;
+    }
+}

+ 72 - 0
src/main/java/cz/senslog/analytics/server/ws/ContentTypeHandler.java

@@ -0,0 +1,72 @@
+package cz.senslog.analytics.server.ws;
+
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Consumer;
+
+public final class ContentTypeHandler {
+
+    private static Set<ContentType> parseContentTypes(String contentTypes) {
+        if (contentTypes == null || contentTypes.isBlank()) {
+            return Collections.emptySet();
+        }
+        String[] types = contentTypes.split(",");
+        Set<ContentType> res = new HashSet<>(ContentType.values().length);
+        for (String type : types) {
+            String mediaType = type.split(";")[0];
+            for (ContentType value : ContentType.values()) {
+                if (mediaType.equalsIgnoreCase(value.contentType())) {
+                    res.add(value);
+                }
+            }
+        }
+        return res;
+    }
+
+    public static ContentTypeHandler createWithSupportedTypes(ContentType... supportedTypes) {
+        return new ContentTypeHandler(supportedTypes);
+    }
+    
+    private final ContentType[] supportedTypes;
+
+    private ContentTypeHandler(ContentType[] supportedTypes) {
+        this.supportedTypes = supportedTypes;
+    }
+    
+    public FluentHandler accept(String accept) {
+        if (accept == null || accept.isBlank()) {
+            throw new HttpIllegalArgumentException(400, "The Media Type is missing. Add 'Accept' attribute in header.");
+        }
+
+        Set<ContentType> parsedTypes = parseContentTypes(accept);
+        Set<ContentType> contentTypes = new HashSet<>();
+        for (ContentType supportedType : supportedTypes) {
+            if (parsedTypes.contains(supportedType)) {
+                contentTypes.add(supportedType);
+            }
+        }
+        if (contentTypes.isEmpty()) {
+            throw new HttpIllegalArgumentException(415,
+                    String.format("The Media Type '%s' is not supported. Supported are %s.", accept, Arrays.toString(ContentType.contentTypes(supportedTypes))));
+        }
+        return new FluentHandler(contentTypes);
+    }
+
+    public static final class FluentHandler {
+        private final Set<ContentType> contentTypes;
+
+        private FluentHandler(Set<ContentType> contentTypes) {
+            this.contentTypes = contentTypes;
+        }
+
+        public FluentHandler handle(ContentType contentType, Consumer<ContentType> handler) {
+            if (contentTypes.contains(contentType) && handler != null) {
+                handler.accept(contentType);
+            }
+            return this;
+        }
+    }
+}

+ 29 - 0
src/main/java/cz/senslog/analytics/server/ws/DisableAuthorizationHandler.java

@@ -0,0 +1,29 @@
+package cz.senslog.analytics.server.ws;
+
+import io.vertx.core.AsyncResult;
+import io.vertx.core.Future;
+import io.vertx.core.Handler;
+import io.vertx.ext.auth.User;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.handler.impl.AuthenticationHandlerInternal;
+
+public final class DisableAuthorizationHandler implements AuthenticationHandlerInternal {
+
+
+    public static DisableAuthorizationHandler create() {
+        return new DisableAuthorizationHandler();
+    }
+
+    private DisableAuthorizationHandler() {}
+
+    @Override
+    public void authenticate(RoutingContext routingContext, Handler<AsyncResult<User>> handler) {
+        handler.handle(Future.succeededFuture());
+    }
+
+    @Override
+    public void handle(RoutingContext routingContext) {
+        routingContext.next();
+    }
+
+}

+ 49 - 0
src/main/java/cz/senslog/analytics/server/ws/ExceptionHandler.java

@@ -0,0 +1,49 @@
+package cz.senslog.analytics.server.ws;
+
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.validation.ParameterProcessorException;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.Optional;
+
+import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
+
+
+public final class ExceptionHandler implements io.vertx.core.Handler<RoutingContext> {
+
+    private static final Logger logger = LogManager.getLogger(ExceptionHandler.class);
+
+    private static final String DEFAULT_ERROR_MESSAGE = "Something went wrong!";
+
+    public static ExceptionHandler createAsJSON() {
+        return new ExceptionHandler();
+    }
+
+    private static int codeByException(Throwable throwable, int defaultCode) {
+        if (throwable instanceof ParameterProcessorException) {
+            return  400;
+        } else if (throwable instanceof HttpIllegalArgumentException) {
+            return ((HttpIllegalArgumentException)throwable).getCode();
+        }
+        return defaultCode;
+    }
+
+    @Override
+    public void handle(RoutingContext rc) {
+        Throwable th = Optional.ofNullable(rc.failure()).orElse(new Throwable(DEFAULT_ERROR_MESSAGE));
+
+        String message = th.getMessage();
+        int code = codeByException(th, rc.statusCode());
+
+        logger.error(message);
+        rc.response()
+                .putHeader(CONTENT_TYPE, ContentType.JSON.contentType())
+                .setStatusCode(code)
+                .end(JsonObject.of(
+                        "code", code,
+                        "message", message
+                ).encode());
+    }
+}

+ 20 - 0
src/main/java/cz/senslog/analytics/server/ws/HttpIllegalArgumentException.java

@@ -0,0 +1,20 @@
+package cz.senslog.analytics.server.ws;
+
+public class HttpIllegalArgumentException extends IllegalArgumentException {
+
+    private final int code;
+
+    public HttpIllegalArgumentException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public HttpIllegalArgumentException(int code, Throwable th) {
+        super(th);
+        this.code = code;
+    }
+
+    public int getCode() {
+        return code;
+    }
+}

+ 40 - 0
src/main/java/cz/senslog/analytics/server/ws/OpenAPIHandler.java

@@ -0,0 +1,40 @@
+package cz.senslog.analytics.server.ws;
+
+import cz.senslog.analytics.app.Application;
+import cz.senslog.analytics.app.PropertyConfig;
+import io.vertx.core.Future;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.RoutingContext;
+
+import java.time.ZoneId;
+import java.util.List;
+
+import static cz.senslog.analytics.server.ws.AuthorizationType.BEARER;
+import static cz.senslog.analytics.server.ws.AuthorizationType.NONE;
+import static cz.senslog.analytics.server.ws.ContentType.JSON;
+import static io.vertx.core.http.HttpHeaders.ACCEPT;
+import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
+import static java.util.stream.Collectors.toList;
+
+public class OpenAPIHandler {
+
+    public static OpenAPIHandler create() {
+        return new OpenAPIHandler();
+    }
+
+    public void info(RoutingContext rc) {
+        boolean authEnable = !PropertyConfig.getInstance().authConfig().getDisabled();
+        ContentTypeHandler.createWithSupportedTypes(JSON)
+                .accept(rc.request().getHeader(ACCEPT))
+                .handle(JSON, type -> rc.response().putHeader(CONTENT_TYPE, type.contentType())
+                        .end(JsonObject.of(
+                                "name", Application.PROJECT_NAME,
+                                "version", Application.COMPILED_VERSION,
+                                "build", Application.BUILD_VERSION,
+                                "uptime", Application.uptimeFormatted(),
+                                "uptimeMillis", Application.uptime(),
+                                "authType", authEnable ? BEARER : NONE
+                        ).encode()));
+    }
+}

+ 11 - 0
src/main/java/cz/senslog/analytics/utils/ResourcesUtils.java

@@ -0,0 +1,11 @@
+package cz.senslog.analytics.utils;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public final class ResourcesUtils {
+
+    public static Path getPath(String resourceFile) {
+        return Paths.get(resourceFile);
+    }
+}

+ 76 - 0
src/main/resources/openAPISpec.yaml

@@ -0,0 +1,76 @@
+openapi: "3.0.0"
+info:
+  version: 2.0.0
+  title: SensLog Analytics
+servers:
+  - url: http://127.0.0.1:8080
+paths:
+  /info:
+    get:
+      operationId: infoGET
+      summary: Information about running instance
+      tags:
+        - Server
+      responses:
+        200:
+          description: Instance information
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Info"
+        default:
+          description: unexpected error
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Error"
+
+components:
+  schemas:
+
+    Info:
+      type: object
+      required:
+        - name
+        - version
+        - build
+        - uptime
+        - uptimeMillis
+        - authType
+      properties:
+        name:
+          type: string
+        version:
+          type: string
+        build:
+          type: string
+        uptimeMillis:
+          type: integer
+          format: int64
+        uptime:
+          type: string
+        authType:
+          type: string
+          enum: [BEARER, NONE]
+      example:
+        name: "senslog-analytics"
+        version: "1.1.0"
+        build: "123456789"
+        uptimeMillis: 1684862333
+        uptime: "1:20:00"
+        authType: "NONE"
+
+    Error:
+      type: object
+      required:
+        - code
+        - message
+      properties:
+        code:
+          type: integer
+          format: int32
+        message:
+          type: string
+      example:
+        code: 404
+        message: "Not Found"