feature/undertow #4
					 15 changed files with 632 additions and 641 deletions
				
			
		|  | @ -1,7 +1,7 @@ | ||||||
| <?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> | ||||||
|  |  | ||||||
							
								
								
									
										75
									
								
								pom.xml
									
										
									
									
									
								
							
							
						
						
									
										75
									
								
								pom.xml
									
										
									
									
									
								
							|  | @ -1,5 +1,6 @@ | ||||||
| <?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" | ||||||
|  |          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> |     <modelVersion>4.0.0</modelVersion> | ||||||
| 
 | 
 | ||||||
|     <parent> |     <parent> | ||||||
|  | @ -11,20 +12,43 @@ | ||||||
|     <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> |  | ||||||
| 		<lombok.version>1.18.26</lombok.version> |  | ||||||
| 		<slf4j.version>2.0.5</slf4j.version> |  | ||||||
|         <java.source.version>17</java.source.version> |         <java.source.version>17</java.source.version> | ||||||
|         <java.target.version>17</java.target.version> |         <java.target.version>17</java.target.version> | ||||||
|  |         <jetbrains-annotations.version>24.0.1</jetbrains-annotations.version> | ||||||
|  |         <junit-jupiter.version>5.9.2</junit-jupiter.version> | ||||||
|  |         <labyrinth-generator.version>0.0.4</labyrinth-generator.version> | ||||||
|  |         <logback.version>1.4.6</logback.version> | ||||||
|  |         <lombok.version>1.18.26</lombok.version> | ||||||
|  |         <slf4j.version>2.0.7</slf4j.version> | ||||||
|  |         <undertow.version>2.3.5.Final</undertow.version> | ||||||
|  |         <vavr.version>0.10.4</vavr.version> | ||||||
|     </properties> |     </properties> | ||||||
| 
 | 
 | ||||||
|  |     <developers> | ||||||
|  |         <developer> | ||||||
|  |             <name>Manuel Friedli</name> | ||||||
|  |             <id>manuel</id> | ||||||
|  |             <url>https://www.fritteli.ch/</url> | ||||||
|  |             <email>manuel@fritteli.ch</email> | ||||||
|  |             <timezone>Europe/Zurich</timezone> | ||||||
|  |             <roles> | ||||||
|  |                 <role>Project Lead</role> | ||||||
|  |                 <role>Software Architect</role> | ||||||
|  |                 <role>Software Engineer</role> | ||||||
|  |                 <role>Operations Manager</role> | ||||||
|  |             </roles> | ||||||
|  |         </developer> | ||||||
|  |     </developers> | ||||||
|  | 
 | ||||||
|     <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.2</version> |             <version>${labyrinth-generator.version}</version> | ||||||
|         </dependency> |         </dependency> | ||||||
|         <dependency> |         <dependency> | ||||||
|             <groupId>io.vavr</groupId> |             <groupId>io.vavr</groupId> | ||||||
|  | @ -48,10 +72,14 @@ | ||||||
|             <artifactId>logback-classic</artifactId> |             <artifactId>logback-classic</artifactId> | ||||||
|             <version>${logback.version}</version> |             <version>${logback.version}</version> | ||||||
|         </dependency> |         </dependency> | ||||||
|  |         <dependency> | ||||||
|  |             <groupId>io.undertow</groupId> | ||||||
|  |             <artifactId>undertow-core</artifactId> | ||||||
|  |             <version>${undertow.version}</version> | ||||||
|  |         </dependency> | ||||||
|         <dependency> |         <dependency> | ||||||
|             <groupId>org.junit.jupiter</groupId> |             <groupId>org.junit.jupiter</groupId> | ||||||
|             <artifactId>junit-jupiter-api</artifactId> |             <artifactId>junit-jupiter-api</artifactId> | ||||||
| 			<scope>test</scope> |  | ||||||
|         </dependency> |         </dependency> | ||||||
|     </dependencies> |     </dependencies> | ||||||
| 
 | 
 | ||||||
|  | @ -60,13 +88,22 @@ | ||||||
|             <plugin> |             <plugin> | ||||||
|                 <groupId>org.apache.maven.plugins</groupId> |                 <groupId>org.apache.maven.plugins</groupId> | ||||||
|                 <artifactId>maven-shade-plugin</artifactId> |                 <artifactId>maven-shade-plugin</artifactId> | ||||||
| 				<version>3.2.4</version> |                 <version>3.4.1</version> | ||||||
|                 <configuration> |                 <configuration> | ||||||
|                     <transformers> |                     <transformers> | ||||||
| 						<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> |                         <transformer | ||||||
|  |                                 implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> | ||||||
|                             <mainClass>ch.fritteli.labyrinth.server.Main</mainClass> |                             <mainClass>ch.fritteli.labyrinth.server.Main</mainClass> | ||||||
|                         </transformer> |                         </transformer> | ||||||
|                     </transformers> |                     </transformers> | ||||||
|  |                     <filters> | ||||||
|  |                         <filter> | ||||||
|  |                             <artifact>ch.fritteli.labyrinth:labyrinth-generator</artifact> | ||||||
|  |                             <excludes> | ||||||
|  |                                 <exclude>logback.xml</exclude> | ||||||
|  |                             </excludes> | ||||||
|  |                         </filter> | ||||||
|  |                     </filters> | ||||||
|                 </configuration> |                 </configuration> | ||||||
|                 <executions> |                 <executions> | ||||||
|                     <execution> |                     <execution> | ||||||
|  | @ -77,13 +114,31 @@ | ||||||
|                     </execution> |                     </execution> | ||||||
|                 </executions> |                 </executions> | ||||||
|             </plugin> |             </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> |         </plugins> | ||||||
|     </build> |     </build> | ||||||
|     <scm> |     <scm> | ||||||
| 		<connection>scm:git:git://gittr.ch/java/labyrinth-server.git</connection> |         <connection>scm:git:https://gittr.ch/java/labyrinth-server.git</connection> | ||||||
|         <developerConnection>scm:git:ssh://git@gittr.ch/java/labyrinth-server.git</developerConnection> |         <developerConnection>scm:git:ssh://git@gittr.ch/java/labyrinth-server.git</developerConnection> | ||||||
|         <url>https://gittr.ch/java/labyrinth-server</url> |         <url>https://gittr.ch/java/labyrinth-server</url> | ||||||
| 		<tag>v0.0.1</tag> |         <tag>HEAD</tag> | ||||||
|     </scm> |     </scm> | ||||||
|     <distributionManagement> |     <distributionManagement> | ||||||
|         <repository> |         <repository> | ||||||
|  |  | ||||||
|  | @ -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(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -6,12 +6,12 @@ | ||||||
|         <!-- 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…
	
	Add table
		Add a link
		
	
		Reference in a new issue