diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md b/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md index 62cd57a7b..99f0d9f64 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md @@ -56,19 +56,23 @@ DidClient client = new DidClient(resourceKey, licenceKey); // The 51Did arrives from the page in the URL-safe base64 alphabet. FodId fodId = FodId.fromBase64(did); -// Offline, against the cloud's published key for the identifier's date. -// No use is charged, because the client holds the keys. -boolean genuine = client.verifySignature(fodId); - -// Server side, with the licence key. One use. -RedeemResult redeemed = client.redeem(fodId, result, challenge); -RedeemResult.Context context = redeemed.getContext(); +// Both client calls answer through a CompletableFuture, so the calling +// thread is released at once. Offline first, against the cloud's +// published key for the identifier's date, where no use is charged +// because the client holds the keys. Then server side, with the licence +// key, which is one use. +CompletableFuture answer = client.verifySignature(fodId) + .thenCompose(genuine -> client.redeem(fodId, result, challenge) + .thenApply(redeemed -> toAnswer(redeemed, genuine))) + .exceptionally(CreatorContextDemoServer::failed); ``` The handler answers the page in the cloud's own shape, `signature`, `context`, `factors` when present, `verifiedAt` and `secondsSinceVerified`, built from the typed result, with one field -added, `serverSignature`, being the offline check. A malformed 51Did +added, `serverSignature`, being the offline check. A failure the client +reports arrives at the demo's `failed` method as the cause of a +`CompletionException` and is mapped there. A malformed 51Did answers 400, a host without the creator context answers 404 with a text body, and an unreachable cloud answers 502 with `{ "error": ... }`. A production server would also remember the challenge it issued and diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java index 22a06bbc6..9fdf503be 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java @@ -44,6 +44,8 @@ import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; /** * 51Did creator context demo server. Serves a page that runs the 51Did @@ -191,18 +193,33 @@ static void servePage(HttpExchange exchange) throws IOException { exchange.close(); } - static void redeem(HttpExchange exchange) throws IOException { + static void redeem(HttpExchange exchange) { Map query = parse( exchange.getRequestURI().getRawQuery()); - Answer answer = redeem( + // The route hands the exchange to the client's future and returns, + // so the web server's own thread is free while the cloud is asked. + redeem( client, decode(valueOr(query, "51did")), decode(valueOr(query, "result")), - decode(valueOr(query, "challenge"))); - exchange.getResponseHeaders().set("Content-Type", answer.type); - exchange.sendResponseHeaders(answer.status, answer.body.length); - exchange.getResponseBody().write(answer.body); - exchange.close(); + decode(valueOr(query, "challenge"))) + .thenAccept(answer -> write(exchange, answer)); + } + + /** Writes one answer to the page and closes the exchange. */ + static void write(HttpExchange exchange, Answer answer) { + try { + exchange.getResponseHeaders().set("Content-Type", answer.type); + exchange.sendResponseHeaders(answer.status, answer.body.length); + exchange.getResponseBody().write(answer.body); + } catch (IOException gone) { + // The browser went away between asking and being answered, + // which is nothing this demo can do anything about. + System.err.println("The page could not be answered: " + + gone.getMessage()); + } finally { + exchange.close(); + } } /** @@ -214,44 +231,74 @@ static void redeem(HttpExchange exchange) throws IOException { * with the licence key, which is added by the client here and only * here, so the browser never sees it. *

+ * Both client calls answer through a {@link CompletableFuture}, so the + * calling thread is released at once and the answer is built when the + * cloud has replied. A failure the client reports arrives at + * {@link #failed(Throwable)} as the cause of a + * {@link CompletionException} and is mapped to a status there. + *

* The page is answered in the cloud's own shape ({@code signature}, * {@code context}, {@code factors} when present, {@code verifiedAt}, * {@code secondsSinceVerified}) with one field added, * {@code serverSignature}, being this server's own offline check. The * page ignores fields it does not know. */ - static Answer redeem( + static CompletableFuture redeem( DidClient client, String did, String result, String challenge) { FodId fodId; try { fodId = FodId.fromBase64(did); } catch (OwidException notA51Did) { - return Answer.json(400, errors( - "'" + did + "' is not a valid Base64-encoded 51Did.")); + return CompletableFuture.completedFuture(Answer.json(400, errors( + "'" + did + "' is not a valid Base64-encoded 51Did."))); } catch (IllegalArgumentException notA51Did) { - return Answer.json(400, errors( - "'" + did + "' is not a valid Base64-encoded 51Did.")); + return CompletableFuture.completedFuture(Answer.json(400, errors( + "'" + did + "' is not a valid Base64-encoded 51Did."))); } - try { - String serverSignature = client.verifySignature(fodId) - ? "verified" : "invalid"; - RedeemResult redeemed = client.redeem(fodId, result, challenge); - return Answer.json( - redeemed.getStatusCode(), toJson(redeemed, serverSignature)); - } catch (DidNotSupportedException unsupported) { + return client.verifySignature(fodId) + .thenCompose(genuine -> client.redeem(fodId, result, challenge) + .thenApply(redeemed -> toAnswer(redeemed, genuine))) + .exceptionally(CreatorContextDemoServer::failed); + } + + /** The cloud's answer and this server's own check, as one answer. */ + static Answer toAnswer(RedeemResult redeemed, boolean genuine) { + return Answer.json(redeemed.getStatusCode(), + toJson(redeemed, genuine ? "verified" : "invalid")); + } + + /** + * What the page is told when the client reports a failure. The failure + * arrives wrapped, so the cause is the part that carries the meaning. + */ + static Answer failed(Throwable reported) { + Throwable failure = reported instanceof CompletionException + && reported.getCause() != null + ? reported.getCause() + : reported; + if (failure instanceof DidNotSupportedException) { // The host does not offer the creator context. The same status // and a text body, which the page reports as not supported by // this host. - return Answer.text(404, unsupported.getBody()); - } catch (IllegalArgumentException malformed) { - return Answer.json(400, errors(malformed.getMessage())); - } catch (DidHttpException other) { + return Answer.text(404, + ((DidNotSupportedException) failure).getBody()); + } + if (failure instanceof IllegalArgumentException) { + return Answer.json(400, errors(failure.getMessage())); + } + if (failure instanceof DidHttpException) { // Relayed as received, so the page sees what the cloud said. + DidHttpException other = (DidHttpException) failure; return Answer.text(other.getStatusCode(), other.getBody()); - } catch (IOException unreachable) { + } + if (failure instanceof IOException) { return Answer.json(502, new JSONObject() - .put("error", String.valueOf(unreachable.getMessage()))); + .put("error", String.valueOf(failure.getMessage()))); } + // Nothing the client is documented to report, so the page is told + // as much rather than being left waiting for an answer. + return Answer.json(500, new JSONObject() + .put("error", String.valueOf(failure))); } /** The cloud's own shape, plus {@code serverSignature}. */ diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java index f5f87e5d9..bd10cfdb3 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java @@ -40,6 +40,7 @@ import java.util.ArrayList; import java.util.Deque; import java.util.List; +import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -89,7 +90,8 @@ public void Redeem_Route_Answers_In_The_Clouds_Shape_With_ServerSignature() { + "\"secondsSinceVerified\":2}"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); assertEquals(200, answer.status); assertEquals("application/json", answer.type); @@ -127,7 +129,8 @@ public void Redeem_Route_Relays_Factors_On_A_Mismatch() { + "\"secondsSinceVerified\":3}"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); JSONObject json = new JSONObject(answer.bodyText()); assertEquals("mismatch", json.getString("context")); @@ -148,7 +151,8 @@ public void Redeem_Route_Reports_Its_Own_Signature_Check() throws Exception { + "\"secondsSinceVerified\":2}"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); JSONObject json = new JSONObject(answer.bodyText()); assertEquals("verified", json.getString("signature")); @@ -161,7 +165,8 @@ public void Redeem_Route_Relays_503_Unconfirmed() { transport.queue(503, "{\"context\":\"unconfirmed\"}"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); assertEquals(503, answer.status); JSONObject json = new JSONObject(answer.bodyText()); @@ -176,7 +181,8 @@ public void Redeem_Route_Answers_404_As_Text_For_A_Host_Without_The_Feature() { transport.queue(404, "Not Found"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); assertEquals(404, answer.status); assertTrue(answer.type.startsWith("text/plain")); @@ -187,7 +193,8 @@ public void Redeem_Route_Answers_404_As_Text_For_A_Host_Without_The_Feature() { public void Redeem_Route_Answers_502_When_The_Cloud_Is_Unreachable() { // Nothing queued, so the first request fails as the network would. CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); assertEquals(502, answer.status); JSONObject json = new JSONObject(answer.bodyText()); @@ -197,7 +204,8 @@ public void Redeem_Route_Answers_502_When_The_Cloud_Is_Unreachable() { @Test public void Redeem_Route_Answers_400_For_A_Malformed_51Did() { CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, "not a 51did", "s", "c"); + CreatorContextDemoServer.redeem(client, "not a 51did", "s", "c") + .join(); assertEquals(400, answer.status); JSONObject json = new JSONObject(answer.bodyText()); @@ -213,7 +221,8 @@ public void Redeem_Route_Answers_400_When_The_Cloud_Refuses_The_51Did() { + "Base64-encoded 51Did.\"]}"); CreatorContextDemoServer.Answer answer = - CreatorContextDemoServer.redeem(client, did, "sealed", "abc"); + CreatorContextDemoServer.redeem(client, did, "sealed", "abc") + .join(); assertEquals(400, answer.status); JSONObject json = new JSONObject(answer.bodyText()); @@ -266,12 +275,17 @@ void queue(int status, String body) { } @Override - public Response send(Request request) throws IOException { + public CompletableFuture send(Request request) { requests.add(request); + CompletableFuture answer = + new CompletableFuture(); if (responses.isEmpty()) { - throw new IOException("Nothing queued for " + request.getUrl()); + answer.completeExceptionally( + new IOException("Nothing queued for " + request.getUrl())); + } else { + answer.complete(responses.removeFirst()); } - return responses.removeFirst(); + return answer; } } } diff --git a/pipeline.did/README.md b/pipeline.did/README.md index 9fc74c88f..bc9e72145 100644 --- a/pipeline.did/README.md +++ b/pipeline.did/README.md @@ -150,7 +150,8 @@ found it, and adds two of its own for the payload rules. Every one of those is an expected data result and comes back as a status. What remains exceptional is a `null` passed to a throwing reader, and on the -client, a cloud that cannot be reached or a key list that cannot be fetched. +client, a cloud that cannot be reached or a key list that cannot be fetched, +which the client reports by failing the future rather than by throwing. ### Reading is not verifying @@ -237,26 +238,31 @@ In the order a server uses them: 2. **Verify offline.** The client fetches the cloud's signing keys once, holds them, and checks the signature against the key in force when the - identifier was created. No use is charged. + identifier was created. No use is charged. Every client method that may + reach the cloud returns at once with a `CompletableFuture`, so a request + thread is never held while the cloud is asked. ```java - boolean genuine = client.verifySignature(fodId); + CompletableFuture genuine = client.verifySignature(fodId); // or, to learn why not: - DidClient.SignatureCheck check = client.verifySignatureDetailed(fodId); + CompletableFuture check = + client.verifySignatureDetailed(fodId); ``` - `publicKeys()` returns the held list and `publicKeyFor(fodId)` the key in - force at the identifier's date. The list is refetched, once, when it has - no key for the date, when the date is later than the newest start held, - or when the list is more than a day old. A key list that cannot be - fetched raises `IOException`, never a false, because not being able to - check is not the same as the signature being wrong. + `publicKeys()` answers with the held list and `publicKeyFor(fodId)` with + the key in force at the identifier's date. The list is refetched, once, + when it has no key for the date, when the date is later than the newest + start held, or when the list is more than a day old, and callers that + arrive while a fetch is under way wait on that one fetch rather than + starting their own. A key list that cannot be fetched fails the future + with `IOException`, never with a false, because not being able to check + is not the same as the signature being wrong. 3. **Verify through the cloud.** The open verify endpoint, one use against the resource key, needing no licence key. ```java - boolean genuine = client.verify(fodId); + CompletableFuture genuine = client.verify(fodId); ``` 4. **Redeem.** A page checks the creator context from the browser with @@ -265,39 +271,65 @@ In the order a server uses them: identifier it knows independently. One use against the resource key. ```java - RedeemResult redeemed = client.redeem(fodId, result, challenge); - switch (redeemed.getContext()) { - case VERIFIED: // presented from where it was created - case MISMATCH: // redeemed.getFactors() says which factor differs - case NO_CONTEXT: // the identifier carries no creator context - case NOT_CHECKABLE: // the cloud could not check it - case EXPIRED: // redeemed outside the freshness window - case REPLAYED: // already redeemed - case UNREADABLE: // tampered, wrong identifier, challenge or key - case UNCONFIRMED: // answered 503, retry - } - redeemed.getSignature(); // VERIFIED, INVALID or UNKNOWN - redeemed.getVerifiedAt(); // when the cloud sealed the result - redeemed.getSecondsSinceVerified(); // how long before this redemption + client.redeem(fodId, result, challenge).thenAccept(redeemed -> { + switch (redeemed.getContext()) { + case VERIFIED: // presented from where it was created + case MISMATCH: // redeemed.getFactors() says which differs + case NO_CONTEXT: // the identifier carries no creator context + case NOT_CHECKABLE: // the cloud could not check it + case EXPIRED: // redeemed outside the freshness window + case REPLAYED: // already redeemed + case UNREADABLE: // tampered, wrong identifier, challenge or key + case UNCONFIRMED: // answered 503, retry + } + redeemed.getSignature(); // VERIFIED, INVALID or UNKNOWN + redeemed.getVerifiedAt(); // when the cloud sealed the result + redeemed.getSecondsSinceVerified(); // how long before this redemption + }); ``` - A malformed identifier raises `IllegalArgumentException`, a host without - the creator context raises `DidNotSupportedException`, any other status - raises `DidHttpException` carrying the status and body, and an - unreachable cloud raises `IOException`. Every cryptographic failure comes - back as the one word `unreadable`, by design, so the client does not try - to distinguish them either. + A failure completes the future exceptionally. A malformed identifier + fails it with `IllegalArgumentException`, a host without the creator + context with `DidNotSupportedException`, any other status with + `DidHttpException` carrying the status and body, and an unreachable + cloud with `IOException`. `get()` reports the failure as the cause of an + `ExecutionException`, `join()` as the cause of a `CompletionException`, + and a stage added with `exceptionally`, `handle` or `whenComplete` + receives a `CompletionException` whose cause it is. Every cryptographic + failure comes back as the one word `unreadable`, by design, so the + client does not try to distinguish them either. The client methods that take the identifier as a string, `verify(String)` and `redeem(String, String, String)`, read the value before doing anything -else, and a value that does not read as a 51Did is refused with -`IllegalArgumentException` naming the status before any key is fetched or -the cloud is called. Two things are worth knowing about that boundary. The -client also turns away any string longer than a generous fixed limit before -reading it, which is client policy against obviously wrong input and says -nothing about how long a 51Did can be. And the client checks the shape, -not the signature, because the signature is the question the cloud is about -to be asked. +else, and for a value that does not read as a 51Did the future fails with +`IllegalArgumentException` naming the status, before any key is fetched or +the cloud is called, in the same shape as a failure the cloud answers with, +so a caller has one place to look. Two things are worth knowing about that +boundary. The client also turns away any string longer than a generous +fixed limit before reading it, which is client policy against obviously +wrong input and says nothing about how long a 51Did can be. And the client +checks the shape, not the signature, because the signature is the question +the cloud is about to be asked. + +The default transport is `java.net.HttpURLConnection`, which only blocks, +so by default each request is blocking I/O on a background thread. Those +threads come from a small shared pool of daemon threads, or from an +executor given to the builder: + +```java +DidClient client = DidClient.builder(resourceKey) + .licenceKey(licenceKey) + .executor(myPool) + .build(); +``` + +On Java 11 and later, supply a transport over +`java.net.http.HttpClient.sendAsync` through `transport(HttpTransport)` +instead, which needs no thread per request, and give no executor, because +the executor belongs to the default transport and a transport of your own +schedules its own work. An +`HttpTransport` is one method, `send(Request)`, answering a +`CompletableFuture`. `verify-context` and `verify-full` are browser calls rather than client methods, because the creator context describes the browser's own @@ -308,6 +340,33 @@ endpoint through the cloud request engine and pipeline. The `pipeline.developer-examples.fodid` module holds a web example whose `/redeem` route is these calls in a running server. +## Migrating from the blocking client + +Earlier versions of `DidClient` blocked the calling thread on every call +that reached the cloud. Those methods are gone, each replaced by one of the +same name and arguments that answers through a `CompletableFuture`: + +| Removed | Replaced by | +| --- | --- | +| `List publicKeys()` | `CompletableFuture> publicKeys()` | +| `SigningKey publicKeyFor(FodId)` | `CompletableFuture publicKeyFor(FodId)` | +| `boolean verifySignature(FodId)` | `CompletableFuture verifySignature(FodId)` | +| `SignatureCheck verifySignatureDetailed(FodId)` | `CompletableFuture verifySignatureDetailed(FodId)` | +| `boolean verify(FodId)` | `CompletableFuture verify(FodId)` | +| `boolean verify(String)` | `CompletableFuture verify(String)` | +| `RedeemResult redeem(FodId, String, String)` | `CompletableFuture redeem(FodId, String, String)` | +| `RedeemResult redeem(String, String, String)` | `CompletableFuture redeem(String, String, String)` | + +None of them declares `IOException` any more, because the failure arrives +through the future. A caller that wants the old blocking behaviour calls +`join()` on the future, or `get()`, and takes the failure from the cause of +the `CompletionException` or `ExecutionException` that reports it. +`HttpTransport.send(Request)` likewise answers a +`CompletableFuture` rather than a `Response`, and does not throw +for a failure of the exchange. A transport of your own that blocks must +run its blocking call on a thread of its own and complete the future from +there. + ## Migrating from the OWID library's removed API Earlier OWID library versions let code build an `Owid` directly, parse one diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java index d542fb77d..6c613375e 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java @@ -51,6 +51,14 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; /** * The client for everything a server does with a 51Did against the @@ -68,6 +76,16 @@ * context result, with the licence key, and returns a typed * {@link RedeemResult}. * + * Every one of those may reach the cloud, so every one of them returns at + * once with a {@link CompletableFuture} and never blocks the calling + * thread. Nothing the client refuses is thrown either, so that a caller has + * one place to look, and a failure arrives as the cause of a + * {@link CompletionException} through {@code join()}, + * {@code exceptionally}, {@code handle} or {@code whenComplete}, and as the + * cause of an {@code ExecutionException} through {@code get()}. The methods + * that answer from what the client already holds, {@link #getResourceKey()}, + * {@link #getEndpoint()} and {@link #hasLicenceKey()}, answer directly. + *

* Creating a 51Did is not part of this client. Creation is the cloud * {@code json} endpoint through the cloud request engine and pipeline, and * a page creates from the browser because the identifier describes the @@ -82,6 +100,8 @@ *

* One instance is safe to share across threads. The key list is kept per * instance, so share the instance rather than creating one per request. + * Callers that arrive while the key list is being fetched wait on that one + * fetch rather than starting another. */ public final class DidClient { @@ -138,6 +158,8 @@ public enum SignatureCheck { private final Object lock = new Object(); private List keys; private Instant keysFetchedAt; + /** The key list fetch under way, shared by every caller waiting. */ + private CompletableFuture> inFlight; /** * A client for the public cloud, or the host named by @@ -181,14 +203,17 @@ private DidClient(Builder builder) { this.licenceKey = blankToNull(builder.licenceKey); this.endpoint = resolveEndpoint(builder.endpoint); this.transport = builder.transport == null - ? new UrlConnectionTransport() + ? new UrlConnectionTransport(builder.executor == null + ? SharedPool.INSTANCE + : builder.executor) : builder.transport; this.clock = builder.clock == null ? Clock.systemUTC() : builder.clock; } /** * Starts a builder for the cases the constructors do not cover, being - * an HTTP transport of the caller's own or, in tests, a clock. + * an HTTP transport of the caller's own, the executor the default + * transport runs on or, in tests, a clock. * * @param resourceKey the page's resource key, public by nature * @return the builder @@ -204,6 +229,7 @@ public static final class Builder { private String licenceKey; private String endpoint; private HttpTransport transport; + private Executor executor; private Clock clock; private Builder(String resourceKey) { @@ -245,6 +271,21 @@ public Builder transport(HttpTransport transport) { return this; } + /** + * The executor the default transport runs its blocking exchanges + * on. It does nothing when a transport of your own is given, + * because that transport schedules its own work, so give one or + * the other and not both. + * + * @param executor the executor to run blocking exchanges on, or + * null for a small shared pool of daemon threads + * @return this builder + */ + public Builder executor(Executor executor) { + this.executor = executor; + return this; + } + /** * @param clock the clock the key list's age is measured by, or null * for the system clock @@ -281,17 +322,19 @@ public boolean hasLicenceKey() { /** * The cloud's published signing keys, fetched on first use and then * held, in order of start. Keys are published ahead of their start, so - * the list normally reaches months into the future. + * the list normally reaches months into the future. A fetch already + * under way is shared rather than repeated. * - * @return the keys, read only - * @throws IOException if the list is not held and cannot be fetched + * @return the keys, read only, or a future failed with + * {@link IOException} where the list is not held and cannot be + * fetched */ - public List publicKeys() throws IOException { + public CompletableFuture> publicKeys() { synchronized (lock) { if (keys == null) { - fetchKeys(); + return fetchKeys(); } - return keys; + return CompletableFuture.completedFuture(keys); } } @@ -305,28 +348,70 @@ public List publicKeys() throws IOException { * this very call is not fetched again, because it cannot get better. * * @param fodId the identifier - * @return the key in force, or null when the date precedes every key - * @throws IOException if the required key list cannot be fetched + * @return the key in force, or null where the date precedes every key, + * or a future failed with {@link IOException} where the + * required key list cannot be fetched */ - public SigningKey publicKeyFor(FodId fodId) throws IOException { - Objects.requireNonNull(fodId, "fodId"); - Instant date = fodId.getDate(); - return inForceAt(keysFor(date), date); + public CompletableFuture publicKeyFor(FodId fodId) { + try { + Objects.requireNonNull(fodId, "fodId"); + Instant date = fodId.getDate(); + return keysFor(date).thenApply(held -> inForceAt(held, date)); + } catch (RuntimeException refused) { + return failed(refused); + } } /** - * Fetches the key list and records when. A failure propagates to the - * caller and leaves whatever was held in place. + * Fetches the key list, records when it was fetched, and hands every + * caller that arrives while it is under way the same future, so one + * fetch answers them all. A failure leaves whatever was held in place. + * Called with the lock held. */ - private void fetchKeys() throws IOException { + private CompletableFuture> fetchKeys() { + if (inFlight != null) { + return inFlight; + } + CompletableFuture> promise = + new CompletableFuture>(); + inFlight = promise; String url = endpoint + "id/key/" + encode(resourceKey); - HttpTransport.Response response = send("GET", url, null); + send("GET", url, null).whenComplete((response, failure) -> { + List fetched = null; + Throwable error = unwrap(failure); + if (error == null) { + try { + fetched = readKeys(response); + } catch (DidHttpException unreadable) { + error = unreadable; + } + } + synchronized (lock) { + inFlight = null; + if (error == null) { + keys = fetched; + keysFetchedAt = clock.instant(); + } + } + // Completed only after the held list is in place, so a caller + // waiting on this future never sees the client mid-update. + if (error == null) { + promise.complete(fetched); + } else { + promise.completeExceptionally(new CompletionException(error)); + } + }); + return promise; + } + + /** The key list a key endpoint answer carries. */ + private static List readKeys(HttpTransport.Response response) + throws DidHttpException { if (response.getStatusCode() != 200) { throw httpError("The public key list", response); } - List fetched; try { - fetched = parseKeys(response.getBody()); + return parseKeys(response.getBody()); } catch (JSONException unreadable) { throw new DidHttpException( "The public key list could not be read: " @@ -338,8 +423,6 @@ private void fetchKeys() throws IOException { + unreadable.getMessage(), response.getStatusCode(), response.getBody()); } - keys = fetched; - keysFetchedAt = clock.instant(); } /** @@ -381,17 +464,15 @@ private static Instant parseInstant(String value) { /** * The key list to answer a question about the given date from, * refetching once first where the held list may not have the answer. A - * failed fetch propagates because the held list may not contain the key - * needed for that date. + * failed fetch fails the future, because the held list may not contain + * the key needed for that date. */ - private List keysFor(Instant date) throws IOException { + private CompletableFuture> keysFor(Instant date) { synchronized (lock) { - if (keys == null) { - fetchKeys(); - } else if (needsRefetch(date)) { - fetchKeys(); + if (keys == null || needsRefetch(date)) { + return fetchKeys(); } - return keys; + return CompletableFuture.completedFuture(keys); } } @@ -460,11 +541,13 @@ private static void addIfNew(List candidates, SigningKey entry) { * {@link #verifySignatureDetailed(FodId)} for why not. * * @param fodId the identifier - * @return true when the signature verifies - * @throws IOException if the required key list cannot be fetched + * @return true where the signature verifies, or a future failed with + * {@link IOException} where the required key list cannot be + * fetched */ - public boolean verifySignature(FodId fodId) throws IOException { - return verifySignatureDetailed(fodId) == SignatureCheck.VERIFIED; + public CompletableFuture verifySignature(FodId fodId) { + return verifySignatureDetailed(fodId) + .thenApply(check -> check == SignatureCheck.VERIFIED); } /** @@ -476,23 +559,36 @@ public boolean verifySignature(FodId fodId) throws IOException { * short tolerance either side of a key boundary, the neighbouring key. * * @param fodId the identifier - * @return the outcome - * @throws IOException if the required key list cannot be fetched + * @return the outcome, or a future failed with {@link IOException} + * where the required key list cannot be fetched */ - public SignatureCheck verifySignatureDetailed(FodId fodId) - throws IOException { - Objects.requireNonNull(fodId, "fodId"); - if (fodId.getVersion() != Version.VERSION3) { - return SignatureCheck.UNSUPPORTED_VERSION; - } - boolean isRandom = fodId.getType() == IdType.RANDOM; - int baseLength = FodId.HEADER_LENGTH - + (isRandom ? FodId.GUID_LENGTH : FodId.MATCH_KEY_LENGTH); - if (fodId.getPayload().length < baseLength) { - return SignatureCheck.MALFORMED_PAYLOAD; - } - Instant date = fodId.getDate(); - List candidates = candidatesFor(keysFor(date), date); + public CompletableFuture verifySignatureDetailed( + FodId fodId) { + try { + Objects.requireNonNull(fodId, "fodId"); + if (fodId.getVersion() != Version.VERSION3) { + return CompletableFuture.completedFuture( + SignatureCheck.UNSUPPORTED_VERSION); + } + boolean isRandom = fodId.getType() == IdType.RANDOM; + int baseLength = FodId.HEADER_LENGTH + + (isRandom ? FodId.GUID_LENGTH : FodId.MATCH_KEY_LENGTH); + if (fodId.getPayload().length < baseLength) { + return CompletableFuture.completedFuture( + SignatureCheck.MALFORMED_PAYLOAD); + } + Instant date = fodId.getDate(); + return keysFor(date) + .thenApply(held -> checkSignature(fodId, held, date)); + } catch (RuntimeException refused) { + return failed(refused); + } + } + + /** The offline check against the candidate keys for the date. */ + private static SignatureCheck checkSignature( + FodId fodId, List held, Instant date) { + List candidates = candidatesFor(held, date); if (candidates.isEmpty()) { return SignatureCheck.NO_KEY_COVERS_DATE; } @@ -518,13 +614,17 @@ public SignatureCheck verifySignatureDetailed(FodId fodId) * against the resource key. * * @param fodId the identifier - * @return true when the cloud answers valid - * @throws IOException if the cloud cannot be reached, or answers with a - * status the client does not map + * @return true where the cloud answers valid, or a future failed with + * {@link IOException} where the cloud cannot be reached or + * answers with a status the client does not map */ - public boolean verify(FodId fodId) throws IOException { - Objects.requireNonNull(fodId, "fodId"); - return verify(base64Url(fodId)); + public CompletableFuture verify(FodId fodId) { + try { + Objects.requireNonNull(fodId, "fodId"); + return verify(base64Url(fodId)); + } catch (RuntimeException refused) { + return failed(refused); + } } /** @@ -532,30 +632,37 @@ public boolean verify(FodId fodId) throws IOException { * verify endpoint. The identifier may be in either base64 alphabet. It * is sent under both parameter names the endpoint accepts, {@code 51did} * and {@code owid}, so a cloud that reads only the older name answers. + *

+ * The future fails with {@link IllegalArgumentException} where the + * value is too long to be an identifier at all, where it does not read + * as a 51Did, with the {@link FodIdParseStatus} in the message, or + * where the cloud says it is not a 51Did, with the cloud's message. It + * fails with {@link IOException} where the cloud cannot be reached or + * answers with a status the client does not map. * * @param fodId the identifier as base64 - * @return true when the cloud answers valid, false when it answers + * @return true where the cloud answers valid, false where it answers * invalid - * @throws IllegalArgumentException if the value is too long to be an - * identifier at all, if it does not - * read as a 51Did, with the - * {@link FodIdParseStatus} in the - * message, or if the cloud says it is - * not a 51Did, with the cloud's message - * @throws IOException if the cloud cannot be reached, or answers with a - * status the client does not map */ - public boolean verify(String fodId) throws IOException { - Objects.requireNonNull(fodId, "fodId"); - ensureEncodedLength(fodId); - ensureReadsAs51Did(fodId); - // Under both names so the request works with hosts that read either - // parameter. Hosts that recognise both prefer 51did and keep owid as - // a compatibility alias. - String encoded = encode(fodId); - String url = endpoint + "id/verify/" + encode(resourceKey) - + "?51did=" + encoded + "&owid=" + encoded; - HttpTransport.Response response = send("GET", url, null); + public CompletableFuture verify(String fodId) { + try { + Objects.requireNonNull(fodId, "fodId"); + ensureEncodedLength(fodId); + ensureReadsAs51Did(fodId); + // Under both names so the request works with hosts that read + // either parameter. Hosts that recognise both prefer 51did and + // keep owid as a compatibility alias. + String encoded = encode(fodId); + String url = endpoint + "id/verify/" + encode(resourceKey) + + "?51did=" + encoded + "&owid=" + encoded; + return send("GET", url, null).thenApply(DidClient::readVerify); + } catch (RuntimeException refused) { + return failed(refused); + } + } + + /** The verdict a verify endpoint answer carries. */ + private static boolean readVerify(HttpTransport.Response response) { JSONObject json = asObject(response.getBody()); int status = response.getStatusCode(); if (status == 200 && json != null && json.has("valid")) { @@ -569,7 +676,8 @@ public boolean verify(String fodId) throws IOException { throw new IllegalArgumentException(errorsText(json)); } } - throw httpError("Signature verification", response); + throw new CompletionException( + httpError("Signature verification", response)); } // ----- Redeem ----- @@ -579,6 +687,13 @@ public boolean verify(String fodId) throws IOException { * caller knows independently, sending the licence key where one was * given. One use against the resource key, the second of the two a * browser-based context check costs. + *

+ * The future fails with {@link IllegalArgumentException} where the + * cloud says the identifier is not a 51Did, with the cloud's message, + * with {@link DidNotSupportedException} where the host does not offer + * the creator context, with {@link DidHttpException} where the cloud + * answers with any other status or a body that is not its own shape, + * and with {@link IOException} where the cloud cannot be reached. * * @param fodId the identifier the sealed result was made for * @param result the sealed result exactly as the verify endpoint @@ -586,80 +701,86 @@ public boolean verify(String fodId) throws IOException { * @param challenge the single-use challenge given to the verify * endpoint, or null where none was * @return the typed result, for a 200 or a 503 answer - * @throws IllegalArgumentException if the cloud says the identifier is - * not a 51Did, with the cloud's message - * @throws DidNotSupportedException if the host does not offer the - * creator context - * @throws DidHttpException if the cloud answers with any other status, - * or a body that is not its own shape - * @throws IOException if the cloud cannot be reached */ - public RedeemResult redeem(FodId fodId, String result, String challenge) - throws IOException { - Objects.requireNonNull(fodId, "fodId"); - return redeem(base64(fodId), result, challenge); + public CompletableFuture redeem( + FodId fodId, String result, String challenge) { + try { + Objects.requireNonNull(fodId, "fodId"); + return redeem(base64(fodId), result, challenge); + } catch (RuntimeException refused) { + return failed(refused); + } } /** * Redeems a sealed creator context result. The identifier may be in * either base64 alphabet. See {@link #redeem(FodId, String, String)}. + * The future fails with {@link IllegalArgumentException} where the + * value is too long to be an identifier at all or does not read as a + * 51Did, with the {@link FodIdParseStatus} in the message, and + * otherwise as {@link #redeem(FodId, String, String)}. * * @param fodId the identifier as base64 * @param result the sealed result * @param challenge the challenge, or null * @return the typed result, for a 200 or a 503 answer - * @throws IllegalArgumentException if the value is too long to be an - * identifier at all, if it does not - * read as a 51Did, with the - * {@link FodIdParseStatus} in the - * message, or as - * {@link #redeem(FodId, String, String)} - * @throws IOException as {@link #redeem(FodId, String, String)} */ - public RedeemResult redeem(String fodId, String result, String challenge) - throws IOException { - Objects.requireNonNull(fodId, "fodId"); - ensureEncodedLength(fodId); - ensureReadsAs51Did(fodId); - // The POST route has no {resource} segment, so the resource key - // goes in the form with everything else. - StringBuilder form = new StringBuilder() - .append("resource=").append(encode(resourceKey)) - .append("&51did=").append(encode(fodId)) - .append("&result=").append(encode(nullToEmpty(result))) - .append("&challenge=").append(encode(nullToEmpty(challenge))); - if (licenceKey != null) { - form.append("&license=").append(encode(licenceKey)); - } - String url = endpoint + "id/redeem"; - HttpTransport.Response response = send( - "POST", url, form.toString().getBytes(StandardCharsets.UTF_8)); + public CompletableFuture redeem( + String fodId, String result, String challenge) { + try { + Objects.requireNonNull(fodId, "fodId"); + ensureEncodedLength(fodId); + ensureReadsAs51Did(fodId); + // The POST route has no {resource} segment, so the resource key + // goes in the form with everything else. + StringBuilder form = new StringBuilder() + .append("resource=").append(encode(resourceKey)) + .append("&51did=").append(encode(fodId)) + .append("&result=").append(encode(nullToEmpty(result))) + .append("&challenge=").append(encode(nullToEmpty(challenge))); + if (licenceKey != null) { + form.append("&license=").append(encode(licenceKey)); + } + String url = endpoint + "id/redeem"; + return send("POST", url, + form.toString().getBytes(StandardCharsets.UTF_8)) + .thenApply(this::readRedeem); + } catch (RuntimeException refused) { + return failed(refused); + } + } + + /** The typed result a redeem answer carries. */ + private RedeemResult readRedeem(HttpTransport.Response response) { int status = response.getStatusCode(); JSONObject json = asObject(response.getBody()); switch (status) { case 200: case 503: if (json == null) { - throw httpError("Redemption", response); + throw new CompletionException( + httpError("Redemption", response)); } return RedeemResult.parse(status, json, response.getBody()); case 400: if (json != null && json.has("errors")) { throw new IllegalArgumentException(errorsText(json)); } - throw httpError("Redemption", response); + throw new CompletionException( + httpError("Redemption", response)); case 404: - throw new DidNotSupportedException( - endpoint, response.getBody()); + throw new CompletionException(new DidNotSupportedException( + endpoint, response.getBody())); default: - throw httpError("Redemption", response); + throw new CompletionException( + httpError("Redemption", response)); } } // ----- HTTP ----- - private HttpTransport.Response send(String method, String url, byte[] body) - throws IOException { + private CompletableFuture send( + String method, String url, byte[] body) { Map headers = new LinkedHashMap(); headers.put("User-Agent", USER_AGENT); headers.put("Accept", "application/json"); @@ -667,8 +788,42 @@ private HttpTransport.Response send(String method, String url, byte[] body) headers.put("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); } - return transport.send( - new HttpTransport.Request(method, url, headers, body)); + try { + CompletableFuture sent = transport.send( + new HttpTransport.Request(method, url, headers, body)); + if (sent == null) { + return failed(new IOException( + "The transport answered no future for " + url + ".")); + } + return sent; + } catch (RuntimeException broken) { + // A transport that throws where it should have failed its + // future is still reported through the future, so a caller has + // one place to look. + return failed(broken); + } + } + + /** + * A future already failed with the given cause, wrapped so that every + * failure the client reports arrives the same way, as the cause of a + * {@link CompletionException}. + */ + private static CompletableFuture failed(Throwable cause) { + CompletableFuture answer = new CompletableFuture(); + answer.completeExceptionally(cause instanceof CompletionException + ? cause + : new CompletionException(cause)); + return answer; + } + + /** The real failure behind a {@link CompletionException}, or null. */ + private static Throwable unwrap(Throwable failure) { + if (failure instanceof CompletionException + && failure.getCause() != null) { + return failure.getCause(); + } + return failure; } /** @@ -801,17 +956,52 @@ private static String version() { } /** - * The default transport, over {@link HttpURLConnection}. Ten seconds to - * connect and ten to read, which is generous for the cloud and short - * enough that a request thread is not held for long by a host that is - * down. + * The default transport, over {@link HttpURLConnection}. Java 8 has no + * non-blocking HTTP client in its standard library, so this is blocking + * I/O on a background thread, meaning the exchange runs on the executor + * and completes the future from there. On Java 11 and later supply a + * transport over {@code java.net.http.HttpClient.sendAsync} instead, + * which needs no thread per request. Ten seconds to connect and ten to + * read, which is generous for the cloud and short enough that a pooled + * thread is not held for long by a host that is down. */ - private static final class UrlConnectionTransport implements HttpTransport { + static final class UrlConnectionTransport implements HttpTransport { private static final int TIMEOUT_MILLIS = 10_000; + private final Executor executor; + + UrlConnectionTransport(Executor executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + @Override - public Response send(Request request) throws IOException { + public CompletableFuture send(Request request) { + CompletableFuture answer = + new CompletableFuture(); + try { + executor.execute(new Runnable() { + @Override + public void run() { + try { + answer.complete(exchange(request)); + } catch (Throwable failure) { + // Anything at all, so that a caller waiting on + // this future is never left waiting for ever. + answer.completeExceptionally(failure); + } + } + }); + } catch (RuntimeException refused) { + // An executor that is shut down or full refuses the work. + // That is a failure of the request like any other. + answer.completeExceptionally(refused); + } + return answer; + } + + /** The blocking exchange, run on the executor's thread. */ + private static Response exchange(Request request) throws IOException { HttpURLConnection connection = (HttpURLConnection) URI.create(request.getUrl()).toURL().openConnection(); connection.setConnectTimeout(TIMEOUT_MILLIS); @@ -861,4 +1051,45 @@ private static String readAll(InputStream stream) throws IOException { } } } + + /** + * The threads the default transport blocks on where the builder is + * given no executor, being a small bounded pool of daemon threads, + * created as they are needed and retired when they have been idle a + * while, so a program that has finished its work still exits. Held in a + * class of its own so that no pool exists at all until a client is + * built without a transport and without an executor. + */ + private static final class SharedPool { + + private static final int MINIMUM_THREADS = 2; + private static final int MAXIMUM_THREADS = 8; + private static final long IDLE_SECONDS = 60L; + + static final Executor INSTANCE = create(); + + private static Executor create() { + int threads = Math.min(MAXIMUM_THREADS, Math.max(MINIMUM_THREADS, + Runtime.getRuntime().availableProcessors())); + ThreadPoolExecutor pool = new ThreadPoolExecutor( + threads, threads, IDLE_SECONDS, TimeUnit.SECONDS, + new LinkedBlockingQueue(), + new ThreadFactory() { + + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public Thread newThread(Runnable work) { + Thread thread = new Thread(work, + "51did-http-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + }); + // Without this the pool keeps its threads for the life of the + // program, which a library has no business doing. + pool.allowCoreThreadTimeOut(true); + return pool; + } + } } diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java index 69f2c2e18..dc7b7c67d 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java @@ -22,31 +22,35 @@ package fiftyone.pipeline.did; -import java.io.IOException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; /** * The one HTTP operation {@link DidClient} needs, so that a test can stand in * for the network and a caller can route the client's requests through an * HTTP stack of its own. The default implementation uses - * {@link java.net.HttpURLConnection}. + * {@link java.net.HttpURLConnection}, which only blocks, so it runs the + * exchange on a background thread. On Java 11 and later a transport over + * {@code java.net.http.HttpClient.sendAsync} needs no thread per request. */ public interface HttpTransport { /** - * Sends the request and returns whatever the server answered, whatever - * the status. Only a failure to reach the server or read its answer is - * an exception. + * Sends the request and answers with whatever the server answered, + * whatever the status. The call returns at once and the answer arrives + * through the future. Only a failure to reach the server or read its + * answer fails the future, normally with a + * {@code java.io.IOException}, and the method itself does not throw for + * one. An implementation that blocks must do its blocking on a thread + * of its own and complete the future from there. * * @param request the request to send * @return the status and body the server answered with - * @throws IOException if the server could not be reached or the answer - * could not be read */ - Response send(Request request) throws IOException; + CompletableFuture send(Request request); /** An HTTP request: method, URL, headers and an optional body. */ final class Request { diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java index fbe521ffa..89208e901 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java @@ -40,6 +40,8 @@ * 51Degrees cloud: it fetches and holds the published signing keys, verifies * a 51Did's signature offline or through the cloud, and redeems a sealed * creator context result into a typed - * {@link fiftyone.pipeline.did.RedeemResult}. + * {@link fiftyone.pipeline.did.RedeemResult}. Every call that may reach + * the cloud returns at once and answers through a + * {@link java.util.concurrent.CompletableFuture}. */ package fiftyone.pipeline.did; diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java index 44c6b5110..c8f521588 100644 --- a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java @@ -33,6 +33,7 @@ import java.net.HttpURLConnection; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletionException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -67,9 +68,9 @@ public void create_Parse_VerifyOffline_VerifyThroughTheCloud() FodId fodId = create(); assertEquals(DidClient.SignatureCheck.VERIFIED, - client.verifySignatureDetailed(fodId)); - assertTrue(client.verifySignature(fodId)); - assertTrue(client.verify(fodId)); + client.verifySignatureDetailed(fodId).join()); + assertTrue(client.verifySignature(fodId).join()); + assertTrue(client.verify(fodId).join()); } @Test @@ -78,11 +79,16 @@ public void redeem_GarbageResult_IsUnreadable() throws Exception { RedeemResult result; try { - result = client.redeem(fodId, "not-base64url!!", "live-test"); - } catch (DidNotSupportedException unsupported) { - Assume.assumeNoException( - "The host does not offer the creator context.", unsupported); - return; + result = client.redeem(fodId, "not-base64url!!", "live-test") + .join(); + } catch (CompletionException reported) { + if (reported.getCause() instanceof DidNotSupportedException) { + Assume.assumeNoException( + "The host does not offer the creator context.", + reported.getCause()); + return; + } + throw reported; } assertEquals(200, result.getStatusCode()); diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java index 2e5ea0a30..d914ffa72 100644 --- a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java @@ -39,8 +39,13 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Deque; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayload; import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayloadWithSection; @@ -141,7 +146,7 @@ public void licenceKey_BlankCountsAsNone() { public void publicKeys_ReadsStartsAtAndIgnoresWeekStart() throws Exception { transport.queue(200, keyList("startsAt", true)); - List keys = client.publicKeys(); + List keys = client.publicKeys().join(); assertEquals(3, keys.size()); assertEquals(WEEK1, keys.get(0).getStartsAt()); @@ -161,7 +166,7 @@ public void publicKeys_ReadsStartsAtAndIgnoresWeekStart() throws Exception { public void publicKeys_FallsBackToCreated() throws Exception { transport.queue(200, keyList("created", false)); - List keys = client.publicKeys(); + List keys = client.publicKeys().join(); assertEquals(WEEK1, keys.get(0).getStartsAt()); assertEquals(WEEK3, keys.get(2).getStartsAt()); @@ -175,7 +180,7 @@ public void publicKeys_SortsByStart() throws Exception { reversed.put(keyEntry("startsAt", WEEK2, key2)); transport.queue(200, reversed.toString()); - List keys = client.publicKeys(); + List keys = client.publicKeys().join(); assertEquals(WEEK1, keys.get(0).getStartsAt()); assertEquals(WEEK2, keys.get(1).getStartsAt()); @@ -186,29 +191,29 @@ public void publicKeys_SortsByStart() throws Exception { public void publicKeys_SecondCallUsesTheCache() throws Exception { transport.queue(200, keyList("startsAt", false)); - List first = client.publicKeys(); - List second = client.publicKeys(); + List first = client.publicKeys().join(); + List second = client.publicKeys().join(); assertSame(first, second); assertEquals(1, transport.requests.size()); } @Test - public void publicKeys_FirstFetchFailureRaises() { + public void publicKeys_FirstFetchFailureFailsTheFuture() { transport.queue(500, "down"); - DidHttpException error = assertThrows(DidHttpException.class, - () -> client.publicKeys()); + DidHttpException error = + failure(DidHttpException.class, client.publicKeys()); assertEquals(500, error.getStatusCode()); assertEquals("down", error.getBody()); } @Test - public void publicKeys_UnreadableListRaises() { + public void publicKeys_UnreadableListFailsTheFuture() { transport.queue(200, "[{\"publicKey\":\"x\"}]"); - assertThrows(DidHttpException.class, () -> client.publicKeys()); + failure(DidHttpException.class, client.publicKeys()); } @Test @@ -217,7 +222,7 @@ public void publicKeyFor_ReturnsTheKeyInForce() throws Exception { FodId fodId = key2.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(3))); - SigningKey key = client.publicKeyFor(fodId); + SigningKey key = client.publicKeyFor(fodId).join(); assertEquals(WEEK2, key.getStartsAt()); assertEquals(1, transport.requests.size()); @@ -228,11 +233,11 @@ public void publicKeyFor_RefetchesWhenDateIsBeyondTheNewestStart() throws Exception { transport.queue(200, keyList("startsAt", false)); transport.queue(200, keyList("startsAt", false)); - client.publicKeys(); + client.publicKeys().join(); FodId fodId = key3.fodIdAt( canonicalPayload(), WEEK3.plus(Duration.ofDays(8))); - SigningKey key = client.publicKeyFor(fodId); + SigningKey key = client.publicKeyFor(fodId).join(); assertEquals(WEEK3, key.getStartsAt()); assertEquals(2, transport.requests.size()); @@ -248,7 +253,7 @@ public void publicKeyFor_DoesNotRefetchStraightAfterTheFirstFetch() FodId fodId = key3.fodIdAt( canonicalPayload(), WEEK3.plus(Duration.ofDays(8))); - SigningKey key = client.publicKeyFor(fodId); + SigningKey key = client.publicKeyFor(fodId).join(); assertEquals(WEEK3, key.getStartsAt()); assertEquals(1, transport.requests.size()); @@ -259,11 +264,11 @@ public void publicKeyFor_RefetchesWhenNoKeyCoversTheDate() throws Exception { transport.queue(200, keyList("startsAt", false)); transport.queue(200, keyList("startsAt", false)); - client.publicKeys(); + client.publicKeys().join(); FodId fodId = key1.fodIdAt( canonicalPayload(), WEEK1.minus(Duration.ofDays(1))); - SigningKey key = client.publicKeyFor(fodId); + SigningKey key = client.publicKeyFor(fodId).join(); assertNull(key); assertEquals(2, transport.requests.size()); @@ -277,9 +282,9 @@ public void publicKeyFor_RefetchesWhenTheListIsADayOld() FodId fodId = key2.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - client.publicKeyFor(fodId); + client.publicKeyFor(fodId).join(); clock.advance(Duration.ofHours(25)); - client.publicKeyFor(fodId); + client.publicKeyFor(fodId).join(); assertEquals(2, transport.requests.size()); } @@ -290,30 +295,76 @@ public void publicKeyFor_DoesNotRefetchWithinADay() throws Exception { FodId fodId = key2.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - client.publicKeyFor(fodId); + client.publicKeyFor(fodId).join(); clock.advance(Duration.ofHours(23)); - client.publicKeyFor(fodId); + client.publicKeyFor(fodId).join(); assertEquals(1, transport.requests.size()); } @Test - public void publicKeyFor_RefetchFailureRaises() + public void publicKeyFor_RefetchFailureFailsTheFuture() throws Exception { transport.queue(200, keyList("startsAt", false)); FodId fodId = key2.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - client.publicKeyFor(fodId); + client.publicKeyFor(fodId).join(); clock.advance(Duration.ofHours(25)); // Nothing queued, so the refetch fails with an I/O error. - IOException error = assertThrows(IOException.class, - () -> client.publicKeyFor(fodId)); + IOException error = + failure(IOException.class, client.publicKeyFor(fodId)); assertTrue(error.getMessage().contains("Nothing queued")); assertEquals(2, transport.requests.size()); } + @Test + public void publicKeys_ConcurrentCallersShareOneFetch() { + // The transport answers only when this test says so, so both + // calls are certainly in flight at the same time. Nothing joins + // a future before the answer is given, so nothing waits here. + HeldTransport held = new HeldTransport(); + DidClient shared = DidClient.builder("resource") + .endpoint(ENDPOINT).transport(held).clock(clock).build(); + + CompletableFuture> first = shared.publicKeys(); + CompletableFuture> second = shared.publicKeys(); + + assertEquals(1, held.requests.size()); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + + held.answer(200, keyList("startsAt", false)); + + assertSame(first.join(), second.join()); + // The fetch is over, so the next caller is answered from the + // held list rather than from a fetch that is no longer running. + assertSame(first.join(), shared.publicKeys().join()); + assertEquals(1, held.requests.size()); + } + + @Test + public void publicKeys_AFailedSharedFetchFailsEveryCallerWaiting() { + HeldTransport held = new HeldTransport(); + DidClient shared = DidClient.builder("resource") + .endpoint(ENDPOINT).transport(held).clock(clock).build(); + + CompletableFuture> first = shared.publicKeys(); + CompletableFuture> second = shared.publicKeys(); + held.fail(new IOException("the cloud could not be reached")); + + failure(IOException.class, first); + failure(IOException.class, second); + + // Nothing is held, so the next caller starts a fetch of its own + // rather than being given the failed one. + CompletableFuture> third = shared.publicKeys(); + assertEquals(2, held.requests.size()); + held.answer(200, keyList("startsAt", false)); + assertEquals(3, third.join().size()); + } + // ----- Selection ----- @Test @@ -370,9 +421,9 @@ public void verifySignature_TrueWithTheKeyInForce() throws Exception { FodId fodId = key2.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - assertTrue(client.verifySignature(fodId)); + assertTrue(client.verifySignature(fodId).join()); assertEquals(DidClient.SignatureCheck.VERIFIED, - client.verifySignatureDetailed(fodId)); + client.verifySignatureDetailed(fodId).join()); } @Test @@ -382,9 +433,9 @@ public void verifySignature_FalseWithTheWrongKey() throws Exception { FodId fodId = unpublished.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - assertFalse(client.verifySignature(fodId)); + assertFalse(client.verifySignature(fodId).join()); assertEquals(DidClient.SignatureCheck.INVALID, - client.verifySignatureDetailed(fodId)); + client.verifySignatureDetailed(fodId).join()); } @Test @@ -396,21 +447,21 @@ public void verifySignature_FalseWithAPublishedKeyFromAnotherPeriod() FodId fodId = key1.fodIdAt( canonicalPayload(), WEEK2.plus(Duration.ofDays(1))); - assertFalse(client.verifySignature(fodId)); + assertFalse(client.verifySignature(fodId).join()); } @Test - public void verifySignature_RefetchFailureRaisesRatherThanFalse() + public void verifySignature_RefetchFailureFailsRatherThanAnsweringFalse() throws Exception { transport.queue(200, keyList("startsAt", false)); - client.publicKeys(); + client.publicKeys().join(); FodIdTestFactory missingKey = new FodIdTestFactory(); FodId fodId = missingKey.fodIdAt( canonicalPayload(), WEEK3.plus(Duration.ofDays(8))); // The held schedule cannot contain the correct key, and the // required refetch has no queued answer. - assertThrows(IOException.class, () -> client.verifySignature(fodId)); + failure(IOException.class, client.verifySignature(fodId)); assertEquals(2, transport.requests.size()); } @@ -423,8 +474,8 @@ public void verifySignature_EarlierNeighbourWithinToleranceAfterBoundary() FodId outside = key1.fodIdAt( canonicalPayload(), WEEK2.plus(WELL_OUTSIDE)); - assertTrue(client.verifySignature(inside)); - assertFalse(client.verifySignature(outside)); + assertTrue(client.verifySignature(inside).join()); + assertFalse(client.verifySignature(outside).join()); } @Test @@ -436,8 +487,8 @@ public void verifySignature_LaterNeighbourWithinToleranceBeforeBoundary() FodId outside = key2.fodIdAt( canonicalPayload(), WEEK2.minus(WELL_OUTSIDE)); - assertTrue(client.verifySignature(inside)); - assertFalse(client.verifySignature(outside)); + assertTrue(client.verifySignature(inside).join()); + assertFalse(client.verifySignature(outside).join()); } @Test @@ -449,8 +500,8 @@ public void verifySignature_NoKeyCoversADateBeforeTheSchedule() canonicalPayload(), WEEK1.minus(Duration.ofDays(1))); assertEquals(DidClient.SignatureCheck.NO_KEY_COVERS_DATE, - client.verifySignatureDetailed(fodId)); - assertFalse(client.verifySignature(fodId)); + client.verifySignatureDetailed(fodId).join()); + assertFalse(client.verifySignature(fodId).join()); } @Test @@ -462,8 +513,8 @@ public void verifySignature_FalseForVersion2() throws Exception { assertEquals(Version.VERSION2, fodId.getVersion()); assertEquals(DidClient.SignatureCheck.UNSUPPORTED_VERSION, - client.verifySignatureDetailed(fodId)); - assertFalse(client.verifySignature(fodId)); + client.verifySignatureDetailed(fodId).join()); + assertFalse(client.verifySignature(fodId).join()); } @Test @@ -476,8 +527,8 @@ public void verifySignature_FalseForPayloadShorterThanBase() FodId fodId = key2.fodIdAt(payload, WEEK2.plus(Duration.ofDays(1))); assertEquals(DidClient.SignatureCheck.MALFORMED_PAYLOAD, - client.verifySignatureDetailed(fodId)); - assertFalse(client.verifySignature(fodId)); + client.verifySignatureDetailed(fodId).join()); + assertFalse(client.verifySignature(fodId).join()); // Refused on shape before any key is needed. assertEquals(0, transport.requests.size()); } @@ -488,7 +539,7 @@ public void verifySignature_TrueForPayloadLongerThanBase() throws Exception { FodId fodId = key2.fodIdAt( canonicalPayloadWithSection(25), WEEK2.plus(Duration.ofDays(1))); - assertTrue(client.verifySignature(fodId)); + assertTrue(client.verifySignature(fodId).join()); } @Test @@ -504,7 +555,7 @@ public void verifySignature_TrueForALongContextSectionAndLongDomain() WEEK2.plus(Duration.ofDays(1)), "a-rather-long-self-hosted-creator.example.internal.51degrees.com"); - assertTrue(client.verifySignature(fodId)); + assertTrue(client.verifySignature(fodId).join()); } @Test @@ -514,7 +565,7 @@ public void verifySignature_TrueForRandomBaseLength() throws Exception { canonicalRandomPayload(), WEEK2.plus(Duration.ofDays(1))); assertEquals(IdType.RANDOM, fodId.getType()); - assertTrue(client.verifySignature(fodId)); + assertTrue(client.verifySignature(fodId).join()); } // ----- Cloud signature verification ----- @@ -524,7 +575,7 @@ public void verify_ValidAnswers200True() throws Exception { transport.queue(200, "{\"valid\":true}"); FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2); - assertTrue(client.verify(fodId)); + assertTrue(client.verify(fodId).join()); HttpTransport.Request request = transport.last(); assertEquals("GET", request.getMethod()); @@ -539,8 +590,8 @@ public void verify_ValidAnswers200True() throws Exception { public void verify_OverLongStringIsRefusedBeforeTransport() { // Nothing this long can be an identifier, so it is turned away // before the client decodes it, fetches a key or calls the cloud. - assertThrows(IllegalArgumentException.class, - () -> client.verify(repeat('A', 8192))); + failure(IllegalArgumentException.class, + client.verify(repeat('A', 8192))); assertEquals(0, transport.requests.size()); } @@ -550,8 +601,7 @@ public void verify_OverLongObjectIsRefusedBeforeTransport() throws Exception { FodId fodId = overLongFodId(key2, WEEK2); - assertThrows(IllegalArgumentException.class, - () -> client.verify(fodId)); + failure(IllegalArgumentException.class, client.verify(fodId)); assertEquals(0, transport.requests.size()); } @@ -560,37 +610,37 @@ public void verify_OverLongObjectIsRefusedBeforeTransport() public void verify_InvalidAnswers400False() throws Exception { transport.queue(400, "{\"valid\":false}"); - assertFalse(client.verify(validDid)); + assertFalse(client.verify(validDid).join()); } @Test - public void verify_ErrorsAnswer400Raises() { + public void verify_ErrorsAnswer400FailsWithArgumentError() { // The cloud's own rejection of an identifier that read locally still // maps to the argument failure, with the cloud's message. transport.queue(400, "{\"errors\":[\"Value for 51did is not a valid " + "Base64-encoded 51Did.\"]}"); - IllegalArgumentException error = assertThrows( - IllegalArgumentException.class, () -> client.verify(validDid)); + IllegalArgumentException error = failure( + IllegalArgumentException.class, client.verify(validDid)); assertTrue(error.getMessage().contains("not a valid")); assertEquals(1, transport.requests.size()); } @Test - public void verify_OtherStatusRaisesWithStatusAndBody() { + public void verify_OtherStatusFailsWithStatusAndBody() { transport.queue(401, "{\"errors\":[\"bad key\"]}"); - DidHttpException error = assertThrows(DidHttpException.class, - () -> client.verify(validDid)); + DidHttpException error = + failure(DidHttpException.class, client.verify(validDid)); assertEquals(401, error.getStatusCode()); assertTrue(error.getBody().contains("bad key")); } @Test - public void verify_TransportFailureRaisesIoException() { - assertThrows(IOException.class, () -> client.verify(validDid)); + public void verify_TransportFailureFailsWithIoException() { + failure(IOException.class, client.verify(validDid)); } // ----- Redeem ----- @@ -606,7 +656,8 @@ public void redeem_RedeemedWithFactors() throws Exception { transport.queue(200, body); FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2); - RedeemResult result = client.redeem(fodId, "sealed", "abc"); + RedeemResult result = + client.redeem(fodId, "sealed", "abc").join(); assertEquals(RedeemResult.Context.MISMATCH, result.getContext()); assertEquals("mismatch", result.getContextValue()); @@ -647,7 +698,8 @@ public void redeem_RedeemedWithoutFactors() throws Exception { + "\"verifiedAt\":\"2026-08-07T09:15:32Z\"," + "\"secondsSinceVerified\":0}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.VERIFIED, result.getContext()); assertEquals(RedeemResult.Signature.VERIFIED, result.getSignature()); @@ -664,7 +716,8 @@ public void redeem_InvalidSignatureIsReported() throws Exception { + "\"verifiedAt\":\"2026-08-07T09:15:32Z\"," + "\"secondsSinceVerified\":1}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Signature.INVALID, result.getSignature()); } @@ -675,7 +728,8 @@ public void redeem_Expired() throws Exception { + "\"verifiedAt\":\"2026-08-07T09:15:32Z\"," + "\"secondsSinceVerified\":14}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.EXPIRED, result.getContext()); assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature()); @@ -689,7 +743,8 @@ public void redeem_Expired() throws Exception { public void redeem_Replayed() throws Exception { transport.queue(200, "{\"context\":\"replayed\"}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.REPLAYED, result.getContext()); assertNull(result.getVerifiedAt()); @@ -700,7 +755,8 @@ public void redeem_Replayed() throws Exception { public void redeem_Unreadable() throws Exception { transport.queue(200, "{\"context\":\"unreadable\"}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.UNREADABLE, result.getContext()); assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature()); @@ -710,7 +766,8 @@ public void redeem_Unreadable() throws Exception { public void redeem_503Unconfirmed() throws Exception { transport.queue(503, "{\"context\":\"unconfirmed\"}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.UNCONFIRMED, result.getContext()); assertEquals(503, result.getStatusCode()); @@ -721,7 +778,8 @@ public void redeem_UnknownContextFailsClosedAndKeepsTheRawValue() throws Exception { transport.queue(200, "{\"context\":\"something-new\"}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.UNREADABLE, result.getContext()); assertEquals("something-new", result.getContextValue()); @@ -731,20 +789,21 @@ public void redeem_UnknownContextFailsClosedAndKeepsTheRawValue() public void redeem_MissingContextFailsClosed() throws Exception { transport.queue(200, "{}"); - RedeemResult result = client.redeem(validDid, "sealed", "abc"); + RedeemResult result = + client.redeem(validDid, "sealed", "abc").join(); assertEquals(RedeemResult.Context.UNREADABLE, result.getContext()); assertEquals("unreadable", result.getContextValue()); } @Test - public void redeem_400ErrorsRaisesArgumentError() { + public void redeem_400ErrorsFailsWithArgumentError() { transport.queue(400, "{\"errors\":[\"'x' is not a valid " + "Base64-encoded 51Did.\"]}"); - IllegalArgumentException error = assertThrows( + IllegalArgumentException error = failure( IllegalArgumentException.class, - () -> client.redeem(validDid, "sealed", "abc")); + client.redeem(validDid, "sealed", "abc")); assertTrue(error.getMessage().contains("not a valid")); assertEquals(1, transport.requests.size()); @@ -755,21 +814,21 @@ public void redeem_OverLongInputsAreRefusedBeforeTransport() throws Exception { FodId fodId = overLongFodId(key2, WEEK2); - assertThrows(IllegalArgumentException.class, - () -> client.redeem(repeat('A', 8192), "sealed", "abc")); - assertThrows(IllegalArgumentException.class, - () -> client.redeem(fodId, "sealed", "abc")); + failure(IllegalArgumentException.class, + client.redeem(repeat('A', 8192), "sealed", "abc")); + failure(IllegalArgumentException.class, + client.redeem(fodId, "sealed", "abc")); assertEquals(0, transport.requests.size()); } @Test - public void redeem_404RaisesNotSupported() { + public void redeem_404FailsWithNotSupported() { transport.queue(404, "Not Found"); - DidNotSupportedException error = assertThrows( + DidNotSupportedException error = failure( DidNotSupportedException.class, - () -> client.redeem(validDid, "sealed", "abc")); + client.redeem(validDid, "sealed", "abc")); assertEquals(404, error.getStatusCode()); assertEquals("Not Found", error.getBody()); @@ -777,30 +836,30 @@ public void redeem_404RaisesNotSupported() { } @Test - public void redeem_OtherStatusRaisesWithStatusAndBody() { + public void redeem_OtherStatusFailsWithStatusAndBody() { transport.queue(500, "boom"); - DidHttpException error = assertThrows(DidHttpException.class, - () -> client.redeem(validDid, "sealed", "abc")); + DidHttpException error = failure(DidHttpException.class, + client.redeem(validDid, "sealed", "abc")); assertEquals(500, error.getStatusCode()); assertEquals("boom", error.getBody()); } @Test - public void redeem_NonJson200Raises() { + public void redeem_NonJson200FailsWithHttpError() { transport.queue(200, "proxy"); - DidHttpException error = assertThrows(DidHttpException.class, - () -> client.redeem(validDid, "sealed", "abc")); + DidHttpException error = failure(DidHttpException.class, + client.redeem(validDid, "sealed", "abc")); assertEquals(200, error.getStatusCode()); } @Test - public void redeem_TransportFailureRaisesIoException() { - assertThrows(IOException.class, - () -> client.redeem(validDid, "sealed", "abc")); + public void redeem_TransportFailureFailsWithIoException() { + failure(IOException.class, + client.redeem(validDid, "sealed", "abc")); } @Test @@ -809,7 +868,7 @@ public void redeem_WithoutLicenceKeyOmitsTheField() throws Exception { .endpoint(ENDPOINT).transport(transport).build(); transport.queue(200, "{\"context\":\"unreadable\"}"); - noLicence.redeem(validDid, "sealed", null); + noLicence.redeem(validDid, "sealed", null).join(); String form = new String( transport.last().getBody(), StandardCharsets.UTF_8); @@ -825,7 +884,7 @@ public void redeem_FormEncodesTheValues() throws Exception { String standard = key2.fodIdAt(canonicalPayload(), WEEK2).asBase64(); assertTrue(standard.endsWith("=")); - client.redeem(standard, "a b&c", "x=y"); + client.redeem(standard, "a b&c", "x=y").join(); String form = new String( transport.last().getBody(), StandardCharsets.UTF_8); @@ -842,9 +901,9 @@ public void redeem_FormEncodesTheValues() throws Exception { public void verify_MalformedStringIsRefusedBeforeTransport() { // Not base64 at all, so the OWID reader's own status is the reason, // and neither a key fetch nor the verify call happens. - IllegalArgumentException error = assertThrows( + IllegalArgumentException error = failure( IllegalArgumentException.class, - () -> client.verify("This is not a 51Did!")); + client.verify("This is not a 51Did!")); assertTrue(error.getMessage(), error.getMessage().contains("INVALID_BASE64")); @@ -857,8 +916,8 @@ public void verify_ShortPayloadStringIsRefusedBeforeTransport() // A genuine envelope whose payload cannot carry the 51Did header. String tooShort = key2.signedOwidAt(new byte[3], WEEK2).asBase64(); - IllegalArgumentException error = assertThrows( - IllegalArgumentException.class, () -> client.verify(tooShort)); + IllegalArgumentException error = failure( + IllegalArgumentException.class, client.verify(tooShort)); assertTrue(error.getMessage(), error.getMessage().contains("PAYLOAD_TOO_SHORT")); @@ -872,11 +931,11 @@ public void redeem_MalformedStringIsRefusedBeforeTransport() Arrays.copyOf(canonicalRandomPayload(), FodId.RANDOM_PAYLOAD_LENGTH - 1), WEEK2).asBase64(); - assertThrows(IllegalArgumentException.class, - () -> client.redeem("This is not a 51Did!", "sealed", "abc")); - IllegalArgumentException error = assertThrows( + failure(IllegalArgumentException.class, + client.redeem("This is not a 51Did!", "sealed", "abc")); + IllegalArgumentException error = failure( IllegalArgumentException.class, - () -> client.redeem(tooShort, "sealed", "abc")); + client.redeem(tooShort, "sealed", "abc")); assertTrue(error.getMessage(), error.getMessage().contains("INVALID_TYPE_PAYLOAD_LENGTH")); @@ -892,7 +951,7 @@ public void verify_LongerContextSectionStringReachesTheCloud() String longer = key2.fodIdAt( canonicalPayloadWithSection(600), WEEK2).asBase64Url(); - assertTrue(client.verify(longer)); + assertTrue(client.verify(longer).join()); assertEquals(1, transport.requests.size()); } @@ -911,24 +970,89 @@ public void verifySignature_TamperedSignatureIsInvalidNotAnError() assertEquals(FodIdParseStatus.PARSED, read.getStatus()); assertEquals(DidClient.SignatureCheck.INVALID, - client.verifySignatureDetailed(read.getValue())); - assertFalse(client.verifySignature(read.getValue())); + client.verifySignatureDetailed(read.getValue()).join()); + assertFalse(client.verifySignature(read.getValue()).join()); } @Test - public void verifySignature_FirstKeyFetchFailureRaisesRatherThanFalse() + public void verifySignature_FirstKeyFetchFailureFailsRatherThanAnsweringFalse() throws Exception { // Nothing queued, so the key list cannot be fetched. That is an // error, never a verdict on the signature. FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2); - assertThrows(IOException.class, () -> client.verifySignature(fodId)); - assertThrows(IOException.class, - () -> client.verifySignatureDetailed(fodId)); + failure(IOException.class, client.verifySignature(fodId)); + failure(IOException.class, client.verifySignatureDetailed(fodId)); + } + + // ----- The default transport ----- + + @Test + public void defaultTransport_RunsTheExchangeOnTheBuildersExecutor() { + // The executor keeps the work rather than running it, so the + // exchange never happens and the test needs no network. What it + // shows is that the work was handed over at all, because the + // calling thread must not do the blocking. + Held work = new Held(); + DidClient onHold = DidClient.builder("resource") + .endpoint(ENDPOINT).executor(work).build(); + + CompletableFuture> keys = onHold.publicKeys(); + + assertEquals(1, work.tasks.size()); + assertFalse(keys.isDone()); + } + + @Test + public void defaultTransport_ReportsAFailedExchangeThroughTheFuture() { + Held work = new Held(); + HttpTransport blocking = + new DidClient.UrlConnectionTransport(work); + + CompletableFuture answer = blocking.send( + new HttpTransport.Request("GET", "no-such-scheme://host/", + Collections.emptyMap(), null)); + + assertFalse(answer.isDone()); + work.tasks.get(0).run(); + + // An unknown scheme is a MalformedURLException, which is an + // IOException, and it arrives through the future rather than + // being thrown at whoever called send. + failure(IOException.class, answer); + } + + @Test + public void defaultTransport_ReportsARefusedExecutorThroughTheFuture() { + HttpTransport blocking = new DidClient.UrlConnectionTransport( + work -> { + throw new RejectedExecutionException("the pool is closed"); + }); + + CompletableFuture answer = blocking.send( + new HttpTransport.Request("GET", ENDPOINT, + Collections.emptyMap(), null)); + + failure(RejectedExecutionException.class, answer); } // ----- Helpers ----- + /** + * The failure a future reports, taken from the cause of the + * {@link CompletionException} that {@code join} reports it as, which + * is how every failure of this client reaches a caller. + */ + private static T failure( + Class type, CompletableFuture answer) { + CompletionException reported = + assertThrows(CompletionException.class, answer::join); + Throwable cause = reported.getCause(); + assertNotNull("No cause on " + reported, cause); + assertTrue(cause.toString(), type.isInstance(cause)); + return type.cast(cause); + } + /** * An identifier whose encoded form is longer than the client will take * from a caller. The identifier itself is perfectly good, so this only @@ -995,12 +1119,53 @@ Request last() { } @Override - public Response send(Request request) throws IOException { + public CompletableFuture send(Request request) { requests.add(request); + CompletableFuture answer = + new CompletableFuture(); if (responses.isEmpty()) { - throw new IOException("Nothing queued for " + request.getUrl()); + answer.completeExceptionally( + new IOException("Nothing queued for " + request.getUrl())); + } else { + answer.complete(responses.removeFirst()); } - return responses.removeFirst(); + return answer; + } + } + + /** A transport whose answers the test gives by hand. */ + static final class HeldTransport implements HttpTransport { + + final List requests = new ArrayList(); + private final Deque> pending = + new ArrayDeque>(); + + @Override + public CompletableFuture send(Request request) { + requests.add(request); + CompletableFuture answer = + new CompletableFuture(); + pending.add(answer); + return answer; + } + + void answer(int status, String body) { + pending.removeFirst().complete(new Response(status, body)); + } + + void fail(Throwable failure) { + pending.removeFirst().completeExceptionally(failure); + } + } + + /** An executor that keeps the work rather than running it. */ + static final class Held implements Executor { + + final List tasks = new ArrayList(); + + @Override + public void execute(Runnable work) { + tasks.add(work); } }