feature/render-binary #1

Merged
manuel merged 4 commits from feature/render-binary into master 2022-02-02 02:19:26 +01:00
9 changed files with 362 additions and 62 deletions

View file

@ -20,7 +20,9 @@ steps:
from_secret: repo-token from_secret: repo-token
commands: commands:
- mvn -s maven-settings.xml deploy -DskipTests=true - mvn -s maven-settings.xml deploy -DskipTests=true
when: trigger:
branch: branch:
include: - master
- master event:
exclude:
- pull_request

View file

@ -12,16 +12,17 @@
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Maven: ch.fritteli.labyrinth:labyrinth-generator:0.0.1" level="project" /> <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:pdfbox:2.0.25" level="project" />
<orderEntry type="library" name="Maven: org.apache.pdfbox:fontbox:2.0.20" 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: 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:0.10.2" level="project" />
<orderEntry type="library" name="Maven: io.vavr:vavr-match: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" 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.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-api:1.7.35" level="project" />
<orderEntry type="library" name="Maven: org.slf4j:slf4j-simple:1.7.30" 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.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.apiguardian:apiguardian-api:1.1.0" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" /> <orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" />

31
pom.xml
View file

@ -14,11 +14,16 @@
<artifactId>labyrinth-server</artifactId> <artifactId>labyrinth-server</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<properties>
<logback.version>1.2.10</logback.version>
<slf4j.version>1.7.35</slf4j.version>
</properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>ch.fritteli.labyrinth</groupId> <groupId>ch.fritteli.labyrinth</groupId>
<artifactId>labyrinth-generator</artifactId> <artifactId>labyrinth-generator</artifactId>
<version>0.0.1</version> <version>0.0.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.vavr</groupId> <groupId>io.vavr</groupId>
@ -35,12 +40,12 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>1.7.30</version> <version>${slf4j.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>ch.qos.logback</groupId>
<artifactId>slf4j-simple</artifactId> <artifactId>logback-classic</artifactId>
<version>1.7.30</version> <version>${logback.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
@ -95,12 +100,24 @@
</distributionManagement> </distributionManagement>
<repositories> <repositories>
<repository> <repository>
<id>repo.gittr.ch</id> <id>repo.gittr.ch.releases</id>
<url>https://repo.gittr.ch/</url> <url>https://repo.gittr.ch/releases/</url>
<releases> <releases>
<enabled>true</enabled> <enabled>true</enabled>
<updatePolicy>never</updatePolicy> <updatePolicy>never</updatePolicy>
</releases> </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> <snapshots>
<enabled>true</enabled> <enabled>true</enabled>
<updatePolicy>always</updatePolicy> <updatePolicy>always</updatePolicy>

View file

@ -4,10 +4,16 @@ import ch.fritteli.labyrinth.generator.model.Labyrinth;
import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer; import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer;
import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer; import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer;
import ch.fritteli.labyrinth.generator.renderer.text.TextRenderer; 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.Headers;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import io.vavr.collection.*; 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.Option;
import io.vavr.control.Try; import io.vavr.control.Try;
import lombok.Getter; import lombok.Getter;
@ -18,17 +24,33 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets; 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.concurrent.atomic.AtomicBoolean;
import java.util.function.Function; import java.util.function.Function;
@Slf4j @Slf4j
public class LabyrinthServer { public class LabyrinthServer {
@NonNull
private final HttpServer httpServer; private final HttpServer httpServer;
@NonNull
private final ExecutorService executorService = new ThreadPoolExecutor(
0,
1_000,
5,
TimeUnit.SECONDS,
new SynchronousQueue<>()
);
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 = 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("/create", this::handleCreate);
this.httpServer.createContext("/render", this::handleRender);
} }
public static Option<LabyrinthServer> createAndStartServer() { public static Option<LabyrinthServer> createAndStartServer() {
@ -83,52 +105,111 @@ public class LabyrinthServer {
public void stop() { public void stop() {
log.info("Stopping server ..."); log.info("Stopping server ...");
this.httpServer.stop(5); 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."); log.info("Server stopped.");
} }
private void handleCreate(HttpExchange exchange) throws IOException { private void handleCreate(HttpExchange exchange) throws IOException {
log.debug("Handling request to {}", exchange.getRequestURI()); this.executorService.submit(() -> {
final String requestMethod = exchange.getRequestMethod(); log.debug("Handling request to {}", exchange.getRequestURI());
if (!requestMethod.equals("GET")) { try {
exchange.getResponseBody().close(); final String requestMethod = exchange.getRequestMethod();
exchange.sendResponseHeaders(405, -1); if (!requestMethod.equals("GET")) {
exchange.close(); exchange.getResponseBody().close();
return; 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 Map<RequestParameter, String> requestParams = this.parseQueryString(exchange.getRequestURI().getQuery());
final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5); final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5);
final Option<Long> idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption(); final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5);
final Option<OutputType> outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString); final Option<Long> idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption();
final Headers responseHeaders = exchange.getResponseHeaders(); final Option<OutputType> outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString);
final AtomicBoolean needsRedirect = new AtomicBoolean(false); final Headers responseHeaders = exchange.getResponseHeaders();
final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime); final AtomicBoolean needsRedirect = new AtomicBoolean(false);
final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML); final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime);
if (needsRedirect.get()) { final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML);
responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id); if (needsRedirect.get()) {
exchange.sendResponseHeaders(302, -1); responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id);
exchange.close(); exchange.sendResponseHeaders(302, -1);
return; return;
} }
final Labyrinth labyrinth = new Labyrinth(width, height, id); final Labyrinth labyrinth = new Labyrinth(width, height, id);
final byte[] render; final byte[] render;
try { try {
render = output.render(labyrinth); render = output.render(labyrinth);
} catch (Exception e) { } catch (Exception e) {
responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); responseHeaders.add("Content-type", "text/plain; charset=UTF-8");
exchange.sendResponseHeaders(500, 0); exchange.sendResponseHeaders(500, 0);
final OutputStream responseBody = exchange.getResponseBody(); final OutputStream responseBody = exchange.getResponseBody();
responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8)); responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8));
responseBody.flush(); responseBody.flush();
exchange.close(); return;
return; }
} responseHeaders.add("Content-type", output.getContentType());
responseHeaders.add("Content-type", output.getContentType()); if (output.equals(OutputType.BINARY)) {
exchange.sendResponseHeaders(200, 0); responseHeaders.add("Content-disposition", "attachment; filename=\"labyrinth-" + width + "x" + height + "-" + id + ".laby\"");
final OutputStream responseBody = exchange.getResponseBody(); }
responseBody.write(render); exchange.sendResponseHeaders(200, 0);
responseBody.flush(); final OutputStream responseBody = exchange.getResponseBody();
exchange.close(); responseBody.write(render);
responseBody.flush();
} catch (Exception e) {
log.error("FSCK!", e);
} finally {
exchange.close();
}
});
}
private void handleRender(final HttpExchange exchange) throws IOException {
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("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(render);
responseBody.flush();
} catch (Exception e) {
log.error("FSCK!", e);
} finally {
exchange.close();
}
});
} }
private enum RequestParameter { private enum RequestParameter {
@ -154,7 +235,8 @@ public class LabyrinthServer {
private enum OutputType { private enum OutputType {
TEXT_PLAIN("text/plain; charset=UTF-8", labyrinth -> TextRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "t", "text"), TEXT_PLAIN("text/plain; charset=UTF-8", labyrinth -> TextRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "t", "text"),
HTML("text/html", labyrinth -> HTMLRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "h", "html"), HTML("text/html", labyrinth -> HTMLRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "h", "html"),
PDF("application/pdf", labyrinth -> PDFRenderer.newInstance().render(labyrinth), "p", "pdf"); PDF("application/pdf", labyrinth -> PDFRenderer.newInstance().render(labyrinth), "p", "pdf"),
BINARY("application/octet-stream", SerializerDeserializer::serialize, "b", "binary");
@Getter @Getter
@NonNull @NonNull
private final String contentType; private final String contentType;

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

View file

@ -4,7 +4,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@ -12,7 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class ServerConfigTest { class ServerConfigTest {
@BeforeEach @BeforeEach
void clearSysProperties() { void clearSysProperties() {
System.setProperties(new Properties()); System.clearProperty(ServerConfig.SYSPROP_HOST);
System.clearProperty(ServerConfig.SYSPROP_PORT);
} }
@Test @Test