Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcdc62d9e | |||
| 1ee2b68471 | |||
| 287b70513a | |||
| 707acbefd9 | |||
| 18a68cae2e | |||
| 9b0c25fb8f | |||
| e945a12a6e | |||
| 100e2aec8c |
18 changed files with 131 additions and 131 deletions
42
.drone.yml
42
.drone.yml
|
|
@ -3,39 +3,39 @@ type: docker
|
||||||
name: default
|
name: default
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: maven:3.9-eclipse-temurin-21
|
image: maven:3.9-eclipse-temurin-25
|
||||||
commands:
|
commands:
|
||||||
- mvn clean install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
|
- mvn clean install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
|
||||||
when:
|
when:
|
||||||
ref:
|
ref:
|
||||||
include:
|
include:
|
||||||
- refs/head/master
|
- refs/heads/main
|
||||||
- refs/head/feature/**
|
- refs/heads/feature/**
|
||||||
- refs/tags/**
|
- refs/tags/**
|
||||||
- name: test
|
- name: test
|
||||||
image: maven:3.9-eclipse-temurin-21
|
image: maven:3.9-eclipse-temurin-25
|
||||||
commands:
|
commands:
|
||||||
- mvn test -B
|
- mvn test -B
|
||||||
when:
|
when:
|
||||||
branch:
|
branch:
|
||||||
include:
|
include:
|
||||||
- master
|
- main
|
||||||
- feature/*
|
- feature/*
|
||||||
- name: deploy
|
# - name: deploy
|
||||||
image: maven:3.9-eclipse-temurin-21
|
# image: maven:3.9-eclipse-temurin-25
|
||||||
environment:
|
# environment:
|
||||||
REPO_TOKEN:
|
# REPO_TOKEN:
|
||||||
from_secret: repo-token
|
# from_secret: repo-token
|
||||||
REPO_TOKEN_OSSRH:
|
# REPO_TOKEN_OSSRH:
|
||||||
from_secret: repo-token-ossrh
|
# from_secret: repo-token-ossrh
|
||||||
commands:
|
# commands:
|
||||||
- mvn -s maven-settings.xml deploy -DskipTests=true
|
# - mvn -s maven-settings.xml deploy -DskipTests=true
|
||||||
when:
|
# when:
|
||||||
branch:
|
# branch:
|
||||||
- master
|
# - main
|
||||||
event:
|
# event:
|
||||||
exclude:
|
# exclude:
|
||||||
- pull_request
|
# - pull_request
|
||||||
- name: build_docker_image
|
- name: build_docker_image
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
|
|
@ -49,7 +49,7 @@ steps:
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
when:
|
when:
|
||||||
branch:
|
branch:
|
||||||
- master
|
- main
|
||||||
event:
|
event:
|
||||||
exclude:
|
exclude:
|
||||||
- pull_request
|
- pull_request
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
FROM eclipse-temurin:21-jre
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
|
|
||||||
COPY target/maze-server-*.jar /app/
|
COPY target/maze-server-shaded.jar /app/app.jar
|
||||||
RUN rm /app/*-sources.jar
|
|
||||||
RUN mv /app/*.jar /app/app.jar
|
|
||||||
|
|
||||||
CMD java \
|
CMD java \
|
||||||
-Dfritteli.maze.server.host=0.0.0.0 \
|
-Dfritteli.maze.server.host=0.0.0.0 \
|
||||||
|
|
|
||||||
19
pom.xml
19
pom.xml
|
|
@ -5,13 +5,14 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>ch.fritteli</groupId>
|
<groupId>ch.fritteli</groupId>
|
||||||
<artifactId>fritteli-build-parent</artifactId>
|
<artifactId>fritteli-build-parent</artifactId>
|
||||||
<version>5.1.0</version>
|
<version>6.1.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>ch.fritteli.a-maze-r</groupId>
|
<groupId>ch.fritteli.a-maze-r</groupId>
|
||||||
<artifactId>maze-server</artifactId>
|
<artifactId>maze-server</artifactId>
|
||||||
<version>0.2.1-SNAPSHOT</version>
|
<version>0.2.1</version>
|
||||||
|
|
||||||
|
<name>A-Maze-R Server</name>
|
||||||
<description>The A-Maze-R server, offering a REST endpoint to access the Maze Generator.</description>
|
<description>The A-Maze-R server, offering a REST endpoint to access the Maze Generator.</description>
|
||||||
<url>https://manuel.friedli.info/maze.html</url>
|
<url>https://manuel.friedli.info/maze.html</url>
|
||||||
<inceptionYear>2022</inceptionYear>
|
<inceptionYear>2022</inceptionYear>
|
||||||
|
|
@ -43,7 +44,7 @@
|
||||||
<connection>scm:git:https://gittr.ch/java/maze-server.git</connection>
|
<connection>scm:git:https://gittr.ch/java/maze-server.git</connection>
|
||||||
<developerConnection>scm:git:ssh://git@gittr.ch/java/maze-server.git</developerConnection>
|
<developerConnection>scm:git:ssh://git@gittr.ch/java/maze-server.git</developerConnection>
|
||||||
<url>https://gittr.ch/java/maze-server</url>
|
<url>https://gittr.ch/java/maze-server</url>
|
||||||
<tag>HEAD</tag>
|
<tag>v0.2.1</tag>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<distributionManagement>
|
<distributionManagement>
|
||||||
|
|
@ -55,9 +56,9 @@
|
||||||
</distributionManagement>
|
</distributionManagement>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maze-generator.version>0.3.0</maze-generator.version>
|
<maze-generator.version>0.4.0</maze-generator.version>
|
||||||
<maven-site-plugin.version>4.0.0-M8</maven-site-plugin.version>
|
<maven-site-plugin.version>4.0.0-M16</maven-site-plugin.version>
|
||||||
<undertow.version>2.3.18.Final</undertow.version>
|
<undertow.version>2.3.22.Final</undertow.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -75,8 +76,8 @@
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains</groupId>
|
<groupId>org.jspecify</groupId>
|
||||||
<artifactId>annotations</artifactId>
|
<artifactId>jspecify</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
|
|
@ -98,7 +99,6 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.assertj</groupId>
|
<groupId>org.assertj</groupId>
|
||||||
<artifactId>assertj-core</artifactId>
|
<artifactId>assertj-core</artifactId>
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
@ -121,6 +121,7 @@
|
||||||
</excludes>
|
</excludes>
|
||||||
</filter>
|
</filter>
|
||||||
</filters>
|
</filters>
|
||||||
|
<finalName>maze-server-shaded</finalName>
|
||||||
</configuration>
|
</configuration>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import io.vavr.collection.List;
|
||||||
import io.vavr.collection.Stream;
|
import io.vavr.collection.Stream;
|
||||||
import io.vavr.control.Option;
|
import io.vavr.control.Option;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
|
@ -17,20 +17,20 @@ public enum Algorithm {
|
||||||
RANDOM_DEPTH_FIRST(RandomDepthFirst::new, "random", "random-depth-first"),
|
RANDOM_DEPTH_FIRST(RandomDepthFirst::new, "random", "random-depth-first"),
|
||||||
WILSON(Wilson::new, "wilson");
|
WILSON(Wilson::new, "wilson");
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Function<Maze, MazeGeneratorAlgorithm> creator;
|
private final Function<Maze, MazeGeneratorAlgorithm> creator;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@NotNull
|
@NonNull
|
||||||
private final List<String> names;
|
private final List<String> names;
|
||||||
|
|
||||||
Algorithm(@NotNull final Function<Maze, MazeGeneratorAlgorithm> creator,
|
Algorithm(@NonNull final Function<Maze, MazeGeneratorAlgorithm> creator,
|
||||||
@NotNull final String... names) {
|
@NonNull final String... names) {
|
||||||
this.creator = creator;
|
this.creator = creator;
|
||||||
this.names = List.of(names);
|
this.names = List.of(names);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public static Option<Algorithm> ofString(@Nullable final String name) {
|
public static Option<Algorithm> ofString(@Nullable final String name) {
|
||||||
return Option.of(name)
|
return Option.of(name)
|
||||||
.map(String::toLowerCase)
|
.map(String::toLowerCase)
|
||||||
|
|
@ -38,8 +38,8 @@ public enum Algorithm {
|
||||||
.find(algorithm -> algorithm.getNames().contains(nameLC)));
|
.find(algorithm -> algorithm.getNames().contains(nameLC)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public MazeGeneratorAlgorithm createAlgorithm(@NotNull final Maze maze) {
|
public MazeGeneratorAlgorithm createAlgorithm(@NonNull final Maze maze) {
|
||||||
return this.creator.apply(maze);
|
return this.creator.apply(maze);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package ch.fritteli.maze.server;
|
package ch.fritteli.maze.server;
|
||||||
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
public class ConfigurationException extends RuntimeException {
|
public class ConfigurationException extends RuntimeException {
|
||||||
public ConfigurationException(@Nullable final String message) {
|
public ConfigurationException(@Nullable final String message) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package ch.fritteli.maze.server;
|
package ch.fritteli.maze.server;
|
||||||
|
|
||||||
import org.wildfly.common.annotation.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
public class InvalidRequestParameterException extends RuntimeException {
|
public class InvalidRequestParameterException extends RuntimeException {
|
||||||
public InvalidRequestParameterException(@NotNull final String s) {
|
public InvalidRequestParameterException(@NonNull final String s) {
|
||||||
super(s);
|
super(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
@UtilityClass
|
@UtilityClass
|
||||||
public class Main {
|
public class Main {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
static void main() {
|
||||||
MazeServer.createAndStartServer()
|
MazeServer.createAndStartServer()
|
||||||
.onFailure(e -> log.error("Failed to create server. Stopping.", e));
|
.onFailure(e -> log.error("Failed to create server. Stopping.", e));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@ import io.undertow.Undertow;
|
||||||
import io.undertow.server.RoutingHandler;
|
import io.undertow.server.RoutingHandler;
|
||||||
import io.vavr.control.Try;
|
import io.vavr.control.Try;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.wildfly.common.annotation.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class MazeServer {
|
public class MazeServer {
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Undertow undertow;
|
private final Undertow undertow;
|
||||||
|
|
||||||
private MazeServer(@NotNull final ServerConfig config) {
|
private MazeServer(@NonNull final ServerConfig config) {
|
||||||
final String hostAddress = config.address().getHostAddress();
|
final String hostAddress = config.address().getHostAddress();
|
||||||
final int port = config.port();
|
final int port = config.port();
|
||||||
log.info("Starting Server at http://{}:{}/", hostAddress, port);
|
log.info("Starting Server at http://{}:{}/", hostAddress, port);
|
||||||
|
|
@ -36,14 +36,14 @@ public class MazeServer {
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public static Try<MazeServer> createAndStartServer() {
|
public static Try<MazeServer> createAndStartServer() {
|
||||||
return Try.of(ServerConfig::init)
|
return Try.of(ServerConfig::init)
|
||||||
.flatMapTry(MazeServer::createAndStartServer);
|
.flatMapTry(MazeServer::createAndStartServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public static Try<MazeServer> createAndStartServer(@NotNull final ServerConfig config) {
|
public static Try<MazeServer> createAndStartServer(@NonNull final ServerConfig config) {
|
||||||
return Try.of(() -> new MazeServer(config))
|
return Try.of(() -> new MazeServer(config))
|
||||||
.peek(MazeServer::start);
|
.peek(MazeServer::start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import io.vavr.collection.List;
|
||||||
import io.vavr.collection.Stream;
|
import io.vavr.collection.Stream;
|
||||||
import io.vavr.control.Option;
|
import io.vavr.control.Option;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
@ -75,24 +75,24 @@ public enum OutputType {
|
||||||
"3",
|
"3",
|
||||||
"binaryv3");
|
"binaryv3");
|
||||||
@Getter
|
@Getter
|
||||||
@NotNull
|
@NonNull
|
||||||
private final String contentType;
|
private final String contentType;
|
||||||
@Getter
|
@Getter
|
||||||
@NotNull
|
@NonNull
|
||||||
private final String fileExtension;
|
private final String fileExtension;
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Function<Maze, byte[]> render;
|
private final Function<@NonNull Maze, byte @NonNull []> render;
|
||||||
@Getter
|
@Getter
|
||||||
private final boolean attachment;
|
private final boolean attachment;
|
||||||
@Getter
|
@Getter
|
||||||
@NotNull
|
@NonNull
|
||||||
private final List<String> names;
|
private final List<String> names;
|
||||||
|
|
||||||
OutputType(@NotNull final String contentType,
|
OutputType(@NonNull final String contentType,
|
||||||
@NotNull final String fileExtension,
|
@NonNull final String fileExtension,
|
||||||
@NotNull final Function<Maze, byte[]> render,
|
@NonNull final Function<Maze, byte[]> render,
|
||||||
final boolean attachment,
|
final boolean attachment,
|
||||||
@NotNull final String... names) {
|
@NonNull final String... names) {
|
||||||
this.contentType = contentType;
|
this.contentType = contentType;
|
||||||
this.render = render;
|
this.render = render;
|
||||||
this.fileExtension = fileExtension;
|
this.fileExtension = fileExtension;
|
||||||
|
|
@ -100,7 +100,7 @@ public enum OutputType {
|
||||||
this.names = List.of(names);
|
this.names = List.of(names);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public static Option<OutputType> ofString(@Nullable final String name) {
|
public static Option<OutputType> ofString(@Nullable final String name) {
|
||||||
return Option.of(name)
|
return Option.of(name)
|
||||||
.map(String::toLowerCase)
|
.map(String::toLowerCase)
|
||||||
|
|
@ -109,14 +109,14 @@ public enum OutputType {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return this.names.last();
|
return this.names.last();
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public byte[] render(@NotNull final Maze maze) {
|
public byte[] render(@NonNull final Maze maze) {
|
||||||
return this.render.apply(maze);
|
return this.render.apply(maze);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,25 @@ package ch.fritteli.maze.server;
|
||||||
import io.vavr.control.Option;
|
import io.vavr.control.Option;
|
||||||
import io.vavr.control.Try;
|
import io.vavr.control.Try;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public record ServerConfig(@NotNull InetAddress address,
|
public record ServerConfig(@NonNull InetAddress address,
|
||||||
int port,
|
int port,
|
||||||
@NotNull Option<Integer> maxMazeHeight,
|
@NonNull Option<Integer> maxMazeHeight,
|
||||||
@NotNull Option<Integer> maxMazeWidth) {
|
@NonNull Option<Integer> maxMazeWidth) {
|
||||||
public static final String SYSPROP_HOST = "fritteli.maze.server.host";
|
public static final String SYSPROP_HOST = "fritteli.maze.server.host";
|
||||||
public static final String SYSPROP_PORT = "fritteli.maze.server.port";
|
public static final String SYSPROP_PORT = "fritteli.maze.server.port";
|
||||||
public static final String SYSPROP_MAX_MAZE_HEIGHT = "fritteli.maze.maxheight";
|
public static final String SYSPROP_MAX_MAZE_HEIGHT = "fritteli.maze.maxheight";
|
||||||
public static final String SYSPROP_MAX_MAZE_WIDTH = "fritteli.maze.maxwidth";
|
public static final String SYSPROP_MAX_MAZE_WIDTH = "fritteli.maze.maxwidth";
|
||||||
|
|
||||||
public ServerConfig(@NotNull final InetAddress address,
|
public ServerConfig(@NonNull final InetAddress address,
|
||||||
final int port,
|
final int port,
|
||||||
@NotNull final Option<Integer> maxMazeHeight,
|
@NonNull final Option<Integer> maxMazeHeight,
|
||||||
@NotNull final Option<Integer> maxMazeWidth) {
|
@NonNull final Option<Integer> maxMazeWidth) {
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = validatePort(port);
|
this.port = validatePort(port);
|
||||||
this.maxMazeHeight = validateDimension(maxMazeHeight, "height");
|
this.maxMazeHeight = validateDimension(maxMazeHeight, "height");
|
||||||
|
|
@ -31,13 +31,13 @@ public record ServerConfig(@NotNull InetAddress address,
|
||||||
|
|
||||||
public ServerConfig(@Nullable final String address,
|
public ServerConfig(@Nullable final String address,
|
||||||
final int port,
|
final int port,
|
||||||
@NotNull final Option<Integer> maxMazeHeight,
|
@NonNull final Option<Integer> maxMazeHeight,
|
||||||
@NotNull final Option<Integer> maxMazeWidth)
|
@NonNull final Option<Integer> maxMazeWidth)
|
||||||
throws ConfigurationException {
|
throws ConfigurationException {
|
||||||
this(validateAddress(address), port, maxMazeHeight, maxMazeWidth);
|
this(validateAddress(address), port, maxMazeHeight, maxMazeWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public static ServerConfig init() throws ConfigurationException {
|
public static ServerConfig init() throws ConfigurationException {
|
||||||
final String host = System.getProperty(SYSPROP_HOST);
|
final String host = System.getProperty(SYSPROP_HOST);
|
||||||
final String portString = System.getProperty(SYSPROP_PORT);
|
final String portString = System.getProperty(SYSPROP_PORT);
|
||||||
|
|
@ -49,7 +49,7 @@ public record ServerConfig(@NotNull InetAddress address,
|
||||||
return new ServerConfig(host, port, maxMazeHeight, maxMazeWidth);
|
return new ServerConfig(host, port, maxMazeHeight, maxMazeWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private static InetAddress validateAddress(@Nullable final String address) throws ConfigurationException {
|
private static InetAddress validateAddress(@Nullable final String address) throws ConfigurationException {
|
||||||
return Try.of(() -> InetAddress.getByName(address))
|
return Try.of(() -> InetAddress.getByName(address))
|
||||||
.getOrElseThrow(cause -> new ConfigurationException(
|
.getOrElseThrow(cause -> new ConfigurationException(
|
||||||
|
|
@ -77,9 +77,9 @@ public record ServerConfig(@NotNull InetAddress address,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private static Option<Integer> validateDimension(@NotNull final Option<Integer> dimension,
|
private static Option<Integer> validateDimension(@NonNull final Option<Integer> dimension,
|
||||||
@NotNull final String identifier) {
|
@NonNull final String identifier) {
|
||||||
if (dimension.exists(d -> d <= 1)) {
|
if (dimension.exists(d -> d <= 1)) {
|
||||||
throw new ConfigurationException("Maximum %s must be greater than 1: %s"
|
throw new ConfigurationException("Maximum %s must be greater than 1: %s"
|
||||||
.formatted(identifier, dimension.get()));
|
.formatted(identifier, dimension.get()));
|
||||||
|
|
@ -88,8 +88,8 @@ public record ServerConfig(@NotNull InetAddress address,
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Option<Integer> validateDimension(@Nullable final String dimensionString,
|
private static Option<Integer> validateDimension(@Nullable final String dimensionString,
|
||||||
@NotNull final String identifier,
|
@NonNull final String identifier,
|
||||||
@NotNull final String syspropName) {
|
@NonNull final String syspropName) {
|
||||||
if (dimensionString == null) {
|
if (dimensionString == null) {
|
||||||
log.info("No maximum {} configured; using default (unlimited).", identifier);
|
log.info("No maximum {} configured; using default (unlimited).", identifier);
|
||||||
return Option.none();
|
return Option.none();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import io.undertow.server.HttpHandler;
|
||||||
import io.undertow.server.HttpServerExchange;
|
import io.undertow.server.HttpServerExchange;
|
||||||
import io.undertow.util.StatusCodes;
|
import io.undertow.util.StatusCodes;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
@ -14,9 +14,9 @@ import java.util.UUID;
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public abstract class AbstractHttpHandler implements HttpHandler {
|
public abstract class AbstractHttpHandler implements HttpHandler {
|
||||||
@Override
|
@Override
|
||||||
public final void handleRequest(@NotNull final HttpServerExchange exchange) {
|
public final void handleRequest(@NonNull final HttpServerExchange exchange) {
|
||||||
final Instant start = Instant.now();
|
final Instant start = Instant.now();
|
||||||
try (final MDC.MDCCloseable closeable = MDC.putCloseable("correlationId", UUID.randomUUID().toString())) {
|
try (final MDC.MDCCloseable _ = MDC.putCloseable("correlationId", UUID.randomUUID().toString())) {
|
||||||
|
|
||||||
if (exchange.isInIoThread()) {
|
if (exchange.isInIoThread()) {
|
||||||
log.debug("Dispatching request");
|
log.debug("Dispatching request");
|
||||||
|
|
@ -35,5 +35,5 @@ public abstract class AbstractHttpHandler implements HttpHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract void handle(@NotNull final HttpServerExchange exchange) throws Exception;
|
protected abstract void handle(@NonNull final HttpServerExchange exchange) throws Exception;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import io.undertow.util.StatusCodes;
|
||||||
import io.vavr.control.Option;
|
import io.vavr.control.Option;
|
||||||
import io.vavr.control.Try;
|
import io.vavr.control.Try;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
@ -23,18 +23,18 @@ import java.util.Map;
|
||||||
public class CreateHandler extends AbstractHttpHandler {
|
public class CreateHandler extends AbstractHttpHandler {
|
||||||
|
|
||||||
public static final String PATH_TEMPLATE = "/create/{output}";
|
public static final String PATH_TEMPLATE = "/create/{output}";
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Option<Integer> maxHeight;
|
private final Option<Integer> maxHeight;
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Option<Integer> maxWidth;
|
private final Option<Integer> maxWidth;
|
||||||
|
|
||||||
public CreateHandler(@NotNull final Option<Integer> maxHeight, @NotNull final Option<Integer> maxWidth) {
|
public CreateHandler(@NonNull final Option<Integer> maxHeight, @NonNull final Option<Integer> maxWidth) {
|
||||||
this.maxHeight = maxHeight;
|
this.maxHeight = maxHeight;
|
||||||
this.maxWidth = maxWidth;
|
this.maxWidth = maxWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void handle(@NotNull final HttpServerExchange exchange) {
|
protected void handle(@NonNull final HttpServerExchange exchange) {
|
||||||
final Instant start = Instant.now();
|
final Instant start = Instant.now();
|
||||||
log.debug("Handling create request");
|
log.debug("Handling create request");
|
||||||
this.createMazeFromRequestParameters(exchange.getQueryParameters())
|
this.createMazeFromRequestParameters(exchange.getQueryParameters())
|
||||||
|
|
@ -65,6 +65,7 @@ public class CreateHandler extends AbstractHttpHandler {
|
||||||
final long durationMillis = start.until(Instant.now(), ChronoUnit.MILLIS);
|
final long durationMillis = start.until(Instant.now(), ChronoUnit.MILLIS);
|
||||||
exchange.getResponseHeaders()
|
exchange.getResponseHeaders()
|
||||||
.put(Headers.CONTENT_TYPE, outputType.getContentType())
|
.put(Headers.CONTENT_TYPE, outputType.getContentType())
|
||||||
|
.put(HttpString.tryFromString("Access-Control-Allow-Origin"), "*")
|
||||||
.put(HttpString.tryFromString("X-Maze-ID"), String.valueOf(maze.getRandomSeed()))
|
.put(HttpString.tryFromString("X-Maze-ID"), String.valueOf(maze.getRandomSeed()))
|
||||||
.put(HttpString.tryFromString("X-Maze-Width"), String.valueOf(maze.getWidth()))
|
.put(HttpString.tryFromString("X-Maze-Width"), String.valueOf(maze.getWidth()))
|
||||||
.put(HttpString.tryFromString("X-Maze-Height"), String.valueOf(maze.getHeight()))
|
.put(HttpString.tryFromString("X-Maze-Height"), String.valueOf(maze.getHeight()))
|
||||||
|
|
@ -84,7 +85,7 @@ public class CreateHandler extends AbstractHttpHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private Try<ParametersToMazeExtractor.GeneratedMaze> createMazeFromRequestParameters(final Map<String, Deque<String>> queryParameters) {
|
private Try<ParametersToMazeExtractor.GeneratedMaze> createMazeFromRequestParameters(final Map<String, Deque<String>> queryParameters) {
|
||||||
return new ParametersToMazeExtractor(queryParameters, this.maxHeight, this.maxWidth).createMaze();
|
return new ParametersToMazeExtractor(queryParameters, this.maxHeight, this.maxWidth).createMaze();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ 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.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -19,14 +19,14 @@ import java.util.Random;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
class ParametersToMazeExtractor {
|
class ParametersToMazeExtractor {
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Map<String, Deque<String>> queryParameters;
|
private final Map<String, Deque<String>> queryParameters;
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Option<Integer> maxHeight;
|
private final Option<Integer> maxHeight;
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Option<Integer> maxWidth;
|
private final Option<Integer> maxWidth;
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
Try<GeneratedMaze> createMaze() {
|
Try<GeneratedMaze> createMaze() {
|
||||||
final Option<OutputType> output = getParameterValue(RequestParameter.OUTPUT);
|
final Option<OutputType> output = getParameterValue(RequestParameter.OUTPUT);
|
||||||
final Option<Integer> width = getParameterValue(RequestParameter.WIDTH);
|
final Option<Integer> width = getParameterValue(RequestParameter.WIDTH);
|
||||||
|
|
@ -87,11 +87,11 @@ class ParametersToMazeExtractor {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private <T> Option<T> getParameterValue(@NotNull final RequestParameter parameter) {
|
private <T> Option<T> getParameterValue(@NonNull final RequestParameter parameter) {
|
||||||
return parameter.getParameterValue(this.queryParameters);
|
return parameter.getParameterValue(this.queryParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record GeneratedMaze(@NotNull Maze maze, @NotNull OutputType outputType) {
|
public record GeneratedMaze(@NonNull Maze maze, @NonNull OutputType outputType) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import io.undertow.util.HeaderValues;
|
||||||
import io.undertow.util.Headers;
|
import io.undertow.util.Headers;
|
||||||
import io.undertow.util.StatusCodes;
|
import io.undertow.util.StatusCodes;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class RenderV1Handler extends AbstractHttpHandler {
|
||||||
public static final String PATH_TEMPLATE = "/render/v1/{output}";
|
public static final String PATH_TEMPLATE = "/render/v1/{output}";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle(@NotNull final HttpServerExchange exchange) {
|
public void handle(@NonNull final HttpServerExchange exchange) {
|
||||||
log.debug("Handling render request");
|
log.debug("Handling render request");
|
||||||
|
|
||||||
if (exchange.isInIoThread()) {
|
if (exchange.isInIoThread()) {
|
||||||
|
|
@ -47,8 +47,8 @@ public class RenderV1Handler extends AbstractHttpHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private OutputType getOutputType(@NotNull final HttpServerExchange httpServerExchange) {
|
private OutputType getOutputType(@NonNull final HttpServerExchange httpServerExchange) {
|
||||||
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
||||||
.getOrElse(() -> {
|
.getOrElse(() -> {
|
||||||
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import io.undertow.util.HeaderValues;
|
||||||
import io.undertow.util.Headers;
|
import io.undertow.util.Headers;
|
||||||
import io.undertow.util.StatusCodes;
|
import io.undertow.util.StatusCodes;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class RenderV2Handler extends AbstractHttpHandler {
|
||||||
public static final String PATH_TEMPLATE = "/render/v2/{output}";
|
public static final String PATH_TEMPLATE = "/render/v2/{output}";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle(@NotNull final HttpServerExchange exchange) {
|
public void handle(@NonNull final HttpServerExchange exchange) {
|
||||||
log.debug("Handling render request");
|
log.debug("Handling render request");
|
||||||
|
|
||||||
if (exchange.isInIoThread()) {
|
if (exchange.isInIoThread()) {
|
||||||
|
|
@ -47,8 +47,8 @@ public class RenderV2Handler extends AbstractHttpHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private OutputType getOutputType(@NotNull final HttpServerExchange httpServerExchange) {
|
private OutputType getOutputType(@NonNull final HttpServerExchange httpServerExchange) {
|
||||||
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
||||||
.getOrElse(() -> {
|
.getOrElse(() -> {
|
||||||
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import io.undertow.util.HeaderValues;
|
||||||
import io.undertow.util.Headers;
|
import io.undertow.util.Headers;
|
||||||
import io.undertow.util.StatusCodes;
|
import io.undertow.util.StatusCodes;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class RenderV3Handler extends AbstractHttpHandler {
|
||||||
public static final String PATH_TEMPLATE = "/render/v3/{output}";
|
public static final String PATH_TEMPLATE = "/render/v3/{output}";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle(@NotNull final HttpServerExchange exchange) {
|
public void handle(@NonNull final HttpServerExchange exchange) {
|
||||||
log.debug("Handling render request");
|
log.debug("Handling render request");
|
||||||
|
|
||||||
if (exchange.isInIoThread()) {
|
if (exchange.isInIoThread()) {
|
||||||
|
|
@ -47,8 +47,8 @@ public class RenderV3Handler extends AbstractHttpHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private OutputType getOutputType(@NotNull final HttpServerExchange httpServerExchange) {
|
private OutputType getOutputType(@NonNull final HttpServerExchange httpServerExchange) {
|
||||||
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
||||||
.getOrElse(() -> {
|
.getOrElse(() -> {
|
||||||
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import io.undertow.util.HeaderValues;
|
||||||
import io.undertow.util.Headers;
|
import io.undertow.util.Headers;
|
||||||
import io.undertow.util.StatusCodes;
|
import io.undertow.util.StatusCodes;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ public class RenderVxHandler implements HttpHandler {
|
||||||
public static final String PATH_TEMPLATE = "/render/dyn/{output}";
|
public static final String PATH_TEMPLATE = "/render/dyn/{output}";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handleRequest(@NotNull final HttpServerExchange exchange) {
|
public void handleRequest(@NonNull final HttpServerExchange exchange) {
|
||||||
log.debug("Handling render request");
|
log.debug("Handling render request");
|
||||||
|
|
||||||
if (exchange.isInIoThread()) {
|
if (exchange.isInIoThread()) {
|
||||||
|
|
@ -55,8 +55,8 @@ public class RenderVxHandler implements HttpHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private Version getVersion(@NotNull final byte[] bytes) throws IllegalArgumentException {
|
private Version getVersion(final byte @NonNull [] bytes) throws IllegalArgumentException {
|
||||||
if (bytes.length < 3) {
|
if (bytes.length < 3) {
|
||||||
throw new IllegalArgumentException("Invalid input: too short");
|
throw new IllegalArgumentException("Invalid input: too short");
|
||||||
}
|
}
|
||||||
|
|
@ -73,8 +73,8 @@ public class RenderVxHandler implements HttpHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
private OutputType getOutputType(@NotNull final HttpServerExchange httpServerExchange) {
|
private OutputType getOutputType(@NonNull final HttpServerExchange httpServerExchange) {
|
||||||
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
return RequestParameter.OUTPUT.<OutputType>getParameterValue(httpServerExchange.getQueryParameters())
|
||||||
.getOrElse(() -> {
|
.getOrElse(() -> {
|
||||||
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
final HeaderValues accept = httpServerExchange.getRequestHeaders().get(Headers.ACCEPT);
|
||||||
|
|
@ -86,6 +86,6 @@ public class RenderVxHandler implements HttpHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Version {
|
private enum Version {
|
||||||
V1, V2, V3;
|
V1, V2, V3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import io.vavr.control.Option;
|
||||||
import io.vavr.control.Try;
|
import io.vavr.control.Try;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
|
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -25,7 +25,7 @@ enum RequestParameter {
|
||||||
HEIGHT(p -> Try.of(() -> Integer.parseInt(p))
|
HEIGHT(p -> Try.of(() -> Integer.parseInt(p))
|
||||||
.toOption()
|
.toOption()
|
||||||
.onEmpty(() -> log.debug("Unparseable value for parameter 'height': '{}'", p)), "h", "height"),
|
.onEmpty(() -> log.debug("Unparseable value for parameter 'height': '{}'", p)), "h", "height"),
|
||||||
ID(p -> Try.of(() -> Long.parseLong(p))
|
ID(p -> Try.of(() -> Long.parseUnsignedLong(p, 16))
|
||||||
.toOption()
|
.toOption()
|
||||||
.onEmpty(() -> log.debug("Unparseable value for parameter 'id': '{}'", p)), "i", "id"),
|
.onEmpty(() -> log.debug("Unparseable value for parameter 'id': '{}'", p)), "i", "id"),
|
||||||
OUTPUT(p -> OutputType.ofString(p)
|
OUTPUT(p -> OutputType.ofString(p)
|
||||||
|
|
@ -48,24 +48,24 @@ enum RequestParameter {
|
||||||
.onEmpty(() -> log.debug("Unparseable value for parameter 'end': '{}'", p)), "e", "end"),
|
.onEmpty(() -> log.debug("Unparseable value for parameter 'end': '{}'", p)), "e", "end"),
|
||||||
ALGORITHM(p -> Algorithm.ofString(p)
|
ALGORITHM(p -> Algorithm.ofString(p)
|
||||||
.onEmpty(() -> log.debug("Unparseable value for parameter 'algorithm': '{}'", p)), "a", "algorithm");
|
.onEmpty(() -> log.debug("Unparseable value for parameter 'algorithm': '{}'", p)), "a", "algorithm");
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Function<String, Option<?>> extractor;
|
private final Function<String, Option<?>> extractor;
|
||||||
@Getter
|
@Getter
|
||||||
@NotNull
|
@NonNull
|
||||||
private final Set<String> names;
|
private final Set<String> names;
|
||||||
|
|
||||||
RequestParameter(@NotNull final Function<String, Option<?>> extractor, @NotNull final String... names) {
|
RequestParameter(@NonNull final Function<String, Option<?>> extractor, @NonNull final String... names) {
|
||||||
this.extractor = extractor;
|
this.extractor = extractor;
|
||||||
this.names = HashSet.of(names);
|
this.names = HashSet.of(names);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
Option<?> extractParameterValue(@NotNull final String parameter) {
|
Option<?> extractParameterValue(@NonNull final String parameter) {
|
||||||
return this.extractor.apply(parameter);
|
return this.extractor.apply(parameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NonNull
|
||||||
public <T> Option<T> getParameterValue(@NotNull final Map<String, Deque<String>> queryParameters) {
|
public <T> Option<T> getParameterValue(@NonNull final Map<String, Deque<String>> queryParameters) {
|
||||||
return (Option<T>) HashMap.ofAll(queryParameters)
|
return (Option<T>) HashMap.ofAll(queryParameters)
|
||||||
.filterKeys(this.names::contains)
|
.filterKeys(this.names::contains)
|
||||||
.flatMap(Tuple2::_2)
|
.flatMap(Tuple2::_2)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue