diff --git a/README-CHANGES.xml b/README-CHANGES.xml
index c86a0c9..859ca77 100644
--- a/README-CHANGES.xml
+++ b/README-CHANGES.xml
@@ -11,12 +11,13 @@
-
+
-
+
+
diff --git a/README-LICENSE.txt b/README-LICENSE.txt
index f627468..8872798 100644
--- a/README-LICENSE.txt
+++ b/README-LICENSE.txt
@@ -1,4 +1,4 @@
-Copyright © 2023 Mark Raynsford https://www.io7m.com
+Copyright © 2024 Mark Raynsford https://www.io7m.com
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
diff --git a/README.in b/README.in
index 01ed361..b67417c 100644
--- a/README.in
+++ b/README.in
@@ -9,7 +9,7 @@ A tiny embedded HTTP server for unit testing.
* Conveniently enqueue responses to arbitrary requests.
* Verify that requests were received as expected.
* Zero dependencies.
- * Written in pure Java 17.
+ * Written in pure Java 21.
* [OSGi](https://www.osgi.org/) ready.
* [JPMS](https://en.wikipedia.org/wiki/Java_Platform_Module_System) ready.
* ISC license.
@@ -82,3 +82,155 @@ public void tearDown()
this.server.close();
}
```
+
+### OCI
+
+The `quixote` server is also capable of acting as a standalone web server
+for use in integration tests involving containers. This is primarily useful
+for situations where an integration test starts multiple containers inside
+an isolated virtual network, and one or more of the test containers need to
+speak to a fake web server, and the host running the test suite needs to
+observe the requests in question. This particular configuration can be
+difficult to set up in an automated manner, as most virtual networking
+solutions don't allow for containers to "talk back" to the host machine. Using
+`quixote` as a container allows for it to be present inside the virtual network,
+and for the host to download and parse request logs after the test has
+completed, sidestepping the need for any containers to know about the host
+machine.
+
+In other words:
+
+1. The host creates a virtual network using its container engine of choice.
+2. The host creates several containers inside the virtual network.
+3. The host writes a `quixote` configuration file in the host directory.
+4. The host creates a `quixote` container inside the virtual network, mounting
+ the host directory into the container as a volume mount.
+5. The host runs the test. This causes the containers inside the virtual
+ network to make requests to the `quixote` container.
+6. The host reads the resulting request log from the host directory and
+ verifies that the correct requests were made for the test.
+
+![Virtual Network](net.png)
+
+Given a configuration file conforming to the provided
+[schema](com.io7m.quixote.xml/src/main/resources/com/io7m/quixote/xml/configuration-1.xsd),
+the `quixote` server can be started using a container engine such as
+[Podman](https://podman.io).
+
+```
+$ cat quixote/config.xml
+
+
+
+
+
+
+
+
+
+ Hello world!
+
+
+
+```
+
+```
+$ podman run \
+ --rm \
+ --interactive \
+ --tty \
+ --volume quixote:/quixote/data:rw \
+ --publish 20001:20001/tcp \
+ quay.io/io7mcom/quixote:1.2.0-SNAPSHOT \
+ /quixote/data/config.xml \
+ /quixote/data/output.bin
+INFO com.io7m.quixote.main.Main: Quixote running at http://localhost:20001/
+```
+
+```
+$ curl http://localhost:20001/
+Hello world!
+```
+
+The server appends every received request to a file consisting of an
+array of request records in a trivial binary format. A request record has the
+following structure:
+
+```
+RequestRecord
+{
+ Unsigned64 quixote;
+ Unsigned32 version;
+ Unsigned64 length;
+ Unsigned8 data[length];
+}
+```
+
+All integer values are in big-endian byte order.
+
+The `quixote` field of the `RequestRecord` structure always has the value
+`0x515549584F544521` ("QUIXOTE!" in ASCII). The `version` field of the
+`RequestRecord` field currently has the value `0x00000001`.
+
+The `data` field is a sequence of `length` bytes that represents a serialized
+[java.util.Properties](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Properties.html)
+XML document. For example, the `curl` request above resulted in the following
+record being appended to `/quixote/output.bin`:
+
+```
+0000:0000 | 51 55 49 58 4F 54 45 21 00 00 00 01 00 00 00 00 | QUIXOTE!........
+0000:0010 | 00 00 01 BF 3C 3F 78 6D 6C 20 76 65 72 73 69 6F | ...¿.
+0000:0080 | 0A 3C 70 72 6F 70 65 72 74 69 65 73 3E 0A 3C 65 | ..12
+0000:00B0 | 37 2E 30 2E 30 2E 31 3C 2F 65 6E 74 72 79 3E 0A | 7.0.0.1.
+0000:00C0 | 3C 65 6E 74 72 79 20 6B 65 79 3D 22 49 6E 66 6F | GET.127.0.0.
+0000:0110 | 31 3C 2F 65 6E 74 72 79 3E 0A 3C 65 6E 74 72 79 | 1./.*/*.curl/8.7.1.127
+0000:01B0 | 2E 30 2E 30 2E 31 3A 32 30 30 30 31 3C 2F 65 6E | .0.0.1:20001..
+```
+
+Extracting the XML document and formatting it gives:
+
+```
+
+
+
+127.0.0.1
+GET
+127.0.0.1
+/
+*/*
+curl/8.7.1
+127.0.0.1:20001
+
+```
+
+We can see that the client made a `GET` request to the `/` path. The
+[QWebRequestLogging](com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebRequestLogging.java)
+class provides convenient functions to read and write request logs.
diff --git a/README.md b/README.md
index b9c1cff..93227a1 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ A tiny embedded HTTP server for unit testing.
* Conveniently enqueue responses to arbitrary requests.
* Verify that requests were received as expected.
* Zero dependencies.
- * Written in pure Java 17.
+ * Written in pure Java 21.
* [OSGi](https://www.osgi.org/) ready.
* [JPMS](https://en.wikipedia.org/wiki/Java_Platform_Module_System) ready.
* ISC license.
@@ -98,3 +98,155 @@ public void tearDown()
}
```
+### OCI
+
+The `quixote` server is also capable of acting as a standalone web server
+for use in integration tests involving containers. This is primarily useful
+for situations where an integration test starts multiple containers inside
+an isolated virtual network, and one or more of the test containers need to
+speak to a fake web server, and the host running the test suite needs to
+observe the requests in question. This particular configuration can be
+difficult to set up in an automated manner, as most virtual networking
+solutions don't allow for containers to "talk back" to the host machine. Using
+`quixote` as a container allows for it to be present inside the virtual network,
+and for the host to download and parse request logs after the test has
+completed, sidestepping the need for any containers to know about the host
+machine.
+
+In other words:
+
+1. The host creates a virtual network using its container engine of choice.
+2. The host creates several containers inside the virtual network.
+3. The host writes a `quixote` configuration file in the host directory.
+4. The host creates a `quixote` container inside the virtual network, mounting
+ the host directory into the container as a volume mount.
+5. The host runs the test. This causes the containers inside the virtual
+ network to make requests to the `quixote` container.
+6. The host reads the resulting request log from the host directory and
+ verifies that the correct requests were made for the test.
+
+![Virtual Network](net.png)
+
+Given a configuration file conforming to the provided
+[schema](com.io7m.quixote.xml/src/main/resources/com/io7m/quixote/xml/configuration-1.xsd),
+the `quixote` server can be started using a container engine such as
+[Podman](https://podman.io).
+
+```
+$ cat quixote/config.xml
+
+
+
+
+
+
+
+
+
+ Hello world!
+
+
+
+```
+
+```
+$ podman run \
+ --rm \
+ --interactive \
+ --tty \
+ --volume quixote:/quixote/data:rw \
+ --publish 20001:20001/tcp \
+ quay.io/io7mcom/quixote:1.2.0-SNAPSHOT \
+ /quixote/data/config.xml \
+ /quixote/data/output.bin
+INFO com.io7m.quixote.main.Main: Quixote running at http://localhost:20001/
+```
+
+```
+$ curl http://localhost:20001/
+Hello world!
+```
+
+The server appends every received request to a file consisting of an
+array of request records in a trivial binary format. A request record has the
+following structure:
+
+```
+RequestRecord
+{
+ Unsigned64 quixote;
+ Unsigned32 version;
+ Unsigned64 length;
+ Unsigned8 data[length];
+}
+```
+
+All integer values are in big-endian byte order.
+
+The `quixote` field of the `RequestRecord` structure always has the value
+`0x515549584F544521` ("QUIXOTE!" in ASCII). The `version` field of the
+`RequestRecord` field currently has the value `0x00000001`.
+
+The `data` field is a sequence of `length` bytes that represents a serialized
+[java.util.Properties](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Properties.html)
+XML document. For example, the `curl` request above resulted in the following
+record being appended to `/quixote/output.bin`:
+
+```
+0000:0000 | 51 55 49 58 4F 54 45 21 00 00 00 01 00 00 00 00 | QUIXOTE!........
+0000:0010 | 00 00 01 BF 3C 3F 78 6D 6C 20 76 65 72 73 69 6F | ...¿.
+0000:0080 | 0A 3C 70 72 6F 70 65 72 74 69 65 73 3E 0A 3C 65 | ..12
+0000:00B0 | 37 2E 30 2E 30 2E 31 3C 2F 65 6E 74 72 79 3E 0A | 7.0.0.1.
+0000:00C0 | 3C 65 6E 74 72 79 20 6B 65 79 3D 22 49 6E 66 6F | GET.127.0.0.
+0000:0110 | 31 3C 2F 65 6E 74 72 79 3E 0A 3C 65 6E 74 72 79 | 1./.*/*.curl/8.7.1.127
+0000:01B0 | 2E 30 2E 30 2E 31 3A 32 30 30 30 31 3C 2F 65 6E | .0.0.1:20001..
+```
+
+Extracting the XML document and formatting it gives:
+
+```
+
+
+
+127.0.0.1
+GET
+127.0.0.1
+/
+*/*
+curl/8.7.1
+127.0.0.1:20001
+
+```
+
+We can see that the client made a `GET` request to the `/` path. The
+[QWebRequestLogging](com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebRequestLogging.java)
+class provides convenient functions to read and write request logs.
+
diff --git a/checkstyle-filter.xml b/checkstyle-filter.xml
index 7f4a289..3f81f3a 100644
--- a/checkstyle-filter.xml
+++ b/checkstyle-filter.xml
@@ -5,5 +5,8 @@
"https://checkstyle.org/dtds/suppressions_1_0.dtd">
-
+
+
diff --git a/com.io7m.quixote.core/src/main/java-descriptor-testing/module-info.java b/com.io7m.quixote.core/src/main/java-descriptor-testing/module-info.java
new file mode 100644
index 0000000..46d48f1
--- /dev/null
+++ b/com.io7m.quixote.core/src/main/java-descriptor-testing/module-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright © 2022 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/**
+ * Embedded test suite web server (Core)
+ */
+
+module com.io7m.quixote.core
+{
+ requires static org.osgi.annotation.bundle;
+ requires static org.osgi.annotation.versioning;
+
+ requires java.logging;
+ requires nanohttpd;
+
+ exports com.io7m.quixote.core;
+}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebConfiguration.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebConfiguration.java
new file mode 100644
index 0000000..026f87f
--- /dev/null
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebConfiguration.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.core;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Configuration for a server.
+ *
+ * @param serverConfiguration The server configuration
+ * @param responses The canned responses
+ */
+
+public record QWebConfiguration(
+ QWebServerConfiguration serverConfiguration,
+ List responses)
+{
+ /**
+ * Configuration for a server.
+ *
+ * @param serverConfiguration The server configuration
+ * @param responses The canned responses
+ */
+
+ public QWebConfiguration
+ {
+ Objects.requireNonNull(serverConfiguration, "serverConfiguration");
+ responses = List.copyOf(responses);
+ }
+}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebRequestLogging.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebRequestLogging.java
new file mode 100644
index 0000000..e7492df
--- /dev/null
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebRequestLogging.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.core;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Functions to read and write logs in the binary request log format.
+ */
+
+public final class QWebRequestLogging
+{
+ private static final byte[] HEADER;
+
+ static {
+ try (var out = new ByteArrayOutputStream()) {
+ out.writeBytes("QUIXOTE!".getBytes(UTF_8));
+ out.write('\0');
+ out.write('\0');
+ out.write('\0');
+ out.write('\1');
+ out.flush();
+ HEADER = out.toByteArray();
+ } catch (final IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private QWebRequestLogging()
+ {
+
+ }
+
+ /**
+ * Append a request to the given log.
+ *
+ * @param outputLog The output stream
+ * @param request The request
+ *
+ * @throws IOException On I/O errors
+ */
+
+ public static void append(
+ final OutputStream outputLog,
+ final QWebRequestReceivedType request)
+ throws IOException
+ {
+ serializeRequest(outputLog, request);
+ }
+
+ private static void serializeRequest(
+ final OutputStream outputLog,
+ final QWebRequestReceivedType request)
+ throws IOException
+ {
+ outputLog.write(HEADER);
+ outputLog.flush();
+
+ final var properties = new Properties();
+ properties.setProperty("Info.Path", request.path());
+ properties.setProperty("Info.Method", request.method());
+
+ for (final var entry : request.headers().entrySet()) {
+ properties.setProperty(
+ "Header.%s".formatted(entry.getKey()),
+ entry.getValue()
+ );
+ }
+
+ for (final var entry : request.files().entrySet()) {
+ properties.setProperty(
+ "File.%s".formatted(entry.getKey()),
+ entry.getValue()
+ );
+ }
+
+ try (var textStream = new ByteArrayOutputStream()) {
+ properties.storeToXML(textStream, "", UTF_8);
+ textStream.flush();
+ writeBytes(outputLog, textStream.toByteArray());
+ }
+
+ outputLog.flush();
+ }
+
+ private static void writeBytes(
+ final OutputStream outputLog,
+ final byte[] data)
+ throws IOException
+ {
+ final var lengthArray =
+ new byte[8];
+ final var length =
+ ByteBuffer.wrap(lengthArray)
+ .order(ByteOrder.BIG_ENDIAN);
+
+ length.putLong(0, Integer.toUnsignedLong(data.length));
+ outputLog.write(lengthArray);
+ outputLog.write(data);
+ }
+
+ /**
+ * Read a request from the given log.
+ *
+ * @param inputLog The input stream
+ *
+ * @return The parsed request properties
+ *
+ * @throws IOException On I/O errors
+ */
+
+ public static QWebRequestReceivedType read(
+ final InputStream inputLog)
+ throws IOException
+ {
+ final var header =
+ inputLog.readNBytes(12);
+
+ if (!Arrays.equals(header, HEADER)) {
+ throw new IOException(
+ "Invalid header: %s".formatted(HexFormat.of().formatHex(header))
+ );
+ }
+
+ final var lengthArray =
+ inputLog.readNBytes(8);
+
+ final var lengthBuffer =
+ ByteBuffer.wrap(lengthArray)
+ .order(ByteOrder.BIG_ENDIAN);
+
+ final var length =
+ lengthBuffer.getLong(0);
+
+ final var body =
+ inputLog.readNBytes(Math.toIntExact(length));
+
+ try (var bodyStream = new ByteArrayInputStream(body)) {
+ final var properties = new Properties();
+ properties.loadFromXML(bodyStream);
+ return new ImmutableRequest(properties);
+ }
+ }
+
+ private record ImmutableRequest(
+ Properties properties)
+ implements QWebRequestReceivedType
+ {
+
+ @Override
+ public String method()
+ {
+ return this.properties.getProperty("Info.Method");
+ }
+
+ @Override
+ public String path()
+ {
+ return this.properties.getProperty("Info.Path");
+
+ }
+
+ @Override
+ public Map headers()
+ {
+ return this.properties.stringPropertyNames()
+ .stream()
+ .filter(x -> x.startsWith("Header."))
+ .map(x -> Map.entry(x, this.properties.getProperty(x)))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ @Override
+ public Map files()
+ {
+ return this.properties.stringPropertyNames()
+ .stream()
+ .filter(x -> x.startsWith("File."))
+ .map(x -> Map.entry(x, this.properties.getProperty(x)))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+ }
+}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebResponseRecorded.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebResponseRecorded.java
new file mode 100644
index 0000000..71a5e22
--- /dev/null
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebResponseRecorded.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package com.io7m.quixote.core;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/**
+ * A recorded response.
+ *
+ * @param method The method for which this response will be returned
+ * @param path The path for which this response will be returned
+ * @param statusCode The status code
+ * @param headers The headers
+ * @param content The content
+ */
+
+public record QWebResponseRecorded(
+ Pattern method,
+ Pattern path,
+ int statusCode,
+ Map headers,
+ byte[] content)
+{
+ /**
+ * A recorded response.
+ *
+ * @param method The method for which this response will be returned
+ * @param path The path for which this response will be returned
+ * @param statusCode The status code
+ * @param headers The headers
+ * @param content The content
+ */
+
+ public QWebResponseRecorded
+ {
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(path, "path");
+ headers = Map.copyOf(headers);
+ content = content.clone();
+ }
+}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerConfiguration.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerConfiguration.java
new file mode 100644
index 0000000..cd2f193
--- /dev/null
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerConfiguration.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.core;
+
+import java.util.Objects;
+
+/**
+ * The web server configuration.
+ *
+ * @param hostName The hostname to which to bind the server
+ * @param port The port to which to bind the server
+ * @param enableGZIP Enable/disable GZIP
+ */
+
+public record QWebServerConfiguration(
+ String hostName,
+ int port,
+ boolean enableGZIP)
+{
+ /**
+ * The web server configuration.
+ *
+ * @param hostName The hostname to which to bind the server
+ * @param port The port to which to bind the server
+ * @param enableGZIP Enable/disable GZIP
+ */
+
+ public QWebServerConfiguration
+ {
+ Objects.requireNonNull(hostName, "hostName");
+ }
+}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerFactoryType.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerFactoryType.java
index 4e0b464..84a3cef 100644
--- a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerFactoryType.java
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerFactoryType.java
@@ -62,7 +62,7 @@ QWebServerType createForAll(int port)
* address.
*
* @param address The address
- * @param port The port
+ * @param port The port
*
* @return A new web server
*
@@ -74,4 +74,19 @@ QWebServerType createForSpecific(
InetAddress address,
int port)
throws IOException;
+
+ /**
+ * Create a new web server from the given configuration.
+ *
+ * @param configuration The configuration
+ *
+ * @return A new web server
+ *
+ * @throws IOException On errors
+ * @since 1.2.0
+ */
+
+ QWebServerType createForConfiguration(
+ QWebConfiguration configuration)
+ throws IOException;
}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerType.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerType.java
index 20641b4..dc1e173 100644
--- a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerType.java
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServerType.java
@@ -21,6 +21,7 @@
import java.io.Closeable;
import java.net.URI;
import java.util.List;
+import java.util.function.Consumer;
/**
* A web server.
@@ -67,4 +68,13 @@ QWebServerType enableGzip(
*/
List requestsReceived();
+
+ /**
+ * Set the callback that will be evaluated on each request.
+ *
+ * @param onRequest The request receiver
+ */
+
+ void setRequestCallback(
+ Consumer onRequest);
}
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServers.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServers.java
index b9a1b68..07a569d 100644
--- a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServers.java
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/QWebServers.java
@@ -27,6 +27,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.function.Consumer;
import java.util.regex.Pattern;
import static fi.iki.elonen.NanoHTTPD.Response.Status.SERVICE_UNAVAILABLE;
@@ -103,6 +104,22 @@ public static QWebServerType createServerForSpecific(
return new QWebServers().createForSpecific(address, port);
}
+ /**
+ * Create a new web server from the given configuration.
+ *
+ * @param configuration The configuration
+ *
+ * @return A new web server
+ *
+ * @throws IOException On errors
+ */
+
+ public static QWebServerType createServerForConfiguration(
+ final QWebConfiguration configuration)
+ throws IOException
+ {
+ return new QWebServers().createForConfiguration(configuration);
+ }
@Override
public QWebServerType create(
@@ -129,6 +146,36 @@ public QWebServerType createForSpecific(
return new QWebServer(address.getHostName(), port);
}
+ @Override
+ public QWebServerType createForConfiguration(
+ final QWebConfiguration configuration)
+ throws IOException
+ {
+ final var serverConfiguration =
+ configuration.serverConfiguration();
+
+ final var server =
+ new QWebServer(
+ serverConfiguration.hostName(),
+ serverConfiguration.port()
+ );
+
+ server.enableGzip(serverConfiguration.enableGZIP());
+
+ for (final var rec : configuration.responses()) {
+ final var r = server.addResponse();
+ r.withStatus(rec.statusCode());
+ r.withFixedData(rec.content());
+ r.withContentLength(rec.content().length);
+
+ for (final var entry : rec.headers().entrySet()) {
+ r.withHeader(entry.getKey(), entry.getValue());
+ }
+ }
+
+ return server;
+ }
+
private record QWebRequestReceived(
String method,
String path,
@@ -152,6 +199,7 @@ private static final class QWebServer extends NanoHTTPD
private final LinkedList responses;
private final LinkedList requests;
private boolean gzipEnabled;
+ private Consumer callback;
QWebServer(
final String hostName,
@@ -167,6 +215,9 @@ private static final class QWebServer extends NanoHTTPD
new LinkedList<>();
this.requests =
new LinkedList<>();
+ this.callback =
+ r -> {
+ };
this.baseURI =
URI.create(
@@ -193,6 +244,12 @@ public Response serve(
this.requests.add(requestReceived);
+ try {
+ this.callback.accept(requestReceived);
+ } catch (final Exception e) {
+ // Ignored
+ }
+
try {
session.parseBody(requestReceived.files);
} catch (final Exception e) {
@@ -274,6 +331,13 @@ public List requestsReceived()
{
return List.copyOf(this.requests);
}
+
+ @Override
+ public void setRequestCallback(
+ final Consumer onRequest)
+ {
+ this.callback = Objects.requireNonNull(onRequest, "onRequest");
+ }
}
private static final class QMutableResponse implements QWebResponseType
diff --git a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/package-info.java b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/package-info.java
index 759de2f..72058a7 100644
--- a/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/package-info.java
+++ b/com.io7m.quixote.core/src/main/java/com/io7m/quixote/core/package-info.java
@@ -19,7 +19,7 @@
*/
@Export
-@Version("1.1.0")
+@Version("1.2.0")
package com.io7m.quixote.core;
import org.osgi.annotation.bundle.Export;
diff --git a/com.io7m.quixote.main/pom.xml b/com.io7m.quixote.main/pom.xml
new file mode 100644
index 0000000..c248adb
--- /dev/null
+++ b/com.io7m.quixote.main/pom.xml
@@ -0,0 +1,142 @@
+
+
+
+
+ 4.0.0
+
+
+ com.io7m.quixote
+ com.io7m.quixote
+ 1.2.0-SNAPSHOT
+
+
+ com.io7m.quixote.main
+
+ com.io7m.quixote.main
+ Embedded test suite web server (Main)
+ https://www.io7m.com/software/quixote
+
+
+
+ ${project.groupId}
+ com.io7m.quixote.core
+ ${project.version}
+
+
+ ${project.groupId}
+ com.io7m.quixote.xml
+ ${project.version}
+
+
+
+ com.io7m.blackthorne
+ com.io7m.blackthorne.core
+
+
+ com.io7m.anethum
+ com.io7m.anethum.slf4j
+
+
+ org.slf4j
+ slf4j-api
+
+
+ com.io7m.anethum
+ com.io7m.anethum.api
+
+
+ ch.qos.logback
+ logback-classic
+
+
+
+ org.osgi
+ org.osgi.annotation.bundle
+ provided
+
+
+ org.osgi
+ org.osgi.annotation.versioning
+ provided
+
+
+
+
+
+
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+
+
+ default
+
+ makeAggregateBom
+
+ prepare-package
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+ true
+
+
+ ch.qos.logback:logback-classic:*
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+
+
+ distribution
+
+ single
+
+ package
+
+
+ src/main/assembly/distribution.xml
+
+
+
+
+
+
+
+ io.github.zlika
+ reproducible-build-maven-plugin
+
+
+ reproducible-zip
+ package
+
+ strip-jar
+
+
+ true
+
+ .*\.zip
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.quixote.main/src/main/assembly/distribution.xml b/com.io7m.quixote.main/src/main/assembly/distribution.xml
new file mode 100644
index 0000000..5c6cf7b
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/assembly/distribution.xml
@@ -0,0 +1,38 @@
+
+
+
+
+ distribution
+
+ quixote
+
+
+ dir
+ zip
+
+
+
+
+ lib
+ true
+ true
+
+
+
+
+
+
+ /bin
+ 0755
+
+
+
+
+ 0644
+
+
+
+
diff --git a/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/Main.java b/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/Main.java
new file mode 100644
index 0000000..16b215b
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/Main.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.main;
+
+import com.io7m.anethum.slf4j.ParseStatusLogging;
+import com.io7m.blackthorne.core.BTPreserveLexical;
+import com.io7m.quixote.core.QWebConfiguration;
+import com.io7m.quixote.core.QWebRequestLogging;
+import com.io7m.quixote.core.QWebServers;
+import com.io7m.quixote.xml.QWebConfigurationXML;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+
+/**
+ * Server main entry point.
+ */
+
+public final class Main
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(Main.class);
+
+ private static final OpenOption[] OPEN_OPTIONS = {
+ StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.APPEND,
+ };
+
+ private Main()
+ {
+
+ }
+
+ /**
+ * Server main entry point.
+ *
+ * @param args The command-line arguments
+ *
+ * @throws Exception On errors
+ */
+
+ public static void main(
+ final String[] args)
+ throws Exception
+ {
+ if (args.length == 1) {
+ if (Objects.equals(args[0], "help")) {
+ LOG.info("Self-check succeeded.");
+ return;
+ }
+ }
+
+ if (args.length != 2) {
+ LOG.info("Usage: input.xml output.bin");
+ throw new IllegalArgumentException(
+ "Missing required command-line arguments.");
+ }
+
+ final var inputFile =
+ Paths.get(args[0]).toAbsolutePath();
+ final var outputFile =
+ Paths.get(args[1]).toAbsolutePath();
+
+ final QWebConfiguration configuration;
+ try (var stream = Files.newInputStream(inputFile)) {
+ configuration =
+ QWebConfigurationXML.parse(
+ inputFile.toUri(),
+ stream,
+ BTPreserveLexical.PRESERVE_LEXICAL_INFORMATION,
+ status -> {
+ ParseStatusLogging.logWithAll(LOG, status);
+ });
+ }
+
+ try (var outputLog =
+ Files.newOutputStream(outputFile, OPEN_OPTIONS)) {
+ try (var server =
+ QWebServers.createServerForConfiguration(configuration)) {
+
+ LOG.info("Quixote running at {}", server.uri());
+
+ server.setRequestCallback(r -> {
+ try {
+ QWebRequestLogging.append(outputLog, r);
+ } catch (final IOException e) {
+ LOG.error("Failed to write output log: ", e);
+ }
+ });
+
+ while (true) {
+ try {
+ Thread.sleep(1_000L);
+ } catch (final InterruptedException e) {
+ return;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/package-info.java b/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/package-info.java
new file mode 100644
index 0000000..a8a0d2f
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/java/com/io7m/quixote/main/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/**
+ * Embedded test suite web server (Main)
+ */
+
+@Export
+@Version("1.0.0")
+package com.io7m.quixote.main;
+
+import org.osgi.annotation.bundle.Export;
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.quixote.main/src/main/java/module-info.java b/com.io7m.quixote.main/src/main/java/module-info.java
new file mode 100644
index 0000000..0cd66f9
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/java/module-info.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/**
+ * Embedded test suite web server (Main)
+ */
+
+module com.io7m.quixote.main
+{
+ requires static org.osgi.annotation.bundle;
+ requires static org.osgi.annotation.versioning;
+
+ requires com.io7m.quixote.core;
+ requires com.io7m.quixote.xml;
+
+ requires com.io7m.anethum.api;
+ requires com.io7m.anethum.slf4j;
+ requires com.io7m.blackthorne.core;
+ requires org.slf4j;
+
+ exports com.io7m.quixote.main;
+}
diff --git a/com.io7m.quixote.main/src/main/resources/logback.xml b/com.io7m.quixote.main/src/main/resources/logback.xml
new file mode 100644
index 0000000..5da69bd
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/resources/logback.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ %level %logger: %msg%n
+
+ System.out
+
+
+
+
+
+
+
diff --git a/com.io7m.quixote.main/src/main/resources/logback.xsd b/com.io7m.quixote.main/src/main/resources/logback.xsd
new file mode 100644
index 0000000..16db5d6
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/resources/logback.xsd
@@ -0,0 +1,523 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.quixote.main/src/main/sh/quixote b/com.io7m.quixote.main/src/main/sh/quixote
new file mode 100755
index 0000000..223d7cd
--- /dev/null
+++ b/com.io7m.quixote.main/src/main/sh/quixote
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+if [ -z "${QUIXOTE_HOME}" ]
+then
+ echo "QUIXOTE_HOME is unset" 1>&2
+ exit 1
+fi
+
+exec /usr/bin/env java \
+-p "${QUIXOTE_HOME}/lib" \
+-m com.io7m.quixote.main/com.io7m.quixote.main.Main \
+"$@"
diff --git a/com.io7m.quixote.oci/pom.xml b/com.io7m.quixote.oci/pom.xml
new file mode 100644
index 0000000..e4c7a32
--- /dev/null
+++ b/com.io7m.quixote.oci/pom.xml
@@ -0,0 +1,181 @@
+
+
+
+
+ 4.0.0
+
+
+ com.io7m.quixote
+ com.io7m.quixote
+ 1.2.0-SNAPSHOT
+
+
+ com.io7m.quixote.oci
+
+ com.io7m.quixote.oci
+ Embedded test suite web server (OCI image)
+ https://www.io7m.com/software/quixote
+
+
+ 3.18.2
+ 21_35-jre-alpine
+
+
+
+
+ ${project.groupId}
+ com.io7m.quixote.main
+ ${project.version}
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+ true
+
+ com.io7m.quixote:*
+ ch.qos.logback:logback-classic::*
+
+
+
+
+
+
+
+
+ io7m-oci-image
+
+
+
+ ${project.groupId}
+ com.io7m.quixote.main
+ ${project.version}
+ distribution
+ zip
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ unpack-sources
+ package
+
+ unpack-dependencies
+
+
+ module-info.java
+ ${project.groupId}
+ com.io7m.quixote.main
+ zip
+ distribution
+ true
+ ${project.build.directory}/oci
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ copy-containerfile
+ package
+
+ copy-resources
+
+
+
+
+ src/main/resources
+ true
+
+
+ ${project.build.directory}/oci
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+
+
+
+ oci-image-create
+
+ exec
+
+ package
+
+ podman
+
+ build
+ --timestamp
+ 1577836800
+ -t
+ quay.io/io7mcom/quixote:${project.version}
+ ${project.build.directory}/oci
+
+
+
+
+
+
+ oci-image-run-check
+
+ exec
+
+ package
+
+ podman
+
+ run
+ --rm
+ --read-only
+ quay.io/io7mcom/quixote:${project.version}
+ help
+
+
+
+
+
+
+ oci-image-push
+
+ exec
+
+ deploy
+
+ podman
+
+ push
+ quay.io/io7mcom/quixote:${project.version}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.quixote.oci/src/main/resources/Containerfile b/com.io7m.quixote.oci/src/main/resources/Containerfile
new file mode 100644
index 0000000..a6b7276
--- /dev/null
+++ b/com.io7m.quixote.oci/src/main/resources/Containerfile
@@ -0,0 +1,17 @@
+FROM docker.io/library/eclipse-temurin:${com.io7m.oci.versionTemurin}
+
+ENV PATH="/quixote/bin:/sbin:/bin:/usr/sbin:/usr/bin:/opt/java/openjdk/bin"
+ENV QUIXOTE_HOME="/quixote"
+
+COPY quixote /quixote
+
+LABEL "org.opencontainers.image.authors"="Mark Raynsford"
+LABEL "org.opencontainers.image.description"="Embedded test suite web server"
+LABEL "org.opencontainers.image.licenses"="ISC"
+LABEL "org.opencontainers.image.source"="https://www.github.com/io7m/quixote"
+LABEL "org.opencontainers.image.title"="Quixote"
+LABEL "org.opencontainers.image.url"="https://www.io7m.com/software/quixote"
+LABEL "org.opencontainers.image.version"="${project.version}"
+LABEL "org.opencontainers.image.revision"="${buildNumber}"
+
+ENTRYPOINT ["/quixote/bin/quixote"]
diff --git a/com.io7m.quixote.tests/pom.xml b/com.io7m.quixote.tests/pom.xml
index be69b01..e014f39 100644
--- a/com.io7m.quixote.tests/pom.xml
+++ b/com.io7m.quixote.tests/pom.xml
@@ -29,6 +29,16 @@
com.io7m.quixote.core
${project.version}
+
+ ${project.groupId}
+ com.io7m.quixote.xml
+ ${project.version}
+
+
+ ${project.groupId}
+ com.io7m.quixote.main
+ ${project.version}
+
org.nanohttpd
@@ -44,6 +54,19 @@
junit-jupiter-api
+
+ org.slf4j
+ slf4j-api
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ org.mockito
+ mockito-core
+
+
org.osgi
org.osgi.annotation.bundle
diff --git a/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebConfigurationXMLTest.java b/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebConfigurationXMLTest.java
new file mode 100644
index 0000000..181b91b
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebConfigurationXMLTest.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.tests;
+
+import com.io7m.anethum.api.ParsingException;
+import com.io7m.blackthorne.core.BTPreserveLexical;
+import com.io7m.quixote.xml.QWebConfigurationXML;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public final class QWebConfigurationXMLTest
+{
+ /**
+ * Configuration parsing.
+ *
+ * @throws Exception On errors
+ */
+
+ @Test
+ public void testConfig0()
+ throws Exception
+ {
+ final var c =
+ QWebConfigurationXML.parse(
+ URI.create("urn:stdin"),
+ resource("conf-0.xml"),
+ BTPreserveLexical.DISCARD_LEXICAL_INFORMATION,
+ status -> {
+ }
+ );
+
+ assertEquals(20001, c.serverConfiguration().port());
+ assertTrue(c.serverConfiguration().enableGZIP());
+
+ {
+ final var r = c.responses().get(0);
+ assertEquals(200, r.statusCode());
+ assertEquals("GET", r.method().pattern());
+ assertEquals("/", r.path().pattern());
+ assertEquals(Map.ofEntries(
+ Map.entry("Content-Type", "application/octet-stream")
+ ), r.headers());
+ assertEquals(
+ new String(
+ resource("README-LICENSE.txt").readAllBytes(),
+ StandardCharsets.UTF_8
+ ),
+ new String(
+ r.content(),
+ StandardCharsets.UTF_8
+ )
+ );
+ }
+
+ assertEquals(1, c.responses().size());
+ }
+
+ /**
+ * Configuration parsing.
+ *
+ * @throws Exception On errors
+ */
+
+ @Test
+ public void testConfig1()
+ throws Exception
+ {
+ final var c =
+ QWebConfigurationXML.parse(
+ URI.create("urn:stdin"),
+ resource("conf-1.xml"),
+ BTPreserveLexical.DISCARD_LEXICAL_INFORMATION,
+ status -> {
+ }
+ );
+
+ assertEquals(20001, c.serverConfiguration().port());
+ assertTrue(c.serverConfiguration().enableGZIP());
+
+ {
+ final var r = c.responses().get(0);
+ assertEquals(200, r.statusCode());
+ assertEquals("GET", r.method().pattern());
+ assertEquals("/", r.path().pattern());
+ assertEquals(Map.ofEntries(
+ Map.entry("Content-Type", "application/octet-stream")
+ ), r.headers());
+ assertEquals(
+ new String(
+ resource("README-LICENSE.txt").readAllBytes(),
+ StandardCharsets.UTF_8
+ ),
+ new String(
+ r.content(),
+ StandardCharsets.UTF_8
+ )
+ );
+ }
+
+ assertEquals(1, c.responses().size());
+ }
+
+ /**
+ * Configuration parsing.
+ */
+
+ @TestFactory
+ public Stream testParseErrors()
+ {
+ return Stream.of(
+ "conf-error-0.xml",
+ "conf-error-1.xml"
+ ).map(QWebConfigurationXMLTest::testParseError);
+ }
+
+ private static DynamicTest testParseError(
+ final String name)
+ {
+ return DynamicTest.dynamicTest(
+ "testParseError_%s".formatted(name),
+ () -> {
+ Assertions.assertThrows(ParsingException.class, () -> {
+ QWebConfigurationXML.parse(
+ URI.create("urn:stdin"),
+ resource(name),
+ BTPreserveLexical.DISCARD_LEXICAL_INFORMATION,
+ status -> {
+
+ }
+ );
+ });
+ });
+ }
+
+ private static InputStream resource(
+ final String name)
+ throws Exception
+ {
+ final var path =
+ "/com/io7m/quixote/tests/%s".formatted(name);
+ final var url =
+ QWebConfigurationXMLTest.class.getResource(path);
+
+ return url.openStream();
+ }
+}
diff --git a/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebRequestLoggingTest.java b/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebRequestLoggingTest.java
new file mode 100644
index 0000000..62d52a2
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/java/com/io7m/quixote/tests/QWebRequestLoggingTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.tests;
+
+import com.io7m.quixote.core.QWebRequestLogging;
+import com.io7m.quixote.core.QWebRequestReceivedType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+public final class QWebRequestLoggingTest
+{
+ private ByteArrayOutputStream out;
+ private QWebRequestReceivedType request;
+
+ @BeforeEach
+ public void setup()
+ {
+ this.out =
+ new ByteArrayOutputStream();
+ this.request =
+ Mockito.mock(QWebRequestReceivedType.class);
+ }
+
+ @Test
+ public void testSimple()
+ throws IOException
+ {
+ when(this.request.method())
+ .thenReturn("POST");
+ when(this.request.path())
+ .thenReturn("/x/y/z");
+ when(this.request.headers())
+ .thenReturn(Map.of());
+ when(this.request.files())
+ .thenReturn(Map.of());
+
+ QWebRequestLogging.append(this.out, this.request);
+ QWebRequestLogging.append(this.out, this.request);
+ QWebRequestLogging.append(this.out, this.request);
+
+ final var in =
+ new ByteArrayInputStream(this.out.toByteArray());
+
+ {
+ final var r = QWebRequestLogging.read(in);
+ assertEquals("/x/y/z", r.path());
+ assertEquals("POST", r.method());
+ assertEquals(Map.of(), r.headers());
+ assertEquals(Map.of(), r.files());
+ }
+
+ {
+ final var r = QWebRequestLogging.read(in);
+ assertEquals("/x/y/z", r.path());
+ assertEquals("POST", r.method());
+ assertEquals(Map.of(), r.headers());
+ assertEquals(Map.of(), r.files());
+ }
+
+ {
+ final var r = QWebRequestLogging.read(in);
+ assertEquals("/x/y/z", r.path());
+ assertEquals("POST", r.method());
+ assertEquals(Map.of(), r.headers());
+ assertEquals(Map.of(), r.files());
+ }
+ }
+}
diff --git a/com.io7m.quixote.tests/src/main/java/module-info.java b/com.io7m.quixote.tests/src/main/java/module-info.java
index 39a0dfb..ff1b297 100644
--- a/com.io7m.quixote.tests/src/main/java/module-info.java
+++ b/com.io7m.quixote.tests/src/main/java/module-info.java
@@ -26,6 +26,12 @@
requires org.junit.platform.engine;
requires com.io7m.quixote.core;
+ requires com.io7m.quixote.xml;
+
+ requires org.mockito;
+ requires com.io7m.anethum.slf4j;
+ requires com.io7m.anethum.api;
+ requires com.io7m.blackthorne.core;
requires java.net.http;
exports com.io7m.quixote.tests;
diff --git a/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/README-LICENSE.txt b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/README-LICENSE.txt
new file mode 100644
index 0000000..8872798
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/README-LICENSE.txt
@@ -0,0 +1,13 @@
+Copyright © 2024 Mark Raynsford https://www.io7m.com
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-0.xml b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-0.xml
new file mode 100644
index 0000000..17786b4
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-0.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+ Q29weXJpZ2h0IMKpIDIwMjQgTWFyayBSYXluc2ZvcmQgPGNvZGVAaW83bS5jb20+IGh0dHBzOi8v
+ d3d3LmlvN20uY29tCgpQZXJtaXNzaW9uIHRvIHVzZSwgY29weSwgbW9kaWZ5LCBhbmQvb3IgZGlz
+ dHJpYnV0ZSB0aGlzIHNvZnR3YXJlIGZvciBhbnkKcHVycG9zZSB3aXRoIG9yIHdpdGhvdXQgZmVl
+ IGlzIGhlcmVieSBncmFudGVkLCBwcm92aWRlZCB0aGF0IHRoZSBhYm92ZQpjb3B5cmlnaHQgbm90
+ aWNlIGFuZCB0aGlzIHBlcm1pc3Npb24gbm90aWNlIGFwcGVhciBpbiBhbGwgY29waWVzLgoKVEhF
+ IFNPRlRXQVJFIElTIFBST1ZJREVEICJBUyBJUyIgQU5EIFRIRSBBVVRIT1IgRElTQ0xBSU1TIEFM
+ TCBXQVJSQU5USUVTCldJVEggUkVHQVJEIFRPIFRISVMgU09GVFdBUkUgSU5DTFVESU5HIEFMTCBJ
+ TVBMSUVEIFdBUlJBTlRJRVMgT0YKTUVSQ0hBTlRBQklMSVRZIEFORCBGSVRORVNTLiBJTiBOTyBF
+ VkVOVCBTSEFMTCBUSEUgQVVUSE9SIEJFIExJQUJMRSBGT1IKQU5ZIFNQRUNJQUwsIERJUkVDVCwg
+ SU5ESVJFQ1QsIE9SIENPTlNFUVVFTlRJQUwgREFNQUdFUyBPUiBBTlkgREFNQUdFUwpXSEFUU09F
+ VkVSIFJFU1VMVElORyBGUk9NIExPU1MgT0YgVVNFLCBEQVRBIE9SIFBST0ZJVFMsIFdIRVRIRVIg
+ SU4gQU4KQUNUSU9OIE9GIENPTlRSQUNULCBORUdMSUdFTkNFIE9SIE9USEVSIFRPUlRJT1VTIEFD
+ VElPTiwgQVJJU0lORyBPVVQgT0YKT1IgSU4gQ09OTkVDVElPTiBXSVRIIFRIRSBVU0UgT1IgUEVS
+ Rk9STUFOQ0UgT0YgVEhJUyBTT0ZUV0FSRS4K
+
+
+
+
diff --git a/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-1.xml b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-1.xml
new file mode 100644
index 0000000..d0a9741
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-1.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+ https://www.io7m.com
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+]]>
+
+
+
diff --git a/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-0.xml b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-0.xml
new file mode 100644
index 0000000..1d2af5f
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-0.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-1.xml b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-1.xml
new file mode 100644
index 0000000..600a624
--- /dev/null
+++ b/com.io7m.quixote.tests/src/main/resources/com/io7m/quixote/tests/conf-error-1.xml
@@ -0,0 +1 @@
+not even XML!
\ No newline at end of file
diff --git a/com.io7m.quixote.xml/pom.xml b/com.io7m.quixote.xml/pom.xml
new file mode 100644
index 0000000..1d79591
--- /dev/null
+++ b/com.io7m.quixote.xml/pom.xml
@@ -0,0 +1,61 @@
+
+
+
+
+ 4.0.0
+
+
+ com.io7m.quixote
+ com.io7m.quixote
+ 1.2.0-SNAPSHOT
+
+
+ com.io7m.quixote.xml
+
+ com.io7m.quixote.xml
+ Embedded test suite web server (XML)
+ https://www.io7m.com/software/quixote
+
+
+
+ ${project.groupId}
+ com.io7m.quixote.core
+ ${project.version}
+
+
+
+ com.io7m.jlexing
+ com.io7m.jlexing.core
+
+
+ com.io7m.blackthorne
+ com.io7m.blackthorne.core
+
+
+ com.io7m.blackthorne
+ com.io7m.blackthorne.jxe
+
+
+ com.io7m.jxe
+ com.io7m.jxe.core
+
+
+ com.io7m.anethum
+ com.io7m.anethum.api
+
+
+
+ org.osgi
+ org.osgi.annotation.bundle
+ provided
+
+
+ org.osgi
+ org.osgi.annotation.versioning
+ provided
+
+
+
+
diff --git a/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebConfigurationXML.java b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebConfigurationXML.java
new file mode 100644
index 0000000..c3754e3
--- /dev/null
+++ b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebConfigurationXML.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.xml;
+
+import com.io7m.anethum.api.ParseSeverity;
+import com.io7m.anethum.api.ParseStatus;
+import com.io7m.anethum.api.ParsingException;
+import com.io7m.anethum.api.SerializationException;
+import com.io7m.blackthorne.core.BTException;
+import com.io7m.blackthorne.core.BTParseError;
+import com.io7m.blackthorne.core.BTPreserveLexical;
+import com.io7m.blackthorne.jxe.BlackthorneJXE;
+import com.io7m.quixote.core.QWebConfiguration;
+import com.io7m.quixote.xml.v1.QWX1;
+import com.io7m.quixote.xml.v1.QWX1File;
+import com.io7m.quixote.xml.v1.QWX1Serializer;
+
+import javax.xml.stream.XMLStreamException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Functions to handle XML configurations.
+ */
+
+public final class QWebConfigurationXML
+{
+ private static final OpenOption[] OPEN_OPTIONS = {
+ StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING,
+ };
+
+ private QWebConfigurationXML()
+ {
+
+ }
+
+ /**
+ * Parse a configuration file.
+ *
+ * @param source The source
+ * @param stream The stream
+ * @param preserveLexical The lexical preservation flag
+ * @param statusConsumer The status consume
+ *
+ * @return A parsed configuration
+ *
+ * @throws ParsingException On errors
+ */
+
+ public static QWebConfiguration parse(
+ final URI source,
+ final InputStream stream,
+ final BTPreserveLexical preserveLexical,
+ final Consumer statusConsumer)
+ throws ParsingException
+ {
+ Objects.requireNonNull(source, "source");
+ Objects.requireNonNull(stream, "stream");
+ Objects.requireNonNull(preserveLexical, "preserveLexical");
+ Objects.requireNonNull(statusConsumer, "statusConsumer");
+
+ try {
+ return BlackthorneJXE.parse(
+ source,
+ stream,
+ Map.ofEntries(
+ Map.entry(
+ QWX1.element("Configuration"),
+ QWX1File::new
+ )
+ ),
+ QWebSchemas.schemas(),
+ preserveLexical
+ );
+ } catch (final BTException e) {
+ final var statuses =
+ e.errors()
+ .stream()
+ .map(QWebConfigurationXML::mapParseError)
+ .toList();
+
+ for (final var status : statuses) {
+ statusConsumer.accept(status);
+ }
+
+ throw new ParsingException(e.getMessage(), List.copyOf(statuses));
+ }
+ }
+
+ /**
+ * Serialize a configuration file.
+ *
+ * @param stream The output stream
+ * @param configuration The server configuration
+ *
+ * @throws SerializationException On errors
+ */
+
+ public static void serialize(
+ final OutputStream stream,
+ final QWebConfiguration configuration)
+ throws SerializationException
+ {
+ try {
+ new QWX1Serializer(stream).execute(configuration);
+ } catch (final XMLStreamException e) {
+ throw new SerializationException(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Serialize a configuration file.
+ *
+ * @param file The output file
+ * @param configuration The server configuration
+ *
+ * @throws SerializationException On errors
+ */
+
+ public static void serialize(
+ final Path file,
+ final QWebConfiguration configuration)
+ throws SerializationException
+ {
+ try (var output = Files.newOutputStream(file, OPEN_OPTIONS)) {
+ serialize(output, configuration);
+ output.flush();
+ } catch (final IOException e) {
+ throw new SerializationException(e.getMessage(), e);
+ }
+ }
+
+ private static ParseStatus mapParseError(
+ final BTParseError error)
+ {
+ return ParseStatus.builder("parse-error", error.message())
+ .withSeverity(mapSeverity(error.severity()))
+ .withLexical(error.lexical())
+ .build();
+ }
+
+ private static ParseSeverity mapSeverity(
+ final BTParseError.Severity severity)
+ {
+ return switch (severity) {
+ case ERROR -> ParseSeverity.PARSE_ERROR;
+ case WARNING -> ParseSeverity.PARSE_WARNING;
+ };
+ }
+}
diff --git a/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebSchemas.java b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebSchemas.java
new file mode 100644
index 0000000..3cb8f3d
--- /dev/null
+++ b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/QWebSchemas.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2023 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.xml;
+
+import com.io7m.jxe.core.JXESchemaDefinition;
+import com.io7m.jxe.core.JXESchemaResolutionMappings;
+
+import java.net.URI;
+
+/**
+ * Configuration XML schemas.
+ */
+
+public final class QWebSchemas
+{
+ private static final JXESchemaDefinition SCHEMA_1 =
+ JXESchemaDefinition.builder()
+ .setFileIdentifier("configuration-1.xsd")
+ .setLocation(QWebSchemas.class.getResource(
+ "/com/io7m/quixote/xml/configuration-1.xsd"))
+ .setNamespace(URI.create("urn:com.io7m.quixote:configuration:1"))
+ .build();
+
+ private static final JXESchemaResolutionMappings SCHEMA_MAPPINGS =
+ JXESchemaResolutionMappings.builder()
+ .putMappings(SCHEMA_1.namespace(), SCHEMA_1)
+ .build();
+
+ /**
+ * @return The v1 schema
+ */
+
+ public static JXESchemaDefinition schema1()
+ {
+ return SCHEMA_1;
+ }
+
+ /**
+ * @return The set of supported schemas.
+ */
+
+ public static JXESchemaResolutionMappings schemas()
+ {
+ return SCHEMA_MAPPINGS;
+ }
+
+ private QWebSchemas()
+ {
+
+ }
+}
diff --git a/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/package-info.java b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/package-info.java
new file mode 100644
index 0000000..aac8926
--- /dev/null
+++ b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/**
+ * Embedded test suite web server (XML)
+ */
+
+@Export
+@Version("1.0.0")
+package com.io7m.quixote.xml;
+
+import org.osgi.annotation.bundle.Export;
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1.java b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1.java
new file mode 100644
index 0000000..5c704de
--- /dev/null
+++ b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2023 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.xml.v1;
+
+import com.io7m.blackthorne.core.BTQualifiedName;
+import com.io7m.quixote.xml.QWebSchemas;
+
+/**
+ * Functions over v1 elements.
+ */
+
+public final class QWX1
+{
+ private QWX1()
+ {
+
+ }
+
+ /**
+ * The element with the given name.
+ *
+ * @param localName The local name
+ *
+ * @return The qualified name
+ */
+
+ public static BTQualifiedName element(
+ final String localName)
+ {
+ return BTQualifiedName.of(
+ QWebSchemas.schema1().namespace().toString(),
+ localName
+ );
+ }
+
+}
diff --git a/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1ContentBase64.java b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1ContentBase64.java
new file mode 100644
index 0000000..ee329c4
--- /dev/null
+++ b/com.io7m.quixote.xml/src/main/java/com/io7m/quixote/xml/v1/QWX1ContentBase64.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2024 Mark Raynsford https://www.io7m.com
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+ * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+package com.io7m.quixote.xml.v1;
+
+import com.io7m.blackthorne.core.BTElementHandlerType;
+import com.io7m.blackthorne.core.BTElementParsingContextType;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Base64;
+import java.util.regex.Pattern;
+
+/**
+ * XML element handler.
+ */
+
+public final class QWX1ContentBase64
+ implements BTElementHandlerType
+
+ com.io7m.blackthorne
+ com.io7m.blackthorne.core
+ 2.0.0
+
+
+ com.io7m.blackthorne
+ com.io7m.blackthorne.jxe
+ 2.0.0
+
+
+ com.io7m.jxe
+ com.io7m.jxe.core
+ 1.0.2
+
+
+ com.io7m.anethum
+ com.io7m.anethum.api
+ 1.1.0
+
+
+ com.io7m.anethum
+ com.io7m.anethum.slf4j
+ 1.1.0
+
+
+ com.io7m.jlexing
+ com.io7m.jlexing.core
+ 3.1.0
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+