diff --git a/changes.md b/changes.md index f0f79c5b01..3d24763a14 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()` + 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 + 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..7ee194dac1 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,17 +105,38 @@ maven-compiler-plugin + + default-compile + + + + --add-reads + org.restlet.ext.smithy=ALL-UNNAMED + + + default-testCompile - + - -Xlint:-serial + -Xlint:-serial,-overloads + + + maven-javadoc-plugin + + + --add-reads + org.restlet.ext.smithy=ALL-UNNAMED + + + org.codehaus.mojo @@ -138,7 +181,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/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java new file mode 100644 index 0000000000..f1a12b7520 --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/RestletSmithyServer.java @@ -0,0 +1,211 @@ +/** + * 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.util.ArrayList; +import java.util.List; +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 software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Server; + +/** + * 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. + */ +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 hosted; + private final List connectors = new ArrayList<>(); + + /** Guards the lifecycle state. */ + private final Object lock = new Object(); + + private boolean started; + private CompletableFuture shutdown; + + RestletSmithyServer(List endpoints, Context context, List hosted) { + this.endpoints = endpoints; + this.context = context; + this.hosted = hosted; + } + + /** 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 (Hosted each : hosted) { + each.adapter().start(); + } + 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, address(endpoint), port, dispatcher); + dispatcher.connector = connector; + connectors.add(connector); + connector.start(); + } + } catch (Exception 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() { + 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. + */ + 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); + } + hosted.forEach(each -> stopping.add(each.adapter())); + 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 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; + 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(); + 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 (Hosted each : hosted) { + if (matches(each.route(), ref, scheme, port) && each.adapter().resolves(request)) { + each.adapter().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 call. + * + * @param route The route. + * @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. + */ + 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; + } + if (route.getProtocol() != null && !route.getProtocol().equalsIgnoreCase(scheme)) { + 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.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 87% 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..72352ec1e6 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; } @@ -157,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 new file mode 100644 index 0000000000..c41a645d06 --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/SmithyServerBuilder.java @@ -0,0 +1,204 @@ +/** + * 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 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("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 first route whose path prefix, host name, and connector port and scheme, + * 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") +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, listens on the default port of its scheme when it has + * none, and has a host but no user info, path, query or fragment. + * + * @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); + } + String path = endpoint.getRawPath(); + if (endpoint.getHost() == null) { + // Opaque, or an authority that isn't a host name: never listen everywhere. + throw new IllegalArgumentException("An endpoint needs a host: " + endpoint); + } + if (endpoint.getRawUserInfo() != null + || (path != null && !path.isEmpty() && !path.equals("/")) + || endpoint.getRawQuery() != null + || endpoint.getRawFragment() != null) { + throw new IllegalArgumentException( + "An endpoint only has a scheme, a host and a port: " + 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) { + Objects.requireNonNull(value, "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) { + 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. Routes that also match hosts, ports or schemes + // keep the order they were added in. + boolean prefixesOnly = + routes.stream() + .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(); + } + + @Override + protected Server buildServer() { + if (routes.isEmpty()) { + throw new IllegalStateException("Add at least one service before building"); + } + var hosted = 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); + } + hosted.add(new RestletSmithyServer.Hosted(route, adapter)); + } + return new RestletSmithyServer( + 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 new file mode 100644 index 0000000000..b91f9a3192 --- /dev/null +++ b/org.restlet.ext.smithy/src/main/java/org/restlet/ext/smithy/internal/RestletServerProvider.java @@ -0,0 +1,47 @@ +/** + * 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() { + 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(); + } + + @Override + public int priority() { + // 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.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..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 @@ -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,276 @@ 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.ServerSocket; + 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 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); + } + 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()) + .build()) + .build(); + server.start(); + var client = CounterClient.builder() + .endpoint("http://localhost:" + port) + .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..87603162ab --- /dev/null +++ b/org.restlet.ext.smithy/src/test/java/org/restlet/ext/smithy/SmithyServerBuilderTestCase.java @@ -0,0 +1,373 @@ +/** + * 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.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.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 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); + } + + @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 { + 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() { + 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])); + for (String endpoint : + List.of( + "http://localhost:1/api", + "http://localhost:1?q", + "http://localhost:1#f", + "http://u@localhost:1", + "http://my_host:1", + "http:localhost")) { + 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); + } + + @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(); + 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); + } + + @Test + void providerIsNamedRestletAndYieldsToNetty() { + var provider = new RestletServerProvider(); + assertEquals("restlet", provider.name()); + assertTrue(provider.priority() < 0, "After 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).