diff --git a/README.md b/README.md index c1daf35..6be2672 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ client.verifyApiToken(); *Note that all exceptions the client throws are unchecked. For details, see [Handling Errors](#handling-errors).* +### Talking to Servers With and Without the v2 API + +The client works with servers that offer the v2 API, and with servers that do not, such as EventSourcingDB 1.2.0. Creating a client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client remembers the answer: from then on, it uses the v2 API if the server offers it, and the v1 API otherwise. If asking fails, e.g. because the server can not be reached, the first request fails, and the next one asks again. + +The v2 API reads with the HTTP method `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which carries a body, as `POST` does, but changes nothing. If a proxy, a load balancer, or a firewall sits in front of the database, it has to let `QUERY` through. The client does not fall back to `POST`. + ### Configuring the HTTP Client By default, the client creates an HTTP client of its own. If you need to configure what it does not, e.g. a shorter connect timeout, a proxy, or TLS, create a `java.net.http.HttpClient` yourself, and hand it over using `ClientOptions`: @@ -87,8 +93,6 @@ var client = new Client(baseUrl, apiToken, new ClientOptions() .withStallTimeout(Duration.ofSeconds(10))); ``` -*Note that [running EventQL queries](#running-eventql-queries) and [observing events](#observing-events) end with a `HeartbeatTimeoutException` instead, once their stream has started.* - ### Configuring the Serialization of Event Data The client serializes and deserializes the data of events with a JSON mapper of its own. To apply the configuration of your application instead, e.g. its modules or its naming strategy, hand over your `JsonMapper` using `ClientOptions`: @@ -221,7 +225,7 @@ try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { } ``` -Writing then ends with a `CancellationException`. If the thread is interrupted before the request is sent, no events are written. If it is interrupted later, the server may have written them nevertheless, so the exception does not tell whether they were written. +If the thread is interrupted before the client starts to send the request, no events are written, and writing ends with a `CancellationException`. If it is interrupted later, even while the client is still connecting to the server, the server may have written them nevertheless, so writing ends with an `OutcomeUnknownException`, whose cause is the `CancellationException`. For details, see [Knowing Whether a Write Was Stored](#knowing-whether-a-write-was-stored). ### Reading Events @@ -395,7 +399,7 @@ The query then ends with a `CancellationException`, so that an aborted query can #### Detecting a Stalled Connection -While a query runs, the server sends a heartbeat every second whenever there is no row to send. If neither a row nor a heartbeat arrives for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because a proxy keeps the connection open but no longer passes anything on, the query ends with a `HeartbeatTimeoutException`, and the client closes the connection: +While a query runs, the server sends a heartbeat every second whenever there is no row to send. If neither a row nor a heartbeat arrives for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because a proxy keeps the connection open but no longer passes anything on, the query ends with a `ConnectionStalledException`, and the client closes the connection: ```java try (var rows = client.runEventQlQuery( @@ -404,7 +408,7 @@ try (var rows = client.runEventQlQuery( rows.forEach(row -> { // ... }); -} catch (HeartbeatTimeoutException ex) { +} catch (ConnectionStalledException ex) { // ... } ``` @@ -428,6 +432,8 @@ try (var events = client.observeEvents( } ``` +Observing does not end on its own. If the server ends it nevertheless, which it does only after a failure it can not report within the stream, the stream ends with an `EventSourcingDbException`, so that you can observe again. + #### Observing From Subjects Recursively If you want to observe not only all the events of a subject, but also the events of all nested subjects, set the `recursive` option to `true`: @@ -506,7 +512,7 @@ Observing then ends with a `CancellationException`. Since observing does not end #### Detecting a Stalled Connection -While there are no new events, the server sends a heartbeat every second. If neither an event nor a heartbeat arrives for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because a proxy keeps the connection open but no longer passes anything on, observing ends with a `HeartbeatTimeoutException`, and the client closes the connection: +While there are no new events, the server sends a heartbeat every second. If neither an event nor a heartbeat arrives for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because a proxy keeps the connection open but no longer passes anything on, observing ends with a `ConnectionStalledException`, and the client closes the connection: ```java try (var events = client.observeEvents( @@ -516,7 +522,7 @@ try (var events = client.observeEvents( events.forEach(event -> { // ... }); -} catch (HeartbeatTimeoutException ex) { +} catch (ConnectionStalledException ex) { // ... } ``` @@ -549,24 +555,41 @@ client.registerEventSchema( ### Listing Subjects -To list all subjects, call the `readSubjects` method with `/` as the base subject. The method returns a `Stream`, which you should close once you are done: +To list subjects, call the `readSubjects` method with a subject and an options object. Set the `recursive` option to `true` to list the subject and all subjects nested in it. To list all subjects, provide `/` as the subject. The method returns a `Stream`, which you should close once you are done: ```java -try (var subjects = client.readSubjects("/")) { +try (var subjects = client.readSubjects( + "/", + new ReadSubjectsOptions(true) +)) { subjects.forEach(subject -> { // ... }); } ``` -If you only want to list subjects within a specific branch, provide the desired base subject instead: +If you only want to list subjects within a specific branch, provide the desired subject instead: ```java -try (var subjects = client.readSubjects("/books")) { +try (var subjects = client.readSubjects( + "/books", + new ReadSubjectsOptions(true) +)) { // ... } ``` +If you set the `recursive` option to `false`, the stream holds the subject alone, provided that there are events within it. This tells whether a subject exists: + +```java +try (var subjects = client.readSubjects( + "/books/42", + new ReadSubjectsOptions(false) +)) { + var exists = subjects.findAny().isPresent(); +} +``` + #### Aborting Listing If you need to abort listing, stop consuming the stream. However, this only works if there is currently a subject to consume. @@ -574,7 +597,10 @@ If you need to abort listing, stop consuming the stream. However, this only work To abort listing independently of that, close the stream from somewhere else: ```java -try (var subjects = client.readSubjects("/")) { +try (var subjects = client.readSubjects( + "/", + new ReadSubjectsOptions(true) +)) { // Somewhere else, close the stream, which will cause // reading to end. scheduler.schedule(subjects::close, 5, TimeUnit.SECONDS); @@ -599,6 +625,8 @@ try (var eventTypes = client.readEventTypes()) { } ``` +Each event type tells its name with `type`, whether events of the type were written with `hasEvents`, and the schema registered for it with `schema`, if there is one. An event type without events exists only because a schema is registered for it. + #### Aborting Listing If you need to abort listing, stop consuming the stream. However, this only works if there is currently an event type to consume. @@ -655,7 +683,7 @@ event.verifySignature(verificationKey); All exceptions the client throws are unchecked. Those that come from the client itself extend `EventSourcingDbException`. -If the server answers a request with an unexpected HTTP status code, the client throws a `DbApiException`. It contains the `statusCode` and the `reason` the server gives, so you can tell failures apart without parsing the error message: +If the server reports an error, the client throws a `DbApiException`. It contains the `code` of the error, together with its `description`, its `details`, and the HTTP `status`, so you can tell failures apart without parsing the error message. The code is the URL of the page in the documentation that explains the error, e.g. `https://docs.eventsourcingdb.io/errors/precondition-violated`. Compare it with the constants of `ErrorCode`: ```java try { @@ -668,13 +696,13 @@ try { ) ); } catch (DbApiException ex) { - switch (ex.statusCode()) { - case 409 -> { - // ex.reason() tells why, e.g. "state conflict: precondition failed". - } - case 401 -> { + switch (ex.code()) { + case ErrorCode.PRECONDITION_VIOLATED -> { // ... } + case ErrorCode.EVENT_SCHEMA_VIOLATED -> { + // ex.description() tells why, and ex.details() which event. + } default -> { // ... } @@ -682,7 +710,22 @@ try { } ``` -*Note that different failures may share a status code: a failed precondition and an event that does not match its schema are both answered with `409`, and only their reasons differ.* +The server reports an error in one of two ways. If it accepts a request and the request fails nevertheless, e.g. because a precondition does not hold, the status is `200`, and the code names the error. If it refuses a request, e.g. because the API token is invalid, the status is the status code it answered with, e.g. `401`, and the code is empty. + +A server without the v2 API reports every error with a status code, e.g. a precondition that does not hold with `409`. For those errors that clearly match an error of the v2 API, the client sets the code of the v2 API nevertheless, so that you can handle them alike, whichever API the server offers. + +If the server refuses a request because the client sent too many, the status is `429`, and `retryAfter` tells how long the server asks you to wait before you send the request again, if it says so. The client does not repeat the request on its own: + +```java +try { + client.ping(); +} catch (DbApiException ex) { + if (ex.status() == 429) { + var timeToWait = ex.retryAfter().orElse(Duration.ofSeconds(1)); + // ... + } +} +``` If the server is not an EventSourcingDB, e.g. because a proxy answers in its place, the client throws an `InvalidServerHeaderException`: @@ -696,10 +739,32 @@ try { If verifying an event fails, the client throws a `HashVerificationFailedException` or a `SignatureVerificationFailedException`, and if the hash of the event is of a version the client does not know, an `UnsupportedHashVersionException`. A signature that is not hexadecimal throws an `IllegalArgumentException` instead. For details, see [Verifying an Event's Hash](#verifying-an-events-hash) and [Verifying an Event's Signature](#verifying-an-events-signature). -If a request receives nothing from the server for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because the server can not be reached, or because a proxy keeps the connection open but no longer passes anything on, the client closes the connection, and the request ends with a `ConnectionStalledException`. Once observing events or running an EventQL query has started, it ends with a `HeartbeatTimeoutException` instead, as nothing arriving there means that not even a heartbeat arrived. +If a request receives nothing from the server for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because the server can not be reached, or because a proxy keeps the connection open but no longer passes anything on, the client closes the connection, and the request ends with a `ConnectionStalledException`. If a request is aborted, because its thread is interrupted, because its stream is closed from somewhere else, or because the client is closed, it ends with a `CancellationException`. +If the server ends a stream before it is complete, e.g. because of a failure it can not report within the stream, the stream ends with an `EventSourcingDbException`. This includes observing events, which never ends on its own. + +#### Knowing Whether a Write Was Stored + +The server never stops a write halfway, so a write whose answer is lost may have been stored nevertheless. If writing events or registering an event schema fails in a way that leaves open whether it was stored, the client throws an `OutcomeUnknownException`, whose cause tells what failed, e.g. a `ConnectionStalledException`, a broken connection, a `CancellationException` if the thread was interrupted once the client had started to send the request, or a `DbApiException` with status `500`. Find out whether the write was stored, e.g. by reading the events of the subject, before you write it again: + +```java +try { + client.writeEvents( + List.of( + // ... + ) + ); +} catch (OutcomeUnknownException ex) { + // The events may have been written, so check before writing them again. +} +``` + +A write that certainly was not stored throws the failure itself instead: an `EventSourcingDbException` if the client could not connect to the server at all, and a `DbApiException` if the server refused the write, e.g. because a precondition does not hold, or because the client sent too many requests. + +*Note that the networking property `jdk.httpclient.enableAllMethodRetry` of the JDK, set as a system property or in the `conf/net.properties` of the JDK, makes the JDK send a write again if the connection closes before the answer arrives, which can store the write twice. If it is set when a client is created, or when a write fails, the client can not tell a write that could not connect from one that was sent again, so from then on, it throws an `OutcomeUnknownException` for that, too.* + ### Using Testcontainers The test container lives in the `eventsourcingdb-testcontainers` artifact. Add it to the dependencies of your tests, e.g. with Gradle: @@ -884,3 +949,77 @@ class BookServiceTest { // ... } ``` + +## Upgrading From Version 1 + +Version 2 talks to servers with and without the v2 API, as described in [Talking to Servers With and Without the v2 API](#talking-to-servers-with-and-without-the-v2-api), and follows the v2 API in its own API. These are the changes that may require changes to your code. + +`DbApiException` describes an error with its `code`, its `description`, its `details`, and its `status`. `statusCode()` is now `status()`, and `reason()` is now `description()`: + +```java +// Before +if (ex.statusCode() == 409) { + var reason = ex.reason(); +} + +// After +if (ex.code().equals(ErrorCode.PRECONDITION_VIOLATED)) { + var description = ex.description(); +} +``` + +A server with the v2 API reports the errors of a request it accepted with the status `200` and a code, e.g. a precondition that does not hold, which a server without it reports with `409`. So tell errors apart by their `code` rather than by their `status`. For details, see [Handling Errors](#handling-errors). + +An error that the server reports within a stream is a `DbApiException` with the status `200`, instead of a plain `EventSourcingDbException`, which it extends. Code that catches `EventSourcingDbException` keeps working. + +`HeartbeatTimeoutException` is removed. Every stall ends with a `ConnectionStalledException`, also once observing events or running an EventQL query has started: + +```java +// Before +} catch (HeartbeatTimeoutException ex) { + +// After +} catch (ConnectionStalledException ex) { +``` + +Writing events and registering an event schema throw an `OutcomeUnknownException` if they fail in a way that leaves open whether the server stored the write, e.g. after a stall, a broken connection, or a `500`. Its cause is the failure that the methods threw before. A write whose thread is interrupted once the client has started to send the request throws it, too, even while the client is still connecting, with the `CancellationException` as its cause: + +```java +// Before +} catch (CancellationException ex) { + // The events may have been written. +} + +// After +} catch (OutcomeUnknownException ex) { + // The events may have been written. +} +``` + +For details, see [Knowing Whether a Write Was Stored](#knowing-whether-a-write-was-stored). + +`EventType` names the type `type`, and tells with `hasEvents` whether events of the type were written, which is the opposite of `isPhantom`: + +```java +// Before +var name = eventType.eventType(); +var isPhantom = eventType.isPhantom(); + +// After +var name = eventType.type(); +var hasEvents = eventType.hasEvents(); +``` + +`readSubjects` takes the subject and options, whose `recursive` you have to set. Set it to `true` to read what `readSubjects` read before: + +```java +// Before +client.readSubjects("/books"); + +// After +client.readSubjects("/books", new ReadSubjectsOptions(true)); +``` + +Observing events ends with an `EventSourcingDbException` if the server ends the stream without an error, which it does only after a failure it can not report within the stream. Before, observing ended as if it were complete. + +The v2 API reads with the HTTP method `QUERY`. If a proxy, a load balancer, or a firewall sits in front of the database, make sure that it lets `QUERY` through. diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index 4a35748..e9843d2 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -14,9 +14,12 @@ import java.util.Base64; import java.util.List; import java.util.function.Consumer; +import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; @@ -56,6 +59,16 @@ public final class Container extends GenericContainer { // it. Consumer starter = Container::startOnce; + // Waits until the database answers pings on the given port inside the + // container. It is a field only so that tests can replace it. + Function pingWait = + port -> Wait.forHttp("/api/v1/ping").forPort(port).withStartupTimeout(Duration.ofSeconds(10)); + + // Whether the container was running when the wait for pings failed. Once + // the wait fails, Testcontainers stops the container before it reports + // the failure, so this can only be told while the wait fails. + private volatile boolean wasUnreachableWhileRunning; + /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the * container, and the API token {@code secret}, and does not sign events. @@ -124,7 +137,7 @@ protected void configure() { String.valueOf(internalPort))); withExposedPorts(internalPort); - waitingFor(Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10))); + waitingFor(new NotingWait(pingWait.apply(internalPort))); if (signingKeyPair != null) { withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH); @@ -157,19 +170,19 @@ private static byte[] pem(PrivateKey privateKey) { @Override public void start() { for (var attempt = 1; ; attempt++) { + wasUnreachableWhileRunning = false; try { starter.accept(this); return; } catch (RuntimeException ex) { - // A container that keeps running although starting it failed + // A container that was running when its wait for pings failed // is one whose database can not be reached, so it is replaced // by a new one. Every other failure is thrown at once. Either // way, the container that failed to start is removed, so that // it is not left behind. - var isRunning = isRunning(); stop(); - if (!isRunning) { + if (!wasUnreachableWhileRunning) { throw ex; } if (attempt == MAX_START_ATTEMPTS) { @@ -187,6 +200,32 @@ void startOnce() { super.start(); } + // NotingWait waits as the given wait does, and notes whether the + // container was still running if that wait fails. + final class NotingWait implements WaitStrategy { + private final WaitStrategy wait; + + NotingWait(WaitStrategy wait) { + this.wait = wait; + } + + @Override + public void waitUntilReady(WaitStrategyTarget target) { + try { + wait.waitUntilReady(target); + } catch (RuntimeException ex) { + wasUnreachableWhileRunning = target.isRunning(); + throw ex; + } + } + + @Override + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + wait.withStartupTimeout(startupTimeout); + return this; + } + } + /** * {@return the host name to reach the container at} * diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 665b338..5d1e8c5 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -9,13 +9,17 @@ import io.thenativeweb.eventsourcingdb.ClientOptions; import io.thenativeweb.eventsourcingdb.EventCandidate; import java.net.URI; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import tools.jackson.databind.PropertyNamingStrategies; import tools.jackson.databind.json.JsonMapper; @@ -165,24 +169,58 @@ private static List existing(List containerIds) { .toList(); } + // A wait for pings that fails the given number of times while the + // container runs, as if its port were not reachable from the host, and + // then waits as the container does. It notes the containers it waited + // for, and the failures it threw. + private static final class UnreachableWait implements WaitStrategy { + private final WaitStrategy ping; + private final int failureCount; + final List containerIds = new ArrayList<>(); + final List failures = new ArrayList<>(); + + UnreachableWait(WaitStrategy ping, int failureCount) { + this.ping = ping; + this.failureCount = failureCount; + } + + @Override + public void waitUntilReady(WaitStrategyTarget target) { + containerIds.add(target.getContainerId()); + if (containerIds.size() > failureCount) { + ping.waitUntilReady(target); + return; + } + + assertTrue(target.isRunning()); + var failure = new ContainerLaunchException("the database did not become reachable"); + failures.add(failure); + throw failure; + } + + @Override + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + return this; + } + } + + // Docker Desktop sometimes does not make the port of a container + // reachable from the host. The container keeps running, but its wait + // fails, as it does here twice. Testcontainers stops a container whose + // wait fails before it reports the failure. @Test void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { - var containerIds = new ArrayList(); var container = new Container().withImageTag(ImageTag.get()); - - // Docker Desktop sometimes does not make the port of a container - // reachable from the host. The container keeps running, but starting - // it fails, as it does here twice. - container.starter = self -> { - self.startOnce(); - containerIds.add(self.getContainerId()); - if (containerIds.size() < 3) { - throw new ContainerLaunchException("the database did not become reachable"); - } + var wait = new AtomicReference(); + container.pingWait = port -> { + var unreachableWait = new UnreachableWait(new Container().pingWait.apply(port), 2); + wait.compareAndSet(null, unreachableWait); + return wait.get(); }; container.start(); try { + var containerIds = wait.get().containerIds; assertEquals(3, containerIds.size()); assertEquals(List.of(containerIds.get(2)), existing(containerIds)); container.getClient().ping(); @@ -193,29 +231,50 @@ void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { @Test void givesUpAfterThreeAttemptsAndSaysWhy() { - var containerIds = new ArrayList(); - var failures = new ArrayList(); var container = new Container().withImageTag(ImageTag.get()); - - container.starter = self -> { - self.startOnce(); - containerIds.add(self.getContainerId()); - var failure = new ContainerLaunchException("the database did not become reachable"); - failures.add(failure); - throw failure; - }; + var wait = new UnreachableWait(new Container().pingWait.apply(3000), 3); + container.pingWait = port -> wait; var exception = assertThrows(IllegalStateException.class, container::start); assertEquals( "failed to start container, the database did not become reachable in 3 attempts", exception.getMessage()); - assertEquals(failures.getLast(), exception.getCause()); - assertEquals(3, containerIds.size()); - assertEquals(List.of(), existing(containerIds)); + assertTrue(causesOf(exception).contains(wait.failures.getLast())); + assertEquals(3, wait.containerIds.size()); + assertEquals(List.of(), existing(wait.containerIds)); assertFalse(container.isRunning()); } + private static List causesOf(Throwable exception) { + var causes = new ArrayList(); + for (var cause = exception.getCause(); cause != null; cause = cause.getCause()) { + causes.add(cause); + } + return causes; + } + + // A startup timeout that is set on the container applies to its wait for + // pings. + @Test + void passesAStartupTimeoutOnToTheWaitForPings() { + var timeouts = new ArrayList(); + var container = new Container(); + var wait = container.new NotingWait(new WaitStrategy() { + @Override + public void waitUntilReady(WaitStrategyTarget target) {} + + @Override + public WaitStrategy withStartupTimeout(Duration startupTimeout) { + timeouts.add(startupTimeout); + return this; + } + }); + + assertEquals(wait, wait.withStartupTimeout(Duration.ofSeconds(30))); + assertEquals(List.of(Duration.ofSeconds(30)), timeouts); + } + @Test void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() { var containerIds = new ArrayList(); diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java index a31eb4f..676aaaa 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java @@ -1,17 +1,10 @@ package io.thenativeweb.eventsourcingdb; -import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -22,37 +15,37 @@ /** * A client for an EventSourcingDB instance. * - *

Every method that sends a request throws a {@link DbApiException} if the server answers with an unexpected HTTP - * status code, and an {@link InvalidServerHeaderException} if the server is not an EventSourcingDB. If a request fails - * on its way, e.g. because the server can not be reached, the method throws an {@link EventSourcingDbException}. If - * nothing arrives from the server for the stall timeout, 30 seconds by default, it throws a - * {@link ConnectionStalledException}; see {@link ClientOptions#withStallTimeout(Duration)}. + *

The client talks to every supported version of EventSourcingDB, whether it offers the v2 API or not. Creating a + * client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client + * uses the v2 API from then on if it does, and the v1 API otherwise. If asking fails, e.g. because the server can not + * be reached, that first request fails, and the next request asks again. The v2 API reads with the HTTP method + * {@code QUERY}, so a proxy in front of the database has to let it through. + * + *

Every method that sends a request throws a {@link DbApiException} if the server reports an error, e.g. a + * precondition that does not hold, and an {@link InvalidServerHeaderException} if the server is not an + * EventSourcingDB. If a request fails on its way, e.g. because the server can not be reached, the method throws an + * {@link EventSourcingDbException}. If nothing arrives from the server for the stall timeout, 30 seconds by default, it + * throws a {@link ConnectionStalledException}; see {@link ClientOptions#withStallTimeout(Duration)}. A write that fails + * in a way that leaves open whether it was stored throws an {@link OutcomeUnknownException}. * *

To abort a request, interrupt the thread that makes it. The request then ends with a - * {@link java.util.concurrent.CancellationException}. If the thread is interrupted before the request is sent, the - * request is not sent at all. + * {@link java.util.concurrent.CancellationException}. If the thread is interrupted before the client starts to send + * the request, the request is not sent at all. Once the client has started to send a write, even if it is still + * connecting to the server, the write ends with an {@link OutcomeUnknownException} instead, whose cause is the + * {@link java.util.concurrent.CancellationException}. * *

The methods that read a stream return a {@link Stream}, which sends its request only once you start to consume * it, and which holds a connection to the server until it ends. Close it once you are done, e.g. using a * {@code try}-with-resources statement. To abort a stream from somewhere else, e.g. from another thread, close it. It * then ends with a {@link java.util.concurrent.CancellationException}, so that an aborted read can be told apart from a - * complete one. + * complete one. A stream that the server ends before it is complete ends with an {@link EventSourcingDbException}. * *

A client is safe to use from multiple threads. */ public final class Client implements AutoCloseable { - private final URI baseUrl; - private final String apiToken; - private final Duration stallTimeout; - private final HttpClient httpClient; - private final boolean isHttpClientOwned; private final JsonMapper dataMapper; - - // What close() needs to end what the client is doing. - private final Object lock = new Object(); - private final Set openRequests = ConcurrentHashMap.newKeySet(); - private final Set> openStreams = ConcurrentHashMap.newKeySet(); - private volatile boolean isClosed; + private final Requests requests; + private final TransportChooser transports; /** * Creates a client for the EventSourcingDB instance at the given URL. @@ -72,26 +65,53 @@ public Client(URI baseUrl, String apiToken) { * @param options the options, e.g. the HTTP client to send requests with */ public Client(URI baseUrl, String apiToken, ClientOptions options) { - this.baseUrl = baseUrl; - this.apiToken = apiToken; - stallTimeout = options.stallTimeout(); + this(baseUrl, apiToken, options, WriteRetries.OF_THE_JDK); + } + // Lets tests tell whether the JDK sends writes again. + Client(URI baseUrl, String apiToken, ClientOptions options, WriteRetries writeRetries) { // The HTTP client of the client's own has no timeouts: the stall // timeout bounds every request, from connecting to the end of its // response. A timeout for whole requests would end every stream that // observes events, and a connect timeout would end a request sooner // than the stall timeout says. var givenHttpClient = options.httpClient(); - httpClient = givenHttpClient != null ? givenHttpClient : HttpClient.newHttpClient(); - isHttpClientOwned = givenHttpClient == null; + var httpClient = givenHttpClient != null ? givenHttpClient : HttpClient.newHttpClient(); + requests = new Requests( + baseUrl, apiToken, options.stallTimeout(), httpClient, givenHttpClient == null, writeRetries); + + // The JDK reads whether it sends writes again with the first request + // of any HTTP client, so the client reads it before it sends one, too, + // and keeps it, should it be cleared afterwards. + writeRetries.areEnabled(); var givenDataMapper = options.dataMapper(); dataMapper = givenDataMapper != null ? givenDataMapper : Json.MAPPER; + + transports = new TransportChooser( + requests, new V1Transport(requests, dataMapper), new V2Transport(requests, dataMapper)); } // Lets tests inspect the HTTP client that requests are sent with. HttpClient httpClient() { - return httpClient; + return requests.httpClient(); + } + + // Lets tests tell which API the client chose, if it has chosen one yet. + @Nullable + Transport chosenTransport() { + return transports.chosen(); + } + + // Learns which API the server offers, which only the first request of a + // client asks the server for. + private Transport transport(String action) { + Http.throwIfInterrupted(action); + return TransportChooser.await(action, transports.transport()); + } + + private Stream stream(String action, Function> specs) { + return LineStream.of(action, requests, transports, specs); } /** @@ -101,31 +121,17 @@ HttpClient httpClient() { * @throws EventSourcingDbException if the instance is not reachable, or does not confirm the ping */ public void ping() { - var request = - HttpRequest.newBuilder(baseUrl.resolve("/api/v1/ping")).GET().build(); - - var result = Json.MAPPER.readTree(request("ping", request)); - if (!"io.eventsourcingdb.api.ping-received".equals(result.path("type").asString())) { - throw new EventSourcingDbException("failed to ping"); - } + transport("ping").ping(); } /** * Verifies the API token. * - * @throws DbApiException with status code 401 if the API token is invalid + * @throws DbApiException with status 401 if the API token is invalid * @throws EventSourcingDbException if the instance does not confirm the API token */ public void verifyApiToken() { - var request = authorized("/api/v1/verify-api-token") - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); - - var result = Json.MAPPER.readTree(request("verify API token", request)); - if (!"io.eventsourcingdb.api.api-token-verified" - .equals(result.path("type").asString())) { - throw new EventSourcingDbException("failed to verify API token"); - } + transport("verify API token").verifyApiToken(); } /** @@ -141,14 +147,17 @@ public List writeEvents(List events) { /** * Writes the given events, provided that all preconditions hold. The events are written all or nothing. * - *

If the thread is interrupted after the request was sent, the server may have written the events nevertheless, - * so the {@link java.util.concurrent.CancellationException} does not tell whether they were written. + *

If writing fails once the client has started to send the request, e.g. because the connection stalls, or + * because the thread is interrupted, even while the client is still connecting to the server, the server may have + * written the events nevertheless, so writing ends with an {@link OutcomeUnknownException}, whose cause tells what + * failed. * * @param events the events to write * @param preconditions the preconditions that must hold, e.g. an {@link IsSubjectPristinePrecondition} * @return the written events, including the fields the server adds, such as their IDs - * @throws DbApiException with status code 409 if a precondition does not hold, or if an event does not match the - * schema of its type, in which case the reason tells which + * @throws DbApiException with the code {@link ErrorCode#PRECONDITION_VIOLATED} if a precondition does not hold, or + * {@link ErrorCode#EVENT_SCHEMA_VIOLATED} if an event does not match the schema of its type + * @throws OutcomeUnknownException if writing failed in a way that leaves open whether the events were written */ public List writeEvents(List events, List preconditions) { var body = Json.MAPPER.createObjectNode(); @@ -176,11 +185,7 @@ public List writeEvents(List events, List p } } - var response = request("write events", post("/api/v1/write-events", body)); - - return RawJson.readArray(response, "data").stream() - .map(cloudEvent -> Event.from(cloudEvent.tree(), cloudEvent.raw(), dataMapper)) - .toList(); + return transport("write events").writeEvents(body); } private static Map toRequest(Precondition precondition) { @@ -233,7 +238,7 @@ public Stream readEvents(String subject, ReadEventsOptions options) { toRequest(fromLatestEvent.subject(), fromLatestEvent.type(), fromLatestEvent.ifEventIsMissing())); } - return stream("read events", post("/api/v1/read-events", body), "event", this::parseEvent, false); + return stream("read events", transport -> transport.readEvents(body)); } /** @@ -243,9 +248,12 @@ public Stream readEvents(String subject, ReadEventsOptions options) { * *

While there are no new events, the server sends a heartbeat every second. If neither an event nor a heartbeat * arrives for the stall timeout, 30 seconds by default, e.g. because a proxy keeps the connection open but no longer - * passes anything on, the stream ends with a {@link HeartbeatTimeoutException}, and the client closes the + * passes anything on, the stream ends with a {@link ConnectionStalledException}, and the client closes the * connection. The time you spend on an event does not count. * + *

The server ends the stream itself only after a failure it can not report within the stream, so the stream then + * ends with an {@link EventSourcingDbException}. + * * @param subject the subject to observe the events of, e.g. {@code /books/42}, or {@code /} to observe all events, if * the options make observing recursive * @param options the options, e.g. whether to observe the events of nested subjects, too @@ -267,7 +275,7 @@ public Stream observeEvents(String subject, ObserveEventsOptions options) toRequest(fromLatestEvent.subject(), fromLatestEvent.type(), fromLatestEvent.ifEventIsMissing())); } - return stream("observe events", post("/api/v1/observe-events", body), "event", this::parseEvent, true); + return stream("observe events", transport -> transport.observeEvents(body)); } private static ObjectNode toRequest(Bound bound) { @@ -290,14 +298,14 @@ private static ObjectNode toRequest(String subject, String type, Enum ifEvent * *

While the query runs, the server sends a heartbeat every second whenever there is no row to send. If neither a * row nor a heartbeat arrives for the stall timeout, 30 seconds by default, the stream ends with a - * {@link HeartbeatTimeoutException}, and the client closes the connection. The time you spend on a row does not + * {@link ConnectionStalledException}, and the client closes the connection. The time you spend on a row does not * count. * * @param query the query, e.g. {@code FROM e IN events PROJECT INTO e} * @return a stream of the rows, each of which matches the projection of the query */ public Stream runEventQlQuery(String query) { - return runEventQlQuery(query, (payload, rawData) -> payload); + return runEventQlQuery(query, row -> row); } /** @@ -313,33 +321,27 @@ public Stream runEventQlQuery(String query) { * @return a stream of the rows */ public Stream runEventQlQuery(String query, Class rowType) { - return runEventQlQuery(query, (payload, rawData) -> dataMapper.treeToValue(payload, rowType)); + return runEventQlQuery(query, row -> dataMapper.treeToValue(row, rowType)); } - private Stream runEventQlQuery(String query, LineStream.Parser parser) { - var body = Json.MAPPER.createObjectNode(); - body.put("query", query); - - return stream("run EventQL query", post("/api/v1/run-eventql-query", body), "row", parser, true); + private Stream runEventQlQuery(String query, Function rowParser) { + return stream("run EventQL query", transport -> transport.runEventQlQuery(query, rowParser)); } /** - * Lists the subjects within the given base subject. The list includes the base subject itself, unless there are no - * events within it. + * Reads the subjects, i.e. the subjects that events were written to, and the subjects they are nested in. + * + *

Read recursively, the stream holds the subject itself, and every subject nested in it, e.g. {@code /books} and + * {@code /books/42} for {@code /books}. Read not recursively, it holds the subject alone. Either way, it holds a + * subject only if there are events within it, so it is empty for a subject that has no events, and none nested. * - * @param baseSubject the subject to list the subjects within, e.g. {@code /} for all subjects + * @param subject the subject to read, e.g. {@code /books}, or {@code /} to read all subjects, if the options make + * the read recursive + * @param options the options, i.e. whether to read the subjects nested in the subject, too * @return a stream of the subjects */ - public Stream readSubjects(String baseSubject) { - var body = Json.MAPPER.createObjectNode(); - body.put("baseSubject", baseSubject); - - return stream( - "read subjects", - post("/api/v1/read-subjects", body), - "subject", - (payload, rawData) -> payload.path("subject").asString(), - false); + public Stream readSubjects(String subject, ReadSubjectsOptions options) { + return stream("read subjects", transport -> transport.readSubjects(subject, options.recursive())); } /** @@ -349,12 +351,7 @@ public Stream readSubjects(String baseSubject) { * @return a stream of the event types */ public Stream readEventTypes() { - return stream( - "read event types", - post("/api/v1/read-event-types", Json.MAPPER.createObjectNode()), - "eventType", - (payload, rawData) -> EventType.from(payload), - false); + return stream("read event types", Transport::readEventTypes); } /** @@ -362,14 +359,10 @@ public Stream readEventTypes() { * * @param eventType the event type, e.g. {@code io.eventsourcingdb.library.book-acquired} * @return the event type - * @throws DbApiException with status code 404 if the event type does not exist, or 400 if it is malformed + * @throws DbApiException with the code {@link ErrorCode#EVENT_TYPE_UNKNOWN} if the event type does not exist */ public EventType readEventType(String eventType) { - var body = Json.MAPPER.createObjectNode(); - body.put("eventType", eventType); - - var response = request("read event type", post("/api/v1/read-event-type", body)); - return EventType.from(Json.MAPPER.readTree(response)); + return transport("read event type").readEventType(eventType); } /** @@ -378,92 +371,21 @@ public EventType readEventType(String eventType) { * * @param eventType the event type, e.g. {@code io.eventsourcingdb.library.book-acquired} * @param schema the JSON schema, e.g. as nested maps and lists - * @throws DbApiException with status code 409 if a schema is already registered for the event type + * @throws DbApiException with the code {@link ErrorCode#EVENT_SCHEMA_ALREADY_REGISTERED} if a schema is already + * registered for the event type + * @throws OutcomeUnknownException if registering failed in a way that leaves open whether the schema was + * registered, e.g. because the connection stalled after the request was sent */ public void registerEventSchema(String eventType, Map schema) { - var body = Json.MAPPER.createObjectNode(); - body.put("eventType", eventType); - body.set("schema", Json.MAPPER.valueToTree(schema)); - - request("register event schema", post("/api/v1/register-event-schema", body)); - } - - private HttpRequest post(String path, JsonNode body) { - return authorized(path) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(Json.MAPPER.writeValueAsString(body))) - .build(); - } - - private HttpRequest.Builder authorized(String path) { - return HttpRequest.newBuilder(baseUrl.resolve(path)).header("Authorization", "Bearer " + apiToken); - } - - // Sends a request and returns the body of its response. The request is - // sent under the same lock under which close() marks the client as - // closed, so that close() either sees the request and ends it, or the - // request sees that the client is closed and is not sent at all. - private String request(String action, HttpRequest request) { - Http.throwIfInterrupted(action); - - CompletableFuture> response; - Runnable abortion; - synchronized (lock) { - throwIfClosed(); - - response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - abortion = () -> Http.abort(response); - openRequests.add(abortion); - } - - try { - return Http.readBody(action, Http.await(action, response, stallTimeout), stallTimeout); - } catch (RuntimeException ex) { - if (isClosed) { - throw Http.cancellation(action, ex); - } - throw ex; - } finally { - openRequests.remove(abortion); - } - } - - private void throwIfClosed() { - if (isClosed) { - throw new IllegalStateException("client is closed"); - } - } - - private Stream stream( - String action, HttpRequest request, String itemType, LineStream.Parser parser, boolean hasHeartbeats) { - // On a stream with heartbeats, a stall means that the heartbeats - // stopped, which HeartbeatTimeoutException reports. - Function stall = hasHeartbeats - ? ex -> new HeartbeatTimeoutException(stallTimeout) - : ex -> new ConnectionStalledException(action, stallTimeout, ex); - - synchronized (lock) { - throwIfClosed(); - - return LineStream.of( - action, - () -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()), - itemType, - parser, - stallTimeout, - stall, - openStreams); - } - } - - private Event parseEvent(JsonNode payload, @Nullable String rawData) { - return Event.from(payload, rawData, dataMapper); + var schemaNode = Json.MAPPER.valueToTree(schema); + transport("register event schema").registerEventSchema(eventType, schemaNode); } /** * Closes the client, and ends everything it is doing at once: requests that are running and streams that are open, * whether they have started or not, end with a {@link java.util.concurrent.CancellationException}, as if they had - * been aborted. Every request afterwards throws an {@link IllegalStateException} without being sent. + * been aborted, and so does a write that was sent, as the cause of an {@link OutcomeUnknownException}. Every + * request afterwards throws an {@link IllegalStateException} without being sent. * *

The client closes the HTTP client it created itself, but not one that was given to it with * {@link ClientOptions#withHttpClient(HttpClient)}, since that one belongs to the caller. Closing a client that is @@ -471,18 +393,6 @@ private Event parseEvent(JsonNode payload, @Nullable String rawData) { */ @Override public void close() { - synchronized (lock) { - if (isClosed) { - return; - } - isClosed = true; - } - - openRequests.forEach(Runnable::run); - openStreams.forEach(LineStream::close); - - if (isHttpClientOwned) { - httpClient.shutdownNow(); - } + requests.close(); } } diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ClientOptions.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ClientOptions.java index 35c9e30..2eabe21 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ClientOptions.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ClientOptions.java @@ -94,8 +94,7 @@ public ClientOptions withDataMapper(JsonMapper dataMapper) { * starts over with everything that arrives, be it a byte, an item, or a heartbeat. It is not a limit for the whole * request, so a long stream that keeps receiving items or heartbeats is never cut off, and the time you spend on an * item does not count. It applies to every request. A request that stalls ends with a - * {@link ConnectionStalledException}, except for observing events and running EventQL queries, which end with a - * {@link HeartbeatTimeoutException} once their stream has started. + * {@link ConnectionStalledException}. * * @param stallTimeout how long a request may receive nothing from the server before it fails, 30 seconds by * default diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/CodeMapping.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/CodeMapping.java new file mode 100644 index 0000000..d4d86cf --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/CodeMapping.java @@ -0,0 +1,13 @@ +package io.thenativeweb.eventsourcingdb; + +// CodeMapping tells which code an error that the server reports with a status +// code other than 200 has. The v2 API gives such errors no code, since it +// reports the errors that have one in its answers. The v1 API reports every +// error with a status code, and some of them match an error of the v2 API, so +// that its requests map those to the code of the v2 API. +@FunctionalInterface +interface CodeMapping { + CodeMapping NONE = (status, description) -> ""; + + String codeOf(int status, String description); +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ConnectionStalledException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ConnectionStalledException.java index 4602f57..0ab74a1 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ConnectionStalledException.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ConnectionStalledException.java @@ -13,9 +13,8 @@ * that the JDK uses for a response that does not arrive in time. If the response stopped in the middle, the cause is * the {@link java.io.IOException} that ended reading it. * - *

Once the stream of {@link Client#observeEvents(String, ObserveEventsOptions)} or - * {@link Client#runEventQlQuery(String)} has started, a stall ends it with a {@link HeartbeatTimeoutException} - * instead. + *

While there is nothing else to send, the server sends a heartbeat every second on its streams, which counts as + * something that arrived, so a stream that waits for events, e.g. one that observes them, is not cut off. */ public final class ConnectionStalledException extends EventSourcingDbException { private static final long serialVersionUID = 1L; diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/DbApiException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/DbApiException.java index 68c36bb..e452349 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/DbApiException.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/DbApiException.java @@ -4,56 +4,129 @@ import java.io.IOException; import java.io.InputStream; +import java.net.http.HttpHeaders; +import java.time.Duration; +import java.util.Optional; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; /** - * Reports that the server answered a request with an unexpected HTTP status code. Every method of the client that - * sends a request throws it in that case, so that you can tell failures apart by {@link #statusCode()} and - * {@link #reason()} instead of parsing the message. + * Reports an error that the server reported. Every method of the client that sends a request throws it in that case, + * so that you can tell failures apart by {@link #code()} instead of parsing the message. * - *

Different failures may share a status code: a failed precondition and an event that does not match its schema, - * for example, are both answered with 409, and only their reasons differ. + *

The server reports an error in one of two ways. It either refuses the request with an HTTP status code other than + * 200, e.g. 401 for an invalid API token, in which case {@link #status()} is that status code, and {@link #code()} is + * empty, unless the error is one of those listed below. Or it accepts the request and reports an error in its answer, + * e.g. a precondition that does not hold, in which case {@link #status()} is 200, and {@link #code()} names the error, + * e.g. {@link ErrorCode#PRECONDITION_VIOLATED}. + * + *

A server that does not offer the v2 API reports every error with a status code. For those errors that clearly + * match an error of the v2 API, the client sets the code of the v2 API nevertheless, e.g. + * {@link ErrorCode#PRECONDITION_VIOLATED} for a write whose precondition does not hold, so that you can handle them + * alike, whichever API the server offers. The status code stays the one the server answered with, e.g. 409. */ public final class DbApiException extends EventSourcingDbException { private static final long serialVersionUID = 1L; - private static final int MAX_REASON_LENGTH = 4096; + private static final int MAX_DESCRIPTION_LENGTH = 4096; + + private static final int TOO_MANY_REQUESTS = 429; + + // The server sends Retry-After as a number of seconds. A date, which HTTP + // permits as well, is not one it sends, so it is ignored. + private static final Pattern SECONDS_PATTERN = Pattern.compile("[0-9]{1,18}"); /** What the client tried to do. */ private final String action; /** The HTTP status code the server answered with. */ - private final int statusCode; - /** The reason the server gave in the response body. */ - private final String reason; + private final int status; + /** The code of the error, or an empty string. */ + private final String code; + /** The description of the error. */ + private final String description; + /** The details of the error. */ + private final ObjectNode details; + /** How long the server asks to wait before sending the request again, or {@code null}. */ + private final @Nullable Duration retryAfter; - private DbApiException(String action, int statusCode, String reason, @Nullable IOException readFailure) { - super(message(action, statusCode, reason, readFailure), readFailure); + private DbApiException( + String message, + String action, + int status, + String code, + String description, + ObjectNode details, + @Nullable Duration retryAfter, + @Nullable IOException readFailure) { + super(message, readFailure); this.action = action; - this.statusCode = statusCode; - this.reason = reason; + this.status = status; + this.code = code; + this.description = description; + this.details = details; + this.retryAfter = retryAfter; } - static DbApiException of(String action, int statusCode, InputStream body) { + // Reports a request that the server refused with a status code other than + // 200. The body of the response is the description, and codes tells which + // code, if any, the error has. + static DbApiException ofStatus( + String action, int status, HttpHeaders headers, InputStream body, CodeMapping codes) { + var retryAfter = status == TOO_MANY_REQUESTS ? retryAfter(headers) : null; + + String description; try (body) { - var reason = new String(body.readNBytes(MAX_REASON_LENGTH), UTF_8).strip(); - return new DbApiException(action, statusCode, reason, null); + description = new String(body.readNBytes(MAX_DESCRIPTION_LENGTH), UTF_8).strip(); } catch (IOException ex) { - return new DbApiException(action, statusCode, "", ex); + return new DbApiException( + statusMessage(action, status) + ", and failed to read the reason: " + ex.getMessage(), + action, + status, + "", + "", + Json.MAPPER.createObjectNode(), + retryAfter, + ex); } - } - private static String message(String action, int statusCode, String reason, @Nullable IOException readFailure) { - var message = "failed to %s, got HTTP status code '%d', expected '200'".formatted(action, statusCode); + var message = statusMessage(action, status) + (description.isEmpty() ? "" : ": " + description); + return new DbApiException( + message, + action, + status, + codes.codeOf(status, description), + description, + Json.MAPPER.createObjectNode(), + retryAfter, + null); + } - if (readFailure != null) { - return message + ", and failed to read the reason: " + readFailure.getMessage(); - } + // Reports an error that the server reported in its answer to a request it + // accepted, i.e. with status code 200. + static DbApiException ofAnswer(String action, String code, String description, JsonNode details) { + return new DbApiException( + "failed to " + action + ", got error: " + description, + action, + 200, + code, + description, + details instanceof ObjectNode objectNode ? objectNode : Json.MAPPER.createObjectNode(), + null, + null); + } - if (reason.isEmpty()) { - return message; - } + private static String statusMessage(String action, int status) { + return "failed to %s, got HTTP status code '%d', expected '200'".formatted(action, status); + } - return message + ": " + reason; + private static @Nullable Duration retryAfter(HttpHeaders headers) { + return headers.firstValue("Retry-After") + .map(String::strip) + .filter(seconds -> SECONDS_PATTERN.matcher(seconds).matches()) + .map(seconds -> Duration.ofSeconds(Long.parseLong(seconds))) + .orElse(null); } /** {@return what the client tried to do, e.g. {@code write events}} */ @@ -61,18 +134,56 @@ public String action() { return action; } - /** {@return the HTTP status code the server answered with} */ - public int statusCode() { - return statusCode; + /** + * {@return the HTTP status code the server answered with} + * + *

It is 200 for an error that the server reported in its answer to a request it accepted. + */ + public int status() { + return status; + } + + /** + * {@return the code of the error, exactly as the server sent it, or an empty string if the error has none} + * + *

The code is the URL of the page in the documentation that explains the error, e.g. + * {@code https://docs.eventsourcingdb.io/errors/precondition-violated}. Compare it with the constants of + * {@link ErrorCode}. + */ + public String code() { + return code; + } + + /** + * {@return the description of the error, as the server gave it} + * + *

For an error with a status code other than 200, it is the body of the response, stripped of leading and + * trailing white space, and cut to at most 4096 bytes. It is empty if the server gave none, or if reading it + * failed, in which case {@link #getCause()} tells why. + */ + public String description() { + return description; + } + + /** + * {@return the details of the error, as the server sent them} + * + *

They locate the error and echo the value it is about, e.g. which event of a write does not match its schema. + * They are an empty object for an error that the server reported without details, e.g. one with a status code + * other than 200. + */ + public JsonNode details() { + return details; } /** - * {@return the reason the server gave in the response body} + * {@return how long the server asks to wait before sending the request again} * - *

It is stripped of leading and trailing white space, and cut to at most 4096 bytes. It is empty if the server gave - * none, or if reading it failed, in which case {@link #getCause()} tells why. + *

It is only present if the server refused the request with status code 429, because the client sent too many + * requests, and gave the time to wait in its {@code Retry-After} header. The client never repeats a request on its + * own. */ - public String reason() { - return reason; + public Optional retryAfter() { + return Optional.ofNullable(retryAfter); } } diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Envelope.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Envelope.java new file mode 100644 index 0000000..021a388 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Envelope.java @@ -0,0 +1,61 @@ +package io.thenativeweb.eventsourcingdb; + +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.MissingNode; + +// Envelope tells how an API wraps the items it sends: each item names its +// type, and carries its payload under a key, which the v1 API calls payload +// and the v2 API data. An item of the type error reports an error, which the +// two APIs describe differently. +record Envelope(String payloadKey, ErrorItem errorItem) { + static final Envelope V1 = new Envelope( + "payload", + (action, payload) -> DbApiException.ofAnswer( + action, "", payload.path("error").asString(), Json.MAPPER.createObjectNode())); + + static final Envelope V2 = new Envelope( + "data", + (action, payload) -> DbApiException.ofAnswer( + action, + payload.path("code").asString(), + payload.path("description").asString(), + payload.path("details"))); + + @FunctionalInterface + interface ErrorItem { + DbApiException toException(String action, JsonNode payload); + } + + // An item: its type, its payload, and the text of the data within its + // payload as the server wrote it, if there is any. + record Item(String type, JsonNode payload, @Nullable String rawData) {} + + // Reads an item in a single pass, since that is what most of the time of + // reading a stream goes into. + Item read(String json) { + try (var parser = Json.MAPPER.createParser(json)) { + var type = ""; + JsonNode payload = MissingNode.getInstance(); + String rawData = null; + + if (parser.nextToken() == JsonToken.START_OBJECT) { + while (parser.nextToken() == JsonToken.PROPERTY_NAME) { + var name = parser.currentName(); + parser.nextToken(); + + var value = RawJson.readValue(parser, json, "data"); + if (name.equals("type")) { + type = value.tree().asString(); + } else if (name.equals(payloadKey)) { + payload = value.tree(); + rawData = value.raw(); + } + } + } + + return new Item(type, payload, rawData); + } + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java new file mode 100644 index 0000000..c49f577 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java @@ -0,0 +1,221 @@ +package io.thenativeweb.eventsourcingdb; + +/** + * The codes of the errors that EventSourcingDB reports, as {@link DbApiException#code()} returns them. Each code is the + * URL of the page in the documentation of EventSourcingDB that explains the error, e.g. + * {@code https://docs.eventsourcingdb.io/errors/precondition-violated}, so compare it with these constants rather than + * with a text of your own: + * + *

{@code
+ * switch (ex.code()) {
+ *     case ErrorCode.PRECONDITION_VIOLATED -> {
+ *         // ...
+ *     }
+ *     default -> {
+ *         // ...
+ *     }
+ * }
+ * }
+ * + *

The server may report codes that a newer release of EventSourcingDB introduces, for which there is no constant + * yet. + */ +public final class ErrorCode { + // A code is this URL plus the name of the error, so every code links to + // the documentation of its error. + private static final String BASE_URL = "https://docs.eventsourcingdb.io/errors/"; + + /** The code {@code subject-missing}: a request leaves out the subject. */ + public static final String SUBJECT_MISSING = BASE_URL + "subject-missing"; + + /** The code {@code subject-malformed}: a subject is malformed. */ + public static final String SUBJECT_MALFORMED = BASE_URL + "subject-malformed"; + + /** The code {@code order-unknown}: the order of a read is neither chronological nor antichronological. */ + public static final String ORDER_UNKNOWN = BASE_URL + "order-unknown"; + + /** The code {@code event-id-missing}: a request leaves out the ID of an event. */ + public static final String EVENT_ID_MISSING = BASE_URL + "event-id-missing"; + + /** The code {@code event-id-malformed}: the ID of an event is malformed. */ + public static final String EVENT_ID_MALFORMED = BASE_URL + "event-id-malformed"; + + /** The code {@code bound-type-missing}: a bound leaves out its type. */ + public static final String BOUND_TYPE_MISSING = BASE_URL + "bound-type-missing"; + + /** The code {@code bound-type-unknown}: the type of a bound is neither inclusive nor exclusive. */ + public static final String BOUND_TYPE_UNKNOWN = BASE_URL + "bound-type-unknown"; + + /** The code {@code range-unsatisfiable}: the bounds of a read select no range at all. */ + public static final String RANGE_UNSATISFIABLE = BASE_URL + "range-unsatisfiable"; + + /** The code {@code from-latest-event-incomplete}: a {@code fromLatestEvent} leaves out one of its fields. */ + public static final String FROM_LATEST_EVENT_INCOMPLETE = BASE_URL + "from-latest-event-incomplete"; + + /** The code {@code if-event-is-missing-unknown}: the {@code ifEventIsMissing} of a {@code fromLatestEvent} is unknown. */ + public static final String IF_EVENT_IS_MISSING_UNKNOWN = BASE_URL + "if-event-is-missing-unknown"; + + /** The code {@code selection-conflicting}: a read combines options that exclude each other, e.g. a lower bound and a {@code fromLatestEvent}. */ + public static final String SELECTION_CONFLICTING = BASE_URL + "selection-conflicting"; + + /** The code {@code event-type-missing}: a request leaves out the event type. */ + public static final String EVENT_TYPE_MISSING = BASE_URL + "event-type-missing"; + + /** The code {@code event-type-malformed}: an event type is malformed. */ + public static final String EVENT_TYPE_MALFORMED = BASE_URL + "event-type-malformed"; + + /** The code {@code event-type-unknown}: an event type has neither events nor a registered schema. */ + public static final String EVENT_TYPE_UNKNOWN = BASE_URL + "event-type-unknown"; + + /** The code {@code source-missing}: an event leaves out its source. */ + public static final String SOURCE_MISSING = BASE_URL + "source-missing"; + + /** The code {@code source-malformed}: the source of an event is malformed. */ + public static final String SOURCE_MALFORMED = BASE_URL + "source-malformed"; + + /** The code {@code data-missing}: an event leaves out its data. */ + public static final String DATA_MISSING = BASE_URL + "data-missing"; + + /** The code {@code traceparent-malformed}: the {@code traceparent} of an event is malformed. */ + public static final String TRACEPARENT_MALFORMED = BASE_URL + "traceparent-malformed"; + + /** The code {@code tracestate-malformed}: the {@code tracestate} of an event is malformed. */ + public static final String TRACESTATE_MALFORMED = BASE_URL + "tracestate-malformed"; + + /** The code {@code tracestate-without-traceparent}: an event has a {@code tracestate}, but no {@code traceparent}. */ + public static final String TRACESTATE_WITHOUT_TRACEPARENT = BASE_URL + "tracestate-without-traceparent"; + + /** The code {@code events-missing}: a write leaves out the events. */ + public static final String EVENTS_MISSING = BASE_URL + "events-missing"; + + /** The code {@code precondition-type-missing}: a precondition leaves out its type. */ + public static final String PRECONDITION_TYPE_MISSING = BASE_URL + "precondition-type-missing"; + + /** The code {@code precondition-unknown}: the type of a precondition is unknown. */ + public static final String PRECONDITION_UNKNOWN = BASE_URL + "precondition-unknown"; + + /** The code {@code payload-missing}: a precondition leaves out its payload. */ + public static final String PAYLOAD_MISSING = BASE_URL + "payload-missing"; + + /** The code {@code license-invalid}: the license is invalid, e.g. because it has expired, so events can not be written. */ + public static final String LICENSE_INVALID = BASE_URL + "license-invalid"; + + /** The code {@code capacity-exceeded}: a write would exceed the capacity of the database. */ + public static final String CAPACITY_EXCEEDED = BASE_URL + "capacity-exceeded"; + + /** The code {@code event-schema-violated}: an event does not match the schema of its type. */ + public static final String EVENT_SCHEMA_VIOLATED = BASE_URL + "event-schema-violated"; + + /** The code {@code event-schema-unregistered}: an event type requires a schema, but none is registered. */ + public static final String EVENT_SCHEMA_UNREGISTERED = BASE_URL + "event-schema-unregistered"; + + /** The code {@code event-schema-missing}: a registration leaves out the schema. */ + public static final String EVENT_SCHEMA_MISSING = BASE_URL + "event-schema-missing"; + + /** The code {@code event-schema-malformed}: a schema is malformed. */ + public static final String EVENT_SCHEMA_MALFORMED = BASE_URL + "event-schema-malformed"; + + /** The code {@code event-schema-not-object}: a schema does not describe an object. */ + public static final String EVENT_SCHEMA_NOT_OBJECT = BASE_URL + "event-schema-not-object"; + + /** The code {@code event-schema-already-registered}: a schema is already registered for the event type. */ + public static final String EVENT_SCHEMA_ALREADY_REGISTERED = BASE_URL + "event-schema-already-registered"; + + /** The code {@code last-audited-event-missing}: an audit leaves out the event the previous audit ended at. */ + public static final String LAST_AUDITED_EVENT_MISSING = BASE_URL + "last-audited-event-missing"; + + /** The code {@code last-audited-event-mismatched}: the event the previous audit ended at does not match the stored one. */ + public static final String LAST_AUDITED_EVENT_MISMATCHED = BASE_URL + "last-audited-event-mismatched"; + + /** The code {@code last-audited-event-unknown}: the event the previous audit ended at does not exist. */ + public static final String LAST_AUDITED_EVENT_UNKNOWN = BASE_URL + "last-audited-event-unknown"; + + /** The code {@code event-hash-missing}: a request leaves out the hash of an event. */ + public static final String EVENT_HASH_MISSING = BASE_URL + "event-hash-missing"; + + /** The code {@code event-hash-malformed}: the hash of an event is malformed. */ + public static final String EVENT_HASH_MALFORMED = BASE_URL + "event-hash-malformed"; + + /** The code {@code event-chain-broken}: the hashes of the stored events do not form an unbroken chain. */ + public static final String EVENT_CHAIN_BROKEN = BASE_URL + "event-chain-broken"; + + /** The code {@code last-backed-up-event-missing}: a backup leaves out the event the previous backup ended at. */ + public static final String LAST_BACKED_UP_EVENT_MISSING = BASE_URL + "last-backed-up-event-missing"; + + /** The code {@code last-backed-up-event-mismatched}: the event the previous backup ended at does not match the stored one. */ + public static final String LAST_BACKED_UP_EVENT_MISMATCHED = BASE_URL + "last-backed-up-event-mismatched"; + + /** The code {@code last-backed-up-event-unknown}: the event the previous backup ended at does not exist. */ + public static final String LAST_BACKED_UP_EVENT_UNKNOWN = BASE_URL + "last-backed-up-event-unknown"; + + /** The code {@code store-not-empty}: a restore requires an empty database, but the database holds events. */ + public static final String STORE_NOT_EMPTY = BASE_URL + "store-not-empty"; + + /** The code {@code event-hash-version-unknown}: the hash of an event is of a version the server does not know. */ + public static final String EVENT_HASH_VERSION_UNKNOWN = BASE_URL + "event-hash-version-unknown"; + + /** The code {@code backup-malformed}: a backup to restore is malformed. */ + public static final String BACKUP_MALFORMED = BASE_URL + "backup-malformed"; + + /** The code {@code backup-incomplete}: a backup to restore was cut short. */ + public static final String BACKUP_INCOMPLETE = BASE_URL + "backup-incomplete"; + + /** The code {@code backup-format-unknown}: a backup to restore is of a format the server does not know. */ + public static final String BACKUP_FORMAT_UNKNOWN = BASE_URL + "backup-format-unknown"; + + /** The code {@code full-backup-missing}: the increments of a backup to restore lack the full backup they build on. */ + public static final String FULL_BACKUP_MISSING = BASE_URL + "full-backup-missing"; + + /** The code {@code backup-increment-disconnected}: an increment of a backup to restore does not follow on the part before it. */ + public static final String BACKUP_INCREMENT_DISCONNECTED = BASE_URL + "backup-increment-disconnected"; + + /** The code {@code backup-failed}: a backup to restore holds the error that ended it when it was created. */ + public static final String BACKUP_FAILED = BASE_URL + "backup-failed"; + + /** The code {@code backup-unreadable}: a backup to restore could not be read to its end. */ + public static final String BACKUP_UNREADABLE = BASE_URL + "backup-unreadable"; + + /** The code {@code event-too-large}: an event exceeds the size the server accepts. */ + public static final String EVENT_TOO_LARGE = BASE_URL + "event-too-large"; + + /** The code {@code data-malformed}: the data of an event is malformed. */ + public static final String DATA_MALFORMED = BASE_URL + "data-malformed"; + + /** The code {@code query-missing}: a request leaves out the EventQL query. */ + public static final String QUERY_MISSING = BASE_URL + "query-missing"; + + /** The code {@code query-malformed}: an EventQL query is malformed. */ + public static final String QUERY_MALFORMED = BASE_URL + "query-malformed"; + + /** The code {@code query-unevaluable}: an EventQL query can not be evaluated. */ + public static final String QUERY_UNEVALUABLE = BASE_URL + "query-unevaluable"; + + /** The code {@code query-not-boolean}: the EventQL query of a precondition does not result in a boolean. */ + public static final String QUERY_NOT_BOOLEAN = BASE_URL + "query-not-boolean"; + + /** The code {@code precondition-violated}: a precondition does not hold. */ + public static final String PRECONDITION_VIOLATED = BASE_URL + "precondition-violated"; + + /** The code {@code store-read-only}: the database is in read-only mode, so it does not accept writes. */ + public static final String STORE_READ_ONLY = BASE_URL + "store-read-only"; + + /** The code {@code read-only-mode-already-enabled}: the read-only mode is enabled already. */ + public static final String READ_ONLY_MODE_ALREADY_ENABLED = BASE_URL + "read-only-mode-already-enabled"; + + /** The code {@code disk-full}: the disk of the database is full. */ + public static final String DISK_FULL = BASE_URL + "disk-full"; + + /** The code {@code restore-in-progress}: a restore is in progress, so the database does not accept the request. */ + public static final String RESTORE_IN_PROGRESS = BASE_URL + "restore-in-progress"; + + /** The code {@code store-failed}: the database failed unexpectedly while it handled the request. */ + public static final String STORE_FAILED = BASE_URL + "store-failed"; + + /** The code {@code indexes-rebuilding}: the database is rebuilding its indexes, so it does not answer the request yet. */ + public static final String INDEXES_REBUILDING = BASE_URL + "indexes-rebuilding"; + + /** The code {@code server-shutting-down}: the server is shutting down. */ + public static final String SERVER_SHUTTING_DOWN = BASE_URL + "server-shutting-down"; + + private ErrorCode() {} +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/EventType.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/EventType.java index 8a29380..3ac3207 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/EventType.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/EventType.java @@ -8,20 +8,17 @@ /** * An event type, as {@link Client#readEventTypes()} and {@link Client#readEventType(String)} return it. * - * @param eventType the name of the event type, e.g. {@code io.eventsourcingdb.library.book-acquired} - * @param isPhantom whether no event of this type was written yet, i.e. whether the type exists only because a schema - * is registered for it + * @param type the name of the event type, e.g. {@code io.eventsourcingdb.library.book-acquired} + * @param hasEvents whether events of this type were written, which is not the case if the type exists only because a + * schema is registered for it * @param schema the JSON schema registered for the event type, if any */ -public record EventType(String eventType, boolean isPhantom, Optional> schema) { +public record EventType(String type, boolean hasEvents, Optional> schema) { private static final TypeReference> SCHEMA_TYPE = new TypeReference<>() {}; - static EventType from(JsonNode eventType) { - var schema = eventType.path("schema"); - - return new EventType( - eventType.path("eventType").asString(), - eventType.path("isPhantom").asBoolean(), - schema.isObject() ? Optional.of(Json.MAPPER.convertValue(schema, SCHEMA_TYPE)) : Optional.empty()); + // A schema is an object, if there is one. The v2 API sends null for an + // event type without a schema, and the v1 API leaves it out. + static Optional> schemaOf(JsonNode schema) { + return schema.isObject() ? Optional.of(Json.MAPPER.convertValue(schema, SCHEMA_TYPE)) : Optional.empty(); } } diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutException.java deleted file mode 100644 index f04148e..0000000 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutException.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.thenativeweb.eventsourcingdb; - -import java.time.Duration; - -/** - * Reports that a stream ended because neither an item nor a heartbeat arrived for the stall timeout, 30 seconds by - * default. While there is nothing else to send, the server sends a heartbeat every second on the streams of - * {@link Client#observeEvents(String, ObserveEventsOptions)} and {@link Client#runEventQlQuery(String)}, so a stream - * that stays silent for that long has stalled, e.g. behind a proxy that keeps the connection open but no longer passes - * anything on. The client then closes the connection. Set the stall timeout with - * {@link ClientOptions#withStallTimeout(Duration)}. - */ -public final class HeartbeatTimeoutException extends EventSourcingDbException { - private static final long serialVersionUID = 1L; - - HeartbeatTimeoutException(Duration stallTimeout) { - super("no event and no heartbeat arrived for " + Durations.describe(stallTimeout)); - } -} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java index 0171015..ae90946 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java @@ -28,15 +28,19 @@ static void throwIfInterrupted(String action) { // Waits for the server to answer with its headers, for the stall timeout // at most, which covers connecting and sending the request as well. - // Interrupting the waiting thread cancels the request. + // Interrupting the waiting thread cancels the request. A status code + // other than 200 ends it with a DbApiException, whose code codes tells. static HttpResponse await( - String action, CompletableFuture> response, Duration stallTimeout) { + String action, + CompletableFuture> response, + Duration stallTimeout, + CodeMapping codes) { try { // TimeUnit.convert saturates instead of overflowing, unlike // Duration.toNanos, so that a stall timeout of hundreds of years // still works. var stallTimeoutNanos = TimeUnit.NANOSECONDS.convert(stallTimeout); - return check(action, response.get(stallTimeoutNanos, TimeUnit.NANOSECONDS), stallTimeout); + return check(action, response.get(stallTimeoutNanos, TimeUnit.NANOSECONDS), stallTimeout, codes); } catch (InterruptedException ex) { response.cancel(true); Thread.currentThread().interrupt(); @@ -46,27 +50,39 @@ static HttpResponse await( } catch (TimeoutException ex) { abort(response); - // The stall timeout ends the wait before the JDK raises anything, - // so the stall carries the exception that the JDK uses for a - // response that does not arrive in time, which callers may check - // for, e.g. those who set timeouts on an HTTP client of their own. - var timeout = - new HttpTimeoutException("nothing arrived from the server for " + Durations.describe(stallTimeout)); - timeout.initCause(ex); - throw new ConnectionStalledException(action, stallTimeout, timeout); + throw new ConnectionStalledException(action, stallTimeout, timeout(stallTimeout, ex)); } } + // The stall timeout ends the wait before the JDK raises anything, so the + // stall carries the exception that the JDK uses for a response that does + // not arrive in time, which callers may check for, e.g. those who set + // timeouts on an HTTP client of their own. + static HttpTimeoutException timeout(Duration stallTimeout, @Nullable Throwable cause) { + var timeout = + new HttpTimeoutException("nothing arrived from the server for " + Durations.describe(stallTimeout)); + timeout.initCause(cause); + return timeout; + } + + static boolean isFromEventSourcingDb(HttpResponse response) { + return response.headers().firstValue("Server").orElse("").startsWith("EventSourcingDB/"); + } + private static HttpResponse check( - String action, HttpResponse response, Duration stallTimeout) { - var serverHeader = response.headers().firstValue("Server").orElse(""); - if (!serverHeader.startsWith("EventSourcingDB/")) { + String action, HttpResponse response, Duration stallTimeout, CodeMapping codes) { + if (!isFromEventSourcingDb(response)) { close(response.body()); throw new InvalidServerHeaderException(); } if (response.statusCode() != 200) { - throw DbApiException.of(action, response.statusCode(), guard(action, response.body(), stallTimeout)); + throw DbApiException.ofStatus( + action, + response.statusCode(), + response.headers(), + guard(action, response.body(), stallTimeout), + codes); } return response; @@ -102,6 +118,12 @@ static RuntimeException failure(String action, IOException ex) { return new EventSourcingDbException("failed to " + action, ex); } + // A stream that ends before it is complete was aborted by the server, e.g. + // because of a failure it could not report within the stream. + static EventSourcingDbException endedUnexpectedly(String action) { + return new EventSourcingDbException("failed to " + action + ", the stream ended unexpectedly"); + } + static CancellationException cancellation(String action, @Nullable Throwable cause) { var exception = new CancellationException("failed to " + action + ", the request was canceled"); exception.initCause(cause); diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java index 340b2b3..6741201 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java @@ -6,74 +6,52 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Set; import java.util.Spliterator; import java.util.Spliterators; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.jspecify.annotations.Nullable; -import tools.jackson.core.JsonToken; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.node.MissingNode; // LineStream hands out the items of an NDJSON stream as the caller consumes -// them. It sends the request only once the caller starts to consume, and it -// ends the stream with an exception if it is closed from somewhere else, or -// if nothing arrives from the server for the stall timeout. +// them. It sends its request only once the caller starts to consume, which is +// when it learns which API the server offers, and so which request to send. +// It ends the stream with an exception if it is closed from somewhere else, +// if nothing arrives from the server for the stall timeout, or if the server +// ends the stream before it is complete. final class LineStream extends Spliterators.AbstractSpliterator { private final String action; - private final Supplier>> request; - private final String itemType; - private final Parser parser; - private final Duration stallTimeout; - private final Function stall; - private final Set> openStreams; + private final Requests requests; + private final TransportChooser transports; + private final Function> specs; private volatile boolean isClosed; - private volatile @Nullable CompletableFuture> response; + private volatile @Nullable CompletableFuture pending; private volatile @Nullable InputStream body; - private @Nullable BufferedReader reader; + private @Nullable Opened opened; + + // A stream whose request was answered: what the stream reads, and what + // its lines are. + private record Opened(BufferedReader reader, StreamSpec spec) {} private LineStream( - String action, - Supplier>> request, - String itemType, - Parser parser, - Duration stallTimeout, - Function stall, - Set> openStreams) { + String action, Requests requests, TransportChooser transports, Function> specs) { super(Long.MAX_VALUE, Spliterator.ORDERED | Spliterator.NONNULL); this.action = action; - this.request = request; - this.itemType = itemType; - this.parser = parser; - this.stallTimeout = stallTimeout; - this.stall = stall; - this.openStreams = openStreams; + this.requests = requests; + this.transports = transports; + this.specs = specs; } - // A stream that receives nothing for the stall timeout after its headers - // arrived ends with the exception that stall returns for the IOException - // that ended the read. - // The stream adds itself to openStreams, and removes itself once it has - // ended, so that the client can end the streams that are still open when - // it is closed. + // Creates a stream that specs describes once the API is known. The + // stream is tracked by the client, so that closing the client closes it, + // and it stops being tracked once it has ended. static Stream of( - String action, - Supplier>> request, - String itemType, - Parser parser, - Duration stallTimeout, - Function stall, - Set> openStreams) { - var lineStream = new LineStream<>(action, request, itemType, parser, stallTimeout, stall, openStreams); - openStreams.add(lineStream); + String action, Requests requests, TransportChooser transports, Function> specs) { + var lineStream = requests.track(new LineStream<>(action, requests, transports, specs)); return StreamSupport.stream(lineStream, false).onClose(lineStream::close); } @@ -88,96 +66,93 @@ public boolean tryAdvance(Consumer consumer) { } private boolean advance(Consumer consumer) { - var reader = this.reader; - if (reader == null) { - reader = open(); + var opened = this.opened; + if (opened == null) { + opened = open(); + this.opened = opened; } + var reader = opened.reader(); + var spec = opened.spec(); while (true) { var line = readLine(reader); if (line == null) { - Http.close(reader); - openStreams.remove(this); + if (spec.ending() != StreamSpec.Ending.WITH_RESPONSE) { + throw Http.endedUnexpectedly(action); + } + + end(reader); return false; } if (line.isBlank()) { continue; } - var message = readMessage(line); - var type = message.type(); - var payload = message.payload(); + var item = spec.envelope().read(line); + var type = item.type(); if (type.equals("heartbeat")) { continue; } - if (type.equals(itemType)) { - consumer.accept(parser.parse(payload, message.rawData())); + if (type.equals(spec.itemType())) { + var parsed = spec.parser().parse(item.payload(), item.rawData()); + if (!spec.isWanted().test(parsed)) { + continue; + } + + consumer.accept(parsed); return true; } if (type.equals("error")) { - throw new EventSourcingDbException("failed to " + action + ", got error: " - + payload.path("error").asString()); + throw spec.envelope().errorItem().toException(action, item.payload()); + } + if (type.equals("end") && spec.ending() == StreamSpec.Ending.WITH_END_ITEM) { + end(reader); + return false; } throw new EventSourcingDbException("failed to handle unsupported line type: " + type); } } - // A line of the stream: its type, its payload, and the text of the data - // within its payload as the server wrote it, if there is any. - private record Message( - String type, JsonNode payload, @Nullable String rawData) {} - - // Reads a line in a single pass, since that is what most of the time of - // reading a stream goes into. - private static Message readMessage(String line) { - try (var parser = Json.MAPPER.createParser(line)) { - var type = ""; - JsonNode payload = MissingNode.getInstance(); - String rawData = null; - - if (parser.nextToken() == JsonToken.START_OBJECT) { - while (parser.nextToken() == JsonToken.PROPERTY_NAME) { - var name = parser.currentName(); - parser.nextToken(); - - var value = RawJson.readValue(parser, line, "data"); - if (name.equals("type")) { - type = value.tree().asString(); - } else if (name.equals("payload")) { - payload = value.tree(); - rawData = value.raw(); - } - } - } - - return new Message(type, payload, rawData); - } + private void end(BufferedReader reader) { + Http.close(reader); + requests.untrack(this); } - private BufferedReader open() { + // Learns which API the server offers, and sends the request of the + // stream through it. + private Opened open() { throwIfClosed(); Http.throwIfInterrupted(action); - var response = request.get(); - this.response = response; + var transport = transports.transport(); + pending = transport; + StreamSpec spec; InputStream body; try { - body = Http.await(action, response, stallTimeout).body(); + throwIfClosed(); + spec = specs.apply(TransportChooser.await(action, transport)); + + var response = requests.sendForStream(spec.request()); + pending = response; + body = Http.await(action, response, requests.stallTimeout(), spec.codes()) + .body(); } catch (RuntimeException ex) { throwIfClosed(); throw ex; } this.body = body; - var reader = new BufferedReader(new InputStreamReader(new StallGuard(body, stallTimeout, stall), UTF_8)); - this.reader = reader; - return reader; + var stallTimeout = requests.stallTimeout(); + var reader = new BufferedReader(new InputStreamReader( + new StallGuard(body, stallTimeout, ex -> new ConnectionStalledException(action, stallTimeout, ex)), + UTF_8)); + return new Opened<>(reader, spec); } - // Returns null once the stream has ended. + // Returns null once the response has ended. private @Nullable String readLine(BufferedReader reader) { throwIfClosed(); @@ -199,20 +174,20 @@ private void throwIfClosed() { } } - // Ends the stream, whether it has not started yet, waits for the server, - // or reads a line. + // Ends the stream, whether it has not started yet, waits for the API to be + // chosen or for the server to answer, or reads a line. It releases what + // the stream holds each time it is called, not only the first time: the + // stream may get hold of the body of its response after it was closed, + // e.g. when it was closed after the headers had arrived, but before it + // had the body. It then notices that it is closed with its next read, + // which ends it, and closes it again, and so the body, too. void close() { - synchronized (this) { - if (isClosed) { - return; - } - isClosed = true; - } - openStreams.remove(this); + isClosed = true; + requests.untrack(this); - var response = this.response; - if (response != null) { - response.cancel(true); + var pending = this.pending; + if (pending != null) { + pending.cancel(true); } var body = this.body; diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java new file mode 100644 index 0000000..6d95668 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java @@ -0,0 +1,24 @@ +package io.thenativeweb.eventsourcingdb; + +/** + * Reports that writing events or registering an event schema failed in a way that leaves open whether the server + * stored it nevertheless. The server never stops a write halfway, so a write whose answer is lost, e.g. because the + * connection stalled or broke, or because the request was aborted once the client had started to send it, may have been + * stored all the same. + * Find out whether it was, e.g. by reading the events of the subject, before you write it again. + * + *

The cause tells what failed, e.g. a {@link ConnectionStalledException}, or a + * {@link java.util.concurrent.CancellationException} if the thread was interrupted once the client had started to send + * the request, even while the client was still connecting to the server. + * + *

A write that certainly was not stored throws the failure itself instead: an {@link EventSourcingDbException} if + * the client could not connect to the server at all, and a {@link DbApiException} if the server refused the write, + * e.g. because a precondition does not hold. + */ +public final class OutcomeUnknownException extends EventSourcingDbException { + private static final long serialVersionUID = 1L; + + OutcomeUnknownException(String action, Throwable cause) { + super("failed to " + action + ", the outcome is unknown", cause); + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java new file mode 100644 index 0000000..7dde8cd --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java @@ -0,0 +1,77 @@ +package io.thenativeweb.eventsourcingdb; + +import java.net.ConnectException; +import java.net.http.HttpConnectTimeoutException; +import java.util.Set; +import java.util.function.Predicate; +import javax.net.ssl.SSLHandshakeException; +import org.jspecify.annotations.Nullable; + +// Outcomes tells whether a write that failed after it was sent may have been +// stored nevertheless. The server never stops a write halfway, so only two +// failures leave no doubt that it was not: a connection that could not be +// established at all, since then the request never left, and an answer in +// which the server refuses the write. Every other failure, e.g. a stall, a +// broken connection, an abort, or an answer that is not from an +// EventSourcingDB, may come after the server stored the write. The rule is +// the same in every official client SDK, whatever its language could tell +// about how far a request got. +final class Outcomes { + // The v1 API refuses a write with a status code that names the reason, + // and only writes it stored answer with 200. A 500 may come after the + // write was stored, and a 502 or a 504 from a proxy in front of the + // database. + static final Predicate V1_REFUSALS = exception -> { + var status = exception.status(); + return (status >= 400 && status < 500) || status == 503 || status == 507; + }; + + // The v2 API refuses a write it accepted with an error in its answer, + // except for store-failed, which reports that storing it failed halfway. + // It refuses a write it did not accept with a status code: 400 for a body + // of the wrong shape, 401 for an invalid API token, 408 for a body that + // did not arrive in time, 413 for a body that is too large, 429 for too + // many requests, and 503 for a server that is shutting down. + static final Predicate V2_REFUSALS = exception -> { + if (exception.status() == 200) { + return !exception.code().equals(ErrorCode.STORE_FAILED); + } + + return Set.of(400, 401, 408, 413, 429, 503).contains(exception.status()); + }; + + private Outcomes() {} + + // Returns the failure itself if the write was certainly not stored, and an + // OutcomeUnknownException that wraps it otherwise. + // A connection that could not be established may follow on a write that + // the server stored already, if the JDK sends writes again. + static RuntimeException ofWrite( + String action, RuntimeException failure, Predicate isRefusal, WriteRetries writeRetries) { + if (failure instanceof DbApiException exception && isRefusal.test(exception)) { + return failure; + } + if (!writeRetries.areEnabled() && couldNotConnect(failure)) { + return failure; + } + + return new OutcomeUnknownException(action, failure); + } + + // The JDK reports a connection that could not be established, e.g. + // because the host is unknown or nothing listens on the port, with a + // ConnectException, or with an HttpConnectTimeoutException once the + // connect timeout of an HTTP client of the caller's own has passed. A + // TLS handshake that fails comes before the request is sent, too. + private static boolean couldNotConnect(@Nullable Throwable failure) { + for (var cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof ConnectException + || cause instanceof HttpConnectTimeoutException + || cause instanceof SSLHandshakeException) { + return true; + } + } + + return false; + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptions.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptions.java new file mode 100644 index 0000000..9c70eda --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptions.java @@ -0,0 +1,12 @@ +package io.thenativeweb.eventsourcingdb; + +/** + * Options for {@link Client#readSubjects(String, ReadSubjectsOptions)}. + * + *

{@code
+ * new ReadSubjectsOptions(true)
+ * }
+ * + * @param recursive whether to read the subjects within the subject, too, rather than the subject alone + */ +public record ReadSubjectsOptions(boolean recursive) {} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Requests.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Requests.java new file mode 100644 index 0000000..40b1eb8 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Requests.java @@ -0,0 +1,220 @@ +package io.thenativeweb.eventsourcingdb; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import tools.jackson.databind.JsonNode; + +// Requests sends the requests of a client, whichever API they go to, and keeps +// track of the requests that are running and the streams that are open, so +// that closing the client ends them. +// +// Every request is sent under the same lock under which close() marks the +// client as closed, so that close() either sees the request and ends it, or +// the request sees that the client is closed and is not sent at all. +final class Requests { + // QUERY is the HTTP method of the v2 API for reading (RFC 10008), which the + // JDK knows no constant for. + private static final String QUERY = "QUERY"; + + private final URI baseUrl; + private final String apiToken; + private final Duration stallTimeout; + private final HttpClient httpClient; + private final boolean isHttpClientOwned; + private final WriteRetries writeRetries; + + private final Object lock = new Object(); + private final Set openRequests = ConcurrentHashMap.newKeySet(); + private final Set> openStreams = ConcurrentHashMap.newKeySet(); + private volatile boolean isClosed; + + Requests( + URI baseUrl, + String apiToken, + Duration stallTimeout, + HttpClient httpClient, + boolean isHttpClientOwned, + WriteRetries writeRetries) { + this.baseUrl = baseUrl; + this.apiToken = apiToken; + this.stallTimeout = stallTimeout; + this.httpClient = httpClient; + this.isHttpClientOwned = isHttpClientOwned; + this.writeRetries = writeRetries; + } + + Duration stallTimeout() { + return stallTimeout; + } + + HttpClient httpClient() { + return httpClient; + } + + // Builds a request that needs no authentication, as pinging does not. + HttpRequest get(String path) { + return HttpRequest.newBuilder(baseUrl.resolve(path)).GET().build(); + } + + HttpRequest post(String path) { + return authorized(path).POST(HttpRequest.BodyPublishers.noBody()).build(); + } + + HttpRequest post(String path, JsonNode body) { + return withBody(authorized(path), "POST", body); + } + + HttpRequest query(String path) { + return authorized(path) + .method(QUERY, HttpRequest.BodyPublishers.noBody()) + .build(); + } + + HttpRequest query(String path, JsonNode body) { + return withBody(authorized(path), QUERY, body); + } + + private HttpRequest.Builder authorized(String path) { + return HttpRequest.newBuilder(baseUrl.resolve(path)).header("Authorization", "Bearer " + apiToken); + } + + private static HttpRequest withBody(HttpRequest.Builder request, String method, JsonNode body) { + return request.header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(Json.MAPPER.writeValueAsString(body))) + .build(); + } + + // Sends a request and returns the body of its response. + String send(String action, HttpRequest request, CodeMapping codes) { + return send(action, request, codes, response -> response, failure -> failure); + } + + // Sends a write, and interprets the body of its response. A write that + // fails after it was sent may have been stored nevertheless, unless the + // failure leaves no doubt that it was not, which isRefusal tells for the + // errors that the server reports. + T write( + String action, + HttpRequest request, + CodeMapping codes, + Predicate isRefusal, + Function interpretation) { + return send( + action, + request, + codes, + interpretation, + failure -> Outcomes.ofWrite(action, failure, isRefusal, writeRetries)); + } + + // Sends a request, and interprets the body of its response. Every failure + // once the request was sent, the interpretation included, goes through + // afterSending. + private T send( + String action, + HttpRequest request, + CodeMapping codes, + Function interpretation, + UnaryOperator afterSending) { + Http.throwIfInterrupted(action); + + CompletableFuture> response; + Runnable abortion; + synchronized (lock) { + throwIfClosed(); + + response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + abortion = () -> Http.abort(response); + openRequests.add(abortion); + } + + try { + return interpretation.apply(receive(action, response, codes)); + } catch (RuntimeException ex) { + throw afterSending.apply(ex); + } finally { + openRequests.remove(abortion); + } + } + + private String receive(String action, CompletableFuture> response, CodeMapping codes) { + try { + return Http.readBody(action, Http.await(action, response, stallTimeout, codes), stallTimeout); + } catch (RuntimeException ex) { + if (isClosed) { + throw Http.cancellation(action, ex); + } + throw ex; + } + } + + // Sends a request whose response the caller handles on its own. Closing + // the client cancels it, until it has completed. + CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) { + synchronized (lock) { + throwIfClosed(); + + var response = httpClient.sendAsync(request, bodyHandler); + Runnable abortion = () -> response.cancel(true); + openRequests.add(abortion); + response.whenComplete((result, failure) -> openRequests.remove(abortion)); + + return response; + } + } + + // Sends the request of a stream, which ends it itself once it is closed. + CompletableFuture> sendForStream(HttpRequest request) { + synchronized (lock) { + throwIfClosed(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + } + } + + // Keeps track of a stream, so that closing the client closes it, too. + LineStream track(LineStream stream) { + synchronized (lock) { + throwIfClosed(); + + openStreams.add(stream); + return stream; + } + } + + void untrack(LineStream stream) { + openStreams.remove(stream); + } + + private void throwIfClosed() { + if (isClosed) { + throw new IllegalStateException("client is closed"); + } + } + + void close() { + synchronized (lock) { + if (isClosed) { + return; + } + isClosed = true; + } + + openRequests.forEach(Runnable::run); + openStreams.forEach(LineStream::close); + + if (isHttpClientOwned) { + httpClient.shutdownNow(); + } + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StallGuard.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StallGuard.java index f9e1d1a..8536990 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StallGuard.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StallGuard.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.Duration; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -46,6 +47,13 @@ private static ScheduledThreadPoolExecutor stallTimers() { return executor; } + // Runs the action once the stall timeout has passed, unless the returned + // future is canceled before, on the thread of the guards, which mostly + // waits. + static ScheduledFuture afterStallTimeout(Duration stallTimeout, Runnable action) { + return STALL_TIMERS.schedule(action, TimeUnit.NANOSECONDS.convert(stallTimeout), TimeUnit.NANOSECONDS); + } + @Override public int read() throws IOException { return guard(super::read); diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StreamSpec.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StreamSpec.java new file mode 100644 index 0000000..675c4aa --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/StreamSpec.java @@ -0,0 +1,37 @@ +package io.thenativeweb.eventsourcingdb; + +import java.net.http.HttpRequest; +import java.util.function.Predicate; + +// StreamSpec describes a stream as an API sends it: the request that opens it, +// the codes of the errors that refuse it, how its items look, which type the +// items have that it hands out, and how it ends. +record StreamSpec( + HttpRequest request, + CodeMapping codes, + Envelope envelope, + Ending ending, + String itemType, + LineStream.Parser parser, + Predicate isWanted) { + StreamSpec( + HttpRequest request, + CodeMapping codes, + Envelope envelope, + Ending ending, + String itemType, + LineStream.Parser parser) { + this(request, codes, envelope, ending, itemType, parser, item -> true); + } + + // How a stream ends once it is complete. The v1 API ends a stream by + // ending the response, and the v2 API sends an item of the type end first, + // so that a stream that the server aborts can be told apart from one that + // is complete. Observing never ends, so the end of its response always + // means that the server aborted it. + enum Ending { + WITH_RESPONSE, + WITH_END_ITEM, + NEVER + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java new file mode 100644 index 0000000..49c22ae --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java @@ -0,0 +1,33 @@ +package io.thenativeweb.eventsourcingdb; + +import java.util.List; +import java.util.function.Function; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +// Transport sends the requests of the client through one API of the server. +// TransportChooser chooses which, so the client itself does not know about +// the APIs. The bodies that both APIs take alike are built by the client, and +// handed over as they are. +sealed interface Transport permits V1Transport, V2Transport { + void ping(); + + void verifyApiToken(); + + List writeEvents(ObjectNode body); + + StreamSpec readEvents(ObjectNode body); + + StreamSpec observeEvents(ObjectNode body); + + // Hands each row to rowParser, as the value that the query projected. + StreamSpec runEventQlQuery(String query, Function rowParser); + + StreamSpec readSubjects(String subject, boolean recursive); + + StreamSpec readEventTypes(); + + EventType readEventType(String eventType); + + void registerEventSchema(String eventType, JsonNode schema); +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java new file mode 100644 index 0000000..665f68a --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java @@ -0,0 +1,212 @@ +package io.thenativeweb.eventsourcingdb; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.ByteArrayInputStream; +import java.net.http.HttpResponse; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; + +// TransportChooser is the one place that chooses between the v1 and the v2 +// API. Creating a client sends no request, so the first request of a client +// asks the server whether it offers the v2 API, with a ping to the v2 API: a +// server that does answers it, and one that does not answers 404 or 405, as +// EventSourcingDB 1.2.0 does for every path of the v2 API it does not know, +// and so does a proxy in front of it that passes on the v1 API only. +// The answer is kept for the client, so the server is asked only once. +// +// Any other outcome, e.g. a server that can not be reached, tells nothing, so +// the request that asked fails, and the next request asks again. Requests +// that are made while the server is being asked wait for its answer rather +// than asking again, and fail along with it, each with its own action. +final class TransportChooser { + private static final String PROBE_PATH = "/api/v2/system/ping"; + + private final Requests requests; + private final V1Transport v1; + private final V2Transport v2; + + private final Object lock = new Object(); + private @Nullable Transport chosen; + private @Nullable CompletableFuture probe; + + TransportChooser(Requests requests, V1Transport v1, V2Transport v2) { + this.requests = requests; + this.v1 = v1; + this.v2 = v2; + } + + // Returns the transport, once the server has told which API it offers. + // The future is the caller's own, so that canceling it ends only the wait + // of the caller, not the probe, which other requests may wait for, too. + CompletableFuture transport() { + synchronized (lock) { + var chosen = this.chosen; + if (chosen != null) { + return CompletableFuture.completedFuture(chosen); + } + + var probe = this.probe; + if (probe == null) { + probe = startProbe(); + } + + return probe.copy(); + } + } + + @Nullable + Transport chosen() { + synchronized (lock) { + return chosen; + } + } + + // Waits for the transport. A failed probe fails the request with an + // exception of its own, so that each request that waited for the probe + // names its own action. + static Transport await(String action, CompletableFuture transport) { + try { + return transport.get(); + } catch (InterruptedException ex) { + transport.cancel(true); + Thread.currentThread().interrupt(); + throw Http.cancellation(action, ex); + } catch (ExecutionException ex) { + // A probe fails with a ProbeFailure only. + var probeFailure = (ProbeFailure) Objects.requireNonNull(ex.getCause()); + throw probeFailure.toException(action); + } + } + + // The answer to the probe is small, so the stall timeout bounds the probe + // as a whole, which spares it a reader of its own. + private CompletableFuture startProbe() { + var response = requests.sendAsync(requests.get(PROBE_PATH), HttpResponse.BodyHandlers.ofString()); + + var stallTimeout = requests.stallTimeout(); + var hasStalled = new AtomicBoolean(); + var stallTimer = StallGuard.afterStallTimeout(stallTimeout, () -> { + hasStalled.set(true); + response.cancel(true); + }); + + var result = new CompletableFuture(); + probe = result; + response.whenComplete((answer, failure) -> { + stallTimer.cancel(false); + complete(result, answer, failure, hasStalled.get()); + }); + + return result; + } + + // Keeps the transport that the probe found out, or forgets the probe if + // it failed, so that the next request probes again. Either way, a probe + // is running no longer. A probe starts only while no other is running, so + // the probe that completes is the one that is kept. + private void complete( + CompletableFuture result, + @Nullable HttpResponse answer, + @Nullable Throwable failure, + boolean hasStalled) { + Transport transport; + try { + transport = interpret(answer, failure, hasStalled); + } catch (ProbeFailure probeFailure) { + synchronized (lock) { + probe = null; + } + result.completeExceptionally(probeFailure); + return; + } + + synchronized (lock) { + chosen = transport; + probe = null; + } + result.complete(transport); + } + + private Transport interpret( + @Nullable HttpResponse answer, @Nullable Throwable failure, boolean hasStalled) { + if (answer == null) { + throw failed(failure, hasStalled); + } + + // A 404 or a 405 means the v1 API, whichever server sends it, since a + // proxy that passes on the v1 API only answers the probe itself. The + // requests to the v1 API check that the server is an EventSourcingDB. + var status = answer.statusCode(); + if (status == 404 || status == 405) { + return v1; + } + if (!Http.isFromEventSourcingDb(answer)) { + throw new ProbeFailure(action -> new InvalidServerHeaderException()); + } + if (status != 200) { + var body = answer.body().getBytes(UTF_8); + throw new ProbeFailure(action -> DbApiException.ofStatus( + action, status, answer.headers(), new ByteArrayInputStream(body), CodeMapping.NONE)); + } + + try { + if (V2Transport.isPingReceived(Json.MAPPER.readTree(answer.body()))) { + return v2; + } + } catch (JacksonException ex) { + throw new ProbeFailure(action -> unexpectedAnswer(action, ex)); + } + + throw new ProbeFailure(action -> unexpectedAnswer(action, null)); + } + + // The JDK reports most failures wrapped in a CompletionException, and + // others, e.g. a cancellation, as they are. + private ProbeFailure failed(@Nullable Throwable failure, boolean hasStalled) { + var cause = + failure instanceof CompletionException completionException ? completionException.getCause() : failure; + var stallTimeout = requests.stallTimeout(); + + if (hasStalled) { + var timeout = Http.timeout(stallTimeout, cause); + return new ProbeFailure(action -> new ConnectionStalledException(action, stallTimeout, timeout)); + } + if (cause instanceof CancellationException) { + return new ProbeFailure(action -> Http.cancellation(action, cause)); + } + + return new ProbeFailure(action -> new EventSourcingDbException("failed to " + action, cause)); + } + + private static EventSourcingDbException unexpectedAnswer(String action, @Nullable Throwable cause) { + return new EventSourcingDbException( + "failed to " + action + ", the server answered " + PROBE_PATH + " unexpectedly", cause); + } + + // ProbeFailure carries what a failed probe tells into the requests that + // waited for it, as the exception that each of them throws. + private static final class ProbeFailure extends RuntimeException { + private static final long serialVersionUID = 1L; + + // The exception is created for each request, which a probe that is + // serialized does not need, so it is not serialized along. + private final transient Function exception; + + ProbeFailure(Function exception) { + super(null, null, false, false); + this.exception = exception; + } + + RuntimeException toException(String action) { + return exception.apply(action); + } + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1ErrorCodes.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1ErrorCodes.java new file mode 100644 index 0000000..66c0fa4 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1ErrorCodes.java @@ -0,0 +1,61 @@ +package io.thenativeweb.eventsourcingdb; + +// V1ErrorCodes maps the errors of the v1 API that clearly match an error of +// the v2 API to the code of the v2 API. The v1 API tells its errors apart by +// their texts only, so each is matched from the start of the exact text that +// EventSourcingDB 1.2.0 sends, which may go on with what the error is about. +final class V1ErrorCodes { + private static final int NOT_FOUND = 404; + private static final int CONFLICT = 409; + private static final int SERVICE_UNAVAILABLE = 503; + private static final int INSUFFICIENT_STORAGE = 507; + + // Every request may meet an operation mode that does not permit it, e.g. + // a write to a database in read-only mode. + static final CodeMapping ANY_REQUEST = (status, description) -> status == SERVICE_UNAVAILABLE + && description.startsWith("the current operation mode conflicts with the given request") + ? ErrorCode.STORE_READ_ONLY + : ""; + + // A write is refused with 507 only once the license has expired. + static final CodeMapping WRITE_EVENTS = (status, description) -> { + if (status == CONFLICT && description.startsWith("state conflict: precondition failed")) { + return ErrorCode.PRECONDITION_VIOLATED; + } + if (status == CONFLICT && description.startsWith("state conflict: exceeded max store capacity")) { + return ErrorCode.CAPACITY_EXCEEDED; + } + if (status == CONFLICT && description.startsWith("schema conflict: event candidate does not match schema")) { + return ErrorCode.EVENT_SCHEMA_VIOLATED; + } + if (status == INSUFFICIENT_STORAGE) { + return ErrorCode.LICENSE_INVALID; + } + + return ANY_REQUEST.codeOf(status, description); + }; + + // A schema that an event written before does not match is refused with + // the ID of that event. + static final CodeMapping REGISTER_EVENT_SCHEMA = (status, description) -> { + if (status == CONFLICT && description.startsWith("schema conflict: schema already exists")) { + return ErrorCode.EVENT_SCHEMA_ALREADY_REGISTERED; + } + if (status == CONFLICT && description.startsWith("schema conflict: event with ID")) { + return ErrorCode.EVENT_SCHEMA_VIOLATED; + } + + return ANY_REQUEST.codeOf(status, description); + }; + + static final CodeMapping READ_EVENTS = (status, description) -> status == CONFLICT + && description.startsWith( + "state conflict: fromLatestEvent results in an event ID greater than upperBound ID") + ? ErrorCode.RANGE_UNSATISFIABLE + : ANY_REQUEST.codeOf(status, description); + + static final CodeMapping READ_EVENT_TYPE = (status, description) -> + status == NOT_FOUND ? ErrorCode.EVENT_TYPE_UNKNOWN : ANY_REQUEST.codeOf(status, description); + + private V1ErrorCodes() {} +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java new file mode 100644 index 0000000..db77e3f --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java @@ -0,0 +1,163 @@ +package io.thenativeweb.eventsourcingdb; + +import java.util.List; +import java.util.function.Function; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +// V1Transport sends the requests of the client through the v1 API, for a +// server that does not offer the v2 API, such as EventSourcingDB 1.2.0. Its +// streams have no end item and no heartbeats, except those of observing and +// of EventQL queries, and it reports every error with a status code. +final class V1Transport implements Transport { + private static final Pattern SURROUNDING_SLASHES = Pattern.compile("^/+|/+$"); + + private final Requests requests; + private final JsonMapper dataMapper; + + V1Transport(Requests requests, JsonMapper dataMapper) { + this.requests = requests; + this.dataMapper = dataMapper; + } + + @Override + public void ping() { + var response = requests.send("ping", requests.get("/api/v1/ping"), V1ErrorCodes.ANY_REQUEST); + + if (!"io.eventsourcingdb.api.ping-received" + .equals(Json.MAPPER.readTree(response).path("type").asString())) { + throw new EventSourcingDbException("failed to ping"); + } + } + + @Override + public void verifyApiToken() { + var response = + requests.send("verify API token", requests.post("/api/v1/verify-api-token"), V1ErrorCodes.ANY_REQUEST); + + if (!"io.eventsourcingdb.api.api-token-verified" + .equals(Json.MAPPER.readTree(response).path("type").asString())) { + throw new EventSourcingDbException("failed to verify API token"); + } + } + + @Override + public List writeEvents(ObjectNode body) { + return requests.write( + "write events", + requests.post("/api/v1/write-events", body), + V1ErrorCodes.WRITE_EVENTS, + Outcomes.V1_REFUSALS, + response -> RawJson.readArray(response, "data").stream() + .map(cloudEvent -> Event.from(cloudEvent.tree(), cloudEvent.raw(), dataMapper)) + .toList()); + } + + @Override + public StreamSpec readEvents(ObjectNode body) { + return new StreamSpec<>( + requests.post("/api/v1/read-events", body), + V1ErrorCodes.READ_EVENTS, + Envelope.V1, + StreamSpec.Ending.WITH_RESPONSE, + "event", + this::parseEvent); + } + + @Override + public StreamSpec observeEvents(ObjectNode body) { + return new StreamSpec<>( + requests.post("/api/v1/observe-events", body), + V1ErrorCodes.ANY_REQUEST, + Envelope.V1, + StreamSpec.Ending.NEVER, + "event", + this::parseEvent); + } + + private Event parseEvent(JsonNode payload, @Nullable String rawData) { + return Event.from(payload, rawData, dataMapper); + } + + // The payload of a row is the value that the query projected. + @Override + public StreamSpec runEventQlQuery(String query, Function rowParser) { + var body = Json.MAPPER.createObjectNode(); + body.put("query", query); + + return new StreamSpec<>( + requests.post("/api/v1/run-eventql-query", body), + V1ErrorCodes.ANY_REQUEST, + Envelope.V1, + StreamSpec.Ending.WITH_RESPONSE, + "row", + (payload, rawData) -> rowParser.apply(payload)); + } + + // The v1 API always reads recursively, so the subject alone is the one + // subject of the stream that is the subject itself. The server names it + // without the slashes around it, e.g. /books for /books/. + @Override + public StreamSpec readSubjects(String subject, boolean recursive) { + var body = Json.MAPPER.createObjectNode(); + body.put("baseSubject", subject); + + var subjectItself = "/" + SURROUNDING_SLASHES.matcher(subject).replaceAll(""); + + return new StreamSpec<>( + requests.post("/api/v1/read-subjects", body), + V1ErrorCodes.ANY_REQUEST, + Envelope.V1, + StreamSpec.Ending.WITH_RESPONSE, + "subject", + (payload, rawData) -> payload.path("subject").asString(), + readSubject -> recursive || readSubject.equals(subjectItself)); + } + + @Override + public StreamSpec readEventTypes() { + return new StreamSpec<>( + requests.post("/api/v1/read-event-types", Json.MAPPER.createObjectNode()), + V1ErrorCodes.ANY_REQUEST, + Envelope.V1, + StreamSpec.Ending.WITH_RESPONSE, + "eventType", + (payload, rawData) -> eventTypeOf(payload)); + } + + // The v1 API names an event type by eventType, and tells by isPhantom + // whether it has no events. + private static EventType eventTypeOf(JsonNode eventType) { + return new EventType( + eventType.path("eventType").asString(), + !eventType.path("isPhantom").asBoolean(), + EventType.schemaOf(eventType.path("schema"))); + } + + @Override + public EventType readEventType(String eventType) { + var body = Json.MAPPER.createObjectNode(); + body.put("eventType", eventType); + + var response = requests.send( + "read event type", requests.post("/api/v1/read-event-type", body), V1ErrorCodes.READ_EVENT_TYPE); + return eventTypeOf(Json.MAPPER.readTree(response)); + } + + @Override + public void registerEventSchema(String eventType, JsonNode schema) { + var body = Json.MAPPER.createObjectNode(); + body.put("eventType", eventType); + body.set("schema", schema); + + requests.write( + "register event schema", + requests.post("/api/v1/register-event-schema", body), + V1ErrorCodes.REGISTER_EVENT_SCHEMA, + Outcomes.V1_REFUSALS, + response -> response); + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java new file mode 100644 index 0000000..0d83885 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java @@ -0,0 +1,202 @@ +package io.thenativeweb.eventsourcingdb; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +// V2Transport sends the requests of the client through the v2 API. It reads +// with the HTTP method QUERY, and wraps every answer in an envelope. Every +// stream but that of observing ends with an end item, and so do the answers to +// writes, which are streams, too, so that heartbeats keep a long write alive. +// An error that the server reports in an answer has a code, and the status +// code 200. +final class V2Transport implements Transport { + private final Requests requests; + private final JsonMapper dataMapper; + + V2Transport(Requests requests, JsonMapper dataMapper) { + this.requests = requests; + this.dataMapper = dataMapper; + } + + // The ping is the one answer that tells a server with the v2 API apart + // from one without, which is why TransportChooser asks it, too. + static boolean isPingReceived(JsonNode answer) { + return isEvent(answer, "io.eventsourcingdb.api.ping-received"); + } + + private static boolean isEvent(JsonNode answer, String eventType) { + return answer.path("type").asString().equals("event") + && answer.path("data").path("type").asString().equals(eventType); + } + + @Override + public void ping() { + var response = requests.send("ping", requests.get("/api/v2/system/ping"), CodeMapping.NONE); + + if (!isPingReceived(Json.MAPPER.readTree(response))) { + throw new EventSourcingDbException("failed to ping"); + } + } + + @Override + public void verifyApiToken() { + var response = + requests.send("verify API token", requests.post("/api/v2/system/verify-token"), CodeMapping.NONE); + + if (!isEvent(Json.MAPPER.readTree(response), "io.eventsourcingdb.api.token-verified")) { + throw new EventSourcingDbException("failed to verify API token"); + } + } + + @Override + public List writeEvents(ObjectNode body) { + var action = "write events"; + + return requests.write( + action, + requests.post("/api/v2/data/write-events", body), + CodeMapping.NONE, + Outcomes.V2_REFUSALS, + response -> readToEnd(action, response, this::parseEvent)); + } + + @Override + public StreamSpec readEvents(ObjectNode body) { + return new StreamSpec<>( + requests.query("/api/v2/data/read-events", body), + CodeMapping.NONE, + Envelope.V2, + StreamSpec.Ending.WITH_END_ITEM, + "event", + this::parseEvent); + } + + @Override + public StreamSpec observeEvents(ObjectNode body) { + return new StreamSpec<>( + requests.query("/api/v2/data/observe-events", body), + CodeMapping.NONE, + Envelope.V2, + StreamSpec.Ending.NEVER, + "event", + this::parseEvent); + } + + private Event parseEvent(JsonNode payload, @Nullable String rawData) { + return Event.from(payload, rawData, dataMapper); + } + + // A row holds the value that the query projected as its value. + @Override + public StreamSpec runEventQlQuery(String query, Function rowParser) { + var body = Json.MAPPER.createObjectNode(); + body.put("query", query); + + return new StreamSpec<>( + requests.query("/api/v2/data/run-eventql-query", body), + CodeMapping.NONE, + Envelope.V2, + StreamSpec.Ending.WITH_END_ITEM, + "row", + (payload, rawData) -> rowParser.apply(payload.path("value"))); + } + + @Override + public StreamSpec readSubjects(String subject, boolean recursive) { + var body = Json.MAPPER.createObjectNode(); + body.put("subject", subject); + body.putObject("options").put("recursive", recursive); + + return new StreamSpec<>( + requests.query("/api/v2/data/read-subjects", body), + CodeMapping.NONE, + Envelope.V2, + StreamSpec.Ending.WITH_END_ITEM, + "subject", + (payload, rawData) -> payload.path("subject").asString()); + } + + @Override + public StreamSpec readEventTypes() { + return new StreamSpec<>( + requests.query("/api/v2/data/read-event-types"), + CodeMapping.NONE, + Envelope.V2, + StreamSpec.Ending.WITH_END_ITEM, + "eventType", + (payload, rawData) -> eventTypeOf(payload)); + } + + private static EventType eventTypeOf(JsonNode eventType) { + return new EventType( + eventType.path("type").asString(), + eventType.path("hasEvents").asBoolean(), + EventType.schemaOf(eventType.path("schema"))); + } + + // The answer is a single envelope, which holds either the event type or + // the error that the server reports. + @Override + public EventType readEventType(String eventType) { + var action = "read event type"; + + var body = Json.MAPPER.createObjectNode(); + body.put("type", eventType); + + var response = requests.send(action, requests.query("/api/v2/data/read-event-type", body), CodeMapping.NONE); + var item = Envelope.V2.read(response); + + return switch (item.type()) { + case "eventType" -> eventTypeOf(item.payload()); + case "error" -> throw Envelope.V2.errorItem().toException(action, item.payload()); + default -> throw new EventSourcingDbException("failed to handle unsupported line type: " + item.type()); + }; + } + + @Override + public void registerEventSchema(String eventType, JsonNode schema) { + var action = "register event schema"; + + var body = Json.MAPPER.createObjectNode(); + body.put("type", eventType); + body.set("schema", schema); + + requests.write( + action, + requests.post("/api/v2/data/register-event-schema", body), + CodeMapping.NONE, + Outcomes.V2_REFUSALS, + response -> readToEnd(action, response, (payload, rawData) -> payload)); + } + + // Reads the items of the answer to a write, which is a stream, up to its + // end item. The answer arrives as a whole, since a write answers with no + // more items than it wrote events. + private static List readToEnd(String action, String response, LineStream.Parser parser) { + var items = new ArrayList(); + + for (var line : response.split("\n", -1)) { + if (line.isBlank()) { + continue; + } + + var item = Envelope.V2.read(line); + switch (item.type()) { + case "heartbeat" -> {} + case "event" -> items.add(parser.parse(item.payload(), item.rawData())); + case "error" -> throw Envelope.V2.errorItem().toException(action, item.payload()); + case "end" -> { + return items; + } + default -> throw new EventSourcingDbException("failed to handle unsupported line type: " + item.type()); + } + } + + throw Http.endedUnexpectedly(action); + } +} diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteRetries.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteRetries.java new file mode 100644 index 0000000..05b4921 --- /dev/null +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteRetries.java @@ -0,0 +1,70 @@ +package io.thenativeweb.eventsourcingdb; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Properties; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +// WriteRetries tells whether the JDK sends a write again over a new +// connection, if the connection it was sent over closed before the answer +// arrived, which it does if its networking property +// jdk.httpclient.enableAllMethodRetry says so. A connection that can not be +// established then may follow on a write that the server stored already. +// +// It reads the property as the JDK does: as a system property, falling back +// to conf/net.properties of the JDK, and with an empty value for true. The +// JDK reads it only once, with the first request of any HTTP client, at a +// time the client does not know of. So the property is read with each write +// that fails instead, as well as when a client is created, and a value that +// was true once counts from then on, since the JDK may have read it while it +// was. +final class WriteRetries { + private static final String PROPERTY = "jdk.httpclient.enableAllMethodRetry"; + + // The JDK always sets java.home. + static final WriteRetries OF_THE_JDK = new WriteRetries( + Path.of(Objects.requireNonNull(System.getProperty("java.home")), "conf", "net.properties"), + System::getProperty); + + private final @Nullable String defaultValue; + private final Function systemProperties; + private volatile boolean haveBeenEnabled; + + WriteRetries(Path netProperties, Function systemProperties) { + defaultValue = load(netProperties).getProperty(PROPERTY); + this.systemProperties = systemProperties; + } + + // Like the JDK, it goes on with what it read so far, if it fails to read + // the file, whether the file is missing or malformed. + private static Properties load(Path netProperties) { + var properties = new Properties(); + try (var input = Files.newInputStream(netProperties)) { + properties.load(input); + } catch (IOException | RuntimeException ex) { + // The properties read so far stay, as they do for the JDK. + } + + return properties; + } + + boolean areEnabled() { + if (haveBeenEnabled) { + return true; + } + + var value = systemProperties.apply(PROPERTY); + if (value == null) { + value = defaultValue; + } + var areEnabled = value != null && (value.isEmpty() || Boolean.parseBoolean(value)); + if (areEnabled) { + haveBeenEnabled = true; + } + + return areEnabled; + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiChoiceTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiChoiceTest.java new file mode 100644 index 0000000..b270de7 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiChoiceTest.java @@ -0,0 +1,43 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.thenativeweb.eventsourcingdb.testcontainers.Container; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +// A database that offers the v1 API as well as the v2 API answers the +// requests of either, so only this test tells whether the client chose the +// API it should. It asks the database on its own which API it offers, rather +// than going by the tag of the image, since a later release offers the v2 API +// under every tag. +class ApiChoiceTest { + private Container container; + + @BeforeEach + void startContainer() { + container = Database.start(); + } + + @AfterEach + void stopContainer() { + container.stop(); + } + + @Test + void choosesTheApiThatTheDatabaseOffers() { + var client = container.getClient(); + var expected = Database.offersV2Api(container) ? V2Transport.class : V1Transport.class; + + client.ping(); + + assertInstanceOf(expected, client.chosenTransport()); + } + + @Test + void choosesNoApiBeforeTheFirstRequest() { + assertNull(container.getClient().chosenTransport()); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiVersion.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiVersion.java new file mode 100644 index 0000000..1444c52 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ApiVersion.java @@ -0,0 +1,56 @@ +package io.thenativeweb.eventsourcingdb; + +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; + +// ApiVersion names the API that a FakeServer offers, and tells how that API +// answers the probe of the client, and how it wraps the items it sends. +enum ApiVersion { + // Answers the probe as EventSourcingDB 1.2.0 does, which does not know the + // path of the v2 API. + V1("payload") { + @Override + void answerProbe(HttpExchange exchange) throws IOException { + FakeServer.respond(exchange, 404, "404 page not found\n"); + } + + @Override + String end() { + return ""; + } + }, + + V2("data") { + @Override + void answerProbe(HttpExchange exchange) throws IOException { + FakeServer.respond(exchange, 200, PING_RECEIVED); + } + + @Override + String end() { + return item("end", "{}"); + } + }; + + static final String PING_RECEIVED = """ + {"type":"event","data":{"specversion":"1.0","id":"0","source":"https://www.eventsourcingdb.io",\ + "type":"io.eventsourcingdb.api.ping-received","subject":"/api/v2/system/ping",\ + "time":"2026-10-18T20:59:13.348809963Z","datacontenttype":"application/json",\ + "data":{"message":"My God, it's full of stars."}}}"""; + + private final String payloadKey; + + ApiVersion(String payloadKey) { + this.payloadKey = payloadKey; + } + + abstract void answerProbe(HttpExchange exchange) throws IOException; + + // The item that ends a stream that is complete, or an empty line, which + // the client skips, for the v1 API, whose streams just end. + abstract String end(); + + String item(String type, String payload) { + return "{\"type\":\"" + type + "\",\"" + payloadKey + "\":" + payload + "}"; + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/BodyHoldingHttpClient.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/BodyHoldingHttpClient.java new file mode 100644 index 0000000..28a2c82 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/BodyHoldingHttpClient.java @@ -0,0 +1,167 @@ +package io.thenativeweb.eventsourcingdb; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; + +// BodyHoldingHttpClient sends requests with an HTTP client of the JDK, and +// holds back the body of the response to every request but the probe, until +// the test lets it go. That way, a test can act at the moment when the +// headers of a response have arrived, but the client does not have its body +// yet. +final class BodyHoldingHttpClient extends HttpClient { + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final CountDownLatch bodyRequested = new CountDownLatch(1); + private final CountDownLatch bodyReleased = new CountDownLatch(1); + + void awaitBodyRequested() throws InterruptedException { + bodyRequested.await(); + } + + void releaseBody() { + bodyReleased.countDown(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + var response = httpClient.sendAsync(request, responseBodyHandler); + if (request.uri().getPath().equals(FakeServer.PROBE_PATH)) { + return response; + } + + return response.>thenApply(HoldingResponse::new); + } + + private final class HoldingResponse implements HttpResponse { + private final HttpResponse response; + + HoldingResponse(HttpResponse response) { + this.response = response; + } + + @Override + public T body() { + bodyRequested.countDown(); + try { + bodyReleased.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return response.body(); + } + + @Override + public int statusCode() { + return response.statusCode(); + } + + @Override + public HttpRequest request() { + return response.request(); + } + + @Override + public Optional> previousResponse() { + return response.previousResponse(); + } + + @Override + public HttpHeaders headers() { + return response.headers(); + } + + @Override + public Optional sslSession() { + return response.sslSession(); + } + + @Override + public URI uri() { + return response.uri(); + } + + @Override + public Version version() { + return response.version(); + } + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + throw new UnsupportedOperationException(); + } + + @Override + public Optional cookieHandler() { + return httpClient.cookieHandler(); + } + + @Override + public Optional connectTimeout() { + return httpClient.connectTimeout(); + } + + @Override + public Redirect followRedirects() { + return httpClient.followRedirects(); + } + + @Override + public Optional proxy() { + return httpClient.proxy(); + } + + @Override + public SSLContext sslContext() { + return httpClient.sslContext(); + } + + @Override + public SSLParameters sslParameters() { + return httpClient.sslParameters(); + } + + @Override + public Optional authenticator() { + return httpClient.authenticator(); + } + + @Override + public Version version() { + return httpClient.version(); + } + + @Override + public Optional executor() { + return httpClient.executor(); + } + + @Override + public void shutdownNow() { + httpClient.shutdownNow(); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CancellationTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CancellationTest.java index d5fee0f..7514e60 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CancellationTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CancellationTest.java @@ -14,9 +14,10 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; // Java has no context like Go, so a caller aborts a request by interrupting @@ -31,12 +32,12 @@ // WriteEventsTest, which this timeout does not cover. @Timeout(10) class CancellationTest { - static List all() { - return Operations.all(); + static List all() { + return Operations.onEveryApi(Operations.all()); } - static List streams() { - return Operations.streams(); + static List streams() { + return Operations.onEveryApi(Operations.streams()); } private record Outcome(Throwable thrown, boolean wasInterrupted) {} @@ -81,24 +82,24 @@ private static Outcome interruptAt(Moment moment, Runnable operation) throws Int @ParameterizedTest @MethodSource("all") - void anInterruptEndsARequestTheServerDoesNotAnswer(Operation operation) throws InterruptedException { - try (var server = FakeServer.start((exchange, fakeServer) -> fakeServer.hang())) { + void anInterruptEndsARequestTheServerDoesNotAnswer(ApiVersion api, Operation operation) + throws InterruptedException { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> fakeServer.hang())) { var client = new Client(server.url(), "secret"); var outcome = interruptAfterRequest(server, () -> operation.call().accept(client)); - assertInstanceOf(CancellationException.class, outcome.thrown()); - assertEquals( - "failed to " + operation.action() + ", the request was canceled", - outcome.thrown().getMessage()); + var cancellation = + Operations.assertFailedAfterSending(CancellationException.class, operation, outcome.thrown()); + assertEquals("failed to " + operation.action() + ", the request was canceled", cancellation.getMessage()); assertTrue(outcome.wasInterrupted()); } } @ParameterizedTest @MethodSource("all") - void anInterruptedThreadDoesNotSendTheRequest(Operation operation) { - try (var server = FakeServer.respondingWith(200, "")) { + void anInterruptedThreadDoesNotSendTheRequest(ApiVersion api, Operation operation) { + try (var server = FakeServer.respondingWith(api, 200, "")) { var client = new Client(server.url(), "secret"); Thread.currentThread().interrupt(); @@ -108,14 +109,14 @@ void anInterruptedThreadDoesNotSendTheRequest(Operation operation) { assertTrue(Thread.interrupted()); } - assertEquals(0, server.requestCount()); + assertEquals(List.of(), server.requests()); } } @ParameterizedTest @MethodSource("streams") - void aCloseEndsAStreamWhoseRequestTheServerDoesNotAnswer(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> fakeServer.hang()); + void aCloseEndsAStreamWhoseRequestTheServerDoesNotAnswer(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> fakeServer.hang()); var items = operation.open().apply(new Client(server.url(), "secret")); var executor = Executors.newVirtualThreadPerTaskExecutor()) { Runnable close = items::close; @@ -132,21 +133,22 @@ void aCloseEndsAStreamWhoseRequestTheServerDoesNotAnswer(Operation operation) { @ParameterizedTest @MethodSource("streams") - void aStreamThatIsClosedBeforeItStartsDoesNotSendTheRequest(Operation operation) { - try (var server = FakeServer.respondingWith(200, "")) { + void aStreamThatIsClosedBeforeItStartsDoesNotSendTheRequest(ApiVersion api, Operation operation) { + try (var server = FakeServer.respondingWith(api, 200, "")) { var items = operation.open().apply(new Client(server.url(), "secret")); var iterator = items.iterator(); items.close(); assertThrows(CancellationException.class, iterator::hasNext); - assertEquals(0, server.requestCount()); + assertEquals(List.of(), server.requests()); } } - @Test - void anInterruptEndsAWriteWhoseResponseStopsInTheMiddle() throws InterruptedException { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void anInterruptEndsAWriteWhoseResponseStopsInTheMiddle(ApiVersion api) throws InterruptedException { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(200, 1000); exchange.getResponseBody().write("[".getBytes()); @@ -157,25 +159,27 @@ void anInterruptEndsAWriteWhoseResponseStopsInTheMiddle() throws InterruptedExce var outcome = interruptAfterRequest(server, () -> client.writeEvents(List.of())); - assertInstanceOf(CancellationException.class, outcome.thrown()); + var outcomeUnknown = assertInstanceOf(OutcomeUnknownException.class, outcome.thrown()); + assertInstanceOf(CancellationException.class, outcomeUnknown.getCause()); assertTrue(outcome.wasInterrupted()); } } - private static FakeServer streamingOneSubjectAndHanging() { - return FakeServer.start((exchange, fakeServer) -> { + private static FakeServer streamingOneSubjectAndHanging(ApiVersion api) { + return FakeServer.start(api, (exchange, fakeServer) -> { FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.SUBJECT); + FakeServer.writeLine(exchange, Lines.subject(api)); fakeServer.hang(); }); } // The stream is closed once the caller has its first subject, rather than // after a fixed time, which a slow machine may need to deliver it. - @Test - void aCloseEndsAStreamThatStopsInTheMiddle() { - try (var server = streamingOneSubjectAndHanging(); - var subjects = new Client(server.url(), "secret").readSubjects("/"); + @ParameterizedTest + @EnumSource(ApiVersion.class) + void aCloseEndsAStreamThatStopsInTheMiddle(ApiVersion api) { + try (var server = streamingOneSubjectAndHanging(api); + var subjects = new Client(server.url(), "secret").readSubjects("/", new ReadSubjectsOptions(true)); var executor = Executors.newVirtualThreadPerTaskExecutor()) { var subjectsRead = new ArrayList(); var hasSubject = new CountDownLatch(1); @@ -199,15 +203,16 @@ void aCloseEndsAStreamThatStopsInTheMiddle() { // The thread is interrupted once the caller has its first subject, rather // than after a fixed time, which a slow machine may need to deliver it. - @Test - void anInterruptEndsAStreamThatStopsInTheMiddle() throws InterruptedException { - try (var server = streamingOneSubjectAndHanging()) { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void anInterruptEndsAStreamThatStopsInTheMiddle(ApiVersion api) throws InterruptedException { + try (var server = streamingOneSubjectAndHanging(api)) { var client = new Client(server.url(), "secret"); var subjectsRead = new ArrayList(); var hasSubject = new CountDownLatch(1); var outcome = interruptAt(hasSubject::await, () -> { - try (var subjects = client.readSubjects("/")) { + try (var subjects = client.readSubjects("/", new ReadSubjectsOptions(true))) { subjects.forEach(subject -> { subjectsRead.add(subject); hasSubject.countDown(); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ClientOptionsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ClientOptionsTest.java index 2200dbb..5721887 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ClientOptionsTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ClientOptionsTest.java @@ -17,7 +17,7 @@ class ClientOptionsTest { private static FakeServer pingingServer() { - return FakeServer.respondingWith(200, "{\"type\":\"io.eventsourcingdb.api.ping-received\"}"); + return FakeServer.respondingWith(ApiVersion.V1, 200, "{\"type\":\"io.eventsourcingdb.api.ping-received\"}"); } // The stall timeout bounds connecting, too, so a connect timeout of the diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CloseTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CloseTest.java index 6a22337..21fd620 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CloseTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/CloseTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import io.thenativeweb.eventsourcingdb.Operations.Operation; +import java.io.IOException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -16,18 +17,21 @@ import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; // Closing a client ends what it is doing at once, as if each of its requests // and streams had been aborted, and the client can not be used afterwards. @Timeout(10) class CloseTest { - static List all() { - return Operations.all(); + static List all() { + return Operations.onEveryApi(Operations.all()); } @FunctionalInterface @@ -70,20 +74,21 @@ private static Throwable closeAt(Moment moment, Client client, Runnable operatio @ParameterizedTest @MethodSource("all") - void endsARequestTheServerDoesNotAnswer(Operation operation) throws InterruptedException { - try (var server = FakeServer.start((exchange, fakeServer) -> fakeServer.hang())) { + void endsARequestTheServerDoesNotAnswer(ApiVersion api, Operation operation) throws InterruptedException { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> fakeServer.hang())) { var client = new Client(server.url(), "secret"); var thrown = closeAfterRequest(server, client, () -> operation.call().accept(client)); - assertInstanceOf(CancellationException.class, thrown); + Operations.assertFailedAfterSending(CancellationException.class, operation, thrown); } } - @Test - void endsARequestWhoseResponseStopsInTheMiddle() throws InterruptedException { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void endsARequestWhoseResponseStopsInTheMiddle(ApiVersion api) throws InterruptedException { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(200, 1000); exchange.getResponseBody().write("[".getBytes()); @@ -94,15 +99,17 @@ void endsARequestWhoseResponseStopsInTheMiddle() throws InterruptedException { var thrown = closeAfterRequest(server, client, () -> client.writeEvents(List.of())); - assertInstanceOf(CancellationException.class, thrown); + var outcomeUnknown = assertInstanceOf(OutcomeUnknownException.class, thrown); + assertInstanceOf(CancellationException.class, outcomeUnknown.getCause()); } } - @Test - void endsAStreamThatIsRunning() throws InterruptedException { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void endsAStreamThatIsRunning(ApiVersion api) throws InterruptedException { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.SUBJECT); + FakeServer.writeLine(exchange, Lines.subject(api)); fakeServer.hang(); })) { var client = new Client(server.url(), "secret"); @@ -113,7 +120,7 @@ void endsAStreamThatIsRunning() throws InterruptedException { // rather than after a fixed time, which a slow machine may need to // deliver it. var thrown = closeAt(hasSubject::await, client, () -> { - try (var subjects = client.readSubjects("/")) { + try (var subjects = client.readSubjects("/", new ReadSubjectsOptions(true))) { subjects.forEach(subject -> { subjectsRead.add(subject); hasSubject.countDown(); @@ -126,23 +133,24 @@ void endsAStreamThatIsRunning() throws InterruptedException { } } - @Test - void endsAStreamThatHasNotStartedYet() { - try (var server = FakeServer.respondingWith(200, "")) { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void endsAStreamThatHasNotStartedYet(ApiVersion api) { + try (var server = FakeServer.respondingWith(api, 200, "")) { var client = new Client(server.url(), "secret"); - var subjects = client.readSubjects("/"); + var subjects = client.readSubjects("/", new ReadSubjectsOptions(true)); client.close(); assertThrows(CancellationException.class, subjects::toList); - assertEquals(0, server.requestCount()); + assertEquals(List.of(), server.requests()); } } @ParameterizedTest @MethodSource("all") - void rejectsEveryRequestOnceItIsClosed(Operation operation) { - try (var server = FakeServer.respondingWith(200, "")) { + void rejectsEveryRequestOnceItIsClosed(ApiVersion api, Operation operation) { + try (var server = FakeServer.respondingWith(api, 200, "")) { var client = new Client(server.url(), "secret"); client.close(); @@ -150,7 +158,50 @@ void rejectsEveryRequestOnceItIsClosed(Operation operation) { IllegalStateException.class, () -> operation.call().accept(client)); assertEquals("client is closed", exception.getMessage()); - assertEquals(0, server.requestCount()); + assertEquals(List.of(), server.requests()); + } + } + + // The headers of the response have arrived, but the stream does not have + // its body yet, when it is closed. It closes the body nevertheless, which + // ends the connection, so that an HTTP client of the caller's own keeps + // none open. + @Test + void closesTheBodyOfAStreamThatIsClosedBeforeItHasTheBody() throws InterruptedException { + var isConnectionClosed = new CountDownLatch(1); + var httpClient = new BodyHoldingHttpClient(); + + try (var server = FakeServer.start(ApiVersion.V1, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + try { + while (true) { + FakeServer.writeLine(exchange, Lines.heartbeat(ApiVersion.V1)); + Thread.sleep(Duration.ofMillis(50)); + } + } catch (IOException ex) { + isConnectionClosed.countDown(); + } + })) { + var client = new Client(server.url(), "secret", new ClientOptions().withHttpClient(httpClient)); + var events = client.observeEvents("/", new ObserveEventsOptions(true)); + var thrown = new AtomicReference(); + var consumer = Thread.ofPlatform().start(() -> { + try { + events.forEach(event -> {}); + } catch (RuntimeException ex) { + thrown.set(ex); + } + }); + + httpClient.awaitBodyRequested(); + events.close(); + httpClient.releaseBody(); + consumer.join(); + + assertInstanceOf(CancellationException.class, thrown.get()); + assertTrue(isConnectionClosed.await(5, TimeUnit.SECONDS)); + } finally { + httpClient.shutdownNow(); } } @@ -173,7 +224,7 @@ void closesTheHttpClientItCreated() throws InterruptedException { @Test void leavesAGivenHttpClientOpen() throws Exception { - try (var server = FakeServer.respondingWith(200, "ok"); + try (var server = FakeServer.respondingWith(ApiVersion.V1, 200, "ok"); var httpClient = HttpClient.newHttpClient()) { var client = new Client(server.url(), "secret", new ClientOptions().withHttpClient(httpClient)); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ConnectionStalledExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ConnectionStalledExceptionTest.java index 23efbbf..2a7966f 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ConnectionStalledExceptionTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ConnectionStalledExceptionTest.java @@ -11,37 +11,72 @@ import java.io.OutputStream; import java.net.http.HttpTimeoutException; import java.time.Duration; +import java.util.Arrays; import java.util.List; -import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; // A request fails once nothing arrives from the server for the stall timeout, -// however far it got, but it is never cut off while something keeps arriving. +// however far it got, but it is never cut off while something keeps arriving, +// be it a byte, an item, or a heartbeat. @Timeout(10) class ConnectionStalledExceptionTest { private static final Duration STALL_TIMEOUT = Duration.ofMillis(300); - // The streams of these operations carry heartbeats, so a stall within them - // ends with a HeartbeatTimeoutException instead. - private static final Set HEARTBEAT_STREAMS = Set.of("observe events", "run EventQL query"); + // A stream along with a line that holds one of its items. + record ItemStream(String name, Function> open, Function item) { + @Override + public String toString() { + return name; + } + } + + static List itemStreams() { + return Arrays.stream(ApiVersion.values()) + .flatMap(api -> itemStreamsOf().stream().map(stream -> Arguments.of(api, stream))) + .toList(); + } - static List all() { - return Operations.all(); + private static List itemStreamsOf() { + return List.of( + new ItemStream( + "read events", client -> client.readEvents("/", new ReadEventsOptions(true)), Lines::event), + new ItemStream( + "observe events", + client -> client.observeEvents("/", new ObserveEventsOptions(true)), + Lines::event), + new ItemStream( + "run EventQL query", + client -> client.runEventQlQuery("FROM e IN events PROJECT INTO e"), + Lines::row), + new ItemStream( + "read subjects", + client -> client.readSubjects("/", new ReadSubjectsOptions(true)), + Lines::subject), + new ItemStream("read event types", Client::readEventTypes, Lines::eventType)); } - static List requests() { - return Operations.all().stream() + static List all() { + return Operations.onEveryApi(Operations.all()); + } + + static List requests() { + return Operations.onEveryApi(Operations.all().stream() .filter(operation -> !operation.isStream()) - .toList(); + .toList()); } - static List streamsWithoutHeartbeats() { - return Operations.streams().stream() - .filter(operation -> !HEARTBEAT_STREAMS.contains(operation.action())) - .toList(); + static List streams() { + return Operations.onEveryApi(Operations.streams()); } private static Client clientWith(FakeServer server) { @@ -51,8 +86,7 @@ private static Client clientWith(FakeServer server) { // Checks the type and the message of the stall, and that its cause is of // the given type, so that callers who check the cause can rely on it. private static void assertStalled(Operation operation, Client client, Class causeType) { - var exception = assertThrows( - ConnectionStalledException.class, () -> operation.call().accept(client)); + var exception = Operations.assertFailsAfterSending(ConnectionStalledException.class, operation, client); assertEquals( "failed to " + operation.action() + ", nothing arrived from the server for 300 milliseconds", @@ -75,16 +109,16 @@ private static void writeInPieces(OutputStream body, String text) throws IOExcep @ParameterizedTest @MethodSource("all") - void endsARequestWhoseResponseDoesNotArrive(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> fakeServer.hang())) { + void endsARequestWhoseResponseDoesNotArrive(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> fakeServer.hang())) { assertStalled(operation, clientWith(server), HttpTimeoutException.class); } } @ParameterizedTest @MethodSource("requests") - void endsARequestWhoseResponseStopsInTheMiddle(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> { + void endsARequestWhoseResponseStopsInTheMiddle(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(200, 1000); exchange.getResponseBody().write("[".getBytes(UTF_8)); @@ -96,20 +130,21 @@ void endsARequestWhoseResponseStopsInTheMiddle(Operation operation) { } @ParameterizedTest - @MethodSource("streamsWithoutHeartbeats") - void endsAStreamWithoutHeartbeatsThatStopsInTheMiddle(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @MethodSource("streams") + void endsAStreamThatStopsAfterAHeartbeat(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.HEARTBEAT); + FakeServer.writeLine(exchange, Lines.heartbeat(api)); fakeServer.hang(); })) { assertStalled(operation, clientWith(server), IOException.class); } } - @Test - void endsAnErrorWhoseReasonStopsInTheMiddle() { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void endsAnErrorWhoseDescriptionStopsInTheMiddle(ApiVersion api) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(500, 1000); exchange.getResponseBody().write("something".getBytes(UTF_8)); @@ -120,11 +155,13 @@ void endsAnErrorWhoseReasonStopsInTheMiddle() { } } - @Test - void keepsARequestAliveWhileItsResponseArrives() { - var response = "{\"type\":\"io.eventsourcingdb.api.ping-received\"}"; + @ParameterizedTest + @EnumSource(ApiVersion.class) + void keepsARequestAliveWhileItsResponseArrives(ApiVersion api) { + var response = + api == ApiVersion.V1 ? "{\"type\":\"io.eventsourcingdb.api.ping-received\"}" : ApiVersion.PING_RECEIVED; - try (var server = FakeServer.start((exchange, fakeServer) -> { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(200, response.getBytes(UTF_8).length); writeInPieces(exchange.getResponseBody(), response); @@ -133,37 +170,83 @@ void keepsARequestAliveWhileItsResponseArrives() { } } - @Test - void keepsAStreamAliveWhileALineArrives() { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void keepsAStreamAliveWhileALineArrives(ApiVersion api) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { FakeServer.startStream(exchange); - writeInPieces(exchange.getResponseBody(), Lines.SUBJECT + "\n"); + writeInPieces(exchange.getResponseBody(), Lines.subject(api) + "\n" + api.end() + "\n"); }); - var subjects = clientWith(server).readSubjects("/")) { + var subjects = clientWith(server).readSubjects("/", new ReadSubjectsOptions(true))) { assertEquals(List.of("/"), subjects.toList()); } } - // The second subject arrives while the caller is still busy with the - // first, so the client has to read again after the caller's time. - @Test - void doesNotCountTheTimeTheCallerSpendsOnAnItem() { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @MethodSource("itemStreams") + void keepsAStreamAliveWhileHeartbeatsArrive(ApiVersion api, ItemStream stream) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.SUBJECT); + for (var i = 0; i < 6; i++) { + FakeServer.writeLine(exchange, Lines.heartbeat(api)); + Thread.sleep(STALL_TIMEOUT.dividedBy(3)); + } + FakeServer.writeLine(exchange, stream.item().apply(api)); + fakeServer.hang(); + }); + var items = stream.open().apply(clientWith(server))) { + assertEquals(1, items.limit(1).count()); + } + } + + // The second item arrives while the caller is still busy with the first, + // so the client has to read again after the caller's time. + @ParameterizedTest + @MethodSource("itemStreams") + void doesNotCountTheTimeTheCallerSpendsOnAnItem(ApiVersion api, ItemStream stream) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, stream.item().apply(api)); Thread.sleep(STALL_TIMEOUT.dividedBy(3)); - FakeServer.writeLine(exchange, Lines.SUBJECT); + FakeServer.writeLine(exchange, stream.item().apply(api)); fakeServer.hang(); }); - var subjects = clientWith(server).readSubjects("/")) { - var count = subjects.limit(2) - .peek(subject -> sleep(STALL_TIMEOUT.multipliedBy(2))) + var items = stream.open().apply(clientWith(server))) { + var count = items.limit(2) + .peek(item -> sleep(STALL_TIMEOUT.multipliedBy(2))) .count(); assertEquals(2, count); } } + @ParameterizedTest + @MethodSource("itemStreams") + void endsAStreamThatIsClosedWithACancellationRatherThanAStall(ApiVersion api, ItemStream stream) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, Lines.heartbeat(api)); + fakeServer.hang(); + }); + var items = stream.open() + .apply(new Client( + server.url(), "secret", new ClientOptions().withStallTimeout(Duration.ofSeconds(5)))); + var scheduler = Executors.newSingleThreadScheduledExecutor()) { + scheduler.schedule(items::close, 300, TimeUnit.MILLISECONDS); + + assertThrows(CancellationException.class, () -> items.forEach(item -> {})); + } + } + + @Test + void namesTheDefaultStallTimeoutInItsMessage() { + var exception = new ConnectionStalledException( + "observe events", new ClientOptions().stallTimeout(), new IOException("stalled")); + + assertEquals( + "failed to observe events, nothing arrived from the server for 30 seconds", exception.getMessage()); + } + private static void sleep(Duration duration) { try { Thread.sleep(duration); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Database.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Database.java index e8923d0..b790d88 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Database.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Database.java @@ -1,6 +1,11 @@ package io.thenativeweb.eventsourcingdb; import io.thenativeweb.eventsourcingdb.testcontainers.Container; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; final class Database { private Database() {} @@ -17,4 +22,24 @@ private static Container start(Container container) { container.withImageTag(ImageTag.get()).start(); return container; } + + // Tells whether the database in the container offers the v2 API, which + // depends on the image the tests run against. It asks the database itself + // rather than the client, so that a test that depends on the answer does + // not take it from the code under test. + static boolean offersV2Api(Container container) { + try (var httpClient = HttpClient.newHttpClient()) { + var request = HttpRequest.newBuilder(container.getBaseUrl().resolve("/api/v2/system/ping")) + .build(); + return httpClient + .send(request, HttpResponse.BodyHandlers.discarding()) + .statusCode() + == 200; + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/DbApiExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/DbApiExceptionTest.java index 13c1682..47512e3 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/DbApiExceptionTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/DbApiExceptionTest.java @@ -7,42 +7,55 @@ import io.thenativeweb.eventsourcingdb.Operations.Operation; import java.io.IOException; +import java.time.Duration; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; class DbApiExceptionTest { - static List all() { - return Operations.all(); + static List all() { + return Operations.onEveryApi(Operations.all()); } static List authenticated() { return Operations.authenticated(); } + private static FakeServer respondingWith(ApiVersion api, int status, String retryAfter) { + return FakeServer.start(api, (exchange, fakeServer) -> { + exchange.getResponseHeaders().set("Retry-After", retryAfter); + FakeServer.respond(exchange, status, "too many requests"); + }); + } + @ParameterizedTest @MethodSource("all") - void everyRequestReportsAnUnexpectedStatusWithItsStatusCodeAndReason(Operation operation) { - try (var server = FakeServer.respondingWith(500, " something went wrong\n")) { + void everyRequestReportsAnUnexpectedStatusWithItsStatusAndDescription(ApiVersion api, Operation operation) { + try (var server = FakeServer.respondingWith(api, 500, " something went wrong\n")) { var client = new Client(server.url(), "secret"); - var exception = - assertThrows(DbApiException.class, () -> operation.call().accept(client)); + var exception = Operations.assertFailsAfterSending(DbApiException.class, operation, client); assertEquals( "failed to " + operation.action() + ", got HTTP status code '500', expected '200': something went wrong", exception.getMessage()); assertEquals(operation.action(), exception.action()); - assertEquals(500, exception.statusCode()); - assertEquals("something went wrong", exception.reason()); + assertEquals(500, exception.status()); + assertEquals("", exception.code()); + assertEquals("something went wrong", exception.description()); + assertEquals(Json.MAPPER.createObjectNode(), exception.details()); + assertEquals(Optional.empty(), exception.retryAfter()); } } @ParameterizedTest @MethodSource("authenticated") - void everyRequestReportsARejectedTokenWithStatusCode401(Operation operation) { + void everyRequestReportsARejectedTokenWithStatus401(Operation operation) { var container = Database.start(); try { var client = new Client(container.getBaseUrl(), "invalid-token"); @@ -50,38 +63,39 @@ void everyRequestReportsARejectedTokenWithStatusCode401(Operation operation) { var exception = assertThrows(DbApiException.class, () -> operation.call().accept(client)); - assertEquals(401, exception.statusCode()); + assertEquals(401, exception.status()); + assertEquals("", exception.code()); } finally { container.stop(); } } @Test - void leavesOutTheReasonIfTheServerGivesNone() { - try (var server = FakeServer.respondingWith(503, "")) { + void leavesOutTheDescriptionIfTheServerGivesNone() { + try (var server = FakeServer.respondingWith(ApiVersion.V1, 503, "")) { var client = new Client(server.url(), "secret"); var exception = assertThrows(DbApiException.class, client::ping); assertEquals("failed to ping, got HTTP status code '503', expected '200'", exception.getMessage()); - assertEquals("", exception.reason()); + assertEquals("", exception.description()); } } @Test - void cutsALongReasonShort() { - try (var server = FakeServer.respondingWith(500, "x".repeat(10_000))) { + void cutsALongDescriptionShort() { + try (var server = FakeServer.respondingWith(ApiVersion.V1, 500, "x".repeat(10_000))) { var client = new Client(server.url(), "secret"); var exception = assertThrows(DbApiException.class, client::ping); - assertEquals("x".repeat(4096), exception.reason()); + assertEquals("x".repeat(4096), exception.description()); } } @Test - void reportsIfTheReasonCannotBeRead() { - try (var server = FakeServer.start((exchange, fakeServer) -> { + void reportsIfTheDescriptionCannotBeRead() { + try (var server = FakeServer.start(ApiVersion.V1, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(500, 1000); exchange.getResponseBody().write("something".getBytes()); @@ -95,8 +109,49 @@ void reportsIfTheReasonCannotBeRead() { .getMessage() .startsWith( "failed to ping, got HTTP status code '500', expected '200', and failed to read the reason: ")); - assertEquals("", exception.reason()); + assertEquals("", exception.description()); assertInstanceOf(IOException.class, exception.getCause()); } } + + @ParameterizedTest + @MethodSource("all") + void everyRequestReportsHowLongToWaitIfThereAreTooManyRequests(ApiVersion api, Operation operation) { + try (var server = respondingWith(api, 429, "7")) { + var client = new Client(server.url(), "secret"); + + var exception = + assertThrows(DbApiException.class, () -> operation.call().accept(client)); + + assertEquals(429, exception.status()); + assertEquals("too many requests", exception.description()); + assertEquals(Optional.of(Duration.ofSeconds(7)), exception.retryAfter()); + assertEquals(1, server.requestCount()); + } + } + + // The server sends a number of seconds only, so anything else, e.g. a + // date, is left out rather than guessed at. + @ParameterizedTest + @ValueSource(strings = {"", "soon", "-1", "1.5", "Wed, 21 Oct 2026 07:28:00 GMT"}) + void leavesOutHowLongToWaitIfItIsNoNumberOfSeconds(String retryAfter) { + try (var server = respondingWith(ApiVersion.V1, 429, retryAfter)) { + var client = new Client(server.url(), "secret"); + + var exception = assertThrows(DbApiException.class, client::ping); + + assertEquals(Optional.empty(), exception.retryAfter()); + } + } + + @Test + void leavesOutHowLongToWaitIfThereAreNotTooManyRequests() { + try (var server = respondingWith(ApiVersion.V1, 503, "7")) { + var client = new Client(server.url(), "secret"); + + var exception = assertThrows(DbApiException.class, client::ping); + + assertEquals(Optional.empty(), exception.retryAfter()); + } + } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ErrorCodeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ErrorCodeTest.java new file mode 100644 index 0000000..899094f --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ErrorCodeTest.java @@ -0,0 +1,70 @@ +package io.thenativeweb.eventsourcingdb; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ErrorCodeTest { + private static final Pattern SLUG_PATTERN = + Pattern.compile("^\\s*Slug\\w+\\s*=\\s*\"([a-z-]+)\"$", Pattern.MULTILINE); + + // Reads the codes from the list of the server, and names each the way + // ErrorCode names it, e.g. precondition-violated as PRECONDITION_VIOLATED. + private static Map codesOfTheServer() { + String slugs; + try (var resource = ErrorCodeTest.class.getResourceAsStream("server-error-slugs.txt")) { + assertNotNull(resource); + slugs = new String(resource.readAllBytes(), UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + + var codes = new TreeMap(); + var matcher = SLUG_PATTERN.matcher(slugs); + while (matcher.find()) { + var slug = matcher.group(1); + codes.put( + slug.toUpperCase(Locale.ROOT).replace('-', '_'), "https://docs.eventsourcingdb.io/errors/" + slug); + } + + return codes; + } + + private static Map codesOfTheClient() { + return Arrays.stream(ErrorCode.class.getFields()) + .filter(field -> Modifier.isStatic(field.getModifiers())) + .collect(Collectors.toMap( + Field::getName, ErrorCodeTest::valueOf, (first, second) -> first, TreeMap::new)); + } + + private static String valueOf(Field field) { + try { + return (String) field.get(null); + } catch (IllegalAccessException ex) { + throw new IllegalStateException(ex); + } + } + + @Test + void hasAConstantForEveryCodeTheServerLists() { + var codesOfTheServer = codesOfTheServer(); + + // The list of the server is not empty, so that a list that fails to + // parse can not pass for one without codes. + assertFalse(codesOfTheServer.isEmpty()); + assertEquals(codesOfTheServer, codesOfTheClient()); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/EventTypeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/EventTypeTest.java new file mode 100644 index 0000000..d61a541 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/EventTypeTest.java @@ -0,0 +1,70 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +// The v1 API tells whether an event type has no events by isPhantom, which +// the client turns around into hasEvents, as the v2 API sends it. +class EventTypeTest { + private static final List EVENT_TYPES = List.of( + new EventType("io.x.a", true, Optional.empty()), + new EventType("io.x.b", false, Optional.of(Map.of("type", "object")))); + + private static List readEventTypes(FakeServer server) { + try (var eventTypes = new Client(server.url(), "secret").readEventTypes()) { + return eventTypes.toList(); + } + } + + @Test + void tellsWhetherAnEventTypeOfTheV1ApiHasEvents() { + var api = ApiVersion.V1; + + try (var server = FakeServer.streaming( + api, + api.item("eventType", "{\"eventType\":\"io.x.a\",\"isPhantom\":false}"), + api.item( + "eventType", + "{\"eventType\":\"io.x.b\",\"isPhantom\":true,\"schema\":{\"type\":\"object\"}}"))) { + assertEquals(EVENT_TYPES, readEventTypes(server)); + } + } + + @Test + void tellsWhetherAnEventTypeOfTheV2ApiHasEvents() { + var api = ApiVersion.V2; + + try (var server = FakeServer.streaming( + api, + api.item("eventType", "{\"type\":\"io.x.a\",\"hasEvents\":true,\"schema\":null}"), + api.item("eventType", "{\"type\":\"io.x.b\",\"hasEvents\":false,\"schema\":{\"type\":\"object\"}}"))) { + assertEquals(EVENT_TYPES, readEventTypes(server)); + } + } + + @Test + void tellsWhetherASingleEventTypeOfTheV1ApiHasEvents() { + try (var server = + FakeServer.respondingWith(ApiVersion.V1, 200, "{\"eventType\":\"io.x.a\",\"isPhantom\":true}")) { + var eventType = new Client(server.url(), "secret").readEventType("io.x.a"); + + assertEquals(new EventType("io.x.a", false, Optional.empty()), eventType); + } + } + + @Test + void tellsWhetherASingleEventTypeOfTheV2ApiHasEvents() { + try (var server = FakeServer.respondingWith( + ApiVersion.V2, + 200, + ApiVersion.V2.item("eventType", "{\"type\":\"io.x.a\",\"hasEvents\":false,\"schema\":null}"))) { + var eventType = new Client(server.url(), "secret").readEventType("io.x.a"); + + assertEquals(new EventType("io.x.a", false, Optional.empty()), eventType); + } + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/FakeServer.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/FakeServer.java index 92391e3..79faeae 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/FakeServer.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/FakeServer.java @@ -10,22 +10,35 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import org.jspecify.annotations.Nullable; // FakeServer stands in for EventSourcingDB where a real instance can not // produce the behavior under test, e.g. a missing Server header, a broken -// response, or a stream that stalls. +// response, or a stream that stalls. It offers the v1 or the v2 API, and +// answers the probe of the client accordingly, so that its handler sees the +// requests of the operations only. final class FakeServer implements AutoCloseable { static final String SERVER_HEADER = "EventSourcingDB/fake"; + static final String PROBE_PATH = "/api/v2/system/ping"; + private final HttpServer server; private final CountDownLatch released = new CountDownLatch(1); private final CountDownLatch requested = new CountDownLatch(1); private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicBoolean hasBeenProbed = new AtomicBoolean(); + private final List requests = new CopyOnWriteArrayList<>(); + + // A request as the server received it. + record Request(String method, String path, String body) {} - private FakeServer(Handler handler) { + private FakeServer(@Nullable ApiVersion api, Handler handler) { try { server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); } catch (IOException ex) { @@ -33,10 +46,21 @@ private FakeServer(Handler handler) { } HttpHandler httpHandler = exchange -> { - requestCount.incrementAndGet(); - requested.countDown(); try (exchange) { - exchange.getRequestBody().readAllBytes(); + var body = new String(exchange.getRequestBody().readAllBytes(), UTF_8); + var request = new Request( + exchange.getRequestMethod(), exchange.getRequestURI().getPath(), body); + requests.add(request); + + // The probe comes first. A ping to the v2 API afterwards is + // a request of an operation. + if (api != null && isProbe(api, request)) { + api.answerProbe(exchange); + return; + } + + requestCount.incrementAndGet(); + requested.countDown(); handler.handle(exchange, this); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); @@ -51,12 +75,38 @@ private FakeServer(Handler handler) { server.start(); } + private boolean isProbe(ApiVersion api, Request request) { + if (!request.path().equals(PROBE_PATH)) { + return false; + } + + return api == ApiVersion.V1 || hasBeenProbed.compareAndSet(false, true); + } + + // Starts a server that offers the given API. + static FakeServer start(ApiVersion api, Handler handler) { + return new FakeServer(api, handler); + } + + // Starts a server that leaves the probe to the handler, too. static FakeServer start(Handler handler) { - return new FakeServer(handler); + return new FakeServer(null, handler); + } + + static FakeServer respondingWith(ApiVersion api, int statusCode, String body) { + return start(api, (exchange, server) -> respond(exchange, statusCode, body)); } - static FakeServer respondingWith(int statusCode, String body) { - return start((exchange, server) -> respond(exchange, statusCode, body)); + // Starts a server that answers with a stream of the given items, which + // ends as a stream that is complete. + static FakeServer streaming(ApiVersion api, String... items) { + return start(api, (exchange, server) -> { + startStream(exchange); + for (var item : items) { + writeLine(exchange, item); + } + writeLine(exchange, api.end()); + }); } static URI unusedUrl() { @@ -93,6 +143,8 @@ void hang() throws InterruptedException { released.await(); } + // Waits for the first request of an operation, i.e. one that is not the + // probe. void awaitRequest() throws InterruptedException { requested.await(); } @@ -101,10 +153,15 @@ URI url() { return URI.create("http://localhost:" + server.getAddress().getPort()); } + // The number of requests of operations, i.e. without the probe. int requestCount() { return requestCount.get(); } + List requests() { + return List.copyOf(requests); + } + @Override public void close() { released.countDown(); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutExceptionTest.java deleted file mode 100644 index 4b0ab5c..0000000 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/HeartbeatTimeoutExceptionTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package io.thenativeweb.eventsourcingdb; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CancellationException; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -@Timeout(10) -class HeartbeatTimeoutExceptionTest { - private static final Duration TIMEOUT = Duration.ofMillis(300); - - record HeartbeatStream(String name, Function> open, String item) { - @Override - public String toString() { - return name; - } - } - - static List streams() { - return List.of( - new HeartbeatStream( - "observe events", - client -> client.observeEvents("/", new ObserveEventsOptions(true)), - Lines.EVENT), - new HeartbeatStream( - "run EventQL query", - client -> client.runEventQlQuery("FROM e IN events PROJECT INTO e"), - Lines.ROW)); - } - - private static Client clientWith(FakeServer server, Duration stallTimeout) { - return new Client(server.url(), "secret", new ClientOptions().withStallTimeout(stallTimeout)); - } - - @ParameterizedTest - @MethodSource("streams") - void endsAStreamIfNothingArrivesAfterAHeartbeat(HeartbeatStream stream) { - try (var server = FakeServer.start((exchange, fakeServer) -> { - FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.HEARTBEAT); - fakeServer.hang(); - }); - var items = stream.open().apply(clientWith(server, TIMEOUT))) { - var exception = assertThrows(HeartbeatTimeoutException.class, () -> items.forEach(item -> {})); - - assertEquals("no event and no heartbeat arrived for 300 milliseconds", exception.getMessage()); - } - } - - @Test - void namesTheDefaultStallTimeoutInItsMessage() { - var exception = new HeartbeatTimeoutException(new ClientOptions().stallTimeout()); - - assertEquals("no event and no heartbeat arrived for 30 seconds", exception.getMessage()); - } - - @ParameterizedTest - @MethodSource("streams") - void keepsAStreamAliveWhileHeartbeatsArrive(HeartbeatStream stream) { - try (var server = FakeServer.start((exchange, fakeServer) -> { - FakeServer.startStream(exchange); - for (var i = 0; i < 6; i++) { - FakeServer.writeLine(exchange, Lines.HEARTBEAT); - Thread.sleep(100); - } - FakeServer.writeLine(exchange, stream.item()); - fakeServer.hang(); - }); - var items = stream.open().apply(clientWith(server, TIMEOUT))) { - assertEquals(1, items.limit(1).count()); - } - } - - @ParameterizedTest - @MethodSource("streams") - void handsOutItemsThatArriveInTime(HeartbeatStream stream) { - try (var server = FakeServer.start((exchange, fakeServer) -> { - FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, stream.item()); - Thread.sleep(100); - FakeServer.writeLine(exchange, stream.item()); - fakeServer.hang(); - }); - var items = stream.open().apply(clientWith(server, TIMEOUT))) { - assertEquals(2, items.limit(2).count()); - } - } - - @ParameterizedTest - @MethodSource("streams") - void doesNotCountTheTimeTheCallerSpendsOnAnItem(HeartbeatStream stream) { - // The second item arrives while the caller is still busy with the - // first, so the client has to read again after the caller's time. - try (var server = FakeServer.start((exchange, fakeServer) -> { - FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, stream.item()); - Thread.sleep(TIMEOUT.dividedBy(3)); - FakeServer.writeLine(exchange, stream.item()); - fakeServer.hang(); - }); - var items = stream.open().apply(clientWith(server, TIMEOUT))) { - var count = - items.limit(2).peek(item -> sleep(TIMEOUT.multipliedBy(2))).count(); - - assertEquals(2, count); - } - } - - @ParameterizedTest - @MethodSource("streams") - void endsAStreamThatIsClosedWithACancellationRatherThanATimeout(HeartbeatStream stream) { - try (var server = FakeServer.start((exchange, fakeServer) -> { - FakeServer.startStream(exchange); - FakeServer.writeLine(exchange, Lines.HEARTBEAT); - fakeServer.hang(); - }); - var items = stream.open().apply(clientWith(server, Duration.ofSeconds(5))); - var scheduler = Executors.newSingleThreadScheduledExecutor()) { - scheduler.schedule(items::close, 300, TimeUnit.MILLISECONDS); - - assertThrows(CancellationException.class, () -> items.forEach(item -> {})); - } - } - - private static void sleep(Duration duration) { - try { - Thread.sleep(duration); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderExceptionTest.java index 770e8aa..120af17 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderExceptionTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderExceptionTest.java @@ -1,26 +1,25 @@ package io.thenativeweb.eventsourcingdb; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import io.thenativeweb.eventsourcingdb.Operations.Operation; import java.util.List; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; class InvalidServerHeaderExceptionTest { - static List all() { - return Operations.all(); + static List all() { + return Operations.onEveryApi(Operations.all()); } @ParameterizedTest @MethodSource("all") - void everyRequestReportsAServerWithoutAServerHeader(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> exchange.sendResponseHeaders(200, -1))) { + void everyRequestReportsAServerWithoutAServerHeader(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> exchange.sendResponseHeaders(200, -1))) { var client = new Client(server.url(), "secret"); - var exception = assertThrows( - InvalidServerHeaderException.class, () -> operation.call().accept(client)); + var exception = Operations.assertFailsAfterSending(InvalidServerHeaderException.class, operation, client); assertEquals("server must be EventSourcingDB", exception.getMessage()); } @@ -28,15 +27,14 @@ void everyRequestReportsAServerWithoutAServerHeader(Operation operation) { @ParameterizedTest @MethodSource("all") - void everyRequestReportsAnotherServerInsteadOfItsStatus(Operation operation) { - try (var server = FakeServer.start((exchange, fakeServer) -> { + void everyRequestReportsAnotherServerInsteadOfItsStatus(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", "nginx"); exchange.sendResponseHeaders(502, -1); })) { var client = new Client(server.url(), "secret"); - assertThrows( - InvalidServerHeaderException.class, () -> operation.call().accept(client)); + Operations.assertFailsAfterSending(InvalidServerHeaderException.class, operation, client); } } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Lines.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Lines.java index 3af66fd..16adf59 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Lines.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Lines.java @@ -1,32 +1,29 @@ package io.thenativeweb.eventsourcingdb; -// Lines holds lines of NDJSON streams as the server sends them. +// Lines holds the items of NDJSON streams as the server sends them, in the +// envelope of the given API. final class Lines { private Lines() {} - static final String HEARTBEAT = "{\"type\":\"heartbeat\"}"; - - static final String ROW = "{\"type\":\"row\",\"payload\":{\"value\":23}}"; - - static final String SUBJECT = "{\"type\":\"subject\",\"payload\":{\"subject\":\"/\"}}"; - // Characters that the server treats differently in the data of an event: - // it escapes <, &, and > as \u003c, \u0026, and \u003e, and passes on + // it escapes <, &, and > as <, &, and >, and passes on // U+00E9 (e with acute, as a single code point) and U+1F600 (grinning // face) as they are. They are written as escapes, so that it is clear // which code points are meant. - static final String SPECIAL_CHARACTERS = "<&> \u00e9 \ud83d\ude00"; + static final String SPECIAL_CHARACTERS = "<&> é 😀"; - // A line that EventSourcingDB sent while reading events, captured as it + // An event that EventSourcingDB sent while reading events, captured as it // was, including the way the server escapes characters in the data. Its // data contains the special characters above. - static final String EVENT = """ - {"type":"event","payload":{"source":"https://x","subject":"/a","type":"io.x.y",\ + static final String EVENT_PAYLOAD = """ + {"source":"https://x","subject":"/a","type":"io.x.y",\ "traceparent":"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01","specversion":"1.0",\ "id":"0","time":"2026-10-07T13:32:50.439461594Z","datacontenttype":"application/json",\ "predecessorhash":"0000000000000000000000000000000000000000000000000000000000000000",\ - "data":{"b":100,"k":{"a":2,"b":1},"n":1.5,"z":"\\u003c\\u0026\\u003e \u00e9 \ud83d\ude00"},\ - "hash":"a6015b0e09ab312b6ff506e267ad46d6e806f3a13041f948cf5c5ebbf83105f0","signature":null}}"""; + "data":{"b":100,"k":{"a":2,"b":1},"n":1.5,"z":"\\u003c\\u0026\\u003e é 😀"},\ + "hash":"a6015b0e09ab312b6ff506e267ad46d6e806f3a13041f948cf5c5ebbf83105f0","signature":null}"""; + + static final String EVENT = ApiVersion.V1.item("event", EVENT_PAYLOAD); // Lines that EventSourcingDB sent while reading events whose hash is of // version 2, captured as they were. The first has a trace context, which @@ -37,7 +34,7 @@ private Lines() {} "traceparent":"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01","tracestate":"vendor=value",\ "specversion":"1.0","id":"0","time":"2026-10-18T13:48:32.370154712Z","datacontenttype":"application/json",\ "predecessorhash":"0000000000000000000000000000000000000000000000000000000000000000",\ - "data":{"b":100,"k":{"a":2,"b":1},"n":1.5,"z":"\\u003c\\u0026\\u003e \u00e9 \ud83d\ude00"},\ + "data":{"b":100,"k":{"a":2,"b":1},"n":1.5,"z":"\\u003c\\u0026\\u003e é 😀"},\ "hash":"esdb:hash:v2:e1a653d0d08fcd0d41d18b8f4082a93561e98dd6453dc9ee45ebe260b3c22ae8","signature":null}}"""; static final String EVENT_WITH_HASH_OF_VERSION_2_WITHOUT_TRACE_CONTEXT = """ @@ -46,4 +43,52 @@ private Lines() {} "predecessorhash":"esdb:hash:v2:e1a653d0d08fcd0d41d18b8f4082a93561e98dd6453dc9ee45ebe260b3c22ae8",\ "data":{"value":42},\ "hash":"esdb:hash:v2:3a48e013665367cff6b0814f44cb9a1660a4c7dbe7994f94d38b4f16cad2fe93","signature":null}}"""; + + // A line that EventSourcingDB sent through the v2 API while reading an + // event without a trace context, captured as it was. The v2 API sends the + // trace context as null then. + static final String EVENT_OF_THE_V2_API = """ + {"type":"event","data":{"specversion":"1.0","id":"0","source":"https://x","subject":"/a",\ + "type":"io.x.y","time":"2026-10-18T20:59:26.979078761Z",\ + "predecessorhash":"0000000000000000000000000000000000000000000000000000000000000000",\ + "datacontenttype":"application/json","data":{"v":1},"traceparent":null,"tracestate":null,\ + "hash":"esdb:hash:v2:524413a3b6567f63735d87e9eb97a77cf0a3052b032b7fb06adce09de518cf11","signature":null}}"""; + + static String heartbeat(ApiVersion api) { + return api == ApiVersion.V1 ? "{\"type\":\"heartbeat\"}" : api.item("heartbeat", "{}"); + } + + static String event(ApiVersion api) { + return api.item("event", EVENT_PAYLOAD); + } + + // A row that holds the number 23. + static String row(ApiVersion api) { + return api.item("row", api == ApiVersion.V1 ? "23" : "{\"value\":23}"); + } + + static String subject(ApiVersion api, String subject) { + return api.item("subject", "{\"subject\":\"" + subject + "\"}"); + } + + static String subject(ApiVersion api) { + return subject(api, "/"); + } + + // An event type of which events were written. + static String eventType(ApiVersion api) { + return api == ApiVersion.V1 + ? api.item("eventType", "{\"eventType\":\"io.x.y\",\"isPhantom\":false}") + : api.item("eventType", "{\"type\":\"io.x.y\",\"hasEvents\":true,\"schema\":null}"); + } + + // An error, as the API reports it within a stream. + static String error(ApiVersion api) { + return api == ApiVersion.V1 + ? api.item("error", "{\"error\":\"something went wrong\"}") + : api.item( + "error", + "{\"code\":\"https://docs.eventsourcingdb.io/errors/store-failed\"," + + "\"description\":\"something went wrong\",\"details\":{\"event\":0}}"); + } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Operations.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Operations.java index 0955a9f..2e9edc1 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Operations.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/Operations.java @@ -1,14 +1,25 @@ package io.thenativeweb.eventsourcingdb; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Stream; +import org.junit.jupiter.params.provider.Arguments; // Operations lists every request the client can make, so that tests can check // a behavior that all of them share. final class Operations { + // The operations that write, whose failures may leave open whether the + // server stored the write. + private static final Set WRITES = Set.of("write events", "register event schema"); + private Operations() {} // A stream is opened by open, and call consumes it completely. A request @@ -30,6 +41,10 @@ boolean isStream() { return open != null; } + boolean isWrite() { + return WRITES.contains(action); + } + @Override public String toString() { return action; @@ -48,7 +63,7 @@ static List all() { Operation.stream("observe events", client -> client.observeEvents("/", new ObserveEventsOptions(true))), Operation.stream( "run EventQL query", client -> client.runEventQlQuery("FROM e IN events PROJECT INTO e")), - Operation.stream("read subjects", client -> client.readSubjects("/")), + Operation.stream("read subjects", client -> client.readSubjects("/", new ReadSubjectsOptions(true))), Operation.stream("read event types", Client::readEventTypes), Operation.request("read event type", client -> client.readEventType("io.eventsourcingdb.test")), Operation.request( @@ -65,4 +80,39 @@ static List authenticated() { static List streams() { return all().stream().filter(Operation::isStream).toList(); } + + static List writes() { + return all().stream().filter(Operation::isWrite).toList(); + } + + // Pairs each operation with each API, so that a test runs every operation + // against a server with each API. + static List onEveryApi(List operations) { + return Arrays.stream(ApiVersion.values()) + .flatMap(api -> operations.stream().map(operation -> Arguments.of(api, operation))) + .toList(); + } + + // Asserts that the operation fails with the given type of exception, for + // a failure after which a write may have been stored nevertheless: a + // write then fails with an OutcomeUnknownException whose cause is of the + // given type. Returns the exception of the given type. + static T assertFailsAfterSending(Class type, Operation operation, Client client) { + return assertFailedAfterSending( + type, + operation, + assertThrows(Throwable.class, () -> operation.call().accept(client))); + } + + // Asserts what assertFailsAfterSending does, for an operation that threw + // the given exception. + static T assertFailedAfterSending(Class type, Operation operation, Throwable thrown) { + if (operation.isWrite()) { + var outcomeUnknown = assertInstanceOf(OutcomeUnknownException.class, thrown); + assertEquals("failed to " + operation.action() + ", the outcome is unknown", outcomeUnknown.getMessage()); + return assertInstanceOf(type, outcomeUnknown.getCause()); + } + + return assertInstanceOf(type, thrown); + } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java new file mode 100644 index 0000000..de108f6 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java @@ -0,0 +1,300 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.thenativeweb.eventsourcingdb.Operations.Operation; +import java.io.IOException; +import java.net.ConnectException; +import java.net.http.HttpConnectTimeoutException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Stream; +import javax.net.ssl.SSLHandshakeException; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import tools.jackson.core.JacksonException; + +// A write that fails is certainly not stored only if the client could not +// connect at all, or if the server refused it. Every other failure may come +// after the server stored it, and ends with an OutcomeUnknownException. +@Timeout(10) +class OutcomeUnknownExceptionTest { + private static final Duration STALL_TIMEOUT = Duration.ofMillis(300); + + static List writes() { + return Operations.onEveryApi(Operations.writes()); + } + + private static Stream writesWith(ApiVersion api, int... statuses) { + return Operations.writes().stream() + .flatMap(operation -> Arrays.stream(statuses).mapToObj(status -> Arguments.of(api, operation, status))); + } + + static Stream refusals() { + return Stream.concat( + writesWith(ApiVersion.V1, 400, 401, 404, 409, 413, 429, 503, 507), + writesWith(ApiVersion.V2, 400, 401, 408, 413, 429, 503)); + } + + static Stream statusesThatLeaveTheOutcomeOpen() { + return Stream.concat( + writesWith(ApiVersion.V1, 302, 500, 502, 504), + writesWith(ApiVersion.V2, 302, 404, 405, 409, 500, 502, 504, 507)); + } + + static Stream v2Writes() { + return Operations.writes().stream().map(Arguments::of); + } + + private static Client clientWith(FakeServer server) { + return new Client(server.url(), "secret", new ClientOptions().withStallTimeout(STALL_TIMEOUT)); + } + + private static T assertOutcomeUnknown( + Class causeType, Operation operation, Client client) { + var exception = assertThrows( + OutcomeUnknownException.class, () -> operation.call().accept(client)); + + assertEquals("failed to " + operation.action() + ", the outcome is unknown", exception.getMessage()); + return assertInstanceOf(causeType, exception.getCause()); + } + + // Answers the probe and the ping, and closes every connection, so that + // the client keeps none open, and has to connect for the write anew. + private static FakeServer answeringThePingAndClosingConnections(ApiVersion api) { + return FakeServer.start((exchange, fakeServer) -> { + exchange.getResponseHeaders().set("Connection", "close"); + + var path = exchange.getRequestURI().getPath(); + if (api == ApiVersion.V1 && path.equals(FakeServer.PROBE_PATH)) { + FakeServer.respond(exchange, 404, "404 page not found"); + return; + } + if (api == ApiVersion.V1) { + FakeServer.respond(exchange, 200, "{\"type\":\"io.eventsourcingdb.api.ping-received\"}"); + return; + } + FakeServer.respond(exchange, 200, ApiVersion.PING_RECEIVED); + }); + } + + @ParameterizedTest + @MethodSource("writes") + void reportsAWriteThatCouldNotConnectAsNotStored(ApiVersion api, Operation operation) { + var server = answeringThePingAndClosingConnections(api); + var client = clientWith(server); + client.ping(); + server.close(); + + var exception = assertThrows( + EventSourcingDbException.class, () -> operation.call().accept(client)); + + assertEquals("failed to " + operation.action(), exception.getMessage()); + assertInstanceOf(ConnectException.class, exception.getCause()); + } + + @ParameterizedTest + @MethodSource("refusals") + void reportsAWriteThatTheServerRefusesWithAStatusAsNotStored(ApiVersion api, Operation operation, int status) { + try (var server = FakeServer.respondingWith(api, status, "refused")) { + var exception = + assertThrows(DbApiException.class, () -> operation.call().accept(clientWith(server))); + + assertEquals(status, exception.status()); + } + } + + @ParameterizedTest + @MethodSource("statusesThatLeaveTheOutcomeOpen") + void reportsAWriteThatTheServerAnswersWithAnotherStatusAsUnknown(ApiVersion api, Operation operation, int status) { + try (var server = FakeServer.respondingWith(api, status, "failed")) { + var cause = assertOutcomeUnknown(DbApiException.class, operation, clientWith(server)); + + assertEquals(status, cause.status()); + } + } + + // The v2 API reports the errors of a write that it accepted within the + // stream that answers it, after heartbeats, if the write takes long. + @ParameterizedTest + @MethodSource("v2Writes") + void reportsAWriteThatTheV2ApiRefusesWithAnErrorAsNotStored(Operation operation) { + var error = ApiVersion.V2.item( + "error", + "{\"code\":\"" + ErrorCode.PRECONDITION_VIOLATED + "\",\"description\":\"refused\",\"details\":{}}"); + + try (var server = FakeServer.streaming(ApiVersion.V2, Lines.heartbeat(ApiVersion.V2), error)) { + var exception = + assertThrows(DbApiException.class, () -> operation.call().accept(clientWith(server))); + + assertEquals(200, exception.status()); + assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); + assertEquals("refused", exception.description()); + } + } + + // store-failed reports that storing failed halfway, so it may have been + // stored nevertheless. + @ParameterizedTest + @MethodSource("v2Writes") + void reportsAWriteThatTheV2ApiFailedToStoreAsUnknown(Operation operation) { + try (var server = FakeServer.streaming(ApiVersion.V2, Lines.error(ApiVersion.V2))) { + var cause = assertOutcomeUnknown(DbApiException.class, operation, clientWith(server)); + + assertEquals(ErrorCode.STORE_FAILED, cause.code()); + } + } + + @ParameterizedTest + @MethodSource("v2Writes") + void reportsAWriteWhoseAnswerOfTheV2ApiEndsWithoutItsEndItemAsUnknown(Operation operation) { + try (var server = FakeServer.start(ApiVersion.V2, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, Lines.heartbeat(ApiVersion.V2)); + })) { + var cause = assertOutcomeUnknown(EventSourcingDbException.class, operation, clientWith(server)); + + assertEquals("failed to " + operation.action() + ", the stream ended unexpectedly", cause.getMessage()); + } + } + + @ParameterizedTest + @MethodSource("v2Writes") + void reportsAWriteWhoseAnswerOfTheV2ApiHoldsAnUnsupportedItemAsUnknown(Operation operation) { + try (var server = FakeServer.streaming(ApiVersion.V2, Lines.subject(ApiVersion.V2))) { + var cause = assertOutcomeUnknown(EventSourcingDbException.class, operation, clientWith(server)); + + assertEquals("failed to handle unsupported line type: subject", cause.getMessage()); + } + } + + @ParameterizedTest + @MethodSource("writes") + void reportsAWriteThatStallsAsUnknown(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> fakeServer.hang())) { + assertOutcomeUnknown(ConnectionStalledException.class, operation, clientWith(server)); + } + } + + @ParameterizedTest + @MethodSource("writes") + void reportsAWriteWhoseConnectionBreaksAsUnknown(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> exchange.close())) { + var cause = assertOutcomeUnknown(EventSourcingDbException.class, operation, clientWith(server)); + + assertInstanceOf(IOException.class, cause.getCause()); + } + } + + @ParameterizedTest + @MethodSource("writes") + void reportsAWriteThatIsNotAnsweredByAnEventSourcingDbAsUnknown(ApiVersion api, Operation operation) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> exchange.sendResponseHeaders(200, -1))) { + assertOutcomeUnknown(InvalidServerHeaderException.class, operation, clientWith(server)); + } + } + + @Test + void reportsAWriteWhoseAnswerOfTheV1ApiIsMalformedAsUnknown() { + try (var server = FakeServer.respondingWith(ApiVersion.V1, 200, "[{")) { + var client = clientWith(server); + + var exception = assertThrows(OutcomeUnknownException.class, () -> client.writeEvents(List.of())); + + assertInstanceOf(JacksonException.class, exception.getCause()); + } + } + + @ParameterizedTest + @MethodSource("v2Writes") + void reportsAWriteWhoseAnswerOfTheV2ApiIsMalformedAsUnknown(Operation operation) { + try (var server = FakeServer.respondingWith(ApiVersion.V2, 200, "{\"type\":")) { + assertOutcomeUnknown(JacksonException.class, operation, clientWith(server)); + } + } + + // How far a connection got is up to the JDK, so these failures are + // checked on their own, as the JDK reports them. + static Stream failuresBeforeSending() { + return Stream.of( + Arguments.of(new ConnectException("connection refused")), + Arguments.of(new HttpConnectTimeoutException("connect timed out")), + Arguments.of(new SSLHandshakeException("handshake failed")), + Arguments.of(new IOException("failed", new ConnectException("connection refused")))); + } + + @ParameterizedTest + @MethodSource("failuresBeforeSending") + void reportsAFailureBeforeSendingAsNotStored(IOException cause) { + var failure = new EventSourcingDbException("failed to write events", cause); + + assertSame(failure, Outcomes.ofWrite("write events", failure, Outcomes.V1_REFUSALS, writeRetries(null))); + assertSame(failure, Outcomes.ofWrite("write events", failure, Outcomes.V2_REFUSALS, writeRetries(null))); + } + + private static WriteRetries writeRetries(@Nullable String systemProperty) { + return new WriteRetries(Path.of("missing", "net.properties"), name -> systemProperty); + } + + // If the JDK sends writes again, a refused connect may follow on a write + // that the server stored already, over a connection that closed before + // the answer arrived. + @Test + void reportsAFailureToConnectAsUnknownIfTheJdkSendsWritesAgain() { + var failure = new EventSourcingDbException("failed to write events", new ConnectException("refused")); + + var outcome = Outcomes.ofWrite("write events", failure, Outcomes.V2_REFUSALS, writeRetries("true")); + + assertInstanceOf(OutcomeUnknownException.class, outcome); + assertSame(failure, outcome.getCause()); + } + + @Test + void reportsAFailureToConnectAsNotStoredIfTheJdkDoesNotSendWritesAgain() { + var failure = new EventSourcingDbException("failed to write events", new ConnectException("refused")); + + var outcome = Outcomes.ofWrite("write events", failure, Outcomes.V2_REFUSALS, writeRetries(null)); + + assertSame(failure, outcome); + } + + // The JDK reads whether it sends writes again with the first request, so + // the client reads it once it is created, too, and keeps it, should it + // be cleared afterwards. + @Test + void reportsAWriteThatCouldNotConnectAsUnknownIfTheJdkSentWritesAgainWhenTheClientWasCreated() { + var systemProperties = new HashMap(); + systemProperties.put("jdk.httpclient.enableAllMethodRetry", "true"); + var writeRetries = new WriteRetries(Path.of("missing", "net.properties"), systemProperties::get); + + var server = answeringThePingAndClosingConnections(ApiVersion.V1); + var client = new Client(server.url(), "secret", new ClientOptions(), writeRetries); + systemProperties.clear(); + client.ping(); + server.close(); + + var exception = assertThrows(OutcomeUnknownException.class, () -> client.writeEvents(List.of())); + + assertInstanceOf(ConnectException.class, exception.getCause().getCause()); + } + + @Test + void reportsAnyOtherFailureAsUnknown() { + var failure = new EventSourcingDbException("failed to write events", new IOException("connection reset")); + + var outcome = Outcomes.ofWrite("write events", failure, Outcomes.V2_REFUSALS, writeRetries(null)); + + assertInstanceOf(OutcomeUnknownException.class, outcome); + assertSame(failure, outcome.getCause()); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PingTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PingTest.java index 6347cea..30cac13 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PingTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PingTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class PingTest { private Container container; @@ -37,9 +39,10 @@ void throwsIfTheServerIsNotReachable() { assertEquals("failed to ping", exception.getMessage()); } - @Test - void throwsIfTheServerDoesNotConfirmThePing() { - try (var server = FakeServer.respondingWith(200, "{\"type\":\"something-else\"}")) { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void throwsIfTheServerDoesNotConfirmThePing(ApiVersion api) { + try (var server = FakeServer.respondingWith(api, 200, api.item("event", "{\"type\":\"something-else\"}"))) { var client = new Client(server.url(), "secret"); var exception = assertThrows(EventSourcingDbException.class, client::ping); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java new file mode 100644 index 0000000..b133d67 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java @@ -0,0 +1,442 @@ +package io.thenativeweb.eventsourcingdb; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.net.ConnectException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.core.JacksonException; + +// The first request of a client asks the server whether it offers the v2 API, +// by pinging the v2 API. The client keeps a definitive answer, and asks again +// after any other. +@Timeout(10) +class ProbeTest { + private static final Duration STALL_TIMEOUT = Duration.ofMillis(300); + + private static final String V1_PING_RECEIVED = "{\"type\":\"io.eventsourcingdb.api.ping-received\"}"; + + private static final String V1_API_TOKEN_VERIFIED = "{\"type\":\"io.eventsourcingdb.api.api-token-verified\"}"; + + // A server that answers each probe with the next of the given answers, + // and every other request as EventSourcingDB 1.2.0 answers a ping or the + // verification of an API token. + private static final class ProbedServer implements AutoCloseable { + private final FakeServer server; + private final AtomicInteger probeCount = new AtomicInteger(); + + @FunctionalInterface + interface Answer { + void answer(HttpExchange exchange, FakeServer server) throws IOException, InterruptedException; + } + + ProbedServer(Answer... answers) { + server = FakeServer.start((exchange, fakeServer) -> { + if (exchange.getRequestURI().getPath().equals(FakeServer.PROBE_PATH)) { + var index = probeCount.getAndIncrement(); + answers[Math.min(index, answers.length - 1)].answer(exchange, fakeServer); + return; + } + + FakeServer.respond( + exchange, + 200, + exchange.getRequestURI().getPath().equals("/api/v1/verify-api-token") + ? V1_API_TOKEN_VERIFIED + : V1_PING_RECEIVED); + }); + } + + Client client() { + return new Client(server.url(), "secret", new ClientOptions().withStallTimeout(STALL_TIMEOUT)); + } + + int probeCount() { + return probeCount.get(); + } + + List paths() { + return server.requests().stream().map(FakeServer.Request::path).toList(); + } + + @Override + public void close() { + server.close(); + } + } + + private static ProbedServer.Answer status(int status) { + return (exchange, server) -> FakeServer.respond(exchange, status, "status " + status); + } + + private static final ProbedServer.Answer V1 = status(404); + + private static final ProbedServer.Answer V2 = + (exchange, server) -> FakeServer.respond(exchange, 200, ApiVersion.PING_RECEIVED); + + @ParameterizedTest + @ValueSource(ints = {404, 405}) + void usesTheV1ApiIfTheServerDoesNotKnowThePathOfThePing(int status) { + try (var server = new ProbedServer(status(status))) { + server.client().ping(); + + assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v1/ping"), server.paths()); + } + } + + @Test + void usesTheV2ApiIfTheServerAnswersThePing() { + try (var server = new ProbedServer(V2)) { + var client = server.client(); + + try (var subjects = client.readSubjects("/", new ReadSubjectsOptions(true))) { + assertThrows(EventSourcingDbException.class, subjects::toList); + } + + assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v2/data/read-subjects"), server.paths()); + } + } + + @Test + void asksOnlyOnceIfTheAnswerIsDefinitive() { + try (var server = new ProbedServer(V1)) { + var client = server.client(); + + client.ping(); + client.ping(); + + assertEquals(1, server.probeCount()); + assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v1/ping", "/api/v1/ping"), server.paths()); + } + } + + @Test + void sendsNoRequestWhenTheClientIsCreated() { + try (var server = new ProbedServer(V1)) { + server.client(); + + assertEquals(List.of(), server.paths()); + } + } + + @Test + void failsTheRequestAndAsksAgainIfTheServerFails() { + try (var server = new ProbedServer(status(500), V1)) { + var client = server.client(); + + var exception = assertThrows(DbApiException.class, client::ping); + assertDoesNotThrow(client::ping); + + assertEquals( + "failed to ping, got HTTP status code '500', expected '200': status 500", exception.getMessage()); + assertEquals(500, exception.status()); + assertEquals("", exception.code()); + assertEquals(2, server.probeCount()); + } + } + + @Test + void reportsHowLongToWaitIfTheProbeIsOneRequestTooMany() { + try (var server = new ProbedServer((exchange, fakeServer) -> { + exchange.getResponseHeaders().set("Retry-After", "3"); + FakeServer.respond(exchange, 429, "too many requests"); + })) { + var exception = assertThrows(DbApiException.class, server.client()::ping); + + assertEquals(429, exception.status()); + assertEquals(Optional.of(Duration.ofSeconds(3)), exception.retryAfter()); + } + } + + @Test + void failsTheRequestAndAsksAgainIfTheProbeStalls() { + var hasStalled = new AtomicInteger(); + + try (var server = new ProbedServer( + (exchange, fakeServer) -> { + hasStalled.incrementAndGet(); + fakeServer.hang(); + }, + V1)) { + var client = server.client(); + + var exception = assertThrows(ConnectionStalledException.class, client::ping); + assertDoesNotThrow(client::ping); + + assertEquals( + "failed to ping, nothing arrived from the server for 300 milliseconds", exception.getMessage()); + assertInstanceOf(HttpTimeoutException.class, exception.getCause()); + assertEquals(2, server.probeCount()); + } + } + + @Test + void failsTheRequestIfTheServerCanNotBeReached() { + var client = new Client(FakeServer.unusedUrl(), "secret"); + + var exception = assertThrows(EventSourcingDbException.class, client::verifyApiToken); + + assertEquals("failed to verify API token", exception.getMessage()); + assertInstanceOf(ConnectException.class, exception.getCause()); + } + + // A proxy that passes on the v1 API only answers the probe itself. The + // requests to the v1 API afterwards check that the server is an + // EventSourcingDB, so the probe does not need to. + @ParameterizedTest + @ValueSource(ints = {404, 405}) + void usesTheV1ApiIfAnotherServerDoesNotKnowThePathOfThePing(int status) { + try (var server = new ProbedServer((exchange, fakeServer) -> { + exchange.getResponseHeaders().set("Server", "nginx"); + exchange.sendResponseHeaders(status, -1); + })) { + var client = server.client(); + + assertDoesNotThrow(client::ping); + assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v1/ping"), server.paths()); + } + } + + // A proxy answers a path it does not pass on with a page of its own. + @ParameterizedTest + @ValueSource(ints = {404, 405}) + void usesTheV1ApiIfAnotherServerAnswersThePingWithAPage(int status) { + try (var server = new ProbedServer((exchange, fakeServer) -> { + var page = "

" + status + "


nginx
"; + exchange.getResponseHeaders().set("Server", "nginx"); + exchange.getResponseHeaders().set("Content-Type", "text/html"); + exchange.sendResponseHeaders(status, page.length()); + exchange.getResponseBody().write(page.getBytes(UTF_8)); + })) { + var client = server.client(); + + assertDoesNotThrow(client::ping); + assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v1/ping"), server.paths()); + } + } + + @ParameterizedTest + @ValueSource(ints = {200, 500}) + void failsTheRequestIfAnotherServerAnswersTheProbeOtherwise(int status) { + try (var server = new ProbedServer( + (exchange, fakeServer) -> { + exchange.getResponseHeaders().set("Server", "nginx"); + exchange.sendResponseHeaders(status, -1); + }, + V1)) { + var client = server.client(); + + assertThrows(InvalidServerHeaderException.class, client::ping); + assertDoesNotThrow(client::ping); + assertEquals(2, server.probeCount()); + } + } + + @Test + void failsTheRequestIfTheServerAnswersThePingWithSomethingElse() { + try (var server = new ProbedServer( + (exchange, fakeServer) -> FakeServer.respond(exchange, 200, V1_PING_RECEIVED), + (exchange, fakeServer) -> FakeServer.respond(exchange, 200, ""), + V1)) { + var client = server.client(); + + var exception = assertThrows(EventSourcingDbException.class, () -> { + try (var eventTypes = client.readEventTypes()) { + eventTypes.toList(); + } + }); + var malformed = assertThrows(EventSourcingDbException.class, client::ping); + assertDoesNotThrow(client::ping); + + assertEquals( + "failed to read event types, the server answered /api/v2/system/ping unexpectedly", + exception.getMessage()); + assertNull(exception.getCause()); + assertEquals( + "failed to ping, the server answered /api/v2/system/ping unexpectedly", malformed.getMessage()); + assertInstanceOf(JacksonException.class, malformed.getCause()); + } + } + + // A write whose probe fails was never sent, so it was certainly not + // stored. + @Test + void reportsAWriteWhoseProbeFailsAsNotStored() { + try (var server = new ProbedServer(status(500))) { + var client = server.client(); + + var exception = assertThrows(DbApiException.class, () -> client.writeEvents(List.of())); + + assertEquals("write events", exception.action()); + } + } + + // Waits until each of the threads waits, which they do only once they + // wait for the probe. + private static void awaitWaiting(List threads) { + while (threads.stream().anyMatch(thread -> thread.getState() != Thread.State.WAITING)) { + Thread.onSpinWait(); + } + } + + // Runs each of the requests in a thread of its own, releases the probe + // once all of them wait for it, and returns what each of them threw, or + // null for those that succeeded. + private static Map> runWhileTheProbeWaits( + CountDownLatch release, Consumer> beforeRelease, List requests) + throws InterruptedException { + var outcomes = new ConcurrentHashMap>(); + var threads = new ArrayList(); + + for (var index = 0; index < requests.size(); index++) { + var request = requests.get(index); + var requestIndex = index; + threads.add(Thread.ofPlatform().start(() -> { + try { + request.run(); + outcomes.put(requestIndex, Optional.empty()); + } catch (RuntimeException ex) { + outcomes.put(requestIndex, Optional.of(ex)); + } + })); + } + + awaitWaiting(threads); + beforeRelease.accept(threads); + release.countDown(); + for (var thread : threads) { + thread.join(); + } + + return outcomes; + } + + @Test + void sharesOneProbeBetweenRequestsThatAreMadeAtTheSameTime() throws InterruptedException { + var release = new CountDownLatch(1); + + try (var server = new ProbedServer((exchange, fakeServer) -> { + release.await(); + FakeServer.respond(exchange, 404, "404 page not found"); + })) { + var client = server.client(); + + var outcomes = runWhileTheProbeWaits( + release, threads -> {}, List.of(client::ping, client::ping, client::verifyApiToken)); + + assertEquals(Map.of(0, Optional.empty(), 1, Optional.empty(), 2, Optional.empty()), outcomes); + assertEquals(1, server.probeCount()); + } + } + + @Test + void failsEachRequestThatSharesAFailedProbeWithItsOwnAction() throws InterruptedException { + var release = new CountDownLatch(1); + + try (var server = new ProbedServer( + (exchange, fakeServer) -> { + release.await(); + FakeServer.respond(exchange, 503, "unavailable"); + }, + V1)) { + var client = server.client(); + + var outcomes = runWhileTheProbeWaits(release, threads -> {}, List.of(client::ping, client::verifyApiToken)); + + assertEquals( + "failed to ping, got HTTP status code '503', expected '200': unavailable", + outcomes.get(0).orElseThrow().getMessage()); + assertEquals( + "failed to verify API token, got HTTP status code '503', expected '200': unavailable", + outcomes.get(1).orElseThrow().getMessage()); + assertEquals(1, server.probeCount()); + } + } + + // A request that is aborted while it waits for the probe ends its own + // wait only, since other requests may wait for the same probe. + @Test + void anInterruptWhileWaitingForTheProbeEndsThatRequestOnly() throws InterruptedException { + var release = new CountDownLatch(1); + + try (var server = new ProbedServer((exchange, fakeServer) -> { + release.await(); + FakeServer.respond(exchange, 404, "404 page not found"); + })) { + var client = server.client(); + + var outcomes = runWhileTheProbeWaits( + release, + threads -> { + threads.getFirst().interrupt(); + while (threads.getFirst().isAlive()) { + Thread.onSpinWait(); + } + }, + List.of(() -> client.writeEvents(List.of()), client::ping)); + + var cancellation = assertInstanceOf( + CancellationException.class, outcomes.get(0).orElseThrow()); + assertEquals("failed to write events, the request was canceled", cancellation.getMessage()); + assertEquals(Optional.empty(), outcomes.get(1)); + assertEquals(1, server.probeCount()); + } + } + + @Test + void aCloseEndsTheRequestsThatWaitForTheProbe() throws InterruptedException { + try (var server = new ProbedServer((exchange, fakeServer) -> fakeServer.hang())) { + var client = new Client(server.server.url(), "secret"); + var release = new CountDownLatch(1); + + var outcomes = runWhileTheProbeWaits(release, threads -> client.close(), List.of(client::ping)); + + assertInstanceOf(CancellationException.class, outcomes.get(0).orElseThrow()); + } + } + + @Test + void aCloseEndsAStreamThatWaitsForTheProbe() throws InterruptedException { + var probed = new CountDownLatch(1); + + try (var server = new ProbedServer((exchange, fakeServer) -> { + probed.countDown(); + fakeServer.hang(); + })) { + var subjects = new Client(server.server.url(), "secret").readSubjects("/", new ReadSubjectsOptions(true)); + var consumer = Thread.currentThread(); + var closing = Thread.ofPlatform().start(() -> { + try { + probed.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + awaitWaiting(List.of(consumer)); + subjects.close(); + }); + + assertThrows(CancellationException.class, subjects::toList); + closing.join(); + } + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypeTest.java index 755849f..bd4e6d9 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypeTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypeTest.java @@ -32,22 +32,25 @@ void failsIfTheEventTypeDoesNotExist() { var exception = assertThrows(DbApiException.class, () -> client.readEventType("io.eventsourcingdb.test.nonexistent")); - assertEquals(404, exception.statusCode()); - assertFalse(exception.reason().isEmpty()); - assertEquals( - "failed to read event type, got HTTP status code '404', expected '200': " + exception.reason(), - exception.getMessage()); + assertEquals(ErrorCode.EVENT_TYPE_UNKNOWN, exception.code()); + assertFalse(exception.description().isEmpty()); } @Test void failsIfTheEventTypeIsMalformed() { var exception = assertThrows(DbApiException.class, () -> client.readEventType("io.eventsourcingdb.test.")); - assertEquals(400, exception.statusCode()); - assertFalse(exception.reason().isEmpty()); - assertEquals( - "failed to read event type, got HTTP status code '400', expected '200': " + exception.reason(), - exception.getMessage()); + // The v1 API refuses the request with a status code. The v2 API + // accepts a request of the right shape, and reports a value that is + // malformed in its answer. + if (Database.offersV2Api(container)) { + assertEquals(200, exception.status()); + assertEquals(ErrorCode.EVENT_TYPE_MALFORMED, exception.code()); + } else { + assertEquals(400, exception.status()); + assertEquals("", exception.code()); + } + assertFalse(exception.description().isEmpty()); } @Test @@ -65,6 +68,6 @@ void readsAnExistingEventType() { var eventType = client.readEventType("io.eventsourcingdb.test.foo"); - assertEquals(new EventType("io.eventsourcingdb.test.foo", true, Optional.of(schema)), eventType); + assertEquals(new EventType("io.eventsourcingdb.test.foo", false, Optional.of(schema)), eventType); } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypesTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypesTest.java index ee2ed89..16dafb2 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypesTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadEventTypesTest.java @@ -45,8 +45,8 @@ void readsAllEventTypes() { assertEquals( List.of( - new EventType("io.eventsourcingdb.test.bar", false, Optional.empty()), - new EventType("io.eventsourcingdb.test.foo", false, Optional.empty())), + new EventType("io.eventsourcingdb.test.bar", true, Optional.empty()), + new EventType("io.eventsourcingdb.test.foo", true, Optional.empty())), readEventTypes()); } @@ -63,6 +63,6 @@ void supportsReadingEventSchemas() { false); client.registerEventSchema("io.eventsourcingdb.test", schema); - assertEquals(List.of(new EventType("io.eventsourcingdb.test", true, Optional.of(schema))), readEventTypes()); + assertEquals(List.of(new EventType("io.eventsourcingdb.test", false, Optional.of(schema))), readEventTypes()); } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptionsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptionsTest.java new file mode 100644 index 0000000..8b5330f --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsOptionsTest.java @@ -0,0 +1,84 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +// The v2 API reads the subject alone or recursively, as the options say. The +// v1 API always reads the subjects within a base subject, so the client reads +// recursively, and keeps the subject alone if it is not to read recursively. +class ReadSubjectsOptionsTest { + private static FakeServer streamingSubjects(ApiVersion api) { + return FakeServer.streaming( + api, Lines.subject(api, "/test"), Lines.subject(api, "/test/1"), Lines.subject(api, "/test/2")); + } + + private static List readSubjects(FakeServer server, String subject, boolean recursive) { + try (var subjects = + new Client(server.url(), "secret").readSubjects(subject, new ReadSubjectsOptions(recursive))) { + return subjects.toList(); + } + } + + @ParameterizedTest + @CsvSource({"true", "false"}) + void readsTheSubjectsWithinTheSubjectFromTheV1Api(boolean recursive) { + try (var server = streamingSubjects(ApiVersion.V1)) { + readSubjects(server, "/test", recursive); + + assertEquals( + "{\"baseSubject\":\"/test\"}", server.requests().getLast().body()); + } + } + + @ParameterizedTest + @CsvSource({"/test", "/test/"}) + void keepsTheSubjectAloneFromTheV1ApiIfItIsNotToReadRecursively(String subject) { + try (var server = streamingSubjects(ApiVersion.V1)) { + assertEquals(List.of("/test"), readSubjects(server, subject, false)); + } + } + + @ParameterizedTest + @CsvSource({"/test", "/test/"}) + void keepsEverySubjectFromTheV1ApiIfItIsToReadRecursively(String subject) { + try (var server = streamingSubjects(ApiVersion.V1)) { + assertEquals(List.of("/test", "/test/1", "/test/2"), readSubjects(server, subject, true)); + } + } + + // The server names a subject with a slash in front and none at the end, + // e.g. /a/b for /a/b/, and lists the subject first, followed by those + // nested in it, as EventSourcingDB 1.2.0 does. + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = {"/a/b/ | /a/b;/a/b/c | /a/b", "/ | /;/a;/a/b | /", "/a | /a;/a/b | /a"}) + void keepsTheSubjectAloneFromTheV1ApiAsTheServerNamesIt(String subject, String subjectsRead, String expected) { + var api = ApiVersion.V1; + + try (var server = FakeServer.streaming( + api, + Arrays.stream(subjectsRead.split(";")) + .map(subjectRead -> Lines.subject(api, subjectRead)) + .toArray(String[]::new))) { + assertEquals(List.of(expected), readSubjects(server, subject, false)); + } + } + + // The v2 API reads as the options say, so the client keeps what it + // reads. + @ParameterizedTest + @CsvSource({"true", "false"}) + void asksTheV2ApiToReadAsTheOptionsSay(boolean recursive) { + try (var server = streamingSubjects(ApiVersion.V2)) { + assertEquals(List.of("/test", "/test/1", "/test/2"), readSubjects(server, "/test", recursive)); + assertEquals( + "{\"subject\":\"/test\",\"options\":{\"recursive\":" + recursive + "}}", + server.requests().getLast().body()); + } + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsTest.java index b841dcc..bf10a8c 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ReadSubjectsTest.java @@ -24,8 +24,8 @@ void stopContainer() { container.stop(); } - private List readSubjects(String baseSubject) { - try (var subjects = client.readSubjects(baseSubject)) { + private List readSubjects(String subject, boolean recursive) { + try (var subjects = client.readSubjects(subject, new ReadSubjectsOptions(recursive))) { return subjects.toList(); } } @@ -38,20 +38,45 @@ private void writeTwoEvents() { @Test void readsNoSubjectsIfTheDatabaseIsEmpty() { - assertEquals(List.of(), readSubjects("/")); + assertEquals(List.of(), readSubjects("/", true)); } @Test void readsAllSubjects() { writeTwoEvents(); - assertEquals(List.of("/", "/test", "/test/1", "/test/2"), readSubjects("/")); + assertEquals(List.of("/", "/test", "/test/1", "/test/2"), readSubjects("/", true)); } @Test - void readsAllSubjectsFromTheBaseSubject() { + void readsAllSubjectsWithinTheSubject() { writeTwoEvents(); - assertEquals(List.of("/test", "/test/1", "/test/2"), readSubjects("/test")); + assertEquals(List.of("/test", "/test/1", "/test/2"), readSubjects("/test", true)); + } + + @Test + void readsTheSubjectAloneIfItIsNotToReadRecursively() { + writeTwoEvents(); + + assertEquals(List.of("/test"), readSubjects("/test", false)); + assertEquals(List.of("/test/1"), readSubjects("/test/1", false)); + } + + // The server names a subject without a slash at its end, e.g. /test for + // /test/, whichever API the client reads it through. + @Test + void readsTheSubjectAloneAsTheServerNamesIt() { + writeTwoEvents(); + + assertEquals(List.of("/test"), readSubjects("/test/", false)); + assertEquals(List.of("/"), readSubjects("/", false)); + } + + @Test + void readsNoSubjectIfThereAreNoEventsWithinIt() { + writeTwoEvents(); + + assertEquals(List.of(), readSubjects("/other", false)); } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RegisterEventSchemaTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RegisterEventSchemaTest.java index d3e0bfc..1b4eb2c 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RegisterEventSchemaTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RegisterEventSchemaTest.java @@ -47,6 +47,6 @@ void throwsIfAnEventSchemaIsAlreadyRegistered() { var exception = assertThrows(DbApiException.class, () -> client.registerEventSchema("io.eventsourcingdb.test", SCHEMA)); - assertEquals(409, exception.statusCode()); + assertEquals(ErrorCode.EVENT_SCHEMA_ALREADY_REGISTERED, exception.code()); } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RoutesTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RoutesTest.java new file mode 100644 index 0000000..d69df72 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/RoutesTest.java @@ -0,0 +1,190 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.thenativeweb.eventsourcingdb.FakeServer.Request; +import io.thenativeweb.eventsourcingdb.Operations.Operation; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +// Each operation goes to the route of the API that the server offers, with +// the method and the body that the API takes. The v2 API reads with QUERY, +// and refuses fields it does not know and null, so no body holds them. +class RoutesTest { + private static Operation operation(String action) { + return Operations.all().stream() + .filter(operation -> operation.action().equals(action)) + .findFirst() + .orElseThrow(); + } + + static Stream routes() { + return Stream.of( + Arguments.of(ApiVersion.V1, operation("ping"), new Request("GET", "/api/v1/ping", "")), + Arguments.of( + ApiVersion.V1, + operation("verify API token"), + new Request("POST", "/api/v1/verify-api-token", "")), + Arguments.of( + ApiVersion.V1, + operation("write events"), + new Request( + "POST", + "/api/v1/write-events", + "{\"events\":[{\"source\":\"https://www.eventsourcingdb.io\",\"subject\":\"/test\"," + + "\"type\":\"io.eventsourcingdb.test\",\"data\":{}}]}")), + Arguments.of( + ApiVersion.V1, + operation("read events"), + new Request( + "POST", "/api/v1/read-events", "{\"subject\":\"/\",\"options\":{\"recursive\":true}}")), + Arguments.of( + ApiVersion.V1, + operation("observe events"), + new Request( + "POST", + "/api/v1/observe-events", + "{\"subject\":\"/\",\"options\":{\"recursive\":true}}")), + Arguments.of( + ApiVersion.V1, + operation("run EventQL query"), + new Request( + "POST", + "/api/v1/run-eventql-query", + "{\"query\":\"FROM e IN events PROJECT INTO e\"}")), + Arguments.of( + ApiVersion.V1, + operation("read subjects"), + new Request("POST", "/api/v1/read-subjects", "{\"baseSubject\":\"/\"}")), + Arguments.of( + ApiVersion.V1, + operation("read event types"), + new Request("POST", "/api/v1/read-event-types", "{}")), + Arguments.of( + ApiVersion.V1, + operation("read event type"), + new Request("POST", "/api/v1/read-event-type", "{\"eventType\":\"io.eventsourcingdb.test\"}")), + Arguments.of( + ApiVersion.V1, + operation("register event schema"), + new Request( + "POST", + "/api/v1/register-event-schema", + "{\"eventType\":\"io.eventsourcingdb.test\",\"schema\":{\"type\":\"object\"}}")), + Arguments.of(ApiVersion.V2, operation("ping"), new Request("GET", "/api/v2/system/ping", "")), + Arguments.of( + ApiVersion.V2, + operation("verify API token"), + new Request("POST", "/api/v2/system/verify-token", "")), + Arguments.of( + ApiVersion.V2, + operation("write events"), + new Request( + "POST", + "/api/v2/data/write-events", + "{\"events\":[{\"source\":\"https://www.eventsourcingdb.io\",\"subject\":\"/test\"," + + "\"type\":\"io.eventsourcingdb.test\",\"data\":{}}]}")), + Arguments.of( + ApiVersion.V2, + operation("read events"), + new Request( + "QUERY", + "/api/v2/data/read-events", + "{\"subject\":\"/\",\"options\":{\"recursive\":true}}")), + Arguments.of( + ApiVersion.V2, + operation("observe events"), + new Request( + "QUERY", + "/api/v2/data/observe-events", + "{\"subject\":\"/\",\"options\":{\"recursive\":true}}")), + Arguments.of( + ApiVersion.V2, + operation("run EventQL query"), + new Request( + "QUERY", + "/api/v2/data/run-eventql-query", + "{\"query\":\"FROM e IN events PROJECT INTO e\"}")), + Arguments.of( + ApiVersion.V2, + operation("read subjects"), + new Request( + "QUERY", + "/api/v2/data/read-subjects", + "{\"subject\":\"/\",\"options\":{\"recursive\":true}}")), + Arguments.of( + ApiVersion.V2, + operation("read event types"), + new Request("QUERY", "/api/v2/data/read-event-types", "")), + Arguments.of( + ApiVersion.V2, + operation("read event type"), + new Request("QUERY", "/api/v2/data/read-event-type", "{\"type\":\"io.eventsourcingdb.test\"}")), + Arguments.of( + ApiVersion.V2, + operation("register event schema"), + new Request( + "POST", + "/api/v2/data/register-event-schema", + "{\"type\":\"io.eventsourcingdb.test\",\"schema\":{\"type\":\"object\"}}"))); + } + + // Sends the request of the operation to a server that refuses it, and + // returns it as the server received it. + private static Request requestOf(ApiVersion api, Operation operation) { + try (var server = FakeServer.respondingWith(api, 500, "refused")) { + try { + operation.call().accept(new Client(server.url(), "secret")); + } catch (EventSourcingDbException ex) { + // The server refuses every request, so that its request is all + // that the test looks at. + } + + return server.requests().getLast(); + } + } + + @ParameterizedTest + @MethodSource("routes") + void sendsEveryOperationToTheRouteOfTheApi(ApiVersion api, Operation operation, Request request) { + assertEquals(request, requestOf(api, operation)); + } + + @Test + void leavesOutTheOptionsOfReadingThatAreNotSetRatherThanSendingNull() { + var request = requestOf( + ApiVersion.V2, + Operation.stream( + "read events", + client -> client.readEvents( + "/", + new ReadEventsOptions(false) + .withOrder(Order.ANTICHRONOLOGICAL) + .withUpperBound(new Bound("7", BoundType.EXCLUSIVE))))); + + assertEquals( + "{\"subject\":\"/\",\"options\":{\"recursive\":false,\"order\":\"antichronological\"," + + "\"upperBound\":{\"id\":\"7\",\"type\":\"exclusive\"}}}", + request.body()); + } + + @Test + void sendsATraceContextOnlyIfTheEventHasOne() { + var request = requestOf( + ApiVersion.V2, + Operation.request( + "write events", + client -> client.writeEvents(List.of( + new EventCandidate("https://x", "/a", "io.x.y", Map.of(), "00-1-2-01", null))))); + + assertEquals( + "{\"events\":[{\"source\":\"https://x\",\"subject\":\"/a\",\"type\":\"io.x.y\",\"data\":{}," + + "\"traceparent\":\"00-1-2-01\"}]}", + request.body()); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/StreamTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/StreamTest.java index 442cc10..893f006 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/StreamTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/StreamTest.java @@ -8,24 +8,55 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import tools.jackson.core.JacksonException; @Timeout(10) class StreamTest { - private static FakeServer streaming(String... lines) { - return FakeServer.start((exchange, server) -> { - FakeServer.startStream(exchange); - for (var line : lines) { - FakeServer.writeLine(exchange, line); - } - }); + // A stream along with a line that holds one of its items. + record ItemStream(String name, Function> open, Function item) { + @Override + public String toString() { + return name; + } } - @Test - void readsAnEventExactlyAsTheServerSentIt() { - try (var server = streaming(Lines.EVENT); + // The streams that end once they are complete, which observing never is. + static List finiteStreams() { + return List.of( + new ItemStream( + "read events", client -> client.readEvents("/", new ReadEventsOptions(true)), Lines::event), + new ItemStream( + "run EventQL query", + client -> client.runEventQlQuery("FROM e IN events PROJECT INTO e"), + Lines::row), + new ItemStream( + "read subjects", + client -> client.readSubjects("/", new ReadSubjectsOptions(true)), + Lines::subject), + new ItemStream("read event types", Client::readEventTypes, Lines::eventType)); + } + + static Stream finiteStreamsOnEveryApi() { + return Stream.of(ApiVersion.values()) + .flatMap(api -> finiteStreams().stream().map(stream -> Arguments.of(api, stream))); + } + + private static Stream readSubjects(FakeServer server) { + return new Client(server.url(), "secret").readSubjects("/", new ReadSubjectsOptions(true)); + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void readsAnEventExactlyAsTheServerSentIt(ApiVersion api) { + try (var server = FakeServer.streaming(api, Lines.event(api)); var events = new Client(server.url(), "secret").readEvents("/a", new ReadEventsOptions(false))) { var event = events.toList().getFirst(); @@ -35,59 +66,207 @@ void readsAnEventExactlyAsTheServerSentIt() { } } + // The v2 API sends a trace context that an event does not have as null, + // rather than leaving it out, as the v1 API does. @Test - void skipsBlankLinesAndHeartbeats() { - try (var server = streaming("", Lines.HEARTBEAT, Lines.SUBJECT); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + void readsAnEventOfTheV2ApiWithoutATraceContext() { + try (var server = FakeServer.streaming(ApiVersion.V2, Lines.EVENT_OF_THE_V2_API); + var events = new Client(server.url(), "secret").readEvents("/a", new ReadEventsOptions(false))) { + var event = events.toList().getFirst(); + + assertEquals(Optional.empty(), event.traceParent()); + assertEquals(Optional.empty(), event.traceState()); + event.verifyHash(); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void skipsBlankLinesAndHeartbeats(ApiVersion api) { + try (var server = FakeServer.streaming(api, "", Lines.heartbeat(api), Lines.subject(api)); + var subjects = readSubjects(server)) { assertEquals(List.of("/"), subjects.toList()); } } + @ParameterizedTest + @EnumSource(ApiVersion.class) + void readsTheValuesThatAQueryProjects(ApiVersion api) { + try (var server = FakeServer.streaming(api, Lines.row(api)); + var rows = new Client(server.url(), "secret").runEventQlQuery("FROM e IN events PROJECT INTO 23")) { + assertEquals(List.of(23), rows.map(row -> row.asInt()).toList()); + } + } + @Test - void endsWithTheErrorTheServerSends() { - try (var server = streaming( - Lines.SUBJECT, "{\"type\":\"error\",\"payload\":{\"error\":\"something went wrong\"}}"); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + void endsWithTheErrorThatTheV1ApiSends() { + try (var server = + FakeServer.streaming(ApiVersion.V1, Lines.subject(ApiVersion.V1), Lines.error(ApiVersion.V1)); + var subjects = readSubjects(server)) { var subjectsRead = new ArrayList(); - var exception = assertThrows(EventSourcingDbException.class, () -> subjects.forEach(subjectsRead::add)); + var exception = assertThrows(DbApiException.class, () -> subjects.forEach(subjectsRead::add)); assertEquals(List.of("/"), subjectsRead); assertEquals("failed to read subjects, got error: something went wrong", exception.getMessage()); + assertEquals(200, exception.status()); + assertEquals("", exception.code()); + assertEquals("something went wrong", exception.description()); + assertEquals(Json.MAPPER.createObjectNode(), exception.details()); } } @Test - void failsOnAnUnsupportedLineType() { - try (var server = streaming("{\"type\":\"something-else\"}"); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { - var exception = assertThrows(EventSourcingDbException.class, subjects::toList); + void endsWithTheErrorThatTheV2ApiSends() { + try (var server = + FakeServer.streaming(ApiVersion.V2, Lines.subject(ApiVersion.V2), Lines.error(ApiVersion.V2)); + var subjects = readSubjects(server)) { + var subjectsRead = new ArrayList(); - assertEquals("failed to handle unsupported line type: something-else", exception.getMessage()); + var exception = assertThrows(DbApiException.class, () -> subjects.forEach(subjectsRead::add)); + + assertEquals(List.of("/"), subjectsRead); + assertEquals("failed to read subjects, got error: something went wrong", exception.getMessage()); + assertEquals(200, exception.status()); + assertEquals(ErrorCode.STORE_FAILED, exception.code()); + assertEquals("something went wrong", exception.description()); + assertEquals(0, exception.details().path("event").asInt(-1)); + } + } + + // The v2 API always sends the details of an error, as an object, which + // the client stands in for if the server sends none nevertheless. + @Test + void reportsAnErrorWithoutDetailsWithDetailsThatAreEmpty() { + var error = + ApiVersion.V2.item("error", "{\"code\":\"" + ErrorCode.STORE_FAILED + "\",\"description\":\"failed\"}"); + + try (var server = FakeServer.streaming(ApiVersion.V2, error); + var subjects = readSubjects(server)) { + var exception = assertThrows(DbApiException.class, subjects::toList); + + assertEquals(Json.MAPPER.createObjectNode(), exception.details()); + } + } + + @ParameterizedTest + @MethodSource("finiteStreamsOnEveryApi") + void endsAStreamThatIsComplete(ApiVersion api, ItemStream stream) { + try (var server = FakeServer.streaming(api, stream.item().apply(api)); + var items = stream.open().apply(new Client(server.url(), "secret"))) { + assertEquals(1, items.count()); + } + } + + // The server ends a stream of the v2 API with an end item, so that one it + // aborts can be told apart. + @ParameterizedTest + @MethodSource("finiteStreams") + void failsIfAStreamOfTheV2ApiEndsWithoutItsEndItem(ItemStream stream) { + try (var server = FakeServer.start(ApiVersion.V2, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, stream.item().apply(ApiVersion.V2)); + }); + var items = stream.open().apply(new Client(server.url(), "secret"))) { + var itemsRead = new ArrayList(); + + var exception = assertThrows(EventSourcingDbException.class, () -> items.forEach(itemsRead::add)); + + assertEquals(1, itemsRead.size()); + assertEquals("failed to " + stream.name() + ", the stream ended unexpectedly", exception.getMessage()); } } @Test - void ignoresPropertiesOfALineItDoesNotKnow() { - try (var server = streaming("{\"type\":\"subject\",\"payload\":{\"subject\":\"/\"},\"extra\":1}"); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + void readsNothingAfterTheEndItemOfAStreamOfTheV2Api() { + try (var server = FakeServer.start(ApiVersion.V2, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, Lines.subject(ApiVersion.V2)); + FakeServer.writeLine(exchange, ApiVersion.V2.end()); + FakeServer.writeLine(exchange, Lines.subject(ApiVersion.V2)); + fakeServer.hang(); + }); + var subjects = readSubjects(server)) { assertEquals(List.of("/"), subjects.toList()); } } + // Observing never ends, so the server ends it only after a failure that + // it could not report as an error. + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsIfObservingEnds(ApiVersion api) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { + FakeServer.startStream(exchange); + FakeServer.writeLine(exchange, Lines.event(api)); + }); + var events = new Client(server.url(), "secret").observeEvents("/", new ObserveEventsOptions(true))) { + var eventsObserved = new ArrayList(); + + var exception = assertThrows(EventSourcingDbException.class, () -> events.forEach(eventsObserved::add)); + + assertEquals(1, eventsObserved.size()); + assertEquals("failed to observe events, the stream ended unexpectedly", exception.getMessage()); + } + } + @Test - void failsOnALineThatIsNoObject() { - try (var server = streaming("[]"); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + void failsOnAnEndItemInAStreamOfTheV1Api() { + try (var server = FakeServer.streaming(ApiVersion.V1, ApiVersion.V1.item("end", "{}")); + var subjects = readSubjects(server)) { var exception = assertThrows(EventSourcingDbException.class, subjects::toList); - assertEquals("failed to handle unsupported line type: ", exception.getMessage()); + assertEquals("failed to handle unsupported line type: end", exception.getMessage()); } } @Test - void failsOnAnEventWithoutData() { - try (var server = streaming("{\"type\":\"event\",\"payload\":{\"id\":\"0\"}}"); + void failsOnAnEndItemWhileObserving() { + try (var server = FakeServer.streaming(ApiVersion.V2, ApiVersion.V2.end()); + var events = new Client(server.url(), "secret").observeEvents("/", new ObserveEventsOptions(true))) { + var exception = assertThrows(EventSourcingDbException.class, events::toList); + + assertEquals("failed to handle unsupported line type: end", exception.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsOnAnUnsupportedLineType(ApiVersion api) { + try (var server = FakeServer.streaming(api, "{\"type\":\"something-else\"}"); + var subjects = readSubjects(server)) { + var exception = assertThrows(EventSourcingDbException.class, subjects::toList); + + assertEquals("failed to handle unsupported line type: something-else", exception.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void ignoresPropertiesOfALineItDoesNotKnow(ApiVersion api) { + var subject = Lines.subject(api); + + try (var server = FakeServer.streaming(api, subject.substring(0, subject.length() - 1) + ",\"extra\":1}"); + var subjects = readSubjects(server)) { + assertEquals(List.of("/"), subjects.toList()); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsOnALineThatIsNoObject(ApiVersion api) { + try (var server = FakeServer.streaming(api, "[]"); + var subjects = readSubjects(server)) { + var exception = assertThrows(EventSourcingDbException.class, subjects::toList); + + assertEquals("failed to handle unsupported line type: ", exception.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsOnAnEventWithoutData(ApiVersion api) { + try (var server = FakeServer.streaming(api, api.item("event", "{\"id\":\"0\"}")); var events = new Client(server.url(), "secret").readEvents("/", new ReadEventsOptions(true))) { var exception = assertThrows(EventSourcingDbException.class, events::toList); @@ -95,22 +274,24 @@ void failsOnAnEventWithoutData() { } } - @Test - void failsOnAMalformedLine() { - try (var server = streaming("{\"type\":"); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsOnAMalformedLine(ApiVersion api) { + try (var server = FakeServer.streaming(api, "{\"type\":"); + var subjects = readSubjects(server)) { assertThrows(JacksonException.class, subjects::toList); } } - @Test - void failsIfTheConnectionBreaksInTheMiddle() { - try (var server = FakeServer.start((exchange, fakeServer) -> { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void failsIfTheConnectionBreaksInTheMiddle(ApiVersion api) { + try (var server = FakeServer.start(api, (exchange, fakeServer) -> { exchange.getResponseHeaders().set("Server", FakeServer.SERVER_HEADER); exchange.sendResponseHeaders(200, 1000); - FakeServer.writeLine(exchange, Lines.SUBJECT); + FakeServer.writeLine(exchange, Lines.subject(api)); }); - var subjects = new Client(server.url(), "secret").readSubjects("/")) { + var subjects = readSubjects(server)) { var subjectsRead = new ArrayList(); var exception = assertThrows(EventSourcingDbException.class, () -> subjects.forEach(subjectsRead::add)); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1ErrorCodesTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1ErrorCodesTest.java new file mode 100644 index 0000000..e041756 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1ErrorCodesTest.java @@ -0,0 +1,121 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.thenativeweb.eventsourcingdb.Operations.Operation; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +// The v1 API tells its errors apart by their texts only. The texts are those +// that EventSourcingDB 1.2.0 sends, which go on with what the error is about. +class V1ErrorCodesTest { + private static Operation operation(String action) { + return Operations.all().stream() + .filter(operation -> operation.action().equals(action)) + .findFirst() + .orElseThrow(); + } + + static Stream errorsWithACode() { + return Stream.of( + Arguments.of( + operation("write events"), + 409, + "state conflict: precondition failed", + ErrorCode.PRECONDITION_VIOLATED), + Arguments.of( + operation("write events"), + 409, + "state conflict: exceeded max store capacity", + ErrorCode.CAPACITY_EXCEEDED), + Arguments.of( + operation("write events"), + 409, + "schema conflict: event candidate does not match schema: missing properties: 'value'", + ErrorCode.EVENT_SCHEMA_VIOLATED), + Arguments.of( + operation("register event schema"), + 409, + "schema conflict: schema already exists", + ErrorCode.EVENT_SCHEMA_ALREADY_REGISTERED), + Arguments.of( + operation("register event schema"), + 409, + "schema conflict: event with ID 0: missing properties: 'value'", + ErrorCode.EVENT_SCHEMA_VIOLATED), + Arguments.of( + operation("read events"), + 409, + "state conflict: fromLatestEvent results in an event ID greater than upperBound ID", + ErrorCode.RANGE_UNSATISFIABLE), + Arguments.of( + operation("read event type"), + 404, + "event type 'io.eventsourcingdb.test' not found", + ErrorCode.EVENT_TYPE_UNKNOWN), + Arguments.of(operation("write events"), 507, "insufficient storage", ErrorCode.LICENSE_INVALID)); + } + + static Stream everyRequest() { + return Operations.all().stream().map(Arguments::of); + } + + // Each text is close to one that has a code, but either the text, the + // status code, or the request differs. + static Stream errorsWithoutACode() { + return Stream.of( + Arguments.of(operation("write events"), 409, "state conflict: something else"), + Arguments.of(operation("write events"), 409, "schema conflict: schema already exists"), + Arguments.of(operation("write events"), 400, "state conflict: precondition failed"), + Arguments.of(operation("register event schema"), 409, "schema conflict: something else"), + Arguments.of(operation("register event schema"), 507, "insufficient storage"), + Arguments.of( + operation("read events"), + 400, + "state conflict: fromLatestEvent results in an event ID greater than upperBound ID"), + Arguments.of(operation("read events"), 404, "event type 'io.eventsourcingdb.test' not found"), + Arguments.of(operation("read events"), 409, "state conflict: precondition failed"), + Arguments.of(operation("read event type"), 400, "invalid event type: 'io.eventsourcingdb.'"), + Arguments.of(operation("ping"), 503, "the database is shutting down"), + Arguments.of(operation("ping"), 500, "the current operation mode conflicts with the given request")); + } + + private static void assertCode(Operation operation, int status, String description, String code) { + try (var server = FakeServer.respondingWith(ApiVersion.V1, status, description + "\n")) { + var client = new Client(server.url(), "secret"); + + var exception = + assertThrows(DbApiException.class, () -> operation.call().accept(client)); + + assertEquals(status, exception.status()); + assertEquals(description, exception.description()); + assertEquals(code, exception.code()); + } + } + + @ParameterizedTest + @MethodSource("errorsWithACode") + void setsTheCodeOfTheV2ApiForAnErrorThatClearlyMatchesOne( + Operation operation, int status, String description, String code) { + assertCode(operation, status, description, code); + } + + @ParameterizedTest + @MethodSource("everyRequest") + void setsTheCodeForAnOperationModeThatDoesNotPermitARequest(Operation operation) { + assertCode( + operation, + 503, + "the current operation mode conflicts with the given request: writing is not permitted at this time", + ErrorCode.STORE_READ_ONLY); + } + + @ParameterizedTest + @MethodSource("errorsWithoutACode") + void leavesTheCodeEmptyForEveryOtherError(Operation operation, int status, String description) { + assertCode(operation, status, description, ""); + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1TransportTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1TransportTest.java new file mode 100644 index 0000000..8a96577 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V1TransportTest.java @@ -0,0 +1,37 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +// The v1 API answers a write with the events it wrote, as a JSON array, and a +// registration with the event that tells of it. +class V1TransportTest { + private static final ApiVersion API = ApiVersion.V1; + + @Test + void writesEvents() { + try (var server = FakeServer.respondingWith(API, 200, "[" + Lines.EVENT_PAYLOAD + "]")) { + var events = new Client(server.url(), "secret") + .writeEvents(List.of(new EventCandidate("https://x", "/a", "io.x.y", Map.of()))); + + assertEquals(1, events.size()); + assertEquals( + Lines.SPECIAL_CHARACTERS, events.getFirst().data().path("z").asString()); + events.getFirst().verifyHash(); + } + } + + @Test + void registersAnEventSchema() { + try (var server = + FakeServer.respondingWith(API, 200, "{\"type\":\"io.eventsourcingdb.api.event-schema-registered\"}")) { + var client = new Client(server.url(), "secret"); + + assertDoesNotThrow(() -> client.registerEventSchema("io.x.y", Map.of("type", "object"))); + } + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V2TransportTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V2TransportTest.java new file mode 100644 index 0000000..c29cc68 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/V2TransportTest.java @@ -0,0 +1,86 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +// The v2 API answers writes with a stream, which ends with an end item, and +// reports an error in its answer with a code. +class V2TransportTest { + private static final ApiVersion API = ApiVersion.V2; + + @Test + void writesEventsWhoseAnswerArrivesAfterHeartbeats() { + try (var server = + FakeServer.streaming(API, Lines.heartbeat(API), Lines.event(API), Lines.EVENT_OF_THE_V2_API)) { + var events = new Client(server.url(), "secret") + .writeEvents(List.of(new EventCandidate("https://x", "/a", "io.x.y", Map.of()))); + + assertEquals(2, events.size()); + assertEquals( + Lines.SPECIAL_CHARACTERS, events.getFirst().data().path("z").asString()); + events.getFirst().verifyHash(); + events.getLast().verifyHash(); + } + } + + @Test + void registersAnEventSchema() { + var registered = API.item( + "event", + "{\"specversion\":\"1.0\",\"id\":\"1\",\"source\":\"https://www.eventsourcingdb.io\"," + + "\"type\":\"io.eventsourcingdb.api.event-schema-registered\"," + + "\"data\":{\"type\":\"io.x.y\",\"schema\":{\"type\":\"object\"}}}"); + + try (var server = FakeServer.streaming(API, Lines.heartbeat(API), registered)) { + var client = new Client(server.url(), "secret"); + + assertDoesNotThrow(() -> client.registerEventSchema("io.x.y", Map.of("type", "object"))); + } + } + + @Test + void reportsAnEventTypeThatDoesNotExistWithTheErrorInTheAnswer() { + var error = API.item( + "error", + "{\"code\":\"" + ErrorCode.EVENT_TYPE_UNKNOWN + "\",\"description\":\"event type is unknown\"," + + "\"details\":{\"value\":\"io.x.y\"}}"); + + try (var server = FakeServer.respondingWith(API, 200, error)) { + var client = new Client(server.url(), "secret"); + + var exception = assertThrows(DbApiException.class, () -> client.readEventType("io.x.y")); + + assertEquals("failed to read event type, got error: event type is unknown", exception.getMessage()); + assertEquals(200, exception.status()); + assertEquals(ErrorCode.EVENT_TYPE_UNKNOWN, exception.code()); + assertEquals("io.x.y", exception.details().path("value").asString()); + } + } + + @Test + void failsOnAnAnswerToReadingAnEventTypeOfAnotherType() { + try (var server = FakeServer.respondingWith(API, 200, Lines.subject(API))) { + var client = new Client(server.url(), "secret"); + + var exception = assertThrows(EventSourcingDbException.class, () -> client.readEventType("io.x.y")); + + assertEquals("failed to handle unsupported line type: subject", exception.getMessage()); + } + } + + @Test + void failsOnAPingThatIsAnsweredWithSomethingOtherThanAnEvent() { + try (var server = FakeServer.respondingWith(API, 200, Lines.error(API))) { + var client = new Client(server.url(), "secret"); + + var exception = assertThrows(EventSourcingDbException.class, client::ping); + + assertEquals("failed to ping", exception.getMessage()); + } + } +} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/VerifyApiTokenTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/VerifyApiTokenTest.java index c89f91a..40ee3cc 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/VerifyApiTokenTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/VerifyApiTokenTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class VerifyApiTokenTest { private Container container; @@ -34,12 +36,27 @@ void throwsIfTheTokenIsInvalid() { var client = new Client(container.getBaseUrl(), "invalid-token"); var exception = assertThrows(DbApiException.class, client::verifyApiToken); - assertEquals(401, exception.statusCode()); + assertEquals(401, exception.status()); } - @Test - void throwsIfTheServerDoesNotConfirmTheToken() { - try (var server = FakeServer.respondingWith(200, "{\"type\":\"something-else\"}")) { + @ParameterizedTest + @EnumSource(ApiVersion.class) + void doesNotThrowIfTheServerConfirmsTheToken(ApiVersion api) { + var confirmation = api == ApiVersion.V1 + ? "{\"type\":\"io.eventsourcingdb.api.api-token-verified\"}" + : api.item("event", "{\"type\":\"io.eventsourcingdb.api.token-verified\"}"); + + try (var server = FakeServer.respondingWith(api, 200, confirmation)) { + var client = new Client(server.url(), "secret"); + + assertDoesNotThrow(client::verifyApiToken); + } + } + + @ParameterizedTest + @EnumSource(ApiVersion.class) + void throwsIfTheServerDoesNotConfirmTheToken(ApiVersion api) { + try (var server = FakeServer.respondingWith(api, 200, api.item("event", "{\"type\":\"something-else\"}"))) { var client = new Client(server.url(), "secret"); var exception = assertThrows(EventSourcingDbException.class, client::verifyApiToken); diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java index d3aa0b9..07aece8 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java @@ -109,7 +109,7 @@ void supportsTheIsSubjectPristinePrecondition() { var exception = assertThrows( DbApiException.class, () -> client.writeEvents(List.of(candidate(42)), List.of(new IsSubjectPristinePrecondition("/test")))); - assertEquals(409, exception.statusCode()); + assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); } @Test @@ -117,7 +117,7 @@ void supportsTheIsSubjectPopulatedPrecondition() { var exception = assertThrows( DbApiException.class, () -> client.writeEvents(List.of(candidate(42)), List.of(new IsSubjectPopulatedPrecondition("/test")))); - assertEquals(409, exception.statusCode()); + assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); client.writeEvents(List.of(candidate(23))); var writtenEvents = @@ -133,7 +133,7 @@ void supportsTheIsSubjectOnEventIdPrecondition() { DbApiException.class, () -> client.writeEvents( List.of(candidate(42)), List.of(new IsSubjectOnEventIdPrecondition("/test", "1")))); - assertEquals(409, exception.statusCode()); + assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); var writtenEvents = client.writeEvents(List.of(candidate(42)), List.of(new IsSubjectOnEventIdPrecondition("/test", "0"))); @@ -149,7 +149,7 @@ void supportsTheIsEventQlQueryTruePrecondition() { () -> client.writeEvents( List.of(candidate(42)), List.of(new IsEventQlQueryTruePrecondition("FROM e IN events PROJECT INTO COUNT() == 0")))); - assertEquals(409, exception.statusCode()); + assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); } @Test @@ -175,10 +175,9 @@ void reportsTheReasonIfAnEventDoesNotMatchItsSchema() { Map.of("value", 23, "extra", true))))); assertEquals("write events", exception.action()); - assertEquals(409, exception.statusCode()); - assertTrue(exception.reason().endsWith("additionalProperties 'extra' not allowed"), exception.reason()); - assertEquals( - "failed to write events, got HTTP status code '409', expected '200': " + exception.reason(), - exception.getMessage()); + assertEquals(ErrorCode.EVENT_SCHEMA_VIOLATED, exception.code()); + assertTrue( + exception.description().contains("additionalProperties 'extra' not allowed"), exception.description()); + assertTrue(exception.getMessage().endsWith(exception.description()), exception.getMessage()); } } diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteRetriesTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteRetriesTest.java new file mode 100644 index 0000000..6df4143 --- /dev/null +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteRetriesTest.java @@ -0,0 +1,93 @@ +package io.thenativeweb.eventsourcingdb; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +// The JDK sends writes again if its networking property +// jdk.httpclient.enableAllMethodRetry says so. It reads the property once, +// as a system property, falling back to conf/net.properties of the JDK, and +// takes an empty value for true. +class WriteRetriesTest { + private static final String PROPERTY = "jdk.httpclient.enableAllMethodRetry"; + + @TempDir + Path directory; + + private Path netProperties(String content) throws IOException { + return Files.writeString(directory.resolve("net.properties"), content); + } + + private Path missingNetProperties() { + return directory.resolve("net.properties"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "true", "TRUE"}) + void areEnabledByTheSystemProperty(String value) { + assertTrue(new WriteRetries(missingNetProperties(), name -> value).areEnabled()); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "yes"}) + void areNotEnabledByAnotherValueOfTheSystemProperty(String value) { + assertFalse(new WriteRetries(missingNetProperties(), name -> value).areEnabled()); + } + + @Test + void areNotEnabledWithoutTheProperty() { + assertFalse(new WriteRetries(missingNetProperties(), name -> null).areEnabled()); + } + + @Test + void areEnabledByTheNetPropertiesOfTheJdk() throws IOException { + var netProperties = netProperties(PROPERTY + "=true\n"); + + assertTrue(new WriteRetries(netProperties, name -> null).areEnabled()); + } + + @Test + void takeTheSystemPropertyOverTheNetPropertiesOfTheJdk() throws IOException { + var netProperties = netProperties(PROPERTY + "=true\n"); + + assertFalse(new WriteRetries(netProperties, name -> "false").areEnabled()); + } + + @Test + void areNotEnabledByOtherNetProperties() throws IOException { + var netProperties = netProperties("jdk.httpclient.disableRetryConnect=true\n"); + + assertFalse(new WriteRetries(netProperties, name -> null).areEnabled()); + } + + // The JDK goes on with the properties it read before a line that is + // malformed. + @Test + void keepTheNetPropertiesThatWereReadBeforeALineThatIsMalformed() throws IOException { + var netProperties = netProperties(PROPERTY + "=true\nbroken=\\u00zz\n"); + + assertTrue(new WriteRetries(netProperties, name -> null).areEnabled()); + } + + // The JDK keeps the value it read with the first request, so clearing the + // property afterwards does not stop it from sending writes again. + @Test + void stayEnabledOnceTheyWereEnabled() { + var systemProperties = new HashMap(); + var writeRetries = new WriteRetries(missingNetProperties(), systemProperties::get); + + systemProperties.put(PROPERTY, "true"); + assertTrue(writeRetries.areEnabled()); + + systemProperties.remove(PROPERTY); + assertTrue(writeRetries.areEnabled()); + } +} diff --git a/eventsourcingdb/src/test/resources/io/thenativeweb/eventsourcingdb/server-error-slugs.txt b/eventsourcingdb/src/test/resources/io/thenativeweb/eventsourcingdb/server-error-slugs.txt new file mode 100644 index 0000000..53c1af8 --- /dev/null +++ b/eventsourcingdb/src/test/resources/io/thenativeweb/eventsourcingdb/server-error-slugs.txt @@ -0,0 +1,71 @@ +// The codes that EventSourcingDB reports, as its server lists them in +// cmd/server/api/error.go on main. ErrorCodeTest checks the constants of +// ErrorCode against this list, so when the server adds a code, replace the +// block below with the one from the server. + +const ( + SlugSubjectMissing = "subject-missing" + SlugSubjectMalformed = "subject-malformed" + SlugOrderUnknown = "order-unknown" + SlugEventIDMissing = "event-id-missing" + SlugEventIDMalformed = "event-id-malformed" + SlugBoundTypeMissing = "bound-type-missing" + SlugBoundTypeUnknown = "bound-type-unknown" + SlugRangeUnsatisfiable = "range-unsatisfiable" + SlugFromLatestEventIncomplete = "from-latest-event-incomplete" + SlugIfEventIsMissingUnknown = "if-event-is-missing-unknown" + SlugSelectionConflicting = "selection-conflicting" + SlugEventTypeMissing = "event-type-missing" + SlugEventTypeMalformed = "event-type-malformed" + SlugEventTypeUnknown = "event-type-unknown" + SlugSourceMissing = "source-missing" + SlugSourceMalformed = "source-malformed" + SlugDataMissing = "data-missing" + SlugTraceParentMalformed = "traceparent-malformed" + SlugTraceStateMalformed = "tracestate-malformed" + SlugTraceStateWithoutParent = "tracestate-without-traceparent" + SlugEventsMissing = "events-missing" + SlugPreconditionTypeMissing = "precondition-type-missing" + SlugPreconditionUnknown = "precondition-unknown" + SlugPayloadMissing = "payload-missing" + SlugLicenseInvalid = "license-invalid" + SlugCapacityExceeded = "capacity-exceeded" + SlugEventSchemaViolated = "event-schema-violated" + SlugEventSchemaUnregistered = "event-schema-unregistered" + SlugEventSchemaMissing = "event-schema-missing" + SlugEventSchemaMalformed = "event-schema-malformed" + SlugEventSchemaNotObject = "event-schema-not-object" + SlugEventSchemaAlreadyRegistered = "event-schema-already-registered" + SlugLastAuditedEventMissing = "last-audited-event-missing" + SlugLastAuditedEventMismatched = "last-audited-event-mismatched" + SlugLastAuditedEventUnknown = "last-audited-event-unknown" + SlugEventHashMissing = "event-hash-missing" + SlugEventHashMalformed = "event-hash-malformed" + SlugEventChainBroken = "event-chain-broken" + SlugLastBackedUpEventMissing = "last-backed-up-event-missing" + SlugLastBackedUpEventMismatched = "last-backed-up-event-mismatched" + SlugLastBackedUpEventUnknown = "last-backed-up-event-unknown" + SlugStoreNotEmpty = "store-not-empty" + SlugEventHashVersionUnknown = "event-hash-version-unknown" + SlugBackupMalformed = "backup-malformed" + SlugBackupIncomplete = "backup-incomplete" + SlugBackupFormatUnknown = "backup-format-unknown" + SlugFullBackupMissing = "full-backup-missing" + SlugBackupIncrementDisconnected = "backup-increment-disconnected" + SlugBackupFailed = "backup-failed" + SlugBackupUnreadable = "backup-unreadable" + SlugEventTooLarge = "event-too-large" + SlugDataMalformed = "data-malformed" + SlugQueryMissing = "query-missing" + SlugQueryMalformed = "query-malformed" + SlugQueryUnevaluable = "query-unevaluable" + SlugQueryNotBoolean = "query-not-boolean" + SlugPreconditionViolated = "precondition-violated" + SlugStoreReadOnly = "store-read-only" + SlugReadOnlyModeAlreadyEnabled = "read-only-mode-already-enabled" + SlugDiskFull = "disk-full" + SlugRestoreInProgress = "restore-in-progress" + SlugStoreFailed = "store-failed" + SlugIndexesRebuilding = "indexes-rebuilding" + SlugServerShuttingDown = "server-shutting-down" +)