Several features:

- Ability to serve static files, HTML and CSS are supported so far
 - Consequently, show a simple HTML form to enter the parameters when
   navigating to / (or /index.html).
 - Make the server multi-threaded (in a rather primitive way so far).
 - Use logback instead of the simple-slf4j logger.
This commit is contained in:
Manuel Friedli 2022-01-27 22:07:00 +01:00
parent cd29c6fe75
commit 4d0c5f5c18
7 changed files with 327 additions and 94 deletions

View file

@ -13,15 +13,16 @@
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="labyrinth-generator" />
<orderEntry type="library" name="Maven: org.apache.pdfbox:pdfbox:2.0.20" level="project" />
<orderEntry type="library" name="Maven: org.apache.pdfbox:fontbox:2.0.20" level="project" />
<orderEntry type="library" name="Maven: org.apache.pdfbox:pdfbox:2.0.25" level="project" />
<orderEntry type="library" name="Maven: org.apache.pdfbox:fontbox:2.0.25" level="project" />
<orderEntry type="library" name="Maven: commons-logging:commons-logging:1.2" level="project" />
<orderEntry type="library" name="Maven: io.vavr:vavr:0.10.2" level="project" />
<orderEntry type="library" name="Maven: io.vavr:vavr-match:0.10.2" level="project" />
<orderEntry type="library" scope="PROVIDED" name="Maven: org.projectlombok:lombok:1.18.12" level="project" />
<orderEntry type="library" name="Maven: org.jetbrains:annotations:19.0.0" level="project" />
<orderEntry type="library" name="Maven: org.slf4j:slf4j-api:1.7.30" level="project" />
<orderEntry type="library" name="Maven: org.slf4j:slf4j-simple:1.7.30" level="project" />
<orderEntry type="library" name="Maven: org.slf4j:slf4j-api:1.7.35" level="project" />
<orderEntry type="library" name="Maven: ch.qos.logback:logback-classic:1.2.10" level="project" />
<orderEntry type="library" name="Maven: ch.qos.logback:logback-core:1.2.10" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-api:5.6.1" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.apiguardian:apiguardian-api:1.1.0" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" />

28
pom.xml
View file

@ -14,6 +14,10 @@
<artifactId>labyrinth-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<slf4j.version>1.7.35</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>ch.fritteli.labyrinth</groupId>
@ -35,12 +39,12 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.30</version>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.30</version>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.10</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
@ -95,12 +99,24 @@
</distributionManagement>
<repositories>
<repository>
<id>repo.gittr.ch</id>
<url>https://repo.gittr.ch/</url>
<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>

View file

@ -24,20 +24,24 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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 = Executors.newCachedThreadPool();
public LabyrinthServer(@NonNull final ServerConfig config) throws IOException {
public LabyrinthServer(@NonNull final ServerConfig config) throws IOException, URISyntaxException {
this.httpServer = HttpServer.create(new InetSocketAddress(config.getAddress(), config.getPort()), 5);
this.httpServer.createContext("/", exchange -> {
exchange.getResponseHeaders().add("Location", "/create");
exchange.sendResponseHeaders(302, -1);
});
this.httpServer.createContext("/", new StaticResourcesFileHandler(this.executorService));
this.httpServer.createContext("/create", this::handleCreate);
this.httpServer.createContext("/render", this::handleRender);
}
@ -94,97 +98,111 @@ public class LabyrinthServer {
public 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);
}
log.info("Server stopped.");
}
private void handleCreate(HttpExchange exchange) throws IOException {
log.debug("Handling request to {}", exchange.getRequestURI());
final String requestMethod = exchange.getRequestMethod();
if (!requestMethod.equals("GET")) {
exchange.getResponseBody().close();
exchange.sendResponseHeaders(405, -1);
exchange.close();
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, 5);
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);
exchange.close();
return;
}
final Labyrinth labyrinth = new Labyrinth(width, height, id);
final byte[] render;
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();
exchange.close();
return;
}
responseHeaders.add("Content-type", output.getContentType());
exchange.sendResponseHeaders(200, 0);
final OutputStream responseBody = exchange.getResponseBody();
responseBody.write(render);
responseBody.flush();
exchange.close();
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, 5);
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 Labyrinth labyrinth = new Labyrinth(width, height, id);
final byte[] render;
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());
if (output.equals(OutputType.BINARY)) {
responseHeaders.add("Content-disposition", "attachment; filename=\"labyrinth-" + width + "x" + height + "-" + id + ".laby\"");
}
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) throws IOException {
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();
@NonNull final Labyrinth labyrinth;
this.executorService.submit(() -> {
try {
labyrinth = SerializerDeserializer.deserialize(bytes);
} catch (Exception e) {
e.printStackTrace();
throw e;
}
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();
OutputType output = exchange.getRequestHeaders()
.get("Accept")
.contains("text/html") ?
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 Labyrinth labyrinth = SerializerDeserializer.deserialize(bytes);
final OutputType output = exchange.getRequestHeaders()
.get("Accept")
.contains("text/html") ?
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(("Error: " + e).getBytes(StandardCharsets.UTF_8));
responseBody.write(render);
responseBody.flush();
return;
} catch (Exception e) {
log.error("FSCK!", e);
} finally {
exchange.close();
}
responseHeaders.add("Content-type", output.getContentType());
exchange.sendResponseHeaders(200, 0);
final OutputStream responseBody = exchange.getResponseBody();
responseBody.write(render);
responseBody.flush();
} finally {
exchange.close();
}
});
}
private enum RequestParameter {

View file

@ -0,0 +1,98 @@
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;
}
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];
}
return IOUtils.toByteArray(stream);
}
@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();
}
});
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/>
<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>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="ch.fritteli.labyrinth.server.StaticResourcesFileHandler" level="debug"/>
</configuration>

View file

@ -0,0 +1,32 @@
<!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="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>

View file

@ -0,0 +1,51 @@
: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;
}