Merge pull request 'feature/undertow' (#4) from feature/undertow into master
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: java/labyrinth-server#4
This commit is contained in:
commit
7abee9b556
15 changed files with 632 additions and 641 deletions
|
@ -1,12 +1,12 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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"
|
<settings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns="http://maven.apache.org/SETTINGS/1.1.0"
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
xmlns="http://maven.apache.org/SETTINGS/1.1.0">
|
||||||
<servers>
|
<servers>
|
||||||
<server>
|
<server>
|
||||||
<id>repo.gittr.ch</id>
|
<id>repo.gittr.ch</id>
|
||||||
<username>ci</username>
|
<username>ci</username>
|
||||||
<password>${env.REPO_TOKEN}</password>
|
<password>${env.REPO_TOKEN}</password>
|
||||||
</server>
|
</server>
|
||||||
</servers>
|
</servers>
|
||||||
</settings>
|
</settings>
|
||||||
|
|
293
pom.xml
293
pom.xml
|
@ -1,126 +1,181 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
<modelVersion>4.0.0</modelVersion>
|
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>
|
<parent>
|
||||||
<groupId>ch.fritteli</groupId>
|
<groupId>ch.fritteli</groupId>
|
||||||
<artifactId>fritteli-build-parent</artifactId>
|
<artifactId>fritteli-build-parent</artifactId>
|
||||||
<version>2.0.4</version>
|
<version>2.0.4</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>ch.fritteli.labyrinth</groupId>
|
<groupId>ch.fritteli.labyrinth</groupId>
|
||||||
<artifactId>labyrinth-server</artifactId>
|
<artifactId>labyrinth-server</artifactId>
|
||||||
<version>0.0.2-SNAPSHOT</version>
|
<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>
|
<properties>
|
||||||
<logback.version>1.4.6</logback.version>
|
<java.source.version>17</java.source.version>
|
||||||
<lombok.version>1.18.26</lombok.version>
|
<java.target.version>17</java.target.version>
|
||||||
<slf4j.version>2.0.5</slf4j.version>
|
<jetbrains-annotations.version>24.0.1</jetbrains-annotations.version>
|
||||||
<java.source.version>17</java.source.version>
|
<junit-jupiter.version>5.9.2</junit-jupiter.version>
|
||||||
<java.target.version>17</java.target.version>
|
<labyrinth-generator.version>0.0.4</labyrinth-generator.version>
|
||||||
</properties>
|
<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>
|
<developers>
|
||||||
<dependency>
|
<developer>
|
||||||
<groupId>ch.fritteli.labyrinth</groupId>
|
<name>Manuel Friedli</name>
|
||||||
<artifactId>labyrinth-generator</artifactId>
|
<id>manuel</id>
|
||||||
<version>0.0.2</version>
|
<url>https://www.fritteli.ch/</url>
|
||||||
</dependency>
|
<email>manuel@fritteli.ch</email>
|
||||||
<dependency>
|
<timezone>Europe/Zurich</timezone>
|
||||||
<groupId>io.vavr</groupId>
|
<roles>
|
||||||
<artifactId>vavr</artifactId>
|
<role>Project Lead</role>
|
||||||
</dependency>
|
<role>Software Architect</role>
|
||||||
<dependency>
|
<role>Software Engineer</role>
|
||||||
<groupId>org.projectlombok</groupId>
|
<role>Operations Manager</role>
|
||||||
<artifactId>lombok</artifactId>
|
</roles>
|
||||||
</dependency>
|
</developer>
|
||||||
<dependency>
|
</developers>
|
||||||
<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>
|
|
||||||
|
|
||||||
<build>
|
<dependencies>
|
||||||
<plugins>
|
<dependency>
|
||||||
<plugin>
|
<groupId>ch.fritteli.labyrinth</groupId>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<artifactId>labyrinth-generator</artifactId>
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
<version>${labyrinth-generator.version}</version>
|
||||||
<version>3.2.4</version>
|
</dependency>
|
||||||
<configuration>
|
<dependency>
|
||||||
<transformers>
|
<groupId>io.vavr</groupId>
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
<artifactId>vavr</artifactId>
|
||||||
<mainClass>ch.fritteli.labyrinth.server.Main</mainClass>
|
</dependency>
|
||||||
</transformer>
|
<dependency>
|
||||||
</transformers>
|
<groupId>org.projectlombok</groupId>
|
||||||
</configuration>
|
<artifactId>lombok</artifactId>
|
||||||
<executions>
|
</dependency>
|
||||||
<execution>
|
<dependency>
|
||||||
<phase>package</phase>
|
<groupId>org.jetbrains</groupId>
|
||||||
<goals>
|
<artifactId>annotations</artifactId>
|
||||||
<goal>shade</goal>
|
</dependency>
|
||||||
</goals>
|
<dependency>
|
||||||
</execution>
|
<groupId>org.slf4j</groupId>
|
||||||
</executions>
|
<artifactId>slf4j-api</artifactId>
|
||||||
</plugin>
|
<version>${slf4j.version}</version>
|
||||||
</plugins>
|
</dependency>
|
||||||
</build>
|
<dependency>
|
||||||
<scm>
|
<groupId>ch.qos.logback</groupId>
|
||||||
<connection>scm:git:git://gittr.ch/java/labyrinth-server.git</connection>
|
<artifactId>logback-classic</artifactId>
|
||||||
<developerConnection>scm:git:ssh://git@gittr.ch/java/labyrinth-server.git</developerConnection>
|
<version>${logback.version}</version>
|
||||||
<url>https://gittr.ch/java/labyrinth-server</url>
|
</dependency>
|
||||||
<tag>v0.0.1</tag>
|
<dependency>
|
||||||
</scm>
|
<groupId>io.undertow</groupId>
|
||||||
<distributionManagement>
|
<artifactId>undertow-core</artifactId>
|
||||||
<repository>
|
<version>${undertow.version}</version>
|
||||||
<id>repo.gittr.ch</id>
|
</dependency>
|
||||||
<name>gittr.ch</name>
|
<dependency>
|
||||||
<url>https://repo.gittr.ch/releases/</url>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
</repository>
|
<artifactId>junit-jupiter-api</artifactId>
|
||||||
<snapshotRepository>
|
</dependency>
|
||||||
<id>repo.gittr.ch</id>
|
</dependencies>
|
||||||
<name>gittr.ch</name>
|
|
||||||
<url>https://repo.gittr.ch/snapshots/</url>
|
<build>
|
||||||
</snapshotRepository>
|
<plugins>
|
||||||
</distributionManagement>
|
<plugin>
|
||||||
<repositories>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<repository>
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
<id>repo.gittr.ch.releases</id>
|
<version>3.4.1</version>
|
||||||
<url>https://repo.gittr.ch/releases/</url>
|
<configuration>
|
||||||
<releases>
|
<transformers>
|
||||||
<enabled>true</enabled>
|
<transformer
|
||||||
<updatePolicy>never</updatePolicy>
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
</releases>
|
<mainClass>ch.fritteli.labyrinth.server.Main</mainClass>
|
||||||
<snapshots>
|
</transformer>
|
||||||
<enabled>false</enabled>
|
</transformers>
|
||||||
<updatePolicy>never</updatePolicy>
|
<filters>
|
||||||
</snapshots>
|
<filter>
|
||||||
</repository>
|
<artifact>ch.fritteli.labyrinth:labyrinth-generator</artifact>
|
||||||
<repository>
|
<excludes>
|
||||||
<id>repo.gittr.ch.snapshots</id>
|
<exclude>logback.xml</exclude>
|
||||||
<url>https://repo.gittr.ch/snapshots/</url>
|
</excludes>
|
||||||
<releases>
|
</filter>
|
||||||
<enabled>false</enabled>
|
</filters>
|
||||||
<updatePolicy>never</updatePolicy>
|
</configuration>
|
||||||
</releases>
|
<executions>
|
||||||
<snapshots>
|
<execution>
|
||||||
<enabled>true</enabled>
|
<phase>package</phase>
|
||||||
<updatePolicy>always</updatePolicy>
|
<goals>
|
||||||
</snapshots>
|
<goal>shade</goal>
|
||||||
</repository>
|
</goals>
|
||||||
</repositories>
|
</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>
|
</project>
|
||||||
|
|
|
@ -1,323 +1,59 @@
|
||||||
package ch.fritteli.labyrinth.server;
|
package ch.fritteli.labyrinth.server;
|
||||||
|
|
||||||
import ch.fritteli.labyrinth.generator.model.Labyrinth;
|
import ch.fritteli.labyrinth.server.handler.CreateHandler;
|
||||||
import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer;
|
import ch.fritteli.labyrinth.server.handler.RenderHandler;
|
||||||
import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer;
|
import io.undertow.Undertow;
|
||||||
import ch.fritteli.labyrinth.generator.renderer.text.TextRenderer;
|
import io.undertow.server.RoutingHandler;
|
||||||
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 io.vavr.control.Try;
|
import io.vavr.control.Try;
|
||||||
import lombok.Getter;
|
import java.net.InetSocketAddress;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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
|
@Slf4j
|
||||||
public class LabyrinthServer {
|
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() {
|
@NonNull
|
||||||
final Option<LabyrinthServer> serverOption = Try.of(ServerConfig::init)
|
private final Undertow undertow;
|
||||||
.mapTry(LabyrinthServer::new)
|
|
||||||
.onFailure(cause -> log.error(
|
private LabyrinthServer(@NonNull final ServerConfig config) {
|
||||||
"Failed to create LabyrinthServer.",
|
final String hostAddress = config.getAddress().getHostAddress();
|
||||||
cause
|
final int port = config.getPort();
|
||||||
))
|
log.info("Starting Server at http://{}:{}/", hostAddress, port);
|
||||||
.toOption();
|
final RoutingHandler routingHandler = new RoutingHandler()
|
||||||
serverOption.forEach(LabyrinthServer::start);
|
.get(CreateHandler.PATH_TEMPLATE, new CreateHandler())
|
||||||
return serverOption;
|
.post(RenderHandler.PATH_TEMPLATE, new RenderHandler());
|
||||||
|
|
||||||
|
this.undertow = Undertow.builder()
|
||||||
|
.addHttpListener(port, hostAddress)
|
||||||
|
.setHandler(routingHandler)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LabyrinthServer(@NonNull final ServerConfig config) throws IOException {
|
@NonNull
|
||||||
this.httpServer = HttpServer.create(new InetSocketAddress(config.getAddress(), config.getPort()), 5);
|
public static Try<LabyrinthServer> createAndStartServer() {
|
||||||
this.httpServer.createContext("/", new StaticResourcesFileHandler(this.executorService));
|
return Try.of(ServerConfig::init)
|
||||||
this.httpServer.createContext("/create", this::handleCreate);
|
.flatMapTry(LabyrinthServer::createAndStartServer);
|
||||||
this.httpServer.createContext("/render", this::handleRender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"));
|
Runtime.getRuntime().addShutdownHook(new Thread(this::stop, "listener-stopper"));
|
||||||
this.httpServer.start();
|
this.undertow.start();
|
||||||
log.info("Listening on http://{}:{}",
|
final InetSocketAddress address = (InetSocketAddress) this.undertow.getListenerInfo().get(0).getAddress();
|
||||||
this.httpServer.getAddress().getHostString(),
|
final String hostAddress = address.getAddress().getHostAddress();
|
||||||
this.httpServer.getAddress().getPort()
|
final int port = address.getPort();
|
||||||
);
|
|
||||||
|
log.info("Listening on http://{}:{}", hostAddress, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleCreate(HttpExchange exchange) {
|
private void stop() {
|
||||||
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() {
|
|
||||||
log.info("Stopping server ...");
|
log.info("Stopping server ...");
|
||||||
this.httpServer.stop(5);
|
this.undertow.stop();
|
||||||
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);
|
|
||||||
}
|
|
||||||
log.info("Server stopped.");
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
package ch.fritteli.labyrinth.server;
|
package ch.fritteli.labyrinth.server;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@UtilityClass
|
||||||
public class Main {
|
public class Main {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
LabyrinthServer.createAndStartServer()
|
LabyrinthServer.createAndStartServer()
|
||||||
.onEmpty(() -> log.error("Failed to create server. Stopping."));
|
.onFailure(e -> log.error("Failed to create server. Stopping.", e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
94
src/main/java/ch/fritteli/labyrinth/server/OutputType.java
Normal file
94
src/main/java/ch/fritteli/labyrinth/server/OutputType.java
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,6 @@ import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
|
|
||||||
|
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Value
|
@Value
|
||||||
|
@ -39,7 +38,17 @@ public class ServerConfig {
|
||||||
@NonNull
|
@NonNull
|
||||||
private static InetAddress validateAddress(@Nullable final String address) {
|
private static InetAddress validateAddress(@Nullable final String address) {
|
||||||
return Try.of(() -> InetAddress.getByName(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) {
|
private static int validatePort(@Nullable final String portString) {
|
||||||
|
@ -48,14 +57,9 @@ public class ServerConfig {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return Try.of(() -> Integer.valueOf(portString))
|
return Try.of(() -> Integer.valueOf(portString))
|
||||||
.map(ServerConfig::validatePort)
|
.getOrElseThrow(cause -> new ConfigurationException(
|
||||||
.getOrElseThrow(cause -> new ConfigurationException("Failed to parse port specified in system property '" + SYSPROP_PORT + "': " + portString, cause));
|
"Failed to parse port specified in system property '%s': %s".formatted(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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,17 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<configuration>
|
<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">
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
<!-- encoders are by default assigned the type
|
<!-- encoders are by default assigned the type
|
||||||
ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
|
ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
|
||||||
<encoder>
|
<encoder>
|
||||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
<pattern>%d{HH:mm:ss.SSS} %-5level %X{correlationId} [%thread] %logger{36} - %msg%n</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
<root level="info">
|
<root level="info">
|
||||||
<appender-ref ref="STDOUT"/>
|
<appender-ref ref="STDOUT"/>
|
||||||
</root>
|
</root>
|
||||||
<logger name="ch.fritteli.labyrinth.server.StaticResourcesFileHandler" level="debug"/>
|
<logger name="ch.fritteli.labyrinth.*" level="debug"/>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|
|
@ -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>
|
|
|
@ -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;
|
|
||||||
}
|
|
Loading…
Reference in a new issue