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 @@ - - - - repo.gittr.ch - ci - ${env.REPO_TOKEN} - - + + + + repo.gittr.ch + ci + ${env.REPO_TOKEN} + + diff --git a/pom.xml b/pom.xml index 5035e90..9b3e6c0 100644 --- a/pom.xml +++ b/pom.xml @@ -1,126 +1,181 @@ - - 4.0.0 + + 4.0.0 - - ch.fritteli - fritteli-build-parent - 2.0.4 - + + ch.fritteli + fritteli-build-parent + 2.0.4 + - ch.fritteli.labyrinth - labyrinth-server - 0.0.2-SNAPSHOT + ch.fritteli.labyrinth + labyrinth-server + 0.0.2-SNAPSHOT + The Labyrinth server, offering a REST endpoint to access the Labyrinth Generator. + https://manuel.friedli.info/labyrinth.html - - 1.4.6 - 1.18.26 - 2.0.5 - 17 - 17 - + + 17 + 17 + 24.0.1 + 5.9.2 + 0.0.4 + 1.4.6 + 1.18.26 + 2.0.7 + 2.3.5.Final + 0.10.4 + - - - ch.fritteli.labyrinth - labyrinth-generator - 0.0.2 - - - io.vavr - vavr - - - org.projectlombok - lombok - - - org.jetbrains - annotations - - - org.slf4j - slf4j-api - ${slf4j.version} - - - ch.qos.logback - logback-classic - ${logback.version} - - - org.junit.jupiter - junit-jupiter-api - test - - + + + Manuel Friedli + manuel + https://www.fritteli.ch/ + manuel@fritteli.ch + Europe/Zurich + + Project Lead + Software Architect + Software Engineer + Operations Manager + + + - - - - org.apache.maven.plugins - maven-shade-plugin - 3.2.4 - - - - ch.fritteli.labyrinth.server.Main - - - - - - package - - shade - - - - - - - - scm:git:git://gittr.ch/java/labyrinth-server.git - scm:git:ssh://git@gittr.ch/java/labyrinth-server.git - https://gittr.ch/java/labyrinth-server - v0.0.1 - - - - repo.gittr.ch - gittr.ch - https://repo.gittr.ch/releases/ - - - repo.gittr.ch - gittr.ch - https://repo.gittr.ch/snapshots/ - - - - - repo.gittr.ch.releases - https://repo.gittr.ch/releases/ - - true - never - - - false - never - - - - repo.gittr.ch.snapshots - https://repo.gittr.ch/snapshots/ - - false - never - - - true - always - - - + + + ch.fritteli.labyrinth + labyrinth-generator + ${labyrinth-generator.version} + + + io.vavr + vavr + + + org.projectlombok + lombok + + + org.jetbrains + annotations + + + org.slf4j + slf4j-api + ${slf4j.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + io.undertow + undertow-core + ${undertow.version} + + + org.junit.jupiter + junit-jupiter-api + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.4.1 + + + + ch.fritteli.labyrinth.server.Main + + + + + ch.fritteli.labyrinth:labyrinth-generator + + logback.xml + + + + + + + package + + shade + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + verify + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-site-plugin + 4.0.0-M6 + + + + + scm:git:https://gittr.ch/java/labyrinth-server.git + scm:git:ssh://git@gittr.ch/java/labyrinth-server.git + https://gittr.ch/java/labyrinth-server + HEAD + + + + repo.gittr.ch + gittr.ch + https://repo.gittr.ch/releases/ + + + repo.gittr.ch + gittr.ch + https://repo.gittr.ch/snapshots/ + + + + + repo.gittr.ch.releases + https://repo.gittr.ch/releases/ + + true + never + + + false + never + + + + repo.gittr.ch.snapshots + https://repo.gittr.ch/snapshots/ + + false + never + + + true + always + + + 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 createAndStartServer() { - final Option 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 createAndStartServer() { + return Try.of(ServerConfig::init) + .flatMapTry(LabyrinthServer::createAndStartServer); } - public void start() { + @NonNull + public static Try 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 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 idOption = requestParams.get(RequestParameter.ID) - .toTry() - .map(Long::valueOf) - .toOption(); - final Option 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 parseQueryString(@Nullable final String query) { - if (query == null) { - return HashMap.empty(); - } - HashMap result = HashMap.empty(); - final String[] parts = query.split("&"); - for (final String part : parts) { - final int split = part.indexOf('='); - if (split == -1) { - final Try 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 tryKey = Try.of(() -> this.normalizeParameterName(key)); - if (tryKey.isSuccess()) { - result = result.put(tryKey.get(), value); - } - } - } - return result; - } - - private T getOrDefault(@NonNull final Option input, - @NonNull final Function 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 names; - - RequestParameter(@NonNull final String... names) { - this.names = HashSet.of(names); - } - - static Option 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 names; - @NonNull - private final Function render; - @Getter - private final boolean attachment; - @Getter - @NonNull - private final String fileExtension; - - OutputType(@NonNull final String contentType, - @NonNull final Function 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 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 render; + @Getter + private final boolean attachment; + @Getter + @NonNull + private final List names; + + OutputType(@NonNull final String contentType, + @NonNull final String fileExtension, + @NonNull final Function 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 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> createLabyrinthFromRequestParameters(final Map> 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> queryParameters; + + @NonNull + Try> createLabyrinth() { + final Option output = getParameterValue(RequestParameter.OUTPUT); + final Option width = getParameterValue(RequestParameter.WIDTH); + final Option height = getParameterValue(RequestParameter.HEIGHT); + final Option 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 Option 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.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> extractor; + @Getter + @NonNull + private final Set names; + + RequestParameter(@NonNull final Function> 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 Option getParameterValue(@NonNull final Map> queryParameters) { + return (Option) 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 @@ - + - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - + + + + %d{HH:mm:ss.SSS} %-5level %X{correlationId} [%thread] %logger{36} - %msg%n + + - - - - + + + + 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 @@ - - - - - Labyrinth Generator - - - -
-

Labyrinth Generator

-

Enter some values, click the "Create!" button and see what happens!

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