From e28fd314753b05c126f717a863ed0fba3fbf5dbe Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:32:21 -0500 Subject: [PATCH 1/4] Merge the Smithy server into the Smithy extension, on par with its client The server moves from the org.restlet.ext.smithy.server artifact into org.restlet.ext.smithy, which reads the SDK server, an optional dependency, on the class path: smithy-java 1.8.0 splits its package. A ServerProvider named restlet and a public SmithyServerBuilder let Server.builder() discover Restlet connectors, as SDK clients discover the transport. RPCv2 CBOR, a named consumer hosting a service and class-path discovery are now tested. Closes #1673 Co-Authored-By: Claude Opus 5.5 --- changes.md | 19 +- org.restlet.ext.smithy.server/pom.xml | 156 --------- .../src/test/smithy/smithy-build.json | 11 - org.restlet.ext.smithy/pom.xml | 45 ++- .../src/main/java/module-info.java | 5 + .../ext/smithy/SmithyServerAdapter.java | 12 +- .../ext/smithy/SmithyServerBuilder.java | 175 ++++++++++ .../internal/RestletServerProvider.java | 35 ++ .../smithy/internal/RestletSmithyServer.java | 191 +++++++++++ .../ext/smithy/internal/SmithyServerHost.java | 0 ...e.amazon.smithy.java.server.ServerProvider | 1 + .../ext/smithy/SmithyModuleTestCase.java | 322 +++++++++++++++--- .../smithy/SmithyServerAdapterTestCase.java | 34 +- .../smithy/SmithyServerBuilderTestCase.java | 256 ++++++++++++++ .../src/test/smithy/counter.smithy | 34 ++ .../src/test/smithy/notes.smithy | 0 .../src/test/smithy/smithy-build.json | 36 +- pom.xml | 1 - readme.md | 2 +- 19 files changed, 1077 insertions(+), 258 deletions(-) delete mode 100644 org.restlet.ext.smithy.server/pom.xml delete mode 100644 org.restlet.ext.smithy.server/src/test/smithy/smithy-build.json rename {org.restlet.ext.smithy.server => org.restlet.ext.smithy}/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java (92%) create mode 100644 org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java create mode 100644 org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java create mode 100644 org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java rename {org.restlet.ext.smithy.server => org.restlet.ext.smithy}/src/main/java/org/restlet/ext/smithy/internal/SmithyServerHost.java (100%) create mode 100644 org.restlet.ext.smithy/src/main/resources/META-INF/services/software.amazon.smithy.java.server.ServerProvider rename {org.restlet.ext.smithy.server => org.restlet.ext.smithy}/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java (93%) create mode 100644 org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java create mode 100644 org.restlet.ext.smithy/src/test/smithy/counter.smithy rename {org.restlet.ext.smithy.server => org.restlet.ext.smithy}/src/test/smithy/notes.smithy (100%) diff --git a/changes.md b/changes.md index f0f79c5b01..efcada48b6 100644 --- a/changes.md +++ b/changes.md @@ -155,14 +155,17 @@ Changes log response. A Host header set by the SDK is kept. Request timeouts, which cover the response head, and thread interruption abort the request. Event streams are not supported. - - Added the experimental Smithy server artifact (`org.restlet.ext.smithy.server`), whose - `SmithyServerAdapter` joins the `org.restlet.ext.smithy` package, hosting - smithy-java 1.8.0 services, generated or built from a model, behind a Restlet router - beside other routes. The SDK protocols resolve, validate and serialize each call, and its - handlers run on the request thread. Request bodies, concurrent calls and call durations - are bounded; deadlines, client disconnects and stopping interrupt the operation. It runs - on the class path only, as smithy-java's server modules split a package, with - `org.restlet.ext.smithy` on the class path too. + - Added experimental smithy-java 1.8.0 server hosting to the Smithy extension. Its + `SmithyServerAdapter` hosts services, generated or built from a model, behind a Restlet + router beside other routes, and its `SmithyServerBuilder`, which `Server.builder()` + discovers under the name `restlet` before the SDK's Netty server, listens on Restlet + HTTP/1.1 and HTTP/2 server connectors. The SDK protocols, such as restJson1 and RPCv2 + CBOR, resolve, validate and serialize each call, and its handlers run on the request + thread. Request bodies, concurrent calls and call durations are bounded; deadlines, + client disconnects and stopping interrupt the operation. As smithy-java's server + modules split a package, its optional `server-core` dependency stays on the class path: + named modules read it with `--add-reads org.restlet.ext.smithy=ALL-UNNAMED`, and the + builder is only discovered on the class path. - Added the supported `org.restlet.util.StreamTransport` seam for protocol-neutral, bounded synchronous byte I/O, serialized writes, borrowed-stream cancellation and owned subprocess lifecycle with separate bounded stderr capture. No protocol diff --git a/org.restlet.ext.smithy.server/pom.xml b/org.restlet.ext.smithy.server/pom.xml deleted file mode 100644 index bd76dd6914..0000000000 --- a/org.restlet.ext.smithy.server/pom.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - 4.0.0 - - org.restlet - org.restlet.parent - 3.0.0-SNAPSHOT - - org.restlet.ext.smithy.server - Restlet Framework - Smithy server extension - Experimental smithy-java server host on Restlet HTTP connectors. - - - - 1.74.0 - ${project.build.directory}/smithy - - - - org.restlet - org.restlet - ${project.version} - - - - software.amazon.smithy.java - server-core - ${lib-smithy-java-version} - - - software.amazon.smithy.java - aws-server-restjson - ${lib-smithy-java-version} - test - - - org.restlet - org.restlet.ext.smithy - ${project.version} - test - - - software.amazon.smithy.java - aws-client-restjson - ${lib-smithy-java-version} - test - - - - software.amazon.smithy.java - codegen-plugin - ${lib-smithy-java-version} - test - - - software.amazon.smithy - smithy-cli - ${lib-smithy-version} - test - - - org.junit.jupiter - junit-jupiter-api - ${lib-junit-version} - test - - - org.junit.jupiter - junit-jupiter-params - ${lib-junit-version} - test - - - - - - maven-jar-plugin - - - - org.restlet.ext.smithy.server - - - - - - maven-compiler-plugin - - - default-testCompile - - - - -Xlint:-serial,-overloads - - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.6.3 - - - smithy-codegen - generate-test-sources - - exec - - - ${java.home}/bin/java - test - - -classpath - - software.amazon.smithy.cli.SmithyCli - build - --config - ${project.basedir}/src/test/smithy/smithy-build.json - --output - ${smithy.output} - --discover - ${project.basedir}/src/test/smithy - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.0 - - - add-generated-test-sources - generate-test-sources - - add-test-source - - - - ${smithy.output}/source/java-codegen/java - - - - - - - - diff --git a/org.restlet.ext.smithy.server/src/test/smithy/smithy-build.json b/org.restlet.ext.smithy.server/src/test/smithy/smithy-build.json deleted file mode 100644 index 58bbd19168..0000000000 --- a/org.restlet.ext.smithy.server/src/test/smithy/smithy-build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "1.0", - "plugins": { - "java-codegen": { - "service": "restlet.test.server#Notes", - "namespace": "org.restlet.ext.smithy.generated", - "modes": ["client", "server"], - "protocol": "aws.protocols#restJson1" - } - } -} diff --git a/org.restlet.ext.smithy/pom.xml b/org.restlet.ext.smithy/pom.xml index 1648c77541..f5712623b3 100644 --- a/org.restlet.ext.smithy/pom.xml +++ b/org.restlet.ext.smithy/pom.xml @@ -9,7 +9,7 @@ org.restlet.ext.smithy Restlet Framework - Smithy extension - smithy-java client transport on Restlet HTTP client connectors. + smithy-java client transport and server host on Restlet HTTP connectors. org.restlet @@ -23,6 +23,16 @@ client-http ${lib-smithy-java-version} + + + software.amazon.smithy.java + server-core + ${lib-smithy-java-version} + true + software.amazon.smithy.java dynamic-client @@ -41,13 +51,25 @@ ${lib-smithy-java-version} test + + software.amazon.smithy.java + aws-server-restjson + ${lib-smithy-java-version} + test + + + software.amazon.smithy.java + server-rpcv2-cbor + ${lib-smithy-java-version} + test + software.amazon.smithy.java aws-sigv4 ${lib-smithy-java-version} test - + software.amazon.smithy.java codegen-plugin @@ -83,12 +105,23 @@ maven-compiler-plugin + + default-compile + + + + --add-reads + org.restlet.ext.smithy=ALL-UNNAMED + + + default-testCompile - + - -Xlint:-serial + -Xlint:-serial,-overloads @@ -138,7 +171,9 @@ - ${smithy.output}/source/java-codegen/java + ${smithy.output}/greeter/java-codegen/java + ${smithy.output}/notes/java-codegen/java + ${smithy.output}/counter/java-codegen/java diff --git a/org.restlet.ext.smithy/src/main/java/module-info.java b/org.restlet.ext.smithy/src/main/java/module-info.java index 09877f9dd3..7272018092 100644 --- a/org.restlet.ext.smithy/src/main/java/module-info.java +++ b/org.restlet.ext.smithy/src/main/java/module-info.java @@ -6,6 +6,11 @@ * *

Restlet is a registered trademark of QlikTech International AB. */ +/* + * The server classes use the smithy-java server, which splits a package in 1.8.0 + * (smithy-java#1437) and can't be required: they read it on the class path, with --add-reads + * org.restlet.ext.smithy=ALL-UNNAMED, and its provider is only declared in META-INF/services. + */ @SuppressWarnings({"requires-automatic", "requires-transitive-automatic"}) module org.restlet.ext.smithy { requires transitive org.restlet; diff --git a/org.restlet.ext.smithy.server/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java similarity index 92% rename from org.restlet.ext.smithy.server/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java rename to org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java index cbbf690691..8b272a343c 100644 --- a/org.restlet.ext.smithy.server/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java @@ -38,10 +38,14 @@ * response. A client disconnecting also interrupts its operation, and stopping the adapter * interrupts every running one: the SDK has no cancellation of its own. This adapter is single-use. * + *

{@link SmithyServerBuilder} builds SDK servers of such adapters, listening on Restlet server + * connectors. + * *

The smithy-java server API is unstable ({@code @SmithyUnstableApi}) in 1.8.0, and its modules - * split a package, so that it only runs on the class path. This artifact shares the packages of - * {@code org.restlet.ext.smithy}, which must then be on the class path too. Event streams are not - * supported. + * split a package, so that they only run on the class path. The optional {@code server-core} + * dependency of this extension must then be added to the class path. A named module using the + * server reads it, as this module does, with {@code --add-reads + * org.restlet.ext.smithy=ALL-UNNAMED}, when compiled and run. Event streams are not supported. */ public final class SmithyServerAdapter extends Restlet { private final List services = new CopyOnWriteArrayList<>(); @@ -68,6 +72,8 @@ public SmithyServerAdapter(Context context) { * * @return The modifiable list of services. */ + // Service comes from the SDK server on the class path, which a named module can't export. + @SuppressWarnings("exports") public List getServices() { return services; } diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java new file mode 100644 index 0000000000..df064ba679 --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java @@ -0,0 +1,175 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.smithy; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.restlet.Context; +import org.restlet.ext.smithy.internal.RestletSmithyServer; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Server; +import software.amazon.smithy.java.server.ServerBuilder; + +/** + * Experimental builder of smithy-java servers listening on Restlet HTTP server connectors, one per + * endpoint, each route of services hosted by a {@link SmithyServerAdapter}. + * + *

On the class path, {@code Server.builder()} discovers it under the name {@code restlet}, in + * preference to the SDK's Netty server. It can also be created directly, to set the Restlet + * settings that the SDK's builder lacks: the connectors' context, for example with an {@code + * http.transport.protocol} parameter set to {@code HTTP2} or the TLS parameters of {@code https} + * endpoints, and the adapters' limits. {@link #numberOfWorkers(int)} sets the number of calls each + * route handles at once. + * + *

A call goes to the route with the longest path prefix whose prefix, port, scheme and host + * name, when set, match its URI, the routes added first winning ties. Operations are resolved on + * the full path, as with the SDK's Netty server. + */ +// The SDK server types come from the class path, which a named module can't export. +@SuppressWarnings("exports") +public final class SmithyServerBuilder extends ServerBuilder { + private List endpoints = List.of(URI.create("http://localhost:8080")); + private Context context; + private Integer maxConcurrentCalls; + private Long maxRequestBytes; + private Duration maxCallDuration; + private List routes = List.of(); + + /** + * Sets the endpoints to listen on, {@code http://localhost:8080} by default. Each needs the + * {@code http} or {@code https} scheme, and listens on the default port of its scheme when it + * has none. + * + * @param endpoints The endpoints. + * @return This builder. + */ + @Override + public SmithyServerBuilder endpoints(URI... endpoints) { + if (endpoints == null || endpoints.length == 0) { + throw new IllegalArgumentException("At least one endpoint is required"); + } + var checked = new ArrayList(endpoints.length); + for (URI endpoint : endpoints) { + Objects.requireNonNull(endpoint, "endpoint"); + String scheme = + endpoint.getScheme() == null + ? "" + : endpoint.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw new IllegalArgumentException("Not an HTTP or HTTPS endpoint: " + endpoint); + } + checked.add(endpoint); + } + this.endpoints = List.copyOf(checked); + return self(); + } + + /** + * Sets the number of calls each route handles at once, like {@link + * SmithyServerAdapter#setMaxConcurrentCalls(int)}. + * + * @param numberOfWorkers A positive number of calls. + * @return This builder. + */ + @Override + public SmithyServerBuilder numberOfWorkers(int numberOfWorkers) { + if (numberOfWorkers <= 0) { + throw new IllegalArgumentException("A positive number of workers is required"); + } + this.maxConcurrentCalls = numberOfWorkers; + return self(); + } + + /** + * Sets the context of the server connectors, whose parameters configure them. + * + * @param context The context of the server connectors. + * @return This builder. + */ + public SmithyServerBuilder context(Context context) { + this.context = Objects.requireNonNull(context, "context"); + return self(); + } + + /** + * Sets the largest request body accepted, like {@link + * SmithyServerAdapter#setMaxRequestBytes(long)}. + * + * @param value A positive number of bytes. + * @return This builder. + */ + public SmithyServerBuilder maxRequestBytes(long value) { + if (value <= 0) { + throw new IllegalArgumentException("A positive request size is required"); + } + this.maxRequestBytes = value; + return self(); + } + + /** + * Sets the time an operation may run, like {@link + * SmithyServerAdapter#setMaxCallDuration(Duration)}. + * + * @param value A positive duration. + * @return This builder. + */ + public SmithyServerBuilder maxCallDuration(Duration value) { + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("A positive duration is required"); + } + this.maxCallDuration = value; + return self(); + } + + @Override + protected SmithyServerBuilder setServerRoutes(List routes) { + // The SDK collects the services added under paths in a hash map: order them so that "/" + // doesn't take the calls of a longer prefix. The sort is stable. + this.routes = + routes.stream() + .sorted( + Comparator.comparingInt( + (Route route) -> + route.getPathPrefix() == null + ? 0 + : route.getPathPrefix().length()) + .reversed()) + .toList(); + return self(); + } + + @Override + protected Server buildServer() { + if (routes.isEmpty()) { + throw new IllegalStateException("Add at least one service before building"); + } + var adapters = new ArrayList(routes.size()); + for (Route route : routes) { + var adapter = new SmithyServerAdapter(); + adapter.getServices().addAll(route.getServices()); + if (maxConcurrentCalls != null) { + adapter.setMaxConcurrentCalls(maxConcurrentCalls); + } + if (maxRequestBytes != null) { + adapter.setMaxRequestBytes(maxRequestBytes); + } + if (maxCallDuration != null) { + adapter.setMaxCallDuration(maxCallDuration); + } + adapters.add(adapter); + } + return new RestletSmithyServer( + endpoints, context == null ? new Context() : context, routes, adapters); + } +} diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java new file mode 100644 index 0000000000..a852d67b29 --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java @@ -0,0 +1,35 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.smithy.internal; + +import org.restlet.ext.smithy.SmithyServerBuilder; +import software.amazon.smithy.java.server.ServerBuilder; +import software.amazon.smithy.java.server.ServerProvider; + +/** + * Provider discovered by {@code Server.builder()} on the class path, under the name {@code + * restlet}. A named module can't declare it: the SDK server isn't in the module graph. + */ +public final class RestletServerProvider implements ServerProvider { + @Override + public String name() { + return "restlet"; + } + + @Override + public ServerBuilder serverBuilder() { + return new SmithyServerBuilder(); + } + + @Override + public int priority() { + // The highest priority is picked first: 1 comes before the SDK's Netty server (0). + return 1; + } +} diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java new file mode 100644 index 0000000000..a7ab8b812e --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java @@ -0,0 +1,191 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.smithy.internal; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import org.restlet.ext.smithy.SmithyServerAdapter; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Server; + +/** + * A smithy-java server built by {@code SmithyServerBuilder}: a Restlet server connector per + * endpoint, dispatching each call to the adapter of its route. Single-use. + */ +public final class RestletSmithyServer implements Server { + private final List endpoints; + private final Context context; + private final List routes; + private final List adapters; + private final List connectors = new ArrayList<>(); + + /** Guards the lifecycle state. */ + private final Object lock = new Object(); + + private boolean started; + private CompletableFuture shutdown; + + public RestletSmithyServer( + List endpoints, + Context context, + List routes, + List adapters) { + this.endpoints = List.copyOf(endpoints); + this.context = context; + this.routes = List.copyOf(routes); + this.adapters = List.copyOf(adapters); + } + + /** Starts the adapters, then a connector per endpoint. */ + @Override + public void start() { + synchronized (lock) { + if (started) { + throw new IllegalStateException("Server is single-use; already started"); + } + started = true; + try { + for (SmithyServerAdapter adapter : adapters) { + adapter.start(); + } + Restlet dispatcher = new Dispatcher(); + for (URI endpoint : endpoints) { + boolean secure = "https".equalsIgnoreCase(endpoint.getScheme()); + Protocol protocol = secure ? Protocol.HTTPS : Protocol.HTTP; + int port = + endpoint.getPort() < 0 ? protocol.getDefaultPort() : endpoint.getPort(); + var connector = + new org.restlet.Server( + context, protocol, endpoint.getHost(), port, dispatcher); + connectors.add(connector); + connector.start(); + } + } catch (Exception e) { + stopAll(); + throw new IllegalStateException("Unable to start the Smithy server", e); + } + } + } + + /** Stops the connectors, then the adapters, interrupting the operations still running. */ + @Override + public CompletableFuture shutdown() { + synchronized (lock) { + if (shutdown == null) { + started = true; + shutdown = new CompletableFuture<>(); + CompletableFuture result = shutdown; + Thread.ofVirtual() + .name("restlet-smithy-shutdown") + .start( + () -> { + try { + stopAll(); + result.complete(null); + } catch (RuntimeException e) { + result.completeExceptionally(e); + } + }); + } + return shutdown; + } + } + + /** + * Returns the ports the connectors listen on, in the order of the endpoints, once started. + * + * @return The actual ports. + */ + public List getActualPorts() { + synchronized (lock) { + return connectors.stream().map(org.restlet.Server::getActualPort).toList(); + } + } + + /** Stops every connector, then every adapter, keeping the first failure. */ + private void stopAll() { + RuntimeException failure = null; + List stopping = new ArrayList<>(); + synchronized (lock) { + stopping.addAll(connectors); + } + stopping.addAll(adapters); + for (Restlet restlet : stopping) { + try { + restlet.stop(); + } catch (Exception e) { + if (failure == null) { + failure = new IllegalStateException("Unable to stop the Smithy server", e); + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } + } + + /** Dispatches each call to the adapter of the first route matching its URI. */ + private final class Dispatcher extends Restlet { + @Override + public void handle(Request request, Response response) { + Reference ref = request.getResourceRef(); + // The SDK resolves operations on the full path, below the host. + ref.setBaseRef(ref.getHostIdentifier()); + for (int i = 0; i < routes.size(); i++) { + if (matches(routes.get(i), ref)) { + adapters.get(i).handle(request, response); + return; + } + } + response.setStatus(Status.CLIENT_ERROR_NOT_FOUND); + } + } + + /** + * Indicates whether the prefix, port, scheme and host name of a route, when set, match a URI. + * + * @param route The route. + * @param ref The URI of a call. + * @return True if the route matches. + */ + public static boolean matches(Route route, Reference ref) { + String path = ref.getPath(); + if (route.getPathPrefix() != null + && (path == null || !path.startsWith(route.getPathPrefix()))) { + return false; + } + String scheme = ref.getScheme() == null ? "" : ref.getScheme().toLowerCase(Locale.ROOT); + if (route.getProtocol() != null && !route.getProtocol().equalsIgnoreCase(scheme)) { + return false; + } + if (route.getPort() != null) { + int port = ref.getHostPort(); + if (port < 0 && ref.getSchemeProtocol() != null) { + port = ref.getSchemeProtocol().getDefaultPort(); + } + if (route.getPort() != port) { + return false; + } + } + return route.getHostName() == null + || route.getHostName().equalsIgnoreCase(ref.getHostDomain()); + } +} diff --git a/org.restlet.ext.smithy.server/src/main/java/org/restlet/ext/smithy/internal/SmithyServerHost.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/SmithyServerHost.java similarity index 100% rename from org.restlet.ext.smithy.server/src/main/java/org/restlet/ext/smithy/internal/SmithyServerHost.java rename to org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/SmithyServerHost.java diff --git a/org.restlet.ext.smithy/src/main/resources/META-INF/services/software.amazon.smithy.java.server.ServerProvider b/org.restlet.ext.smithy/src/main/resources/META-INF/services/software.amazon.smithy.java.server.ServerProvider new file mode 100644 index 0000000000..f584d79eaa --- /dev/null +++ b/org.restlet.ext.smithy/src/main/resources/META-INF/services/software.amazon.smithy.java.server.ServerProvider @@ -0,0 +1 @@ +org.restlet.ext.smithy.internal.RestletServerProvider diff --git a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java index b96dabe3ef..ad753458ff 100644 --- a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java @@ -10,32 +10,41 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.PrintStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.spi.ToolProvider; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.restlet.Client; /** - * A named module calls a restJson1 service through the transport, set explicitly or discovered. - * RPCv2 CBOR is left out: in smithy-java 1.8.0, its client modules split a package - * (smithy-java#1437) and only work on the class path. + * Consumers in child JVMs. A named module calls a restJson1 service through the transport, set + * explicitly or discovered, and hosts a generated service with the SDK server on the class path. A + * class-path consumer discovers both the server and the transport, over RPCv2 CBOR. In smithy-java + * 1.8.0, the RPCv2 CBOR modules and the server modules split packages (smithy-java#1437), so they + * stay off the module path. */ class SmithyModuleTestCase { /** - * Jars left off the module path: the RPCv2 CBOR client modules, and the code generator of the - * tests, used at build time only, whose modules also split packages. + * Jars left off the module path: the RPCv2 CBOR modules and the SDK server, and the code + * generator of the tests, used at build time only, whose modules also split packages. */ private static final List EXCLUDED = List.of( + "aws-server-", "client-rpcv2", + "server-", "codegen-", "commonmark-", "jsoup-", @@ -50,31 +59,7 @@ private static boolean excluded(String jar) { @Test void namedConsumerUsesTheTransport(@TempDir Path temp) throws Exception { - String classPath = - System.getProperty( - "surefire.test.class.path", System.getProperty("java.class.path")); - var paths = - new ArrayList<>( - Arrays.stream(classPath.split(File.pathSeparator)) - .map(Path::of) - .filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".jar")) - .filter(path -> !excluded(path.getFileName().toString())) - .toList()); - paths.add( - Path.of(Client.class.getProtectionDomain().getCodeSource().getLocation().toURI())); - paths.add( - Path.of( - SmithyClientAdapter.class - .getProtectionDomain() - .getCodeSource() - .getLocation() - .toURI())); - String modulePath = - paths.stream() - .distinct() - .map(Path::toString) - .collect(Collectors.joining(File.pathSeparator)); + String modulePath = modulePath(); Path descriptor = temp.resolve("module-info.java"); Path example = temp.resolve("Main.java"); Files.writeString( @@ -170,35 +155,272 @@ public void handle(Request request, Response response) { } """); Path classes = temp.resolve("classes"); - var diagnostics = new java.io.ByteArrayOutputStream(); - var diagnosticStream = new java.io.PrintStream(diagnostics, true); + compile( + "--module-path", + modulePath, + "-d", + classes.toString(), + descriptor.toString(), + example.toString()); + run( + temp, + "--module-path", + modulePath + File.pathSeparator + classes, + "--module", + "r5.consumer/example.Main"); + } + + /** + * A named module hosts a generated service with the adapter. The SDK server and the generated + * code are on the class path, which the named modules read. + */ + @Test + void namedConsumerHostsAService(@TempDir Path temp) throws Exception { + String classPath = join(serverClassPath()); + String modulePath = modulePath(); + Path descriptor = temp.resolve("module-info.java"); + Path example = temp.resolve("Main.java"); + Files.writeString( + descriptor, + """ + module r5.server.consumer { + requires org.restlet.ext.smithy; + requires software.amazon.smithy.java.aws.client.restjson; + requires software.amazon.smithy.java.core; + } + """); + Files.writeString( + example, + """ + package example; + import org.restlet.Client; + import org.restlet.Context; + import org.restlet.Server; + import org.restlet.data.Protocol; + import org.restlet.ext.smithy.SmithyClientAdapter; + import org.restlet.ext.smithy.SmithyServerAdapter; + import org.restlet.ext.smithy.generated.notes.client.NotesClient; + import org.restlet.ext.smithy.generated.notes.model.GetNoteInput; + import org.restlet.ext.smithy.generated.notes.model.GetNoteOutput; + import org.restlet.ext.smithy.generated.notes.model.PutNoteOutput; + import org.restlet.ext.smithy.generated.notes.model.RequiredUploadOutput; + import org.restlet.ext.smithy.generated.notes.model.UploadOutput; + import org.restlet.ext.smithy.generated.notes.model.WaitOutput; + import org.restlet.ext.smithy.generated.notes.service.GetNoteOperation; + import org.restlet.ext.smithy.generated.notes.service.Notes; + import org.restlet.ext.smithy.generated.notes.service.PutNoteOperation; + import org.restlet.ext.smithy.generated.notes.service.RequiredUploadOperation; + import org.restlet.ext.smithy.generated.notes.service.UploadOperation; + import org.restlet.ext.smithy.generated.notes.service.WaitOperation; + import org.restlet.routing.Router; + public final class Main { + public static void main(String[] args) throws Exception { + var adapter = new SmithyServerAdapter(); + adapter.getServices().add(Notes.builder() + .addGetNoteOperation((GetNoteOperation) (input, context) -> + GetNoteOutput.builder().text("named " + input.getId()).build()) + .addPutNoteOperation((PutNoteOperation) (input, context) -> + PutNoteOutput.builder().build()) + .addRequiredUploadOperation((RequiredUploadOperation) + (input, context) -> RequiredUploadOutput.builder().build()) + .addUploadOperation((UploadOperation) (input, context) -> + UploadOutput.builder().build()) + .addWaitOperation((WaitOperation) (input, context) -> + WaitOutput.builder().build()) + .build()); + adapter.start(); + var router = new Router(); + adapter.attachTo(router); + var server = new Server(Protocol.HTTP, 0, router); + server.start(); + var context = new Context(); + context.getParameters().set("followRedirects", "false"); + var client = new Client(context, Protocol.HTTP); + client.start(); + var notes = NotesClient.builder() + .transport(new SmithyClientAdapter(client)) + .endpoint("http://localhost:" + server.getActualPort()) + .build(); + try { + var text = notes.getNote(GetNoteInput.builder().id("n1").build()) + .getText(); + if (!"named n1".equals(text)) { + throw new AssertionError("Round trip failed: " + text); + } + } finally { + try { client.stop(); } + finally { try { server.stop(); } finally { adapter.stop(); } } + } + } + } + """); + Path classes = temp.resolve("classes"); + compile( + "--module-path", + modulePath, + "--class-path", + classPath, + "--add-reads", + "org.restlet.ext.smithy=ALL-UNNAMED", + "--add-reads", + "r5.server.consumer=ALL-UNNAMED", + "-d", + classes.toString(), + descriptor.toString(), + example.toString()); + run( + temp, + "--module-path", + modulePath + File.pathSeparator + classes, + "--class-path", + classPath, + "--add-reads", + "org.restlet.ext.smithy=ALL-UNNAMED", + "--add-reads", + "r5.server.consumer=ALL-UNNAMED", + "--module", + "r5.server.consumer/example.Main"); + } + + /** + * On the class path, SDK servers and clients built without naming one discover the Restlet + * server and transport, here over RPCv2 CBOR. + */ + @Test + void classPathConsumerDiscoversTheServer(@TempDir Path temp) throws Exception { + String classPath = join(Stream.concat(testClassPath().stream(), Stream.of(testClasses()))); + Path example = temp.resolve("Main.java"); + Files.writeString( + example, + """ + package example; + import java.net.URI; + import org.restlet.ext.smithy.SmithyServerBuilder; + import org.restlet.ext.smithy.generated.counter.client.CounterClient; + import org.restlet.ext.smithy.generated.counter.model.AddInput; + import org.restlet.ext.smithy.generated.counter.model.AddOutput; + import org.restlet.ext.smithy.generated.counter.service.AddOperation; + import org.restlet.ext.smithy.generated.counter.service.Counter; + import org.restlet.ext.smithy.internal.RestletSmithyServer; + import software.amazon.smithy.java.client.core.ClientTransportFactory; + import software.amazon.smithy.java.server.Server; + public final class Main { + public static void main(String[] args) throws Exception { + var builder = Server.builder(); + if (!(builder instanceof SmithyServerBuilder)) { + throw new AssertionError("Discovered " + builder); + } + if (!(Server.builder("restlet") instanceof SmithyServerBuilder)) { + throw new AssertionError("Not found by name"); + } + var first = ClientTransportFactory.load(Main.class.getClassLoader()) + .getFirst().name(); + if (!"restlet".equals(first)) { + throw new AssertionError("Discovered first: " + first); + } + var server = (RestletSmithyServer) builder + .endpoints(URI.create("http://localhost:0")) + .addService(Counter.builder() + .addAddOperation((AddOperation) (input, context) -> + AddOutput.builder().total(input.getValue() + 1).build()) + .build()) + .build(); + server.start(); + var client = CounterClient.builder() + .endpoint("http://localhost:" + server.getActualPorts().getFirst()) + .build(); + try { + long total = client.add(AddInput.builder().value(1L).label("cp") + .build()).getTotal(); + if (total != 2L) { + throw new AssertionError("Round trip failed: " + total); + } + } finally { + // The generated interface isn't closeable: its client owns the + // discovered transport. + try { ((AutoCloseable) client).close(); } + finally { server.shutdown().get(); } + } + } + } + """); + Path classes = temp.resolve("classes"); + compile("--class-path", classPath, "-d", classes.toString(), example.toString()); + run(temp, "--class-path", classPath + File.pathSeparator + classes, "example.Main"); + } + + /** The test class path's jars, with Restlet's and this module's classes. */ + private static List testClassPath() throws Exception { + String classPath = + System.getProperty( + "surefire.test.class.path", System.getProperty("java.class.path")); + var paths = + new ArrayList<>( + Arrays.stream(classPath.split(File.pathSeparator)) + .map(Path::of) + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".jar")) + .toList()); + paths.add(classesOf(Client.class)); + paths.add(classesOf(SmithyClientAdapter.class)); + return paths.stream().distinct().toList(); + } + + /** The module path of the named consumers, without the jars that split packages. */ + private static String modulePath() throws Exception { + return join( + testClassPath().stream().filter(path -> !excluded(path.getFileName().toString()))); + } + + /** The SDK server jars, and the generated test code, beside a named consumer. */ + private static Stream serverClassPath() throws Exception { + return Stream.concat( + testClassPath().stream() + .filter( + path -> { + String name = path.getFileName().toString(); + return name.startsWith("server-") + || name.startsWith("aws-server-"); + }), + Stream.of(testClasses())); + } + + private static Path testClasses() throws Exception { + return classesOf(SmithyModuleTestCase.class); + } + + private static Path classesOf(Class type) throws Exception { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + + private static String join(Stream paths) { + return paths.distinct().map(Path::toString).collect(Collectors.joining(File.pathSeparator)); + } + + private static void compile(String... arguments) { + var diagnostics = new ByteArrayOutputStream(); + var stream = new PrintStream(diagnostics, true); assertEquals( 0, - java.util.spi.ToolProvider.findFirst("javac") - .orElseThrow() - .run( - diagnosticStream, - diagnosticStream, - "--module-path", - modulePath, - "-d", - classes.toString(), - descriptor.toString(), - example.toString()), + ToolProvider.findFirst("javac").orElseThrow().run(stream, stream, arguments), diagnostics.toString()); + } + + private static void run(Path temp, String... arguments) throws Exception { + var command = new ArrayList(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.addAll(List.of(arguments)); Path output = temp.resolve("consumer.log"); Process child = - new ProcessBuilder( - Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "--module-path", - modulePath + File.pathSeparator + classes, - "--module", - "r5.consumer/example.Main") + new ProcessBuilder(command) .redirectErrorStream(true) .redirectOutput(output.toFile()) .start(); try { - assertTrue(child.waitFor(60, TimeUnit.SECONDS), "Named consumer did not terminate"); + if (!child.waitFor(60, TimeUnit.SECONDS)) { + child.descendants().forEach(ProcessHandle::destroy); + fail("Consumer did not terminate: " + Files.readString(output)); + } assertEquals(0, child.exitValue(), Files.readString(output)); } finally { if (child.isAlive()) { diff --git a/org.restlet.ext.smithy.server/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java similarity index 93% rename from org.restlet.ext.smithy.server/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java rename to org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java index cbd8e71480..de7d5769c9 100644 --- a/org.restlet.ext.smithy.server/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerAdapterTestCase.java @@ -35,23 +35,23 @@ import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Protocol; -import org.restlet.ext.smithy.generated.client.NotesClient; -import org.restlet.ext.smithy.generated.model.GetNoteInput; -import org.restlet.ext.smithy.generated.model.GetNoteOutput; -import org.restlet.ext.smithy.generated.model.NoteNotFound; -import org.restlet.ext.smithy.generated.model.PutNoteInput; -import org.restlet.ext.smithy.generated.model.PutNoteOutput; -import org.restlet.ext.smithy.generated.model.RequiredUploadOutput; -import org.restlet.ext.smithy.generated.model.UploadInput; -import org.restlet.ext.smithy.generated.model.UploadOutput; -import org.restlet.ext.smithy.generated.model.WaitOutput; -import org.restlet.ext.smithy.generated.service.GetNoteOperation; -import org.restlet.ext.smithy.generated.service.Notes; -import org.restlet.ext.smithy.generated.service.PutNoteOperation; -import org.restlet.ext.smithy.generated.service.RequiredUploadOperation; -import org.restlet.ext.smithy.generated.service.UploadOperation; -import org.restlet.ext.smithy.generated.service.WaitOperation; -import org.restlet.ext.smithy.generated.service.WaitOperationAsync; +import org.restlet.ext.smithy.generated.notes.client.NotesClient; +import org.restlet.ext.smithy.generated.notes.model.GetNoteInput; +import org.restlet.ext.smithy.generated.notes.model.GetNoteOutput; +import org.restlet.ext.smithy.generated.notes.model.NoteNotFound; +import org.restlet.ext.smithy.generated.notes.model.PutNoteInput; +import org.restlet.ext.smithy.generated.notes.model.PutNoteOutput; +import org.restlet.ext.smithy.generated.notes.model.RequiredUploadOutput; +import org.restlet.ext.smithy.generated.notes.model.UploadInput; +import org.restlet.ext.smithy.generated.notes.model.UploadOutput; +import org.restlet.ext.smithy.generated.notes.model.WaitOutput; +import org.restlet.ext.smithy.generated.notes.service.GetNoteOperation; +import org.restlet.ext.smithy.generated.notes.service.Notes; +import org.restlet.ext.smithy.generated.notes.service.PutNoteOperation; +import org.restlet.ext.smithy.generated.notes.service.RequiredUploadOperation; +import org.restlet.ext.smithy.generated.notes.service.UploadOperation; +import org.restlet.ext.smithy.generated.notes.service.WaitOperation; +import org.restlet.ext.smithy.generated.notes.service.WaitOperationAsync; import org.restlet.representation.InputRepresentation; import org.restlet.representation.StringRepresentation; import org.restlet.routing.Router; diff --git a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java new file mode 100644 index 0000000000..77b232d966 --- /dev/null +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java @@ -0,0 +1,256 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.smithy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.ext.smithy.generated.counter.client.CounterClient; +import org.restlet.ext.smithy.generated.counter.model.AddInput; +import org.restlet.ext.smithy.generated.counter.model.AddOutput; +import org.restlet.ext.smithy.generated.counter.model.Overflow; +import org.restlet.ext.smithy.generated.counter.service.AddOperation; +import org.restlet.ext.smithy.generated.counter.service.Counter; +import org.restlet.ext.smithy.internal.RestletServerProvider; +import org.restlet.ext.smithy.internal.RestletSmithyServer; +import org.restlet.representation.ByteArrayRepresentation; +import software.amazon.smithy.java.client.core.error.TransportException; +import software.amazon.smithy.java.core.error.CallException; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Server; + +/** + * An RPCv2 CBOR service generated by smithy-java, hosted by servers of {@link SmithyServerBuilder}, + * and called by the generated client over {@link SmithyClientAdapter}. + */ +class SmithyServerBuilderTestCase { + private RestletSmithyServer server; + private Client restlet; + + @AfterEach + void stop() throws Exception { + if (restlet != null) { + restlet.stop(); + } + if (server != null) { + server.shutdown().get(10, TimeUnit.SECONDS); + } + } + + private static Counter counter() { + return Counter.builder() + .addAddOperation( + (AddOperation) + (input, context) -> { + long total = input.getValue() + 1; + if (total > 100) { + throw Overflow.builder().total(total).build(); + } + return AddOutput.builder() + .total(total) + .label(input.getLabel()) + .build(); + }) + .build(); + } + + private void start(SmithyServerBuilder builder, boolean http2) throws Exception { + server = (RestletSmithyServer) builder.endpoints(URI.create("http://localhost:0")).build(); + server.start(); + Context context = new Context(); + context.getParameters().set("followRedirects", "false"); + if (http2) { + context.getParameters().set("httpClientTransportMode", "HTTP2"); + } + restlet = new Client(context, Protocol.HTTP); + restlet.start(); + } + + private String uri(String path) { + return "http://localhost:" + server.getActualPorts().getFirst() + path; + } + + private CounterClient client(String base) { + return CounterClient.builder() + .transport(new SmithyClientAdapter(restlet)) + .endpoint(uri(base)) + .build(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void generatedClientCallsTheCborService(boolean http2) throws Exception { + SmithyServerBuilder builder = new SmithyServerBuilder().addService(counter()); + if (http2) { + Context context = new Context(); + context.getParameters().set("http.transport.protocol", "HTTP2"); + builder.context(context); + } + start(builder, http2); + AddOutput output = client("").add(AddInput.builder().value(41L).label("ünï").build()); + assertEquals(42L, output.getTotal()); + assertEquals("ünï", output.getLabel()); + } + + /** + * Pins a smithy-java 1.8.0 bug: its RPCv2 server serializes a modeled error without its {@code + * __type}, so the client can't tell which error it is. + */ + @Test + void cborModeledErrorsLoseTheirTypeUpstream() throws Exception { + start(new SmithyServerBuilder().addService(counter()), false); + AddInput input = AddInput.builder().value(100L).label("x").build(); + CounterClient client = client(""); + CallException error = assertThrows(CallException.class, () -> client.add(input)); + assertFalse(error instanceof Overflow, error.toString()); + } + + /** The SDK validates the input: a raw CBOR call, which no client validated, gets a 400. */ + @Test + void invalidInputIsRejected() throws Exception { + start(new SmithyServerBuilder().addService(counter()), false); + Request request = new Request(Method.POST, uri("/service/Counter/operation/Add")); + // {"value": -1, "label": "x"}: the value is below its range. + request.setEntity( + new ByteArrayRepresentation( + HexFormat.of().parseHex("a26576616c756520656c6162656c6178"), + MediaType.valueOf("application/cbor"))); + request.getHeaders().add("smithy-protocol", "rpc-v2-cbor"); + Response response = restlet.handle(request); + assertEquals(400, response.getStatus().getCode()); + assertEquals("rpc-v2-cbor", response.getHeaders().getFirstValue("smithy-protocol", true)); + } + + /** Services added under a path go to the route of that path prefix, as with the SDK. */ + @Test + void callsGoToTheRouteOfTheirPathPrefix() throws Exception { + start(new SmithyServerBuilder().addService("/service", counter()), false); + assertEquals( + 2L, client("").add(AddInput.builder().value(1L).label("a").build()).getTotal()); + Response other = restlet.handle(new Request(Method.GET, uri("/other"))); + assertEquals(404, other.getStatus().getCode()); + } + + /** The longest matching prefix wins, whatever the order of the SDK's routes. */ + @Test + void longestPathPrefixWins() throws Exception { + Counter root = + Counter.builder() + .addAddOperation( + (AddOperation) + (input, context) -> AddOutput.builder().total(-1L).build()) + .build(); + start( + new SmithyServerBuilder() + .addService("/", root) + .addService("/serv", root) + .addService("/service", counter()), + false); + assertEquals( + 2L, client("").add(AddInput.builder().value(1L).label("a").build()).getTotal()); + } + + @Test + void routesMatchTheirPortSchemeAndHostName() { + Reference ref = new Reference("http://api.example.com/service/Counter/operation/Add"); + assertTrue(RestletSmithyServer.matches(route().pathPrefix("/service").build(), ref)); + assertFalse(RestletSmithyServer.matches(route().pathPrefix("/other").build(), ref)); + assertTrue(RestletSmithyServer.matches(route().port(80).protocol("http").build(), ref)); + assertFalse(RestletSmithyServer.matches(route().port(8080).build(), ref)); + assertFalse(RestletSmithyServer.matches(route().protocol("https").build(), ref)); + assertTrue(RestletSmithyServer.matches(route().hostName("API.example.com").build(), ref)); + assertFalse(RestletSmithyServer.matches(route().hostName("other.com").build(), ref)); + } + + private static Route.Builder route() { + return Route.builder().services(List.of(counter())); + } + + @Test + void limitsApplyToEachRoute() throws Exception { + start( + new SmithyServerBuilder() + .addService(counter()) + .numberOfWorkers(1) + .maxRequestBytes(16) + .maxCallDuration(Duration.ofSeconds(5)), + false); + Request request = new Request(Method.POST, uri("/service/Counter/operation/Add")); + request.setEntity( + new ByteArrayRepresentation(new byte[64], MediaType.valueOf("application/cbor"))); + request.getHeaders().add("smithy-protocol", "rpc-v2-cbor"); + assertEquals(413, restlet.handle(request).getStatus().getCode()); + } + + @Test + void invalidSettingsAreRejected() { + var builder = new SmithyServerBuilder(); + assertThrows(IllegalArgumentException.class, () -> builder.numberOfWorkers(0)); + assertThrows(IllegalArgumentException.class, () -> builder.maxRequestBytes(0)); + assertThrows(IllegalArgumentException.class, () -> builder.maxCallDuration(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, () -> builder.endpoints(URI.create("ftp://x:1"))); + assertThrows(IllegalArgumentException.class, () -> builder.endpoints(new URI[0])); + // No service. + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + void serverIsSingleUse() throws Exception { + start(new SmithyServerBuilder().addService(counter()), false); + assertThrows(IllegalStateException.class, server::start); + CounterClient client = client(""); + server.shutdown().get(10, TimeUnit.SECONDS); + // Shutting down again returns the same completed future. + assertTrue(server.shutdown().isDone()); + AddInput input = AddInput.builder().value(1L).label("x").build(); + assertThrows(TransportException.class, () -> client.add(input)); + } + + @Test + void startFailuresStopWhatStarted() throws Exception { + start(new SmithyServerBuilder().addService(counter()), false); + int taken = server.getActualPorts().getFirst(); + Server clash = + new SmithyServerBuilder() + .addService(counter()) + .endpoints(URI.create("http://localhost:" + taken)) + .build(); + assertThrows(IllegalStateException.class, clash::start); + clash.shutdown().get(10, TimeUnit.SECONDS); + } + + @Test + void providerIsNamedRestletAndPreferred() { + var provider = new RestletServerProvider(); + assertEquals("restlet", provider.name()); + assertTrue(provider.priority() > 0, "Before the SDK's Netty server"); + assertInstanceOf(SmithyServerBuilder.class, provider.serverBuilder()); + } +} diff --git a/org.restlet.ext.smithy/src/test/smithy/counter.smithy b/org.restlet.ext.smithy/src/test/smithy/counter.smithy new file mode 100644 index 0000000000..cbff93fec2 --- /dev/null +++ b/org.restlet.ext.smithy/src/test/smithy/counter.smithy @@ -0,0 +1,34 @@ +$version: "2" + +namespace restlet.test.cbor + +use smithy.protocols#rpcv2Cbor + +/// An RPCv2 CBOR service whose client and server are generated by smithy-java for the tests. +@rpcv2Cbor +service Counter { + version: "1" + operations: [Add] + errors: [Overflow] +} + +operation Add { + input := { + @required + @range(min: 0) + value: Long + + @required + @length(max: 32) + label: String + } + output := { + total: Long + label: String + } +} + +@error("client") +structure Overflow { + total: Long +} diff --git a/org.restlet.ext.smithy.server/src/test/smithy/notes.smithy b/org.restlet.ext.smithy/src/test/smithy/notes.smithy similarity index 100% rename from org.restlet.ext.smithy.server/src/test/smithy/notes.smithy rename to org.restlet.ext.smithy/src/test/smithy/notes.smithy diff --git a/org.restlet.ext.smithy/src/test/smithy/smithy-build.json b/org.restlet.ext.smithy/src/test/smithy/smithy-build.json index ee4447f4e3..1f33e810ab 100644 --- a/org.restlet.ext.smithy/src/test/smithy/smithy-build.json +++ b/org.restlet.ext.smithy/src/test/smithy/smithy-build.json @@ -1,11 +1,35 @@ { "version": "1.0", - "plugins": { - "java-codegen": { - "service": "restlet.test#Greeter", - "namespace": "org.restlet.ext.smithy.generated", - "modes": ["client"], - "protocol": "aws.protocols#restJson1" + "projections": { + "greeter": { + "plugins": { + "java-codegen": { + "service": "restlet.test#Greeter", + "namespace": "org.restlet.ext.smithy.generated", + "modes": ["client"], + "protocol": "aws.protocols#restJson1" + } + } + }, + "notes": { + "plugins": { + "java-codegen": { + "service": "restlet.test.server#Notes", + "namespace": "org.restlet.ext.smithy.generated.notes", + "modes": ["client", "server"], + "protocol": "aws.protocols#restJson1" + } + } + }, + "counter": { + "plugins": { + "java-codegen": { + "service": "restlet.test.cbor#Counter", + "namespace": "org.restlet.ext.smithy.generated.counter", + "modes": ["client", "server"], + "protocol": "smithy.protocols#rpcv2Cbor" + } + } } } } diff --git a/pom.xml b/pom.xml index dabc01d58f..85c635469d 100644 --- a/pom.xml +++ b/pom.xml @@ -91,7 +91,6 @@ org.restlet.ext.openapi org.restlet.ext.slf4j org.restlet.ext.smithy - org.restlet.ext.smithy.server org.restlet.ext.spring org.restlet.ext.thymeleaf org.restlet.ext.velocity diff --git a/readme.md b/readme.md index 71d3caf222..98aa718d77 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ Restlet 3.0 lets the same application and the same connectors serve APIs in seve | [GraphQL](https://graphql.org/) | `org.restlet.ext.graphql` | Experimental: server and client on graphql-java, with subscriptions over SSE and WebSocket | | [gRPC](https://grpc.io/) | `org.restlet.ext.grpc` | Experimental: services and clients of every streaming type, over HTTP/2 | | [MCP](https://modelcontextprotocol.io/) | `org.restlet.ext.mcp` | Experimental: tools, resources and prompts for AI agents on the official MCP Java SDK, over stateless Streamable HTTP and STDIO | -| [Smithy](https://smithy.io/) | `org.restlet.ext.smithy` | smithy-java clients on Restlet HTTP/1.1 and HTTP/2 connectors, discovered or set explicitly; experimental server hosting with the `org.restlet.ext.smithy.server` artifact, on the class path | +| [Smithy](https://smithy.io/) | `org.restlet.ext.smithy` | smithy-java clients and experimental servers on Restlet HTTP/1.1 and HTTP/2 connectors, discovered or set explicitly | Upcoming enhancements include MCP sessions and resumable streams. Feedback is welcome in [Discussions](https://github.com/restlet/restlet-framework-java/discussions). From 34512334b5a9aff04964974ad1b0843f92a2cf78 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:38:41 -0500 Subject: [PATCH 2/4] Route Smithy server calls on their connector, and yield to Netty Routes match the port and scheme of the connector that took a call, not those of its Host header. Path prefixes are only reordered when routes set nothing else, so that routes on hosts, ports or schemes keep their order. Endpoints with a path, query, fragment or user info, routes without services and null durations are rejected. The server provider now comes after the SDK's Netty server, so that adding the extension for its client doesn't change an application's server. Co-Authored-By: Claude Opus 5.5 --- changes.md | 4 +- .../ext/smithy/SmithyServerBuilder.java | 66 +++++++++----- .../internal/RestletServerProvider.java | 6 +- .../smithy/internal/RestletSmithyServer.java | 45 ++++++---- .../smithy/SmithyServerBuilderTestCase.java | 86 ++++++++++++++++--- 5 files changed, 154 insertions(+), 53 deletions(-) diff --git a/changes.md b/changes.md index efcada48b6..3d24763a14 100644 --- a/changes.md +++ b/changes.md @@ -158,8 +158,8 @@ Changes log - Added experimental smithy-java 1.8.0 server hosting to the Smithy extension. Its `SmithyServerAdapter` hosts services, generated or built from a model, behind a Restlet router beside other routes, and its `SmithyServerBuilder`, which `Server.builder()` - discovers under the name `restlet` before the SDK's Netty server, listens on Restlet - HTTP/1.1 and HTTP/2 server connectors. The SDK protocols, such as restJson1 and RPCv2 + finds under the name `restlet`, or alone when the SDK's Netty server is absent, listens + on Restlet HTTP/1.1 and HTTP/2 server connectors. The SDK protocols, such as restJson1 and RPCv2 CBOR, resolve, validate and serialize each call, and its handlers run on the request thread. Request bodies, concurrent calls and call durations are bounded; deadlines, client disconnects and stopping interrupt the operation. As smithy-java's server diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java index df064ba679..54e460b53a 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java @@ -25,15 +25,16 @@ * Experimental builder of smithy-java servers listening on Restlet HTTP server connectors, one per * endpoint, each route of services hosted by a {@link SmithyServerAdapter}. * - *

On the class path, {@code Server.builder()} discovers it under the name {@code restlet}, in - * preference to the SDK's Netty server. It can also be created directly, to set the Restlet - * settings that the SDK's builder lacks: the connectors' context, for example with an {@code - * http.transport.protocol} parameter set to {@code HTTP2} or the TLS parameters of {@code https} - * endpoints, and the adapters' limits. {@link #numberOfWorkers(int)} sets the number of calls each - * route handles at once. + *

On the class path, {@code Server.builder("restlet")} finds it, and so does {@code + * Server.builder()} when the SDK's Netty server isn't there too. It can also be created directly, + * to set the Restlet settings that the SDK's builder lacks: the connectors' context, for example + * with an {@code http.transport.protocol} parameter set to {@code HTTP2} or the TLS parameters of + * {@code https} endpoints, and the adapters' limits. {@link #numberOfWorkers(int)} sets the number + * of calls each route handles at once. * - *

A call goes to the route with the longest path prefix whose prefix, port, scheme and host - * name, when set, match its URI, the routes added first winning ties. Operations are resolved on + *

A call goes to the first route whose path prefix, host name, and connector port and scheme, + * when set, match it. When the routes only set path prefixes, as with services added under paths, + * whose order the SDK doesn't keep, the longest matching prefix wins. Operations are resolved on * the full path, as with the SDK's Netty server. */ // The SDK server types come from the class path, which a named module can't export. @@ -48,8 +49,8 @@ public final class SmithyServerBuilder extends ServerBuilder routes) { + for (Route route : routes) { + if (route.getServices() == null || route.getServices().isEmpty()) { + throw new IllegalArgumentException("A route has no service"); + } + } // The SDK collects the services added under paths in a hash map: order them so that "/" - // doesn't take the calls of a longer prefix. The sort is stable. - this.routes = + // doesn't take the calls of a longer prefix. Routes that also match hosts, ports or schemes + // keep the order they were added in. + boolean prefixesOnly = routes.stream() - .sorted( - Comparator.comparingInt( - (Route route) -> - route.getPathPrefix() == null - ? 0 - : route.getPathPrefix().length()) - .reversed()) - .toList(); + .allMatch( + route -> + route.getHostName() == null + && route.getPort() == null + && route.getProtocol() == null); + this.routes = + !prefixesOnly + ? List.copyOf(routes) + : routes.stream() + .sorted( + Comparator.comparingInt( + (Route route) -> + route.getPathPrefix() == null + ? 0 + : route.getPathPrefix() + .length()) + .reversed()) + .toList(); return self(); } diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java index a852d67b29..6c4d7d065d 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java @@ -29,7 +29,9 @@ public ServerBuilder serverBuilder() { @Override public int priority() { - // The highest priority is picked first: 1 comes before the SDK's Netty server (0). - return 1; + // The highest priority is picked first: the SDK's Netty server (0) stays the default when + // present, so that adding this extension for its client doesn't change an application's + // server. Name it "restlet" to pick it then. + return -1; } } diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java index a7ab8b812e..fe9afb7915 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java @@ -11,7 +11,6 @@ import java.net.URI; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.concurrent.CompletableFuture; import org.restlet.Context; import org.restlet.Request; @@ -64,15 +63,16 @@ public void start() { for (SmithyServerAdapter adapter : adapters) { adapter.start(); } - Restlet dispatcher = new Dispatcher(); for (URI endpoint : endpoints) { boolean secure = "https".equalsIgnoreCase(endpoint.getScheme()); Protocol protocol = secure ? Protocol.HTTPS : Protocol.HTTP; int port = endpoint.getPort() < 0 ? protocol.getDefaultPort() : endpoint.getPort(); + var dispatcher = new Dispatcher(protocol.getSchemeName()); var connector = new org.restlet.Server( context, protocol, endpoint.getHost(), port, dispatcher); + dispatcher.connector = connector; connectors.add(connector); connector.start(); } @@ -142,15 +142,29 @@ private void stopAll() { } } - /** Dispatches each call to the adapter of the first route matching its URI. */ + /** + * Dispatches the calls of a connector to the adapter of the first route matching them. Ports + * and schemes are the connector's, not the ones a client names in its Host header. + */ private final class Dispatcher extends Restlet { + private final String scheme; + private volatile org.restlet.Server connector; + + private Dispatcher(String scheme) { + this.scheme = scheme; + } + @Override public void handle(Request request, Response response) { Reference ref = request.getResourceRef(); - // The SDK resolves operations on the full path, below the host. - ref.setBaseRef(ref.getHostIdentifier()); + String host = ref.getHostIdentifier(); + if (host != null) { + // The SDK resolves operations on the full path, below the host. + ref.setBaseRef(host); + } + int port = connector.getActualPort(); for (int i = 0; i < routes.size(); i++) { - if (matches(routes.get(i), ref)) { + if (matches(routes.get(i), ref, scheme, port)) { adapters.get(i).handle(request, response); return; } @@ -160,30 +174,25 @@ public void handle(Request request, Response response) { } /** - * Indicates whether the prefix, port, scheme and host name of a route, when set, match a URI. + * Indicates whether the prefix, port, scheme and host name of a route, when set, match a call. * * @param route The route. - * @param ref The URI of a call. + * @param ref The URI of the call, whose host name a client may choose. + * @param scheme The scheme of the connector that took the call. + * @param port The port of the connector that took the call. * @return True if the route matches. */ - public static boolean matches(Route route, Reference ref) { + public static boolean matches(Route route, Reference ref, String scheme, int port) { String path = ref.getPath(); if (route.getPathPrefix() != null && (path == null || !path.startsWith(route.getPathPrefix()))) { return false; } - String scheme = ref.getScheme() == null ? "" : ref.getScheme().toLowerCase(Locale.ROOT); if (route.getProtocol() != null && !route.getProtocol().equalsIgnoreCase(scheme)) { return false; } - if (route.getPort() != null) { - int port = ref.getHostPort(); - if (port < 0 && ref.getSchemeProtocol() != null) { - port = ref.getSchemeProtocol().getDefaultPort(); - } - if (route.getPort() != port) { - return false; - } + if (route.getPort() != null && route.getPort() != port) { + return false; } return route.getHostName() == null || route.getHostName().equalsIgnoreCase(ref.getHostDomain()); diff --git a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java index 77b232d966..79aaf9138e 100644 --- a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java @@ -177,15 +177,61 @@ void longestPathPrefixWins() throws Exception { } @Test - void routesMatchTheirPortSchemeAndHostName() { - Reference ref = new Reference("http://api.example.com/service/Counter/operation/Add"); - assertTrue(RestletSmithyServer.matches(route().pathPrefix("/service").build(), ref)); - assertFalse(RestletSmithyServer.matches(route().pathPrefix("/other").build(), ref)); - assertTrue(RestletSmithyServer.matches(route().port(80).protocol("http").build(), ref)); - assertFalse(RestletSmithyServer.matches(route().port(8080).build(), ref)); - assertFalse(RestletSmithyServer.matches(route().protocol("https").build(), ref)); - assertTrue(RestletSmithyServer.matches(route().hostName("API.example.com").build(), ref)); - assertFalse(RestletSmithyServer.matches(route().hostName("other.com").build(), ref)); + void routesMatchTheirConnectorPortAndSchemeAndHostName() { + Reference ref = new Reference("http://api.example.com:9000/service/Counter/operation/Add"); + assertTrue(matches(route().pathPrefix("/service"), ref)); + assertFalse(matches(route().pathPrefix("/other"), ref)); + assertTrue(matches(route().port(8080).protocol("http"), ref)); + // The port and scheme are the connector's, whatever the Host header says. + assertFalse(matches(route().port(9000), ref)); + assertFalse(matches(route().protocol("https"), ref)); + assertTrue(matches(route().hostName("API.example.com"), ref)); + assertFalse(matches(route().hostName("other.com"), ref)); + } + + private static boolean matches(Route.Builder route, Reference ref) { + return RestletSmithyServer.matches(route.build(), ref, "http", 8080); + } + + /** A Host header naming another port doesn't reach the routes of that port. */ + @Test + void hostHeaderPortsDontSelectRoutes() throws Exception { + start(new SmithyServerBuilder().addService(counter()), false); + int port = server.getActualPorts().getFirst(); + server.shutdown().get(10, TimeUnit.SECONDS); + server = + (RestletSmithyServer) + new SmithyServerBuilder() + .addService(route().port(port + 1).build()) + .endpoints(URI.create("http://localhost:" + port)) + .build(); + server.start(); + Request request = new Request(Method.POST, uri("/service/Counter/operation/Add")); + request.setHostRef(new Reference("http://localhost:" + (port + 1))); + assertEquals(404, restlet.handle(request).getStatus().getCode()); + } + + /** Routes matching hosts keep the order they were added in. */ + @Test + void routesMatchingHostsKeepTheirOrder() throws Exception { + Counter root = + Counter.builder() + .addAddOperation( + (AddOperation) + (input, context) -> AddOutput.builder().total(-1L).build()) + .build(); + start( + new SmithyServerBuilder() + .addService( + Route.builder() + .hostName("localhost") + .pathPrefix("/") + .services(List.of(root)) + .build()) + .addService(route().pathPrefix("/service").build()), + false); + assertEquals( + -1L, client("").add(AddInput.builder().value(1L).label("a").build()).getTotal()); } private static Route.Builder route() { @@ -217,6 +263,24 @@ void invalidSettingsAreRejected() { assertThrows( IllegalArgumentException.class, () -> builder.endpoints(URI.create("ftp://x:1"))); assertThrows(IllegalArgumentException.class, () -> builder.endpoints(new URI[0])); + for (String endpoint : + List.of( + "http://localhost:1/api", + "http://localhost:1?q", + "http://localhost:1#f", + "http://u@localhost:1")) { + assertThrows( + IllegalArgumentException.class, + () -> builder.endpoints(URI.create(endpoint)), + endpoint); + } + assertThrows(NullPointerException.class, () -> builder.maxCallDuration(null)); + assertThrows( + IllegalArgumentException.class, + () -> + new SmithyServerBuilder() + .addService(Route.builder().pathPrefix("/x").build()) + .build()); // No service. assertThrows(IllegalStateException.class, builder::build); } @@ -247,10 +311,10 @@ void startFailuresStopWhatStarted() throws Exception { } @Test - void providerIsNamedRestletAndPreferred() { + void providerIsNamedRestletAndYieldsToNetty() { var provider = new RestletServerProvider(); assertEquals("restlet", provider.name()); - assertTrue(provider.priority() > 0, "Before the SDK's Netty server"); + assertTrue(provider.priority() < 0, "After the SDK's Netty server"); assertInstanceOf(SmithyServerBuilder.class, provider.serverBuilder()); } } From 9de13ebfa5a7e66f1ca5bcac3032c58d6092de35 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:20:23 -0500 Subject: [PATCH 3/4] Fall through Smithy server routes that don't resolve a call A call now goes to the first matching route whose services resolve its operation, so a longer prefix or an earlier route no longer hides the operations of later ones. Prefixes match the decoded path, endpoints need a host, IPv6 hosts lose their brackets, a failed start keeps its cause, and the provider reports a missing server-core clearly. The server class moves next to the adapter, holding each route with its adapter. Co-Authored-By: Claude Opus 5.5 --- .../{internal => }/RestletSmithyServer.java | 67 +++++++++++-------- .../ext/smithy/SmithyServerAdapter.java | 14 +++- .../ext/smithy/SmithyServerBuilder.java | 19 +++--- .../internal/RestletServerProvider.java | 10 +++ .../ext/smithy/SmithyModuleTestCase.java | 12 ++-- .../smithy/SmithyServerBuilderTestCase.java | 59 +++++++++++++++- 6 files changed, 136 insertions(+), 45 deletions(-) rename org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/{internal => }/RestletSmithyServer.java (76%) diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java similarity index 76% rename from org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java rename to org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java index fe9afb7915..f1a12b7520 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletSmithyServer.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.ext.smithy.internal; +package org.restlet.ext.smithy; import java.net.URI; import java.util.ArrayList; @@ -19,19 +19,20 @@ import org.restlet.data.Protocol; import org.restlet.data.Reference; import org.restlet.data.Status; -import org.restlet.ext.smithy.SmithyServerAdapter; import software.amazon.smithy.java.server.Route; import software.amazon.smithy.java.server.Server; /** - * A smithy-java server built by {@code SmithyServerBuilder}: a Restlet server connector per + * A smithy-java server built by {@link SmithyServerBuilder}: a Restlet server connector per * endpoint, dispatching each call to the adapter of its route. Single-use. */ -public final class RestletSmithyServer implements Server { +final class RestletSmithyServer implements Server { + /** A route, and the adapter hosting its services. */ + record Hosted(Route route, SmithyServerAdapter adapter) {} + private final List endpoints; private final Context context; - private final List routes; - private final List adapters; + private final List hosted; private final List connectors = new ArrayList<>(); /** Guards the lifecycle state. */ @@ -40,15 +41,10 @@ public final class RestletSmithyServer implements Server { private boolean started; private CompletableFuture shutdown; - public RestletSmithyServer( - List endpoints, - Context context, - List routes, - List adapters) { - this.endpoints = List.copyOf(endpoints); + RestletSmithyServer(List endpoints, Context context, List hosted) { + this.endpoints = endpoints; this.context = context; - this.routes = List.copyOf(routes); - this.adapters = List.copyOf(adapters); + this.hosted = hosted; } /** Starts the adapters, then a connector per endpoint. */ @@ -60,8 +56,8 @@ public void start() { } started = true; try { - for (SmithyServerAdapter adapter : adapters) { - adapter.start(); + for (Hosted each : hosted) { + each.adapter().start(); } for (URI endpoint : endpoints) { boolean secure = "https".equalsIgnoreCase(endpoint.getScheme()); @@ -71,18 +67,31 @@ public void start() { var dispatcher = new Dispatcher(protocol.getSchemeName()); var connector = new org.restlet.Server( - context, protocol, endpoint.getHost(), port, dispatcher); + context, protocol, address(endpoint), port, dispatcher); dispatcher.connector = connector; connectors.add(connector); connector.start(); } } catch (Exception e) { - stopAll(); - throw new IllegalStateException("Unable to start the Smithy server", e); + var failure = new IllegalStateException("Unable to start the Smithy server", e); + try { + stopAll(); + } catch (RuntimeException stopFailure) { + failure.addSuppressed(stopFailure); + } + throw failure; } } } + /** The host of an endpoint, without the brackets of an IPv6 address. */ + static String address(URI endpoint) { + String host = endpoint.getHost(); + return host.startsWith("[") && host.endsWith("]") + ? host.substring(1, host.length() - 1) + : host; + } + /** Stops the connectors, then the adapters, interrupting the operations still running. */ @Override public CompletableFuture shutdown() { @@ -112,7 +121,7 @@ public CompletableFuture shutdown() { * * @return The actual ports. */ - public List getActualPorts() { + List getActualPorts() { synchronized (lock) { return connectors.stream().map(org.restlet.Server::getActualPort).toList(); } @@ -125,7 +134,7 @@ private void stopAll() { synchronized (lock) { stopping.addAll(connectors); } - stopping.addAll(adapters); + hosted.forEach(each -> stopping.add(each.adapter())); for (Restlet restlet : stopping) { try { restlet.stop(); @@ -143,8 +152,9 @@ private void stopAll() { } /** - * Dispatches the calls of a connector to the adapter of the first route matching them. Ports - * and schemes are the connector's, not the ones a client names in its Host header. + * Dispatches the calls of a connector to the adapter of the first route matching them that + * resolves their operation. Ports and schemes are the connector's, not the ones a client names + * in its Host header. */ private final class Dispatcher extends Restlet { private final String scheme; @@ -163,9 +173,9 @@ public void handle(Request request, Response response) { ref.setBaseRef(host); } int port = connector.getActualPort(); - for (int i = 0; i < routes.size(); i++) { - if (matches(routes.get(i), ref, scheme, port)) { - adapters.get(i).handle(request, response); + for (Hosted each : hosted) { + if (matches(each.route(), ref, scheme, port) && each.adapter().resolves(request)) { + each.adapter().handle(request, response); return; } } @@ -182,8 +192,9 @@ public void handle(Request request, Response response) { * @param port The port of the connector that took the call. * @return True if the route matches. */ - public static boolean matches(Route route, Reference ref, String scheme, int port) { - String path = ref.getPath(); + static boolean matches(Route route, Reference ref, String scheme, int port) { + // Decoded, as the SDK matches prefixes. + String path = ref.getPath(true); if (route.getPathPrefix() != null && (path == null || !path.startsWith(route.getPathPrefix()))) { return false; diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java index 8b272a343c..72352ec1e6 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerAdapter.java @@ -163,12 +163,22 @@ public void attachTo(Router router) { new Route(router, this) { @Override public float score(Request request, Response response) { - SmithyServerHost current = host; - return current != null && current.resolves(request) ? 1F : 0F; + return resolves(request) ? 1F : 0F; } }); } + /** + * Indicates whether the started adapter resolves the operation of a call. + * + * @param request The call's request. + * @return True if a hosted operation takes the call. + */ + boolean resolves(Request request) { + SmithyServerHost current = host; + return current != null && current.resolves(request); + } + @Override public synchronized void start() throws Exception { synchronized (lock) { diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java index 54e460b53a..c41a645d06 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java @@ -16,7 +16,6 @@ import java.util.Locale; import java.util.Objects; import org.restlet.Context; -import org.restlet.ext.smithy.internal.RestletSmithyServer; import software.amazon.smithy.java.server.Route; import software.amazon.smithy.java.server.Server; import software.amazon.smithy.java.server.ServerBuilder; @@ -33,9 +32,9 @@ * of calls each route handles at once. * *

A call goes to the first route whose path prefix, host name, and connector port and scheme, - * when set, match it. When the routes only set path prefixes, as with services added under paths, - * whose order the SDK doesn't keep, the longest matching prefix wins. Operations are resolved on - * the full path, as with the SDK's Netty server. + * when set, match it, and that resolves its operation. When the routes only set path prefixes, as + * with services added under paths, whose order the SDK doesn't keep, the longest matching prefix + * wins. Operations are resolved on the full path, as with the SDK's Netty server. */ // The SDK server types come from the class path, which a named module can't export. @SuppressWarnings("exports") @@ -50,7 +49,7 @@ public final class SmithyServerBuilder extends ServerBuilder(routes.size()); + var hosted = new ArrayList(routes.size()); for (Route route : routes) { var adapter = new SmithyServerAdapter(); adapter.getServices().addAll(route.getServices()); @@ -193,9 +196,9 @@ protected Server buildServer() { if (maxCallDuration != null) { adapter.setMaxCallDuration(maxCallDuration); } - adapters.add(adapter); + hosted.add(new RestletSmithyServer.Hosted(route, adapter)); } return new RestletSmithyServer( - endpoints, context == null ? new Context() : context, routes, adapters); + endpoints, context == null ? new Context() : context, List.copyOf(hosted)); } } diff --git a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java index 6c4d7d065d..b91f9a3192 100644 --- a/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java @@ -24,6 +24,16 @@ public String name() { @Override public ServerBuilder serverBuilder() { + try { + Class.forName( + "software.amazon.smithy.java.server.core.HandlerAssembler", + false, + RestletServerProvider.class.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "The Restlet Smithy server needs smithy-java's server-core on the class path", + e); + } return new SmithyServerBuilder(); } diff --git a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java index ad753458ff..25690113a5 100644 --- a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyModuleTestCase.java @@ -294,6 +294,7 @@ void classPathConsumerDiscoversTheServer(@TempDir Path temp) throws Exception { example, """ package example; + import java.net.ServerSocket; import java.net.URI; import org.restlet.ext.smithy.SmithyServerBuilder; import org.restlet.ext.smithy.generated.counter.client.CounterClient; @@ -301,7 +302,6 @@ void classPathConsumerDiscoversTheServer(@TempDir Path temp) throws Exception { import org.restlet.ext.smithy.generated.counter.model.AddOutput; import org.restlet.ext.smithy.generated.counter.service.AddOperation; import org.restlet.ext.smithy.generated.counter.service.Counter; - import org.restlet.ext.smithy.internal.RestletSmithyServer; import software.amazon.smithy.java.client.core.ClientTransportFactory; import software.amazon.smithy.java.server.Server; public final class Main { @@ -318,8 +318,12 @@ public static void main(String[] args) throws Exception { if (!"restlet".equals(first)) { throw new AssertionError("Discovered first: " + first); } - var server = (RestletSmithyServer) builder - .endpoints(URI.create("http://localhost:0")) + int port; + try (var socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + var server = builder + .endpoints(URI.create("http://localhost:" + port)) .addService(Counter.builder() .addAddOperation((AddOperation) (input, context) -> AddOutput.builder().total(input.getValue() + 1).build()) @@ -327,7 +331,7 @@ public static void main(String[] args) throws Exception { .build(); server.start(); var client = CounterClient.builder() - .endpoint("http://localhost:" + server.getActualPorts().getFirst()) + .endpoint("http://localhost:" + port) .build(); try { long total = client.add(AddInput.builder().value(1L).label("cp") diff --git a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java index 79aaf9138e..87603162ab 100644 --- a/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java @@ -37,8 +37,18 @@ import org.restlet.ext.smithy.generated.counter.model.Overflow; import org.restlet.ext.smithy.generated.counter.service.AddOperation; import org.restlet.ext.smithy.generated.counter.service.Counter; +import org.restlet.ext.smithy.generated.notes.model.GetNoteOutput; +import org.restlet.ext.smithy.generated.notes.model.PutNoteOutput; +import org.restlet.ext.smithy.generated.notes.model.RequiredUploadOutput; +import org.restlet.ext.smithy.generated.notes.model.UploadOutput; +import org.restlet.ext.smithy.generated.notes.model.WaitOutput; +import org.restlet.ext.smithy.generated.notes.service.GetNoteOperation; +import org.restlet.ext.smithy.generated.notes.service.Notes; +import org.restlet.ext.smithy.generated.notes.service.PutNoteOperation; +import org.restlet.ext.smithy.generated.notes.service.RequiredUploadOperation; +import org.restlet.ext.smithy.generated.notes.service.UploadOperation; +import org.restlet.ext.smithy.generated.notes.service.WaitOperation; import org.restlet.ext.smithy.internal.RestletServerProvider; -import org.restlet.ext.smithy.internal.RestletSmithyServer; import org.restlet.representation.ByteArrayRepresentation; import software.amazon.smithy.java.client.core.error.TransportException; import software.amazon.smithy.java.core.error.CallException; @@ -193,6 +203,45 @@ private static boolean matches(Route.Builder route, Reference ref) { return RestletSmithyServer.matches(route.build(), ref, "http", 8080); } + @Test + void prefixesMatchTheDecodedPath() { + Reference ref = new Reference("http://localhost/my%20svc/service/Counter/operation/Add"); + assertTrue(matches(route().pathPrefix("/my svc"), ref)); + } + + @Test + void ipv6EndpointsLoseTheirBrackets() { + assertEquals("::1", RestletSmithyServer.address(URI.create("http://[::1]:8080"))); + assertEquals("localhost", RestletSmithyServer.address(URI.create("http://localhost:1"))); + } + + /** A matching route whose services lack the operation lets a later route take the call. */ + @Test + void callsFallThroughToARouteResolvingThem() throws Exception { + Notes notes = + Notes.builder() + .addGetNoteOperation( + (GetNoteOperation) + (input, context) -> GetNoteOutput.builder().build()) + .addPutNoteOperation( + (PutNoteOperation) + (input, context) -> PutNoteOutput.builder().build()) + .addRequiredUploadOperation( + (RequiredUploadOperation) + (input, context) -> RequiredUploadOutput.builder().build()) + .addUploadOperation( + (UploadOperation) + (input, context) -> UploadOutput.builder().build()) + .addWaitOperation( + (WaitOperation) (input, context) -> WaitOutput.builder().build()) + .build(); + start( + new SmithyServerBuilder().addService("/", counter()).addService("/service", notes), + false); + assertEquals( + 2L, client("").add(AddInput.builder().value(1L).label("a").build()).getTotal()); + } + /** A Host header naming another port doesn't reach the routes of that port. */ @Test void hostHeaderPortsDontSelectRoutes() throws Exception { @@ -268,7 +317,9 @@ void invalidSettingsAreRejected() { "http://localhost:1/api", "http://localhost:1?q", "http://localhost:1#f", - "http://u@localhost:1")) { + "http://u@localhost:1", + "http://my_host:1", + "http:localhost")) { assertThrows( IllegalArgumentException.class, () -> builder.endpoints(URI.create(endpoint)), @@ -306,7 +357,9 @@ void startFailuresStopWhatStarted() throws Exception { .addService(counter()) .endpoints(URI.create("http://localhost:" + taken)) .build(); - assertThrows(IllegalStateException.class, clash::start); + IllegalStateException failure = assertThrows(IllegalStateException.class, clash::start); + // The bind failure stays the cause. + assertTrue(failure.getCause() != null, failure.toString()); clash.shutdown().get(10, TimeUnit.SECONDS); } From b20061eeb14eb19392da76c933bbb3c6efabd862 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:38:13 -0500 Subject: [PATCH 4/4] Let Javadoc read the Smithy server on the class path Like the compiler, the Javadoc tool needs --add-reads org.restlet.ext.smithy=ALL-UNNAMED to resolve the smithy-java server types, which stay outside the module graph. Co-Authored-By: Claude Opus 5.5 --- org.restlet.ext.smithy/pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/org.restlet.ext.smithy/pom.xml b/org.restlet.ext.smithy/pom.xml index f5712623b3..7ee194dac1 100644 --- a/org.restlet.ext.smithy/pom.xml +++ b/org.restlet.ext.smithy/pom.xml @@ -127,6 +127,16 @@ + + + maven-javadoc-plugin + + + --add-reads + org.restlet.ext.smithy=ALL-UNNAMED + + + org.codehaus.mojo