diff --git a/maven-settings.xml b/maven-settings.xml index adce7ab..18a15cb 100644 --- a/maven-settings.xml +++ b/maven-settings.xml @@ -1,12 +1,12 @@ <?xml version="1.0" encoding="UTF-8"?> -<settings xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd" - xmlns="http://maven.apache.org/SETTINGS/1.1.0" - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - <servers> - <server> - <id>repo.gittr.ch</id> - <username>ci</username> - <password>${env.REPO_TOKEN}</password> - </server> - </servers> +<settings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd" + xmlns="http://maven.apache.org/SETTINGS/1.1.0"> + <servers> + <server> + <id>repo.gittr.ch</id> + <username>ci</username> + <password>${env.REPO_TOKEN}</password> + </server> + </servers> </settings> diff --git a/pom.xml b/pom.xml index 5035e90..9b3e6c0 100644 --- a/pom.xml +++ b/pom.xml @@ -1,126 +1,181 @@ <?xml version="1.0" encoding="UTF-8"?> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - <modelVersion>4.0.0</modelVersion> +<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> - <parent> - <groupId>ch.fritteli</groupId> - <artifactId>fritteli-build-parent</artifactId> - <version>2.0.4</version> - </parent> + <parent> + <groupId>ch.fritteli</groupId> + <artifactId>fritteli-build-parent</artifactId> + <version>2.0.4</version> + </parent> - <groupId>ch.fritteli.labyrinth</groupId> - <artifactId>labyrinth-server</artifactId> - <version>0.0.2-SNAPSHOT</version> + <groupId>ch.fritteli.labyrinth</groupId> + <artifactId>labyrinth-server</artifactId> + <version>0.0.2-SNAPSHOT</version> + <description>The Labyrinth server, offering a REST endpoint to access the Labyrinth Generator.</description> + <url>https://manuel.friedli.info/labyrinth.html</url> - <properties> - <logback.version>1.4.6</logback.version> - <lombok.version>1.18.26</lombok.version> - <slf4j.version>2.0.5</slf4j.version> - <java.source.version>17</java.source.version> - <java.target.version>17</java.target.version> - </properties> + <properties> + <java.source.version>17</java.source.version> + <java.target.version>17</java.target.version> + <jetbrains-annotations.version>24.0.1</jetbrains-annotations.version> + <junit-jupiter.version>5.9.2</junit-jupiter.version> + <labyrinth-generator.version>0.0.4</labyrinth-generator.version> + <logback.version>1.4.6</logback.version> + <lombok.version>1.18.26</lombok.version> + <slf4j.version>2.0.7</slf4j.version> + <undertow.version>2.3.5.Final</undertow.version> + <vavr.version>0.10.4</vavr.version> + </properties> - <dependencies> - <dependency> - <groupId>ch.fritteli.labyrinth</groupId> - <artifactId>labyrinth-generator</artifactId> - <version>0.0.2</version> - </dependency> - <dependency> - <groupId>io.vavr</groupId> - <artifactId>vavr</artifactId> - </dependency> - <dependency> - <groupId>org.projectlombok</groupId> - <artifactId>lombok</artifactId> - </dependency> - <dependency> - <groupId>org.jetbrains</groupId> - <artifactId>annotations</artifactId> - </dependency> - <dependency> - <groupId>org.slf4j</groupId> - <artifactId>slf4j-api</artifactId> - <version>${slf4j.version}</version> - </dependency> - <dependency> - <groupId>ch.qos.logback</groupId> - <artifactId>logback-classic</artifactId> - <version>${logback.version}</version> - </dependency> - <dependency> - <groupId>org.junit.jupiter</groupId> - <artifactId>junit-jupiter-api</artifactId> - <scope>test</scope> - </dependency> - </dependencies> + <developers> + <developer> + <name>Manuel Friedli</name> + <id>manuel</id> + <url>https://www.fritteli.ch/</url> + <email>manuel@fritteli.ch</email> + <timezone>Europe/Zurich</timezone> + <roles> + <role>Project Lead</role> + <role>Software Architect</role> + <role>Software Engineer</role> + <role>Operations Manager</role> + </roles> + </developer> + </developers> - <build> - <plugins> - <plugin> - <groupId>org.apache.maven.plugins</groupId> - <artifactId>maven-shade-plugin</artifactId> - <version>3.2.4</version> - <configuration> - <transformers> - <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> - <mainClass>ch.fritteli.labyrinth.server.Main</mainClass> - </transformer> - </transformers> - </configuration> - <executions> - <execution> - <phase>package</phase> - <goals> - <goal>shade</goal> - </goals> - </execution> - </executions> - </plugin> - </plugins> - </build> - <scm> - <connection>scm:git:git://gittr.ch/java/labyrinth-server.git</connection> - <developerConnection>scm:git:ssh://git@gittr.ch/java/labyrinth-server.git</developerConnection> - <url>https://gittr.ch/java/labyrinth-server</url> - <tag>v0.0.1</tag> - </scm> - <distributionManagement> - <repository> - <id>repo.gittr.ch</id> - <name>gittr.ch</name> - <url>https://repo.gittr.ch/releases/</url> - </repository> - <snapshotRepository> - <id>repo.gittr.ch</id> - <name>gittr.ch</name> - <url>https://repo.gittr.ch/snapshots/</url> - </snapshotRepository> - </distributionManagement> - <repositories> - <repository> - <id>repo.gittr.ch.releases</id> - <url>https://repo.gittr.ch/releases/</url> - <releases> - <enabled>true</enabled> - <updatePolicy>never</updatePolicy> - </releases> - <snapshots> - <enabled>false</enabled> - <updatePolicy>never</updatePolicy> - </snapshots> - </repository> - <repository> - <id>repo.gittr.ch.snapshots</id> - <url>https://repo.gittr.ch/snapshots/</url> - <releases> - <enabled>false</enabled> - <updatePolicy>never</updatePolicy> - </releases> - <snapshots> - <enabled>true</enabled> - <updatePolicy>always</updatePolicy> - </snapshots> - </repository> - </repositories> + <dependencies> + <dependency> + <groupId>ch.fritteli.labyrinth</groupId> + <artifactId>labyrinth-generator</artifactId> + <version>${labyrinth-generator.version}</version> + </dependency> + <dependency> + <groupId>io.vavr</groupId> + <artifactId>vavr</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + </dependency> + <dependency> + <groupId>org.jetbrains</groupId> + <artifactId>annotations</artifactId> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + <version>${slf4j.version}</version> + </dependency> + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + <version>${logback.version}</version> + </dependency> + <dependency> + <groupId>io.undertow</groupId> + <artifactId>undertow-core</artifactId> + <version>${undertow.version}</version> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-api</artifactId> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-shade-plugin</artifactId> + <version>3.4.1</version> + <configuration> + <transformers> + <transformer + implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> + <mainClass>ch.fritteli.labyrinth.server.Main</mainClass> + </transformer> + </transformers> + <filters> + <filter> + <artifact>ch.fritteli.labyrinth:labyrinth-generator</artifact> + <excludes> + <exclude>logback.xml</exclude> + </excludes> + </filter> + </filters> + </configuration> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>shade</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-source-plugin</artifactId> + <executions> + <execution> + <id>attach-sources</id> + <phase>verify</phase> + <goals> + <goal>jar-no-fork</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-site-plugin</artifactId> + <version>4.0.0-M6</version> + </plugin> + </plugins> + </build> + <scm> + <connection>scm:git:https://gittr.ch/java/labyrinth-server.git</connection> + <developerConnection>scm:git:ssh://git@gittr.ch/java/labyrinth-server.git</developerConnection> + <url>https://gittr.ch/java/labyrinth-server</url> + <tag>HEAD</tag> + </scm> + <distributionManagement> + <repository> + <id>repo.gittr.ch</id> + <name>gittr.ch</name> + <url>https://repo.gittr.ch/releases/</url> + </repository> + <snapshotRepository> + <id>repo.gittr.ch</id> + <name>gittr.ch</name> + <url>https://repo.gittr.ch/snapshots/</url> + </snapshotRepository> + </distributionManagement> + <repositories> + <repository> + <id>repo.gittr.ch.releases</id> + <url>https://repo.gittr.ch/releases/</url> + <releases> + <enabled>true</enabled> + <updatePolicy>never</updatePolicy> + </releases> + <snapshots> + <enabled>false</enabled> + <updatePolicy>never</updatePolicy> + </snapshots> + </repository> + <repository> + <id>repo.gittr.ch.snapshots</id> + <url>https://repo.gittr.ch/snapshots/</url> + <releases> + <enabled>false</enabled> + <updatePolicy>never</updatePolicy> + </releases> + <snapshots> + <enabled>true</enabled> + <updatePolicy>always</updatePolicy> + </snapshots> + </repository> + </repositories> </project> diff --git a/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java b/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java index aab20f6..16e0aae 100644 --- a/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java +++ b/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java @@ -1,323 +1,59 @@ package ch.fritteli.labyrinth.server; -import ch.fritteli.labyrinth.generator.model.Labyrinth; -import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer; -import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer; -import ch.fritteli.labyrinth.generator.renderer.text.TextRenderer; -import ch.fritteli.labyrinth.generator.serialization.SerializerDeserializer; -import com.sun.net.httpserver.Headers; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import io.vavr.collection.HashMap; -import io.vavr.collection.HashSet; -import io.vavr.collection.List; -import io.vavr.collection.Map; -import io.vavr.collection.Set; -import io.vavr.collection.Stream; -import io.vavr.control.Option; +import ch.fritteli.labyrinth.server.handler.CreateHandler; +import ch.fritteli.labyrinth.server.handler.RenderHandler; +import io.undertow.Undertow; +import io.undertow.server.RoutingHandler; import io.vavr.control.Try; -import lombok.Getter; +import java.net.InetSocketAddress; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Function; @Slf4j public class LabyrinthServer { - @NonNull - private final HttpServer httpServer; - @NonNull - private final ExecutorService executorService = new ThreadPoolExecutor(0, - 1_000, - 5, - TimeUnit.SECONDS, - new SynchronousQueue<>() - ); - public static Option<LabyrinthServer> createAndStartServer() { - final Option<LabyrinthServer> serverOption = Try.of(ServerConfig::init) - .mapTry(LabyrinthServer::new) - .onFailure(cause -> log.error( - "Failed to create LabyrinthServer.", - cause - )) - .toOption(); - serverOption.forEach(LabyrinthServer::start); - return serverOption; + @NonNull + private final Undertow undertow; + + private LabyrinthServer(@NonNull final ServerConfig config) { + final String hostAddress = config.getAddress().getHostAddress(); + final int port = config.getPort(); + log.info("Starting Server at http://{}:{}/", hostAddress, port); + final RoutingHandler routingHandler = new RoutingHandler() + .get(CreateHandler.PATH_TEMPLATE, new CreateHandler()) + .post(RenderHandler.PATH_TEMPLATE, new RenderHandler()); + + this.undertow = Undertow.builder() + .addHttpListener(port, hostAddress) + .setHandler(routingHandler) + .build(); } - public LabyrinthServer(@NonNull final ServerConfig config) throws IOException { - this.httpServer = HttpServer.create(new InetSocketAddress(config.getAddress(), config.getPort()), 5); - this.httpServer.createContext("/", new StaticResourcesFileHandler(this.executorService)); - this.httpServer.createContext("/create", this::handleCreate); - this.httpServer.createContext("/render", this::handleRender); + @NonNull + public static Try<LabyrinthServer> createAndStartServer() { + return Try.of(ServerConfig::init) + .flatMapTry(LabyrinthServer::createAndStartServer); } - public void start() { + @NonNull + public static Try<LabyrinthServer> createAndStartServer(@NonNull final ServerConfig config) { + return Try.of(() -> new LabyrinthServer(config)) + .peek(LabyrinthServer::start); + } + + private void start() { Runtime.getRuntime().addShutdownHook(new Thread(this::stop, "listener-stopper")); - this.httpServer.start(); - log.info("Listening on http://{}:{}", - this.httpServer.getAddress().getHostString(), - this.httpServer.getAddress().getPort() - ); + this.undertow.start(); + final InetSocketAddress address = (InetSocketAddress) this.undertow.getListenerInfo().get(0).getAddress(); + final String hostAddress = address.getAddress().getHostAddress(); + final int port = address.getPort(); + + log.info("Listening on http://{}:{}", hostAddress, port); } - private void handleCreate(HttpExchange exchange) { - this.executorService.submit(() -> { - log.debug("Handling request to {}", exchange.getRequestURI()); - try { - final String requestMethod = exchange.getRequestMethod(); - if (!requestMethod.equals("GET")) { - exchange.getResponseBody().close(); - exchange.sendResponseHeaders(405, -1); - return; - } - final Map<RequestParameter, String> requestParams = this.parseQueryString(exchange.getRequestURI() - .getQuery()); - final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5); - final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 7); - final Option<Long> idOption = requestParams.get(RequestParameter.ID) - .toTry() - .map(Long::valueOf) - .toOption(); - final Option<OutputType> outputOption = requestParams.get(RequestParameter.OUTPUT) - .flatMap(OutputType::ofString); - final Headers responseHeaders = exchange.getResponseHeaders(); - final AtomicBoolean needsRedirect = new AtomicBoolean(false); - final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime); - final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)) - .getOrElse(OutputType.HTML); - if (needsRedirect.get()) { - responseHeaders.add("Location", - "create?width=" + width + "&height=" + height + "&output=" + output.toString() + - "&id=" + id - ); - exchange.sendResponseHeaders(302, -1); - return; - } - final byte[] render; - try { - final Labyrinth labyrinth = new Labyrinth(width, height, id); - render = output.render(labyrinth); - } catch (Exception e) { - responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); - exchange.sendResponseHeaders(500, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(("Error: " + e.getMessage()).getBytes(StandardCharsets.UTF_8)); - responseBody.flush(); - return; - } - responseHeaders.add("Content-type", output.getContentType()); - if (output.isAttachment()) { - responseHeaders.add( - "Content-disposition", - String.format("attachment; filename=\"labyrinth-%dx%d-%d.%s\"", - width, - height, - id, - output.getFileExtension() - ) - ); - } - exchange.sendResponseHeaders(200, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(render); - responseBody.flush(); - } catch (Exception e) { - log.error("FSCK!", e); - } finally { - exchange.close(); - } - }); - } - - private void handleRender(final HttpExchange exchange) { - this.executorService.submit(() -> { - try { - log.debug("Handling request to {}", exchange.getRequestURI()); - final String requestMethod = exchange.getRequestMethod(); - if (!requestMethod.equals("POST")) { - exchange.getResponseBody().close(); - exchange.sendResponseHeaders(405, -1); - return; - } - final byte[] bytes = exchange.getRequestBody().readAllBytes(); - - final Labyrinth labyrinth = SerializerDeserializer.deserialize(bytes); - - final OutputType output = exchange.getRequestHeaders() - .get("Accept") - .contains(OutputType.HTML.getContentType()) ? - OutputType.HTML : - OutputType.TEXT_PLAIN; - final byte[] render; - final Headers responseHeaders = exchange.getResponseHeaders(); - try { - render = output.render(labyrinth); - } catch (Exception e) { - responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); - exchange.sendResponseHeaders(500, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8)); - responseBody.flush(); - return; - } - responseHeaders.add("Content-type", output.getContentType()); - exchange.sendResponseHeaders(200, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(render); - responseBody.flush(); - } catch (Exception e) { - log.error("FSCK!", e); - } finally { - exchange.close(); - } - }); - } - - public void stop() { + private void stop() { log.info("Stopping server ..."); - this.httpServer.stop(5); - this.executorService.shutdown(); - try { - if (!this.executorService.awaitTermination(5, TimeUnit.SECONDS)) { - log.warn("Timeout occurred while awaiting termination of executor service"); - } - } catch (final InterruptedException e) { - log.error("Failed to await termination of executor service", e); - } + this.undertow.stop(); log.info("Server stopped."); } - - @NonNull - private Map<RequestParameter, String> parseQueryString(@Nullable final String query) { - if (query == null) { - return HashMap.empty(); - } - HashMap<RequestParameter, String> result = HashMap.empty(); - final String[] parts = query.split("&"); - for (final String part : parts) { - final int split = part.indexOf('='); - if (split == -1) { - final Try<RequestParameter> tryKey = Try.of(() -> this.normalizeParameterName(part)); - if (tryKey.isSuccess()) { - result = result.put(tryKey.get(), null); - } - } else { - final String key = part.substring(0, split); - final String value = part.substring(split + 1); - final Try<RequestParameter> tryKey = Try.of(() -> this.normalizeParameterName(key)); - if (tryKey.isSuccess()) { - result = result.put(tryKey.get(), value); - } - } - } - return result; - } - - private <T> T getOrDefault(@NonNull final Option<String> input, - @NonNull final Function<String, T> mapper, - @Nullable final T defaultValue) { - return input.toTry().map(mapper).getOrElse(defaultValue); - } - - private RequestParameter normalizeParameterName(final String paramName) { - return RequestParameter.parseName(paramName).get(); - } - - private enum RequestParameter { - WIDTH("w", "width"), - HEIGHT("h", "height"), - ID("i", "id"), - OUTPUT("o", "output"); - private final Set<String> names; - - RequestParameter(@NonNull final String... names) { - this.names = HashSet.of(names); - } - - static Option<RequestParameter> parseName(@Nullable final String name) { - if (name == null) { - return Option.none(); - } - return Stream.of(values()).find(param -> param.names.exists(name::equalsIgnoreCase)); - } - } - - private enum OutputType { - TEXT_PLAIN("text/plain; charset=UTF-8", - labyrinth -> TextRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), - "txt", - false, - "t", - "text" - ), - HTML("text/html", - labyrinth -> HTMLRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), - "html", - false, - "h", - "html" - ), - PDF("application/pdf", labyrinth -> PDFRenderer.newInstance().render(labyrinth), "pdf", false, "p", "pdf"), - PDFFILE("application/pdf", - labyrinth -> PDFRenderer.newInstance().render(labyrinth), - "pdf", - true, - "f", - "pdffile" - ), - BINARY("application/octet-stream", SerializerDeserializer::serialize, "laby", true, "b", "binary"); - @Getter - @NonNull - private final String contentType; - @NonNull - private final List<String> names; - @NonNull - private final Function<Labyrinth, byte[]> render; - @Getter - private final boolean attachment; - @Getter - @NonNull - private final String fileExtension; - - OutputType(@NonNull final String contentType, - @NonNull final Function<Labyrinth, byte[]> render, - @NonNull final String fileExtension, - final boolean attachment, - @NonNull final String... names) { - this.contentType = contentType; - this.render = render; - this.fileExtension = fileExtension; - this.attachment = attachment; - this.names = List.of(names); - } - - static Option<OutputType> ofString(@Nullable final String name) { - if (name == null) { - return Option.none(); - } - final String nameLC = name.toLowerCase(); - return Stream.of(values()).find(param -> param.names.contains(nameLC)); - } - - @Override - public String toString() { - return this.names.last(); - } - - byte[] render(@NonNull final Labyrinth labyrinth) { - return this.render.apply(labyrinth); - } - } } diff --git a/src/main/java/ch/fritteli/labyrinth/server/Main.java b/src/main/java/ch/fritteli/labyrinth/server/Main.java index ced13bd..9800d8c 100644 --- a/src/main/java/ch/fritteli/labyrinth/server/Main.java +++ b/src/main/java/ch/fritteli/labyrinth/server/Main.java @@ -1,11 +1,14 @@ package ch.fritteli.labyrinth.server; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @Slf4j +@UtilityClass public class Main { + public static void main(String[] args) { LabyrinthServer.createAndStartServer() - .onEmpty(() -> log.error("Failed to create server. Stopping.")); + .onFailure(e -> log.error("Failed to create server. Stopping.", e)); } } diff --git a/src/main/java/ch/fritteli/labyrinth/server/OutputType.java b/src/main/java/ch/fritteli/labyrinth/server/OutputType.java new file mode 100644 index 0000000..c1d08d9 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/OutputType.java @@ -0,0 +1,94 @@ +package ch.fritteli.labyrinth.server; + +import ch.fritteli.labyrinth.generator.model.Labyrinth; +import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer; +import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer; +import ch.fritteli.labyrinth.generator.renderer.text.TextRenderer; +import ch.fritteli.labyrinth.generator.serialization.SerializerDeserializer; +import io.vavr.collection.List; +import io.vavr.collection.Stream; +import io.vavr.control.Option; +import lombok.Getter; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.util.function.Function; + +public enum OutputType { + TEXT_PLAIN("text/plain; charset=UTF-8", + "txt", + labyrinth -> TextRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), + false, + "t", + "text"), + HTML("text/html", + "html", + labyrinth -> HTMLRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), + false, + "h", + "html"), + PDF("application/pdf", + "pdf", + labyrinth -> PDFRenderer.newInstance().render(labyrinth).toByteArray(), + false, + "p", + "pdf"), + PDFFILE("application/pdf", + "pdf", + labyrinth -> PDFRenderer.newInstance().render(labyrinth).toByteArray(), + true, + "f", + "pdffile"), + BINARY("application/octet-stream", + "laby", + SerializerDeserializer::serialize, + true, + "b", + "binary"); + @Getter + @NonNull + private final String contentType; + @Getter + @NonNull + private final String fileExtension; + @NonNull + private final Function<Labyrinth, byte[]> render; + @Getter + private final boolean attachment; + @Getter + @NonNull + private final List<String> names; + + OutputType(@NonNull final String contentType, + @NonNull final String fileExtension, + @NonNull final Function<Labyrinth, byte[]> render, + final boolean attachment, + @NonNull final String... names) { + this.contentType = contentType; + this.render = render; + this.fileExtension = fileExtension; + this.attachment = attachment; + this.names = List.of(names); + } + + @NonNull + public static Option<OutputType> ofString(@Nullable final String name) { + return Option.of(name) + .map(String::toLowerCase) + .flatMap(nameLC -> Stream.of(values()) + .find(param -> param.names.contains(nameLC))); + + } + + @NonNull + @Override + public String toString() { + return this.names.last(); + } + + @NonNull + public byte[] render(@NonNull final Labyrinth labyrinth) { + return this.render.apply(labyrinth); + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/server/ServerConfig.java b/src/main/java/ch/fritteli/labyrinth/server/ServerConfig.java index 58cee13..196b2c5 100644 --- a/src/main/java/ch/fritteli/labyrinth/server/ServerConfig.java +++ b/src/main/java/ch/fritteli/labyrinth/server/ServerConfig.java @@ -10,7 +10,6 @@ import org.jetbrains.annotations.Nullable; import java.net.InetAddress; - @AllArgsConstructor(access = AccessLevel.PRIVATE) @Slf4j @Value @@ -39,7 +38,17 @@ public class ServerConfig { @NonNull private static InetAddress validateAddress(@Nullable final String address) { return Try.of(() -> InetAddress.getByName(address)) - .getOrElseThrow(cause -> new ConfigurationException("Invalid hostname/address: " + address, cause)); + .getOrElseThrow(cause -> new ConfigurationException( + "Invalid hostname/address: %s".formatted(address), + cause + )); + } + + private static int validatePort(final int port) { + if (port < 0 || port > 0xFFFF) { + throw new ConfigurationException("Port out of range (0..65535): %s".formatted(port)); + } + return port; } private static int validatePort(@Nullable final String portString) { @@ -48,14 +57,9 @@ public class ServerConfig { return 0; } return Try.of(() -> Integer.valueOf(portString)) - .map(ServerConfig::validatePort) - .getOrElseThrow(cause -> new ConfigurationException("Failed to parse port specified in system property '" + SYSPROP_PORT + "': " + portString, cause)); - } - - private static int validatePort(final int port) { - if (port < 0 || port > 0xFFFF) { - throw new ConfigurationException("Port out of range (0..65535): " + port); - } - return port; + .getOrElseThrow(cause -> new ConfigurationException( + "Failed to parse port specified in system property '%s': %s".formatted(SYSPROP_PORT, portString), + cause + )); } } diff --git a/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java b/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java deleted file mode 100644 index d1cabfc..0000000 --- a/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java +++ /dev/null @@ -1,101 +0,0 @@ -package ch.fritteli.labyrinth.server; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; -import org.apache.pdfbox.io.IOUtils; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ExecutorService; - -@Slf4j -public class StaticResourcesFileHandler implements HttpHandler { - - public static final String WEBASSETS_DIRECTORY = "webassets"; - private final ExecutorService executorService; - - public StaticResourcesFileHandler(final ExecutorService executorService) { - this.executorService = executorService; - log.debug("Created {}", this.getClass().getSimpleName()); - } - - private static void redirect(@NonNull final HttpExchange exchange, @NonNull final String target) throws IOException { - log.debug("Sending redirect to {}", target); - exchange.getResponseHeaders().add("Location", target); - exchange.sendResponseHeaders(302, -1); - } - - private static void notFound(@NonNull final HttpExchange exchange, @NonNull final String path) throws IOException { - log.debug("Resource '{}' not found, replying with HTTP 404", path); - exchange.getResponseHeaders().add("Content-type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(404, 0); - exchange.getResponseBody().write("404 - Not found".getBytes(StandardCharsets.UTF_8)); - exchange.getResponseBody().flush(); - } - - @NonNull - private static byte[] getBytes(@NonNull final String path) throws IOException { - final InputStream stream = StaticResourcesFileHandler.class.getClassLoader().getResourceAsStream(WEBASSETS_DIRECTORY + path); - if (stream == null) { - log.debug("Resource '{}' not found in classpath.", path); - return new byte[0]; - } - final byte[] response = IOUtils.toByteArray(stream); - log.debug("Sending reply; {} bytes", response.length); - return response; - } - - @Nullable - private static String getMimeType(@NonNull final String path) { - if (path.endsWith(".html")) { - return "text/html"; - } - if (path.endsWith(".css")) { - return "text/css"; - } - return null; - } - - @Override - public void handle(@NonNull final HttpExchange exchange) throws IOException { - this.executorService.submit(() -> { - try { - final URI requestURI = exchange.getRequestURI(); - final String path = requestURI.getPath(); - log.debug("Handling request to {}", path); - if ("/".equals(path)) { - redirect(exchange, "index.html"); - return; - } - if (!path.startsWith("/")) { - notFound(exchange, path); - return; - } - final String mimeType = getMimeType(path); - if (mimeType == null) { - notFound(exchange, path); - return; - } - final byte[] responseBytes = getBytes(path); - if (responseBytes.length == 0) { - notFound(exchange, path); - return; - } - log.debug("Serving {}{} with mimetype {}: {} bytes", WEBASSETS_DIRECTORY, path, mimeType, responseBytes.length); - exchange.getResponseHeaders().add("Content-type", mimeType); - exchange.sendResponseHeaders(200, 0); - exchange.getResponseBody().write(responseBytes); - exchange.getResponseBody().flush(); - } catch (Exception e) { - log.error("FSCK!", e); - } finally { - exchange.close(); - } - }); - } -} diff --git a/src/main/java/ch/fritteli/labyrinth/server/handler/AbstractHttpHandler.java b/src/main/java/ch/fritteli/labyrinth/server/handler/AbstractHttpHandler.java new file mode 100644 index 0000000..198c7a4 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/handler/AbstractHttpHandler.java @@ -0,0 +1,39 @@ +package ch.fritteli.labyrinth.server.handler; + +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.StatusCodes; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +@Slf4j +public abstract class AbstractHttpHandler implements HttpHandler { + @Override + public final void handleRequest(@NonNull final HttpServerExchange exchange) { + final Instant start = Instant.now(); + try (final MDC.MDCCloseable closeable = MDC.putCloseable("correlationId", UUID.randomUUID().toString())) { + + if (exchange.isInIoThread()) { + log.debug("Dispatching request"); + exchange.dispatch(this); + return; + } + try { + this.handle(exchange); + } catch (@NonNull final Exception e) { + log.error("Error handling request", e); + exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR) + .getResponseSender() + .send(StatusCodes.INTERNAL_SERVER_ERROR_STRING); + } + log.debug("Completed request in {}ms.", start.until(Instant.now(), ChronoUnit.MILLIS)); + } + } + + protected abstract void handle(@NonNull final HttpServerExchange exchange) throws Exception; +} diff --git a/src/main/java/ch/fritteli/labyrinth/server/handler/CreateHandler.java b/src/main/java/ch/fritteli/labyrinth/server/handler/CreateHandler.java new file mode 100644 index 0000000..344a907 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/handler/CreateHandler.java @@ -0,0 +1,76 @@ +package ch.fritteli.labyrinth.server.handler; + +import ch.fritteli.labyrinth.generator.model.Labyrinth; +import ch.fritteli.labyrinth.server.OutputType; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; +import io.undertow.util.StatusCodes; +import io.vavr.Tuple2; +import io.vavr.control.Try; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Deque; +import java.util.Map; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; + +@Slf4j +public class CreateHandler extends AbstractHttpHandler { + + public static final String PATH_TEMPLATE = "/create/{output}"; + + @Override + protected void handle(@NonNull final HttpServerExchange exchange) { + final Instant start = Instant.now(); + log.debug("Handling create request"); + this.createLabyrinthFromRequestParameters(exchange.getQueryParameters()) + .onFailure(e -> { + log.error("Error creating Labyrinth from request", e); + exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR) + .setReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR_STRING) + .getResponseSender() + .send(e.getMessage()); + }) + .forEach(tuple -> { + final OutputType outputType = tuple._1(); + final Labyrinth labyrinth = tuple._2(); + final byte[] bytes; + try { + bytes = outputType.render(labyrinth); + } catch (@NonNull final Exception e) { + log.error("Error rendering Labyrinth", e); + exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR) + .setReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR_STRING) + .getResponseSender() + .send("Error creating the Labyrinth. Please contact the administrator. Request id=%s".formatted(MDC.get("correlationId"))); + return; + } + final long durationMillis = start.until(Instant.now(), ChronoUnit.MILLIS); + exchange.getResponseHeaders() + .put(Headers.CONTENT_TYPE, outputType.getContentType()) + .put(HttpString.tryFromString("X-Labyrinth-ID"), String.valueOf(labyrinth.getRandomSeed())) + .put(HttpString.tryFromString("X-Labyrinth-Width"), String.valueOf(labyrinth.getWidth())) + .put(HttpString.tryFromString("X-Labyrinth-Height"), String.valueOf(labyrinth.getHeight())) + .put(HttpString.tryFromString("X-Labyrinth-Generation-Duration-millis"), String.valueOf(durationMillis)); + if (outputType.isAttachment()) { + exchange.getResponseHeaders() + .put(Headers.CONTENT_DISPOSITION, "attachment; filename=\"labyrinth-%dx%d-%d.%s\"".formatted( + labyrinth.getWidth(), + labyrinth.getHeight(), + labyrinth.getRandomSeed(), + outputType.getFileExtension() + )); + } + exchange.getResponseSender().send(ByteBuffer.wrap(bytes)); + log.debug("Create request handled in {}ms.", durationMillis); + }); + } + + @NonNull + private Try<Tuple2<OutputType, Labyrinth>> createLabyrinthFromRequestParameters(final Map<String, Deque<String>> queryParameters) { + return new ParametersToLabyrinthExtractor(queryParameters).createLabyrinth(); + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/server/handler/ParametersToLabyrinthExtractor.java b/src/main/java/ch/fritteli/labyrinth/server/handler/ParametersToLabyrinthExtractor.java new file mode 100644 index 0000000..a1cf854 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/handler/ParametersToLabyrinthExtractor.java @@ -0,0 +1,55 @@ +package ch.fritteli.labyrinth.server.handler; + +import ch.fritteli.labyrinth.generator.model.Labyrinth; +import ch.fritteli.labyrinth.server.OutputType; +import io.vavr.Tuple; +import io.vavr.Tuple2; +import io.vavr.collection.Stream; +import io.vavr.control.Option; +import io.vavr.control.Try; +import java.util.Deque; +import java.util.Map; +import java.util.Random; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class ParametersToLabyrinthExtractor { + + @NonNull + private final Map<String, Deque<String>> queryParameters; + + @NonNull + Try<Tuple2<OutputType, Labyrinth>> createLabyrinth() { + final Option<OutputType> output = getParameterValue(RequestParameter.OUTPUT); + final Option<Integer> width = getParameterValue(RequestParameter.WIDTH); + final Option<Integer> height = getParameterValue(RequestParameter.HEIGHT); + final Option<Long> id = getParameterValue(RequestParameter.ID); + + if (output.isEmpty()) { + return Try.failure(new IllegalArgumentException("Path parameter %s is required and must be one of: %s".formatted( + RequestParameter.OUTPUT.getNames().mkString("'", " / ", "'"), + Stream.of(OutputType.values()) + .flatMap(OutputType::getNames) + .mkString(", ") + ))); + } + if (width.isEmpty()) { + return Try.failure(new IllegalArgumentException("Query parameter %s is required and must be a positive integer value".formatted( + RequestParameter.WIDTH.getNames().mkString("'", " / ", "'") + ))); + } + if (height.isEmpty()) { + return Try.failure(new IllegalArgumentException("Query parameter %s is required and must be a positive integer value".formatted( + RequestParameter.HEIGHT.getNames().mkString("'", " / ", "'") + ))); + } + + return Try.of(() -> Tuple.of(output.get(), new Labyrinth(width.get(), height.get(), id.getOrElse(() -> new Random().nextLong())))); + } + + @NonNull + private <T> Option<T> getParameterValue(@NonNull final RequestParameter parameter) { + return parameter.getParameterValue(this.queryParameters); + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/server/handler/RenderHandler.java b/src/main/java/ch/fritteli/labyrinth/server/handler/RenderHandler.java new file mode 100644 index 0000000..52a61d5 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/handler/RenderHandler.java @@ -0,0 +1,60 @@ +package ch.fritteli.labyrinth.server.handler; + +import ch.fritteli.labyrinth.generator.model.Labyrinth; +import ch.fritteli.labyrinth.generator.serialization.SerializerDeserializer; +import ch.fritteli.labyrinth.server.OutputType; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderValues; +import io.undertow.util.Headers; +import io.undertow.util.StatusCodes; +import java.nio.ByteBuffer; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RenderHandler extends AbstractHttpHandler { + + public static final String PATH_TEMPLATE = "/render/{output}"; + + @Override + public void handle(final HttpServerExchange exchange) { + log.debug("Handling render request"); + + if (exchange.isInIoThread()) { + exchange.dispatch(this); + return; + } + exchange.getRequestReceiver().receiveFullBytes((httpServerExchange, bytes) -> { + final OutputType output = this.getOutputType(httpServerExchange); + final byte[] render; + try { + final Labyrinth labyrinth = SerializerDeserializer.deserialize(bytes); + render = output.render(labyrinth); + } catch (final Exception e) { + log.error("Error rendering binary labyrinth data", e); + httpServerExchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR) + .getResponseSender() + .send("Error rendering labyrinth: %s".formatted(e.getMessage())); + return; + } + httpServerExchange + .setStatusCode(StatusCodes.OK) + .getResponseHeaders() + .put(Headers.CONTENT_TYPE, output.getContentType()); + httpServerExchange.getResponseSender() + .send(ByteBuffer.wrap(render)); + }); + } + + @NonNull + private OutputType getOutputType(@NonNull final HttpServerExchange httpServerExchange) { + return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters()) + .getOrElse(() -> { + final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT); + if (accept.contains(OutputType.HTML.getContentType())) { + return OutputType.HTML; + } + return OutputType.TEXT_PLAIN; + }); + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/server/handler/RequestParameter.java b/src/main/java/ch/fritteli/labyrinth/server/handler/RequestParameter.java new file mode 100644 index 0000000..314ee22 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/handler/RequestParameter.java @@ -0,0 +1,54 @@ +package ch.fritteli.labyrinth.server.handler; + +import ch.fritteli.labyrinth.server.OutputType; +import io.vavr.Tuple2; +import io.vavr.collection.HashMap; +import io.vavr.collection.HashSet; +import io.vavr.collection.Set; +import io.vavr.control.Option; +import io.vavr.control.Try; +import java.util.Deque; +import java.util.Map; +import java.util.function.Function; +import lombok.Getter; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +enum RequestParameter { + WIDTH(p -> Try.of(() -> Integer.parseInt(p)) + .toOption() + .onEmpty(() -> log.debug("Unparseable value for parameter 'width': '{}'", p)), "w", "width"), + HEIGHT(p -> Try.of(() -> Integer.parseInt(p)) + .toOption() + .onEmpty(() -> log.debug("Unparseable value for parameter 'height': '{}'", p)), "h", "height"), + ID(p -> Try.of(() -> Long.parseLong(p)) + .toOption() + .onEmpty(() -> log.debug("Unparseable value for parameter 'id': '{}'", p)), "i", "id"), + OUTPUT(p -> OutputType.ofString(p) + .onEmpty(() -> log.debug("Unparseable value for parameter 'output': '{}'", p)), "o", "output"); + @NonNull + private final Function<String, Option<?>> extractor; + @Getter + @NonNull + private final Set<String> names; + + RequestParameter(@NonNull final Function<String, Option<?>> extractor, @NonNull final String... names) { + this.extractor = extractor; + this.names = HashSet.of(names); + } + + @NonNull + Option<?> extractParameterValue(@NonNull final String parameter) { + return this.extractor.apply(parameter); + } + + @NonNull + public <T> Option<T> getParameterValue(@NonNull final Map<String, Deque<String>> queryParameters) { + return (Option<T>) HashMap.ofAll(queryParameters) + .filterKeys(this.names::contains) + .flatMap(Tuple2::_2) + .flatMap(this::extractParameterValue) + .headOption(); + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 4bba3c1..d660474 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,17 +1,17 @@ <?xml version="1.0" encoding="utf-8" ?> <configuration> - <shutdownHook class="ch.qos.logback.core.hook.DefaultShutdownHook"/> + <shutdownHook class="ch.qos.logback.core.hook.DefaultShutdownHook"/> - <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> - <!-- encoders are by default assigned the type - ch.qos.logback.classic.encoder.PatternLayoutEncoder --> - <encoder> - <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> - </encoder> - </appender> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <!-- encoders are by default assigned the type + ch.qos.logback.classic.encoder.PatternLayoutEncoder --> + <encoder> + <pattern>%d{HH:mm:ss.SSS} %-5level %X{correlationId} [%thread] %logger{36} - %msg%n</pattern> + </encoder> + </appender> - <root level="info"> - <appender-ref ref="STDOUT"/> - </root> - <logger name="ch.fritteli.labyrinth.server.StaticResourcesFileHandler" level="debug"/> + <root level="info"> + <appender-ref ref="STDOUT"/> + </root> + <logger name="ch.fritteli.labyrinth.*" level="debug"/> </configuration> diff --git a/src/main/resources/webassets/index.html b/src/main/resources/webassets/index.html deleted file mode 100644 index 3444b42..0000000 --- a/src/main/resources/webassets/index.html +++ /dev/null @@ -1,33 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title>Labyrinth Generator</title> - <link rel="stylesheet" href="style.css"> -</head> -<body> -<div class="content"> - <h1>Labyrinth Generator</h1> - <p>Enter some values, click the "Create!" button and see what happens!</p> - <form action="/create" method="get"> - <div class="inputs-wrapper"> - <label for="width">Width:</label><input id="width" name="width" type="number" min="1" required> - <label for="height">Height:</label><input id="height" name="height" type="number" min="1" required> - <label for="output">Output format:</label> - <select id="output" name="output" required> - <option label="HTML Document" value="html"></option> - <option label="Plain text" value="text"></option> - <option label="PDF Document" value="pdf"></option> - <option label="PDF Document (Download)" value="pdffile"></option> - <option label="Binary" value="binary"></option> - </select> - <label for="id">Seed (optional):</label><input id="id" name="id" type="number"> - </div> - <div class="controls-wrapper"> - <button type="submit" class="primary">Create!</button> - <button type="reset">Reset form</button> - </div> - </form> -</div> -</body> -</html> diff --git a/src/main/resources/webassets/style.css b/src/main/resources/webassets/style.css deleted file mode 100644 index 153f8e8..0000000 --- a/src/main/resources/webassets/style.css +++ /dev/null @@ -1,51 +0,0 @@ -:root { - --color-background: #181a1b; - --color-foreground: #e8e6e3; - --color-border: #5d6164; - --color-background-highlight: #292b2c; - --color-foreground-highlight: #f8f7f4; - --color-border-highlight: #6e7275; -} - -body { - display: flex; - font-family: sans-serif; - justify-content: center; -} - -body, button, input, select { - background-color: var(--color-background); - color: var(--color-foreground); -} - -button, input, select { - border: 1px solid var(--color-border); -} - -button:active, input:active, select:active, -button:focus, input:focus, select:focus, -button:hover, input:hover, select:hover { - background-color: var(--color-background-highlight); - border-color: var(--color-border-highlight); - color: var(--color-foreground-highlight); -} - -button.primary { - background-color: #ffcc00; - border-color: #eebb00; - color: var(--color-background); -} - -.content { - width: 75%; -} - -.content h1 { - text-align: center; -} - -.inputs-wrapper { - display: grid; - grid-row-gap: 1em; - grid-template-columns: 0.5fr 1fr; -}