From b2074452609d0b82869bea44235c00e3aec11489 Mon Sep 17 00:00:00 2001 From: Peter Liu Date: Sat, 23 May 2026 13:03:03 -0400 Subject: [PATCH] Add quarkus-smithy extension and Smithy Vert.x server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smithy services run inside Quarkus applications via a new extension that mounts every CDI-discovered Service bean on Quarkus's main HTTP router. Smithy operations share the Quarkus HTTP server's port — no separate Smithy listener. ## Customer-facing surface Users produce a `@Produces Service` bean (the generated service stub): @ApplicationScoped public class CoffeeShopServerConfig { @Produces @Singleton Service coffeeShop() { return CoffeeShop.builder() .addCreateOrderOperation(new CreateOrder()) .addGetMenuOperation(new GetMenu()) .addGetOrderOperation(new GetOrder()) .build(); } } The extension mounts a `SmithyVertxServer` on Quarkus's `Router` as a single catch-all route. Per-request, a `ProtocolResolver` iterates a precision-ordered list of `ServerProtocol`s and returns one of three outcomes: - claim -> dispatch to the operation - no-claim -> ctx.next() (delegate to a sibling handler) - claim-and-reject -> 404 directly (request is Smithy's but malformed) This implements the Smithy 2.0 Wire-protocol-selection guide. End-to-end CoffeeShop example at `examples/quarkus-server/`. Standalone Gradle build that consumes smithy-java via mavenLocal — required because including it as a subproject causes Quarkus dev-mode workspace discovery to substitute sibling raw `build/classes` for the published jars, splitting classloaders in ways that break `:codecs:json-codec`'s shadowJar. ## Modules - `:server:server-vertx` — `SmithyVertxServer implements Handler`, `ServerOptions`, `VertxRequestHeaders`. 18 integration tests against a real Vert.x HTTP server. - `:quarkus-smithy` (runtime) — `SmithyVertxRecorder` and `SmithyServerConfig` (`@ConfigMapping` for `quarkus.smithy.server.{path-prefix, workers, shutdown-grace}`). The recorder collects `Service` beans from Arc, walks TCCL+own-loader for `ServerProtocolProvider`s (required because `ProtocolResolver`'s static SPI cache can't see runtime jars under `QuarkusClassLoader`), constructs the server, mounts it on the main router, and registers ordered shutdown tasks. - `:quarkus-smithy-deployment` (build-time) — `SmithyProcessor` `@BuildStep`s and `SmithyCodeGenProvider` that hooks Smithy code generation into `quarkusGenerateCode` (no `smithy-base` Gradle plugin needed). - `:quarkus-smithy-integration-tests` — `SmithyCodeGenProviderTest`. ## Cross-module changes - `:server:server-core` - `ProtocolResolver` gains `resolveOrEmpty(...)` returning `Optional` and a second ctor accepting a pre-loaded protocol list (for callers like the Quarkus recorder where the static SPI cache is blind). - `HttpResponseSerializer` is new: status + header-copy + content-type/length logic shared between Netty and the Vert.x server. Body exposed as the underlying `DataStream` so Netty preserves zero-copy via `Unpooled.wrappedBuffer(ByteBuffer)`. - `ServerProtocolProvider.precision()` Javadoc documents the AWS service-protocol scale (rpcv2Cbor=1 ... restXml=8). - `:server:server-netty` — `HttpRequestHandler.writeResponse` adopts `HttpResponseSerializer`. Behavior unchanged. - Provider precision values: `RpcV2CborProtocolProvider` -> 1, `RpcV2JsonProtocolProvider` -> 2, `AwsRestJson1ProtocolProvider` -> 7. Previously all 0 (precision sort was a no-op against classpath order). - `:aws:server:aws-server-restjson` — drops dead `routes` field and `smithyToVertxPath` helper (the Vert.x server no longer enumerates per-operation routes). ## Verification - `./gradlew :server:server-vertx:check` green. 19 integration tests against a real Vert.x HTTP server: protocol resolution outcomes, HTTP/2 round-trip, lifecycle, options, precision regression. - `./gradlew :server:server-core:check` green. New tests: `HttpResponseSerializerTest` (6) and `ProtocolResolverTest` (7). - `./gradlew :quarkus-smithy:build :quarkus-smithy-deployment:build` green (with `--no-configuration-cache` to work around a pre-existing Quarkus extension-validation gradle-plugin issue). - `examples/quarkus-server` end-to-end (`quarkusDev`): `GET /menu`, `PUT /order`, `GET /order/` all 200 under restJson1; CoffeeShop also reachable via `POST /service/CoffeeShop/operation/` with `smithy-protocol: rpc-v2-cbor` and `smithy-protocol: rpc-v2-json` headers; rpcv2 path with no header -> Quarkus default 404 via ctx.next(); rpcv2 path with header but malformed URI -> server 404 with empty body (claim-and-reject). The empty-body 404 vs Quarkus's default-page 404 confirms the resolution-outcome distinctions are observable. ## Known limitations (deferred) - `@streaming Blob` operations not supported. The recorder installs Vert.x's `BodyHandler` upstream of the server, fully buffering request bodies before resolution runs. - Native-image support is out of scope for this cut. - CORS support on the Vert.x server (Netty has it). - Cross-service `@http(uri)` collisions are silent at construction time — the matcher's tie-break wins. --- .../AwsRestJson1ProtocolProvider.java | 2 +- examples/quarkus-server/README.md | 234 +++++++++ examples/quarkus-server/build.gradle.kts | 57 +++ examples/quarkus-server/gradle.properties | 8 + examples/quarkus-server/settings.gradle.kts | 22 + examples/quarkus-server/smithy-build.json | 11 + .../quarkus/CoffeeShopServerConfig.java | 39 ++ .../java/example/quarkus/CreateOrder.java | 31 ++ .../smithy/java/example/quarkus/GetMenu.java | 57 +++ .../smithy/java/example/quarkus/GetOrder.java | 31 ++ .../smithy/java/example/quarkus/Order.java | 24 + .../java/example/quarkus/OrderTracker.java | 31 ++ .../src/main/resources/application.properties | 6 + .../src/main/smithy/coffee.smithy | 34 ++ .../src/main/smithy/main.smithy | 39 ++ .../src/main/smithy/order.smithy | 91 ++++ gradle/libs.versions.toml | 6 + quarkus-smithy-deployment/build.gradle.kts | 94 ++++ .../deployment/SmithyCodeGenProvider.java | 267 ++++++++++ .../quarkus/deployment/SmithyProcessor.java | 77 +++ .../java/quarkus/deployment/package-info.java | 9 + .../io.quarkus.deployment.CodeGenProvider | 1 + .../build.gradle.kts | 57 +++ .../SmithyCodeGenProviderTest.java | 264 ++++++++++ quarkus-smithy/CONTEXT.md | 147 ++++++ quarkus-smithy/README.md | 163 ++++++ quarkus-smithy/build.gradle.kts | 88 ++++ .../quarkus/runtime/SmithyServerConfig.java | 54 ++ .../quarkus/runtime/SmithyVertxRecorder.java | 200 ++++++++ .../java/quarkus/runtime/package-info.java | 9 + .../server/core/HttpResponseSerializer.java | 104 ++++ .../java/server/core/ProtocolResolver.java | 93 +++- .../server/core/ServerProtocolProvider.java | 36 ++ .../core/HttpResponseSerializerTest.java | 150 ++++++ .../server/core/ProtocolResolverTest.java | 266 ++++++++++ .../java/server/netty/HttpRequestHandler.java | 44 +- .../rpcv2/RpcV2CborProtocolProvider.java | 2 +- .../rpcv2json/RpcV2JsonProtocolProvider.java | 2 +- server/server-vertx/build.gradle.kts | 35 ++ .../java/server/vertx/ServerOptions.java | 95 ++++ .../java/server/vertx/SmithyVertxServer.java | 395 +++++++++++++++ .../server/vertx/VertxRequestHeaders.java | 71 +++ .../java/server/vertx/package-info.java | 15 + .../smithy/java/server/vertx/MenuFixture.java | 220 +++++++++ .../smithy/java/server/vertx/PingFixture.java | 177 +++++++ .../server/vertx/SmithyVertxServerTest.java | 462 ++++++++++++++++++ settings.gradle.kts | 26 + 47 files changed, 4328 insertions(+), 18 deletions(-) create mode 100644 examples/quarkus-server/README.md create mode 100644 examples/quarkus-server/build.gradle.kts create mode 100644 examples/quarkus-server/gradle.properties create mode 100644 examples/quarkus-server/settings.gradle.kts create mode 100644 examples/quarkus-server/smithy-build.json create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CoffeeShopServerConfig.java create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CreateOrder.java create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetMenu.java create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetOrder.java create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/Order.java create mode 100644 examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/OrderTracker.java create mode 100644 examples/quarkus-server/src/main/resources/application.properties create mode 100644 examples/quarkus-server/src/main/smithy/coffee.smithy create mode 100644 examples/quarkus-server/src/main/smithy/main.smithy create mode 100644 examples/quarkus-server/src/main/smithy/order.smithy create mode 100644 quarkus-smithy-deployment/build.gradle.kts create mode 100644 quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyCodeGenProvider.java create mode 100644 quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyProcessor.java create mode 100644 quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/package-info.java create mode 100644 quarkus-smithy-deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider create mode 100644 quarkus-smithy-integration-tests/build.gradle.kts create mode 100644 quarkus-smithy-integration-tests/src/test/java/software/amazon/smithy/java/quarkus/integration/SmithyCodeGenProviderTest.java create mode 100644 quarkus-smithy/CONTEXT.md create mode 100644 quarkus-smithy/README.md create mode 100644 quarkus-smithy/build.gradle.kts create mode 100644 quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyServerConfig.java create mode 100644 quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyVertxRecorder.java create mode 100644 quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/package-info.java create mode 100644 server/server-core/src/main/java/software/amazon/smithy/java/server/core/HttpResponseSerializer.java create mode 100644 server/server-core/src/test/java/software/amazon/smithy/java/server/core/HttpResponseSerializerTest.java create mode 100644 server/server-core/src/test/java/software/amazon/smithy/java/server/core/ProtocolResolverTest.java create mode 100644 server/server-vertx/build.gradle.kts create mode 100644 server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/ServerOptions.java create mode 100644 server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/SmithyVertxServer.java create mode 100644 server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/VertxRequestHeaders.java create mode 100644 server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/package-info.java create mode 100644 server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/MenuFixture.java create mode 100644 server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/PingFixture.java create mode 100644 server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/SmithyVertxServerTest.java diff --git a/aws/server/aws-server-restjson/src/main/java/software/amazon/smithy/java/aws/server/restjson/AwsRestJson1ProtocolProvider.java b/aws/server/aws-server-restjson/src/main/java/software/amazon/smithy/java/aws/server/restjson/AwsRestJson1ProtocolProvider.java index 536dd846c0..eed4dd5a8c 100644 --- a/aws/server/aws-server-restjson/src/main/java/software/amazon/smithy/java/aws/server/restjson/AwsRestJson1ProtocolProvider.java +++ b/aws/server/aws-server-restjson/src/main/java/software/amazon/smithy/java/aws/server/restjson/AwsRestJson1ProtocolProvider.java @@ -25,6 +25,6 @@ public ShapeId getProtocolId() { @Override public int precision() { - return 0; + return 7; } } diff --git a/examples/quarkus-server/README.md b/examples/quarkus-server/README.md new file mode 100644 index 0000000000..3da02eaa8f --- /dev/null +++ b/examples/quarkus-server/README.md @@ -0,0 +1,234 @@ +## Example: Quarkus Server + +A Smithy-Java service running inside a Quarkus application via the +[`quarkus-smithy` extension](../../quarkus-smithy/README.md). Smithy +operations share Quarkus's HTTP port — no separate Smithy listener. + +### Run it + +This example is a standalone Gradle build (see "Why a standalone +Gradle build" below). Drive it from the smithy-java root with the +repo's wrapper: + +```console +# from smithy-java/ — publishes the smithy-java jars to your local repo +./gradlew publishToMavenLocal + +# still from smithy-java/ — runs the example's standalone build +./gradlew --project-dir examples/quarkus-server quarkusDev +``` + +The server listens on `http://localhost:8080`. Watch the boot log for +the recorder's mount line (`Smithy mounted at /* with N service(s)`) +and the server's own construction line (`Smithy server constructed +with N service(s), M operation(s); protocols (precision order): […]`). + +Re-run `publishToMavenLocal` whenever you change smithy-java sources. + +### Curl the operations + +The CoffeeShop service declares `@restJson1 @rpcv2Cbor @rpcv2Json`, so +each operation is reachable via any of three on-the-wire shapes. The +server picks one per request in protocol-precision order +(rpcv2Cbor → rpcv2Json → restJson1). + +#### restJson1 — HTTP routes via `@http(method, uri)` + +```console +curl http://localhost:8080/menu +curl -X PUT http://localhost:8080/order -H 'Content-Type: application/json' \ + -d '{"coffeeType":"LATTE"}' +curl http://localhost:8080/order/ +``` + +#### rpcv2Cbor — `/service/CoffeeShop/operation/` + `smithy-protocol` header + +```console +# GetMenu — empty input is the CBOR empty map (0xa0) +printf '\xa0' > /tmp/empty.cbor +curl -X POST http://localhost:8080/service/CoffeeShop/operation/GetMenu \ + -H 'smithy-protocol: rpc-v2-cbor' -H 'content-type: application/cbor' \ + --data-binary @/tmp/empty.cbor + +# CreateOrder — {"coffeeType":"LATTE"} +python3 -c 'import sys; sys.stdout.buffer.write(bytes([0xa1, 0x6a]) + b"coffeeType" + bytes([0x65]) + b"LATTE")' \ + > /tmp/createorder.cbor +curl -X POST http://localhost:8080/service/CoffeeShop/operation/CreateOrder \ + -H 'smithy-protocol: rpc-v2-cbor' -H 'content-type: application/cbor' \ + --data-binary @/tmp/createorder.cbor +``` + +The response body is CBOR; pipe through `xxd` or a CBOR diagnostic tool +to read it. + +#### rpcv2Json — same URI scheme, JSON body + +```console +curl -X POST http://localhost:8080/service/CoffeeShop/operation/GetMenu \ + -H 'smithy-protocol: rpc-v2-json' -H 'content-type: application/json' -d '{}' + +curl -X POST http://localhost:8080/service/CoffeeShop/operation/CreateOrder \ + -H 'smithy-protocol: rpc-v2-json' -H 'content-type: application/json' \ + -d '{"coffeeType":"ESPRESSO"}' + +curl -X POST http://localhost:8080/service/CoffeeShop/operation/GetOrder \ + -H 'smithy-protocol: rpc-v2-json' -H 'content-type: application/json' \ + -d '{"id":""}' +``` + +#### Fall-through behavior + +The server distinguishes three outcomes per request, observable as +distinct 404 shapes: + +```console +# no-claim → ctx.next() → Quarkus default 404 (text/plain ~358B page) +curl -i -X POST http://localhost:8080/service/CoffeeShop/operation/GetMenu + +# claim-and-reject → server 404 with empty body +curl -i -X POST http://localhost:8080/service/CoffeeShop/operation/ \ + -H 'smithy-protocol: rpc-v2-cbor' -H 'content-type: application/cbor' \ + --data-binary @/tmp/empty.cbor + +# unrelated path → ctx.next() → Quarkus (or your own sibling handler) +curl -i http://localhost:8080/q/notreal +``` + +The empty-body 404 (claim-and-reject) vs the Quarkus default-page 404 +(no-claim) is the observable signal for routing correctness. A request +that *claimed* the rpcv2-cbor protocol but failed URI parsing is +intercepted before `ctx.next()`, so a sibling handler can't +misinterpret it. A request that no protocol claimed falls through, so +Quarkus (or any other Vert.x handler on the same router) gets a chance +to serve it. + +### Hot reload + +While `quarkusDev` is running: + +- Edit `CreateOrder.java` (e.g., change a status string), save → re-curl + `PUT /order`. The response reflects the change without a restart. +- Edit `src/main/smithy/coffee.smithy` (e.g., add a member), save → the + `CodeGenProvider` regenerates the stub and the recorder removes the + previous Vert.x route before installing the new one. + +### Path-prefix mode + +To put Smithy operations under `/api/smithy/...` (so REST endpoints can +own the root), set in `src/main/resources/application.properties`: + +```properties +quarkus.smithy.server.path-prefix=/api/smithy +``` + +`@http(uri:"/menu")` then becomes reachable at `/api/smithy/menu`. Verify: + +```console +curl -i http://localhost:8080/api/smithy/menu # 200 +curl -i http://localhost:8080/menu # 404 +``` + +In dev mode you can also live-edit this from the Dev UI Configuration +tile — see below. + +### Packaged jar (prod profile) + +```console +# from smithy-java/ +./gradlew --project-dir examples/quarkus-server quarkusBuild +java -jar examples/quarkus-server/build/quarkus-app/quarkus-run.jar +``` + +Run the same curl probes against this — they should all 200, boot is +sub-2s. + +### Dev UI + +While `quarkusDev` is running, open `http://localhost:8080/q/dev-ui`. + +There is no Smithy-specific Dev UI card today (none of the extension's +build steps emit a `CardPageBuildItem`), so use the standard tiles: + +- **Endpoints** — confirms the Smithy server's catch-all route + alongside Quarkus's own routes. +- **Configuration** — search for `quarkus.smithy.server` to live-edit + `path-prefix`, `workers`, and `shutdown-grace`. +- **ArC** — confirms the `@Produces Service` bean is present and + unremovable (the extension marks it via `UnremovableBeanBuildItem`). +- **Build Steps** — confirms `SmithyProcessor` ran and which build + items it produced. +- **Continuous Testing** — press `r` in the dev terminal (or open the + tile) to re-run tests on save. + +--- + +### How it's wired + +The user produces a `@Produces Service` bean (the generated `CoffeeShop` +stub) and the extension mounts a `SmithyVertxServer` from the upstream +`:server:server-vertx` module on Quarkus's main HTTP router: + +```java +@ApplicationScoped +public class CoffeeShopServerConfig { + + @Produces + @Singleton + Service coffeeShop() { + return CoffeeShop.builder() + .addCreateOrderOperation(new CreateOrder()) + .addGetMenuOperation(new GetMenu()) + .addGetOrderOperation(new GetOrder()) + .build(); + } +} +``` + +#### Project layout + +``` +. +├── build.gradle.kts ← apply io.quarkus, depend on quarkus-smithy +├── settings.gradle.kts +├── gradle.properties +├── smithy-build.json ← project root, configures java-codegen +├── src/main/smithy/ ← .smithy models (Quarkus convention) +│ ├── coffee.smithy +│ ├── main.smithy +│ └── order.smithy +├── src/main/java/.../CoffeeShopServerConfig.java +├── src/main/java/.../CreateOrder.java +├── src/main/java/.../GetMenu.java +├── src/main/java/.../GetOrder.java +└── src/main/resources/application.properties +``` + +No `afterEvaluate { ... srcDir(...) }` wiring. No +`compileJava.dependsOn(smithyBuild)`. The `quarkus-smithy` extension's +`CodeGenProvider` runs as part of `quarkusGenerateCode`, generates Java +sources directly into Quarkus's +`build/classes/java/quarkus-generated-sources/smithy/` output directory, +and `compileJava` picks them up automatically. + +#### Why a standalone Gradle build + +This example is intentionally not included in `smithy-java`'s root +`settings.gradle.kts`. Quarkus dev-mode workspace discovery would +otherwise substitute sibling smithy-java projects' raw `build/classes` +directories for their published jars — bypassing +`:codecs:json-codec`'s shadowJar (which relocates Jackson 3) and +splitting the classloader graph in ways that break the +`SchemaExtensionProvider` SPI lookup. Running standalone, against the +locally-published jars, makes the example behave exactly the way a +real customer's project would. + +### Running the extension's tests + +These live in the parent smithy-java build, not in this example: + +```console +# from smithy-java/ +./gradlew :server:server-vertx:test # Smithy Vert.x server tests +./gradlew :quarkus-smithy-integration-tests:test # extension integ +./gradlew :aws:server:aws-server-restjson:integ # protocol integ +``` diff --git a/examples/quarkus-server/build.gradle.kts b/examples/quarkus-server/build.gradle.kts new file mode 100644 index 0000000000..9d548a8b5b --- /dev/null +++ b/examples/quarkus-server/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + `java-library` + id("io.quarkus") version "3.35.3" +} + +// This example is a standalone Gradle build (it is *not* included in +// smithy-java's root settings.gradle.kts). All smithy-java dependencies +// are resolved from mavenLocal, the same way a real customer would +// consume them. To rebuild after changing smithy-java sources, run +// `gradle publishToMavenLocal` from the smithy-java root first. +repositories { + mavenLocal() + mavenCentral() +} + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project +val smithyJavaVersion: String by project + +dependencies { + // The quarkus-smithy extension. Brings in: + // - SmithyVertxRecorder (mounts services on Quarkus's HTTP router) + // - the deployment-time CodeGenProvider that runs Smithy code generation + // during quarkusGenerateCode (no smithy-base Gradle plugin needed) + // - quarkus-vertx-http transitively (Smithy operations share the + // Quarkus HTTP server's port, per ADR-0003) + // - the upstream :server:server-vertx module + implementation("software.amazon.smithy.java:quarkus-smithy:$smithyJavaVersion") + + // Quarkus runtime + implementation(enforcedPlatform("$quarkusPlatformGroupId:$quarkusPlatformArtifactId:$quarkusPlatformVersion")) + implementation("io.quarkus:quarkus-arc") + + // Server-side protocol implementations. The bridge looks for + // ServerProtocol providers via ServiceLoader; the user adds + // whichever protocol jar(s) their .smithy services declare. The + // CoffeeShop service in this example declares all three: + // @restJson1 + @rpcv2Cbor + @rpcv2Json. Each request resolves to + // exactly one protocol per the precision-ordered list (rpcv2Cbor + // first, then rpcv2Json, then restJson1). + implementation("software.amazon.smithy.java:aws-server-restjson:$smithyJavaVersion") + implementation("software.amazon.smithy.java:server-rpcv2-cbor:$smithyJavaVersion") + implementation("software.amazon.smithy.java:server-rpcv2-json:$smithyJavaVersion") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +// .smithy files live under src/main/smithy/ — Quarkus's CodeGenProvider +// finds them automatically and IntelliJ's Gradle import surfaces them +// without extra source-set wiring. diff --git a/examples/quarkus-server/gradle.properties b/examples/quarkus-server/gradle.properties new file mode 100644 index 0000000000..dd6ecad487 --- /dev/null +++ b/examples/quarkus-server/gradle.properties @@ -0,0 +1,8 @@ +# Pinned to whatever version is in mavenLocal. Before running `quarkusBuild` +# or `quarkusDev` from this directory, publish the smithy-java artifacts: +# (from smithy-java/) gradle :quarkus-smithy:publishToMavenLocal :quarkus-smithy-deployment:publishToMavenLocal +smithyJavaVersion=1.2.0 +quarkusPluginVersion=3.35.3 +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.35.3 diff --git a/examples/quarkus-server/settings.gradle.kts b/examples/quarkus-server/settings.gradle.kts new file mode 100644 index 0000000000..e44116fa85 --- /dev/null +++ b/examples/quarkus-server/settings.gradle.kts @@ -0,0 +1,22 @@ +/** + * Example showing the user-facing experience for a Smithy-Java server inside + * Quarkus, using the experimental `quarkus-smithy` extension. The extension + * owns codegen (no smithy-base needed) and the Server lifecycle (no manual + * StartupEvent observer needed). + */ + +pluginManagement { + val quarkusPluginVersion: String by settings + + plugins { + id("io.quarkus").version(quarkusPluginVersion) + } + + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "Quarkus-Server" diff --git a/examples/quarkus-server/smithy-build.json b/examples/quarkus-server/smithy-build.json new file mode 100644 index 0000000000..f9c10350bd --- /dev/null +++ b/examples/quarkus-server/smithy-build.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "sources": ["src/main/smithy"], + "plugins": { + "java-codegen": { + "service": "com.example#CoffeeShop", + "namespace": "software.amazon.smithy.java.example.quarkus", + "modes": ["server"] + } + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CoffeeShopServerConfig.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CoffeeShopServerConfig.java new file mode 100644 index 0000000000..72fd626032 --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CoffeeShopServerConfig.java @@ -0,0 +1,39 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; +import software.amazon.smithy.java.example.quarkus.service.CoffeeShop; +import software.amazon.smithy.java.server.Service; + +/** + * The user-facing wiring for a Smithy-Java service inside Quarkus. + * + *

This producer returns a built {@link Service} (the generated + * {@code CoffeeShop} stub). The {@code quarkus-smithy} extension + * discovers every {@code @Produces Service} bean and mounts the + * operations on Quarkus's main HTTP router via the upstream Vert.x + * server — see the README for config options. + * + *

There is no user-supplied {@code URI} or port: the service shares + * Quarkus's HTTP server, so configure host/port via the standard + * {@code quarkus.http.host} / {@code quarkus.http.port} keys. + */ +@ApplicationScoped +public class CoffeeShopServerConfig { + + @Produces + @Singleton + Service coffeeShop() { + return CoffeeShop.builder() + .addCreateOrderOperation(new CreateOrder()) + .addGetMenuOperation(new GetMenu()) + .addGetOrderOperation(new GetOrder()) + .build(); + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CreateOrder.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CreateOrder.java new file mode 100644 index 0000000000..964441db98 --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/CreateOrder.java @@ -0,0 +1,31 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import java.util.UUID; +import software.amazon.smithy.java.example.quarkus.model.CreateOrderInput; +import software.amazon.smithy.java.example.quarkus.model.CreateOrderOutput; +import software.amazon.smithy.java.example.quarkus.model.OrderStatus; +import software.amazon.smithy.java.example.quarkus.service.CreateOrderOperation; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Create an order for a coffee. + */ +final class CreateOrder implements CreateOrderOperation { + @Override + public CreateOrderOutput createOrder(CreateOrderInput input, RequestContext context) { + var id = UUID.randomUUID(); + + OrderTracker.putOrder(new Order(id, input.getCoffeeType(), OrderStatus.IN_PROGRESS)); + + return CreateOrderOutput.builder() + .id(id.toString()) + .coffeeType(input.getCoffeeType()) + .status(OrderStatus.IN_PROGRESS) + .build(); + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetMenu.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetMenu.java new file mode 100644 index 0000000000..f75a1bb3cb --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetMenu.java @@ -0,0 +1,57 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import java.util.List; +import software.amazon.smithy.java.example.quarkus.model.CoffeeItem; +import software.amazon.smithy.java.example.quarkus.model.CoffeeType; +import software.amazon.smithy.java.example.quarkus.model.GetMenuInput; +import software.amazon.smithy.java.example.quarkus.model.GetMenuOutput; +import software.amazon.smithy.java.example.quarkus.service.GetMenuOperation; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Returns the menu for the coffee shop. + */ +final class GetMenu implements GetMenuOperation { + private static final List MENU = List.of( + CoffeeItem.builder() + .type(CoffeeType.DRIP) + .description(""" + A clean-bodied, rounder, and more simplistic flavour profile. + Often praised for mellow and less intense notes. + Far less concentrated than espresso. + """) + .build(), + CoffeeItem.builder() + .type(CoffeeType.POUR_OVER) + .description(""" + Similar to drip coffee, but with a process that brings out more subtle nuances in flavor. + More concentrated than drip, but less than espresso. + """) + .build(), + CoffeeItem.builder() + .type(CoffeeType.LATTE) + .description(""" + A creamier, milk-based drink made with espresso. + A subtle coffee taste, with smooth texture. + High milk-to-coffee ratio. + """) + .build(), + CoffeeItem.builder() + .type(CoffeeType.ESPRESSO) + .description(""" + A highly concentrated form of coffee, brewed under high pressure. + Syrupy, thick liquid in a small serving size. + Full bodied and intensely aromatic. + """) + .build()); + + @Override + public GetMenuOutput getMenu(GetMenuInput input, RequestContext context) { + return GetMenuOutput.builder().items(MENU).build(); + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetOrder.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetOrder.java new file mode 100644 index 0000000000..da40a82b0b --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/GetOrder.java @@ -0,0 +1,31 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import java.util.UUID; +import software.amazon.smithy.java.example.quarkus.model.GetOrderInput; +import software.amazon.smithy.java.example.quarkus.model.GetOrderOutput; +import software.amazon.smithy.java.example.quarkus.model.OrderNotFound; +import software.amazon.smithy.java.example.quarkus.service.GetOrderOperation; +import software.amazon.smithy.java.server.RequestContext; + +final class GetOrder implements GetOrderOperation { + @Override + public GetOrderOutput getOrder(GetOrderInput input, RequestContext context) { + var order = OrderTracker.getOrderById(UUID.fromString(input.getId())); + if (order == null) { + throw OrderNotFound.builder() + .orderId(input.getId()) + .message("Order not found") + .build(); + } + return GetOrderOutput.builder() + .id(input.getId()) + .coffeeType(order.type()) + .status(order.status()) + .build(); + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/Order.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/Order.java new file mode 100644 index 0000000000..cc8f17062a --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/Order.java @@ -0,0 +1,24 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import java.util.UUID; +import software.amazon.smithy.java.example.quarkus.model.CoffeeType; +import software.amazon.smithy.java.example.quarkus.model.OrderStatus; + +/** + * A coffee drink order. + * + * @param id UUID of the order + * @param type Type of drink for the order + * @param status status of the order. + */ +public record Order(UUID id, CoffeeType type, OrderStatus status) { + + Order complete() { + return new Order(id, type, OrderStatus.COMPLETED); + } +} diff --git a/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/OrderTracker.java b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/OrderTracker.java new file mode 100644 index 0000000000..5236351811 --- /dev/null +++ b/examples/quarkus-server/src/main/java/software/amazon/smithy/java/example/quarkus/OrderTracker.java @@ -0,0 +1,31 @@ +/* + * Example file license header. + * File header line two + */ + +package software.amazon.smithy.java.example.quarkus; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * This class is a stand-in for a database. + */ +final class OrderTracker { + private static final Map ORDERS = new ConcurrentHashMap<>(); + + private OrderTracker() {} + + public static void putOrder(Order order) { + ORDERS.put(order.id(), order); + } + + public static void completeOrder(String id) { + ORDERS.computeIfPresent(UUID.fromString(id), (k, v) -> v.complete()); + } + + public static Order getOrderById(UUID id) { + return ORDERS.get(id); + } +} diff --git a/examples/quarkus-server/src/main/resources/application.properties b/examples/quarkus-server/src/main/resources/application.properties new file mode 100644 index 0000000000..a798ac9dcc --- /dev/null +++ b/examples/quarkus-server/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# Smithy operations are served by Quarkus's HTTP server (per ADR-0003). +# Configure host/port using the standard Quarkus HTTP keys; the bridge +# mounts Smithy operations on the same router that serves /q/health etc. +quarkus.http.port=8080 + +quarkus.banner.enabled=false diff --git a/examples/quarkus-server/src/main/smithy/coffee.smithy b/examples/quarkus-server/src/main/smithy/coffee.smithy new file mode 100644 index 0000000000..1ce5b22181 --- /dev/null +++ b/examples/quarkus-server/src/main/smithy/coffee.smithy @@ -0,0 +1,34 @@ +$version: "2" + +namespace com.example + +/// An enum describing the types of coffees available +enum CoffeeType { + DRIP + POUR_OVER + LATTE + ESPRESSO + COLD_BREW +} + +/// A structure which defines a coffee item which can be ordered +structure CoffeeItem { + /// A type of coffee + @required + type: CoffeeType + + @required + description: String + + extraItems: ExtraItems +} + +/// A list of coffee items +list CoffeeItems { + member: CoffeeItem +} + +map ExtraItems { + key: String + value: String +} diff --git a/examples/quarkus-server/src/main/smithy/main.smithy b/examples/quarkus-server/src/main/smithy/main.smithy new file mode 100644 index 0000000000..6567302189 --- /dev/null +++ b/examples/quarkus-server/src/main/smithy/main.smithy @@ -0,0 +1,39 @@ +$version: "2" + +namespace com.example + +use aws.protocols#restJson1 +use smithy.protocols#rpcv2Cbor +use smithy.protocols#rpcv2Json + +/// Allows users to retrieve a menu, create a coffee order, and +/// and to view the status of their orders. +/// +/// Declares three protocols. restJson1 routes by @http traits; +/// rpcv2Cbor and rpcv2Json route by /service/CoffeeShop/operation/ +/// + smithy-protocol header. Per the Smithy 2.0 Wire-protocol-selection +/// guide and the protocol-test-harness pluggable-hosts compliance +/// tests, the server iterates protocols in precision order +/// (rpcv2Cbor=1, rpcv2Json=2, restJson1=7) per request. +@title("Coffee Shop Service") +@restJson1 +@rpcv2Cbor +@rpcv2Json +service CoffeeShop { + version: "2024-08-23" + operations: [ + GetMenu + ] + resources: [ + Order + ] +} + +/// Retrieve the menu +@http(method: "GET", uri: "/menu") +@readonly +operation GetMenu { + output := { + items: CoffeeItems + } +} diff --git a/examples/quarkus-server/src/main/smithy/order.smithy b/examples/quarkus-server/src/main/smithy/order.smithy new file mode 100644 index 0000000000..4379799e66 --- /dev/null +++ b/examples/quarkus-server/src/main/smithy/order.smithy @@ -0,0 +1,91 @@ +$version: "2.0" + +namespace com.example + +/// An Order resource, which has an id and describes an order by the type of coffee +/// and the order's status +resource Order { + identifiers: { + id: Uuid + } + properties: { + coffeeType: CoffeeType + status: OrderStatus + } + read: GetOrder + create: CreateOrder +} + +/// Create an order +@idempotent +@http(method: "PUT", uri: "/order") +operation CreateOrder { + input := for Order { + @required + $coffeeType + } + + output := for Order { + @required + $id + + @required + $coffeeType + + @required + $status + } +} + +/// Retrieve an order +@readonly +@http(method: "GET", uri: "/order/{id}") +operation GetOrder { + input := for Order { + @httpLabel + @required + $id + } + + output := for Order { + @required + $id + + @required + $coffeeType + + @required + $status + } + + errors: [ + OrderNotFound + OtherError + ] +} + +/// An error indicating an order could not be found +@httpError(404) +@error("client") +@title("Thrown when no existing order is found for an ID.") +structure OrderNotFound { + message: String + orderId: Uuid +} + +@httpError(405) +@error("client") +structure OtherError { + message: String +} + +/// An identifier to describe a unique order +@length(min: 1, max: 128) +@pattern("^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$") +string Uuid + +/// An enum describing the status of an order +enum OrderStatus { + IN_PROGRESS + COMPLETED +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84c236a8b5..32854d49e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ assertj = "3.27.7" jackson = "3.1.3" fastdoubleparser = "2.0.1" netty = "4.2.13.Final" +vertx = "4.5.27" dep-analysis = "3.10.0" aws-sdk = "2.44.2" osdetector = "1.7.3" @@ -62,6 +63,11 @@ fastdoubleparser = {module = "ch.randelshofer:fastdoubleparser", version.ref = " netty-all = {module = "io.netty:netty-all", version.ref = "netty"} +vertx-core = {module = "io.vertx:vertx-core", version.ref = "vertx"} +vertx-web = {module = "io.vertx:vertx-web", version.ref = "vertx"} +vertx-web-client = {module = "io.vertx:vertx-web-client", version.ref = "vertx"} +vertx-junit5 = {module = "io.vertx:vertx-junit5", version.ref = "vertx"} + # CLI related dependencies picocli = { module = "info.picocli:picocli", version.ref = "picocli" } picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" } diff --git a/quarkus-smithy-deployment/build.gradle.kts b/quarkus-smithy-deployment/build.gradle.kts new file mode 100644 index 0000000000..073c76eed8 --- /dev/null +++ b/quarkus-smithy-deployment/build.gradle.kts @@ -0,0 +1,94 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id("smithy-java.java-conventions") + id("smithy-java.publishing-conventions") +} + +description = "Experimental Quarkus extension for Smithy-Java :: deployment" + +extra["displayName"] = "Smithy :: Java :: Quarkus :: Deployment" +extra["moduleName"] = "software.amazon.smithy.java.quarkus.deployment" + +val quarkusPlatformVersion = "3.35.3" + +// Override the JDK 21 default from smithy-java.java-conventions: the Quarkus +// extension targets JDK 25. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType { + options.release.set(25) +} + +// SpotBugs 4.8.x cannot parse Java 25 (class file 69) bytecode. Bump locally. +spotbugs { + toolVersion = "4.9.8" +} + +// Quarkus's junit5-internal test machinery references Project at execution +// time, which Gradle's configuration cache disallows. Opt this task out so +// the rest of the repo (config-cache enabled in gradle.properties) keeps +// its caching benefit. +tasks.named("test").configure { + notCompatibleWithConfigurationCache("io.quarkus:quarkus-junit5-internal references DefaultProject at execution time") +} + +// Quarkus loads -deployment artifacts via a separate classloader at build time +// only. They never reach the user's runtime classpath. The artifact GAV +// software.amazon.smithy.java:quarkus-smithy-deployment: matches the +// descriptor written by :quarkus-smithy. + +dependencies { + // Quarkus build-time API (BuildStep, BuildItems, Recorders, CodeGenProvider). + implementation(platform("io.quarkus.platform:quarkus-bom:$quarkusPlatformVersion")) + implementation("io.quarkus:quarkus-arc-deployment") + implementation("io.quarkus:quarkus-core-deployment") + + // Bundle Smithy-Java codegen so users do not need to declare it. (See + // /docs/adr/0002-bundle-codegen-in-deployment-artifact.md and + // /docs/adr/0004-limit-deployment-bundling-scope.md.) + implementation(project(":codegen:codegen-plugin")) + + // SmithyBuild + SmithyBuildConfig.load + ModelAssembler. Pulled in transitively + // via codegen-plugin, but declared explicitly so a refactor upstream does not + // silently remove it from this module's classpath. + implementation(libs.smithy.model) + implementation(libs.smithy.utils) + + // Trait-defining jars (smithy-aws-traits, smithy-protocol-traits, smithy-rules) + // are deliberately NOT bundled here. They reach the deployment classloader + // transitively via the user's runtime protocol deps (e.g. aws-server-restjson, + // aws-client-restjson). See ADR-0004 for the rationale. + + // Quarkus's vertx-http deployment artifact: VertxWebRouterBuildItem + // is the producer of the main Router that the bridge mounts on. + implementation("io.quarkus:quarkus-vertx-http-deployment") + + // The runtime module is on the deployment classpath so we can reference + // recorder + bean classes from @BuildStep methods. + implementation(project(":quarkus-smithy")) + + // For InternalLogger used inside SmithyCodeGenProvider. + implementation(project(":logging")) + + // The Quarkus extension annotation processor scans @BuildStep methods and + // generates META-INF/quarkus-build-steps.list. Activated automatically via + // the io.quarkus.extension plugin applied to :quarkus-smithy, but the + // deployment module needs the processor on its compile classpath too. + annotationProcessor("io.quarkus:quarkus-extension-processor:$quarkusPlatformVersion") + + // Tests. + testImplementation("io.quarkus:quarkus-junit5-internal") + // The codegen integration tests run code generation; they need + // restJson1 traits on the classpath to compile the test smithy + // models. server-netty is gone — Phase 2 of ADR-0006 made the + // Vert.x bridge the single transport. + testImplementation(project(":aws:server:aws-server-restjson")) +} diff --git a/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyCodeGenProvider.java b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyCodeGenProvider.java new file mode 100644 index 0000000000..d2b5ce3065 --- /dev/null +++ b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyCodeGenProvider.java @@ -0,0 +1,267 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.quarkus.deployment; + +import io.quarkus.bootstrap.prebuild.CodeGenException; +import io.quarkus.deployment.CodeGenContext; +import io.quarkus.deployment.CodeGenProvider; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import software.amazon.smithy.build.SmithyBuild; +import software.amazon.smithy.build.SmithyBuildResult; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.java.logging.InternalLogger; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.ModelAssembler; + +/** + * Quarkus {@link CodeGenProvider} that runs Smithy code generation in-process + * during {@code quarkusGenerateCode}. + * + *

Activated when Quarkus's build pipeline finds a non-empty + * {@code src/main/smithy/} directory in the project. Locates + * {@code smithy-build.json} at the project root, runs {@link SmithyBuild} with + * the deployment classloader (so {@code java-codegen} plugin is discovered via + * SPI), and lays generated Java sources into Quarkus's expected output + * directory. + * + *

Layout note: {@code SmithyBuild} writes to + * {@code ///{java,resources}/...}. To avoid that + * extra nesting confusing Quarkus's source-set wiring, we point SmithyBuild at + * a sibling working directory and copy the generated {@code java/} and + * {@code resources/} payloads up to {@link CodeGenContext#outDir()}. + */ +public final class SmithyCodeGenProvider implements CodeGenProvider { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(SmithyCodeGenProvider.class); + + private static final String PROVIDER_ID = "smithy"; + private static final String SMITHY_BUILD_JSON = "smithy-build.json"; + private static final String JAVA_CODEGEN_PLUGIN = "java-codegen"; + private static final String DEFAULT_PROJECTION = "source"; + + @Override + public String providerId() { + return PROVIDER_ID; + } + + @Override + public String[] inputExtensions() { + return new String[] {"smithy"}; + } + + @Override + public String inputDirectory() { + return PROVIDER_ID; + } + + @Override + public boolean trigger(CodeGenContext context) throws CodeGenException { + Path projectRoot = resolveProjectRoot(context.inputDir()); + Path smithyBuildJson = projectRoot.resolve(SMITHY_BUILD_JSON); + if (!Files.exists(smithyBuildJson)) { + LOGGER.info( + "No {} at project root {} — skipping Smithy code generation", + SMITHY_BUILD_JSON, + projectRoot); + return false; + } + + SmithyBuildConfig config; + try { + config = SmithyBuildConfig.load(smithyBuildJson); + } catch (RuntimeException e) { + throw new CodeGenException("Failed to load " + smithyBuildJson, e); + } + + if (!config.getPlugins().containsKey(JAVA_CODEGEN_PLUGIN)) { + LOGGER.info( + "{} does not configure the '{}' plugin — skipping Smithy code generation", + SMITHY_BUILD_JSON, + JAVA_CODEGEN_PLUGIN); + return false; + } + + Path stagingDir = context.workDir().resolve("smithy-build-staging"); + try { + // Always start with a clean staging area so removed shapes do not + // leave stale generated files behind. + deleteRecursive(stagingDir); + Files.createDirectories(stagingDir); + } catch (IOException e) { + throw new CodeGenException("Failed to prepare staging directory " + stagingDir, e); + } + + SmithyBuildResult result; + try { + ClassLoader classLoader = SmithyCodeGenProvider.class.getClassLoader(); + SmithyBuild build = SmithyBuild.create(classLoader) + .config(config) + .importBasePath(projectRoot) + .outputDirectory(stagingDir); + + // Resolve `sources` from smithy-build.json relative to the project + // root and register them as absolute paths. SmithyBuild's config + // path resolution otherwise depends on the JVM working directory. + List resolvedSources = new ArrayList<>(); + for (String source : config.getSources()) { + Path resolved = projectRoot.resolve(source).toAbsolutePath(); + if (Files.exists(resolved)) { + resolvedSources.add(resolved); + } else { + LOGGER.warn("Smithy source path {} does not exist; skipping", resolved); + } + } + // If smithy-build.json had no `sources`, fall back to {projectRoot}/model/ + // (Smithy CLI's documented default). + if (resolvedSources.isEmpty()) { + Path defaultModelDir = projectRoot.resolve("model"); + if (Files.isDirectory(defaultModelDir)) { + resolvedSources.add(defaultModelDir.toAbsolutePath()); + } + } + if (!resolvedSources.isEmpty()) { + build.registerSources(resolvedSources.toArray(new Path[0])); + } + + // Also explicitly load every .smithy file under each source root into + // a Model and pass that to SmithyBuild so the projection has shapes + // to project. registerSources() alone is not enough — it tags shapes + // as "source" but does not always preload them when an explicit + // SmithyBuildConfig is also provided. + Model model = loadModel(classLoader, resolvedSources); + build.model(model); + + result = build.build(); + } catch (RuntimeException e) { + throw new CodeGenException("Smithy code generation failed", e); + } catch (IOException e) { + throw new CodeGenException("Failed to walk Smithy source directories", e); + } + + if (!result.getProjectionResult(DEFAULT_PROJECTION).isPresent()) { + LOGGER.warn( + "Smithy build produced no '{}' projection result; nothing to copy", + DEFAULT_PROJECTION); + return false; + } + + Path generatedRoot = stagingDir + .resolve(DEFAULT_PROJECTION) + .resolve(JAVA_CODEGEN_PLUGIN); + try { + return mergeGeneratedSources(generatedRoot, context.outDir()); + } catch (IOException e) { + throw new CodeGenException("Failed to copy generated sources to " + context.outDir(), e); + } + } + + private static Model loadModel(ClassLoader classLoader, List sources) throws IOException { + ModelAssembler assembler = Model.assembler(classLoader).discoverModels(classLoader); + for (Path src : sources) { + if (Files.isDirectory(src)) { + try (var paths = Files.walk(src)) { + paths.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".smithy")) + .forEach(assembler::addImport); + } + } else { + assembler.addImport(src); + } + } + return assembler.assemble().unwrap(); + } + + /** + * Smithy's {@code java-codegen} plugin writes Java sources at + * {@code /java//...} and resources at + * {@code /resources//...}. Quarkus's compileJava + * picks up everything under {@link CodeGenContext#outDir()} as sources, so + * we flatten one level. + * + * @return {@code true} if at least one file was copied. + */ + private static boolean mergeGeneratedSources(Path pluginRoot, Path destRoot) throws IOException { + if (!Files.isDirectory(pluginRoot)) { + return false; + } + boolean copiedAny = false; + Path javaDir = pluginRoot.resolve("java"); + if (Files.isDirectory(javaDir)) { + copiedAny |= copyTree(javaDir, destRoot); + } + Path resourcesDir = pluginRoot.resolve("resources"); + if (Files.isDirectory(resourcesDir)) { + copiedAny |= copyTree(resourcesDir, destRoot); + } + return copiedAny; + } + + private static boolean copyTree(Path source, Path target) throws IOException { + Files.createDirectories(target); + boolean[] copied = new boolean[1]; + Files.walkFileTree(source, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Files.createDirectories(target.resolve(source.relativize(dir).toString())); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path resolved = target.resolve(source.relativize(file).toString()); + Files.copy(file, resolved, StandardCopyOption.REPLACE_EXISTING); + copied[0] = true; + return FileVisitResult.CONTINUE; + } + }); + return copied[0]; + } + + private static void deleteRecursive(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + Files.walkFileTree(path, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + /** + * {@link CodeGenContext#inputDir()} resolves to + * {@code /src/main/smithy} for the main code generation pass. + * The project root is therefore three levels up. (For tests it would be + * {@code /src/test/smithy}.) + */ + private static Path resolveProjectRoot(Path inputDir) { + Path root = inputDir; + for (int i = 0; i < 3; i++) { + Path parent = root.getParent(); + if (parent == null) { + return inputDir; + } + root = parent; + } + return root; + } +} diff --git a/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyProcessor.java b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyProcessor.java new file mode 100644 index 0000000000..c41f1b0b58 --- /dev/null +++ b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/SmithyProcessor.java @@ -0,0 +1,77 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.quarkus.deployment; + +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.vertx.http.deployment.VertxWebRouterBuildItem; +import io.vertx.ext.web.Router; +import software.amazon.smithy.java.quarkus.runtime.SmithyVertxRecorder; +import software.amazon.smithy.java.server.Service; + +/** + * Quarkus {@code @BuildStep} processors for the {@code quarkus-smithy} + * extension. + * + *

The runtime mounts every CDI-discovered {@link Service} bean on + * Quarkus's main Vert.x {@code Router} via a {@code SmithyVertxServer} + * from the upstream {@code :server:server-vertx} module. This class is + * the Quarkus-specific glue. + */ +public final class SmithyProcessor { + + private static final String FEATURE = "smithy"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + /** + * {@link Service} beans must not be removed by Arc's bean-removal + * pass even when the user does not {@code @Inject} them directly. + * Marking the type unremovable keeps multi-{@code Service} + * composition working without each user adding the + * {@code @Unremovable} annotation themselves. + */ + @BuildStep + UnremovableBeanBuildItem keepServiceBeans() { + return UnremovableBeanBuildItem.beanTypes(Service.class); + } + + /** + * Wire the recorder at {@code RUNTIME_INIT}. The recorder pulls + * {@link Service} beans from Arc, translates the user's + * {@link SmithyServerConfig} into + * {@link software.amazon.smithy.java.server.vertx.ServerOptions}, + * and mounts a {@link software.amazon.smithy.java.server.vertx.SmithyVertxServer} + * on the main Router. + * + *

{@code mainRouter} is the unprefixed router; when + * {@code quarkus.http.root-path=/} (the default) Vert.x exposes it + * as {@code httpRouter}, and {@code mainRouter} is {@code null}. + * We fall back to {@code httpRouter} in that case so the server + * always has a router to mount on. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void mountSmithyRoutes( + SmithyVertxRecorder recorder, + VertxWebRouterBuildItem routers, + ShutdownContextBuildItem shutdown + ) { + RuntimeValue target = routers.getMainRouter(); + if (target == null) { + target = routers.getHttpRouter(); + } + recorder.mount(target, shutdown); + } +} diff --git a/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/package-info.java b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/package-info.java new file mode 100644 index 0000000000..9fa566472c --- /dev/null +++ b/quarkus-smithy-deployment/src/main/java/software/amazon/smithy/java/quarkus/deployment/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +@SmithyUnstableApi +package software.amazon.smithy.java.quarkus.deployment; + +import software.amazon.smithy.utils.SmithyUnstableApi; diff --git a/quarkus-smithy-deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider b/quarkus-smithy-deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider new file mode 100644 index 0000000000..eef1b201d7 --- /dev/null +++ b/quarkus-smithy-deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.quarkus.deployment.SmithyCodeGenProvider diff --git a/quarkus-smithy-integration-tests/build.gradle.kts b/quarkus-smithy-integration-tests/build.gradle.kts new file mode 100644 index 0000000000..4218bdedad --- /dev/null +++ b/quarkus-smithy-integration-tests/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id("smithy-java.java-conventions") +} + +description = "Experimental Quarkus extension for Smithy-Java :: integration tests" + +extra["displayName"] = "Smithy :: Java :: Quarkus :: Integration Tests" +extra["moduleName"] = "software.amazon.smithy.java.quarkus.integration" + +val quarkusPlatformVersion = "3.35.3" + +// Override the JDK 21 default from smithy-java.java-conventions: the Quarkus +// extension targets JDK 25. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType { + options.release.set(25) +} + +// SpotBugs 4.8.x cannot parse Java 25 (class file 69) bytecode. Bump locally. +spotbugs { + toolVersion = "4.9.8" +} + +dependencies { + // Test the deployment-side code (CodeGenProvider) directly. + testImplementation(project(":quarkus-smithy-deployment")) + testImplementation(project(":codegen:codegen-plugin")) + + // JavaCodegenPlugin.validateDependencies() does Class.forName checks on the + // current classloader for each codegen mode. server-api covers SERVER mode; + // client-core covers CLIENT mode. TYPES mode needs neither. + testImplementation(project(":server:server-api")) + testImplementation(project(":client:client-core")) + + // ClientInterfaceGenerator.getFactory() resolves a ClientProtocolFactory via + // ServiceLoader when the model declares a protocol trait. The restJson1 + // factory ships with aws-client-restjson; without it, client-mode codegen + // for an @restJson1 service fails at the codegen stage (not the model load). + testImplementation(project(":aws:client:aws-client-restjson")) + + // CodeGenContext lives in quarkus-core-deployment. + testImplementation(platform("io.quarkus.platform:quarkus-bom:$quarkusPlatformVersion")) + testImplementation("io.quarkus:quarkus-core-deployment") + + testImplementation(libs.smithy.model) + testImplementation(libs.smithy.utils) +} diff --git a/quarkus-smithy-integration-tests/src/test/java/software/amazon/smithy/java/quarkus/integration/SmithyCodeGenProviderTest.java b/quarkus-smithy-integration-tests/src/test/java/software/amazon/smithy/java/quarkus/integration/SmithyCodeGenProviderTest.java new file mode 100644 index 0000000000..3eede9a903 --- /dev/null +++ b/quarkus-smithy-integration-tests/src/test/java/software/amazon/smithy/java/quarkus/integration/SmithyCodeGenProviderTest.java @@ -0,0 +1,264 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.quarkus.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.quarkus.deployment.CodeGenContext; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.quarkus.deployment.SmithyCodeGenProvider; + +/** + * Exercises {@link SmithyCodeGenProvider} end-to-end without booting a Quarkus + * application: builds a synthetic project layout in a temporary directory, + * invokes {@code trigger(...)}, and asserts the expected generated Java files + * landed in {@code outDir}. + * + *

Booting a real Quarkus app requires applying the io.quarkus Gradle plugin + * to this module, which in turn requires {@code pluginManagement} + * registrations in the root settings file. That's deliberately out of scope + * for this experimental extension's first cut. + */ +class SmithyCodeGenProviderTest { + + @Test + void generatesServerSourcesFromSmithyBuildJson(@TempDir Path projectRoot) throws Exception { + // Lay out a minimal Smithy project: model + smithy-build.json. + Path modelDir = Files.createDirectories(projectRoot.resolve("src/main/smithy")); + Files.writeString( + modelDir.resolve("coffee.smithy"), + """ + $version: "2" + namespace com.example + + use aws.protocols#restJson1 + + @restJson1 + service CoffeeShop { + operations: [GetMenu] + } + + @http(method: "GET", uri: "/menu") + @readonly + operation GetMenu { + output := { items: CoffeeItems } + } + + list CoffeeItems { member: CoffeeItem } + + structure CoffeeItem { + @required + name: String + } + """); + + Files.writeString( + projectRoot.resolve("smithy-build.json"), + """ + { + "version": "1.0", + "sources": ["src/main/smithy"], + "plugins": { + "java-codegen": { + "service": "com.example#CoffeeShop", + "namespace": "com.example.generated", + "modes": ["server"] + } + } + } + """); + + Path workDir = Files.createDirectories(projectRoot.resolve("build")); + Path outDir = Files.createDirectories(workDir.resolve("generated-sources/smithy")); + // Quarkus passes inputDir as /src/main/; the provider + // uses that path to walk back to the project root. + Path inputDir = projectRoot.resolve("src/main/smithy"); + + CodeGenContext context = new CodeGenContext( + /* model */ null, + outDir, + workDir, + inputDir, + /* redirectIO */ false, + /* config */ null, + /* test */ false); + + SmithyCodeGenProvider provider = new SmithyCodeGenProvider(); + boolean generated = provider.trigger(context); + + assertThat(generated).isTrue(); + + // The generated CoffeeShop service stub lands at + // outDir/com/example/generated/service/CoffeeShop.java. + Path coffeeShopJava = outDir.resolve("com/example/generated/service/CoffeeShop.java"); + assertThat(coffeeShopJava).exists(); + + Path getMenuOperation = outDir.resolve("com/example/generated/service/GetMenuOperation.java"); + assertThat(getMenuOperation).exists(); + + Path getMenuInput = outDir.resolve("com/example/generated/model/GetMenuInput.java"); + assertThat(getMenuInput).exists(); + } + + @Test + void noSmithyBuildJsonShortCircuits(@TempDir Path projectRoot) throws Exception { + Files.createDirectories(projectRoot.resolve("src/main/smithy")); + Path workDir = Files.createDirectories(projectRoot.resolve("build")); + Path outDir = Files.createDirectories(workDir.resolve("generated-sources/smithy")); + Path inputDir = projectRoot.resolve("src/main/smithy"); + + CodeGenContext context = new CodeGenContext( + /* model */ null, + outDir, + workDir, + inputDir, + /* redirectIO */ false, + /* config */ null, + /* test */ false); + + SmithyCodeGenProvider provider = new SmithyCodeGenProvider(); + assertThat(provider.trigger(context)).isFalse(); + } + + @Test + void generatesClientSourcesFromSmithyBuildJson(@TempDir Path projectRoot) throws Exception { + Path modelDir = Files.createDirectories(projectRoot.resolve("src/main/smithy")); + Files.writeString( + modelDir.resolve("coffee.smithy"), + """ + $version: "2" + namespace com.example + + use aws.protocols#restJson1 + + @restJson1 + service CoffeeShop { + operations: [GetMenu] + } + + @http(method: "GET", uri: "/menu") + @readonly + operation GetMenu { + output := { items: CoffeeItems } + } + + list CoffeeItems { member: CoffeeItem } + + structure CoffeeItem { + @required + name: String + } + """); + + Files.writeString( + projectRoot.resolve("smithy-build.json"), + """ + { + "version": "1.0", + "sources": ["src/main/smithy"], + "plugins": { + "java-codegen": { + "service": "com.example#CoffeeShop", + "namespace": "com.example.generated", + "protocol": "aws.protocols#restJson1", + "modes": ["client"] + } + } + } + """); + + Path workDir = Files.createDirectories(projectRoot.resolve("build")); + Path outDir = Files.createDirectories(workDir.resolve("generated-sources/smithy")); + Path inputDir = projectRoot.resolve("src/main/smithy"); + + CodeGenContext context = new CodeGenContext( + /* model */ null, + outDir, + workDir, + inputDir, + /* redirectIO */ false, + /* config */ null, + /* test */ false); + + boolean generated = new SmithyCodeGenProvider().trigger(context); + + assertThat(generated).isTrue(); + // Generated client lands at outDir/com/example/generated/client/CoffeeShopClient.java. + assertThat(outDir.resolve("com/example/generated/client/CoffeeShopClient.java")).exists(); + assertThat(outDir.resolve("com/example/generated/model/GetMenuInput.java")).exists(); + // No server-side stubs in client-only mode. + assertThat(outDir.resolve("com/example/generated/service/CoffeeShop.java")).doesNotExist(); + assertThat(outDir.resolve("com/example/generated/service/GetMenuOperation.java")).doesNotExist(); + } + + @Test + void generatesTypesOnlyFromSmithyBuildJson(@TempDir Path projectRoot) throws Exception { + Path modelDir = Files.createDirectories(projectRoot.resolve("src/main/smithy")); + Files.writeString( + modelDir.resolve("menu.smithy"), + """ + $version: "2" + namespace com.example + + structure Menu { + @required + items: MenuItems + } + + list MenuItems { member: MenuItem } + + structure MenuItem { + @required + name: String + + @required + price: Integer + } + """); + + // No `service` field — TypeCodegenSettings synthesizes one for types-only mode. + Files.writeString( + projectRoot.resolve("smithy-build.json"), + """ + { + "version": "1.0", + "sources": ["src/main/smithy"], + "plugins": { + "java-codegen": { + "namespace": "com.example.generated", + "modes": ["types"] + } + } + } + """); + + Path workDir = Files.createDirectories(projectRoot.resolve("build")); + Path outDir = Files.createDirectories(workDir.resolve("generated-sources/smithy")); + Path inputDir = projectRoot.resolve("src/main/smithy"); + + CodeGenContext context = new CodeGenContext( + /* model */ null, + outDir, + workDir, + inputDir, + /* redirectIO */ false, + /* config */ null, + /* test */ false); + + boolean generated = new SmithyCodeGenProvider().trigger(context); + + assertThat(generated).isTrue(); + // Model classes land under model/ for types-only mode. + assertThat(outDir.resolve("com/example/generated/model/Menu.java")).exists(); + assertThat(outDir.resolve("com/example/generated/model/MenuItem.java")).exists(); + // No client or server stubs. + assertThat(outDir.resolve("com/example/generated/client")).doesNotExist(); + assertThat(outDir.resolve("com/example/generated/service")).doesNotExist(); + } +} diff --git a/quarkus-smithy/CONTEXT.md b/quarkus-smithy/CONTEXT.md new file mode 100644 index 0000000000..031743d5d4 --- /dev/null +++ b/quarkus-smithy/CONTEXT.md @@ -0,0 +1,147 @@ +# Quarkus Smithy Extension + +The Quarkus extension that integrates Smithy-Java codegen and the +Vert.x-mounted server runtime into Quarkus applications. Terminology +here disambiguates concepts that appear under the same English word +inside a single Quarkus JVM. + +## Language + +### Servers and listeners + +**Quarkus HTTP server**: +The Vert.x-based HTTP server provided by `quarkus-vertx-http`. Hosts +the user's Smithy operations (mounted by this extension), plus +`/q/dev`, `/q/health`, REST endpoints, etc. Smithy operations share +this server's port (per ADR-0003). + +**Smithy Vert.x server (`SmithyVertxServer`)**: +The upstream module `:server:server-vertx` that this extension +consumes. A `Handler` mounted on Quarkus's main +`Router` as a single catch-all route (under +`quarkus.smithy.server.path-prefix` if set; root otherwise). On every +request, runs `ProtocolResolver` over the precision-ordered list of +`ServerProtocol`s the recorder loaded. Requests no protocol claims +fall through via `ctx.next()`; requests a protocol claims-but-rejects +return 404. See ADR-0006 for the public API (`SmithyVertxServer`, +`ServerOptions`) and ADR-0008 for the resolution model. + +**Smithy listener (deprecated)**: +The previous architecture, before ADR-0003. Was a separate +`software.amazon.smithy.java.server.Server` (Netty) instance owning +its own listener and port. No longer present in `quarkus-smithy` — +the Vert.x server above mounts on Quarkus's HTTP server instead of +running its own. +_Avoid_: this term referring to anything in the current architecture. + +### Programming model + +The extension is a **server extension**. The supported user-facing +programming model is the Service-bean model. + +**Service-bean model** (supported, the canonical pattern): +The user supplies a CDI producer method that returns a built +`software.amazon.smithy.java.server.Service` (the generated service +stub from `modes: ["server"]` codegen output). The extension +discovers every `@Produces Service` bean and mounts a +`SmithyVertxServer` on Quarkus's main HTTP router from the upstream +`:server:server-vertx` module. There is **no separate Smithy +listener**; operations share the Quarkus HTTP server's port. +_Replaces (per ADR-0003 + ADR-0006)_: "Server-bean model", "@Produces +Server" model. +_Avoid_: "manual server", "explicit server", "two-port mode". + +**Annotation-discovery model** (named, deferred): +The hypothetical future programming model in which a CDI annotation +(e.g. `@SmithyService`) marks operation implementations and the +extension builds the `Service` itself, analogous to `quarkus-grpc`'s +`@GrpcService`. Not implemented; called out so that "the model we did +not pick" has a name. + +**Typed-client model** (future direction, not shipped): +Generate a Smithy client (`modes: ["client"]`) and expose it as a CDI +bean. The `CodeGenProvider` is mode-agnostic and will emit client +code on demand, but the extension does not surface a documented +runtime path for this pattern. The recorder's no-Service +short-circuit is the only current accommodation. +_Avoid_: treating this as a supported programming model; it is a +forward-looking direction only. + +**Types-only model** (future direction, not shipped): +Generate Smithy POJOs (`modes: ["types"]`) and use them as you like. +Same status as Typed-client — codegen runs, no runtime story is +shipped. +_Avoid_: treating this as a supported programming model. + +### Smithy concepts (as used inside this extension) + +**Smithy `Service`**: +The generated service stub class corresponding to a `service` shape in +the user's `.smithy` model. The user attaches operation implementations +to it via its builder (`CoffeeShop.builder().addCreateOrderOperation(...).build()`). +The extension's recorder discovers `Service` instances via +`Instance` and hands them to a `SmithyVertxServer`. + +**Smithy operation**: +A generated interface for a single RPC, implemented by the user. The +implementation class is referenced by name in the +`.builder().addOperation(...)` chain inside +the user's `@Produces Service` method. + +**`java-codegen` plugin**: +The Smithy build plugin (from `:codegen:codegen-plugin`) that turns +`.smithy` shapes into Java source. The extension only honors this plugin +inside `smithy-build.json`; `smithy-base` Gradle plugin wiring is +intentionally not used. The plugin is **mode-agnostic**: whatever +`modes` (`server`, `client`, `types`) the user puts in +`smithy-build.json` is what's emitted. + +## Relationships + +- A Quarkus application has zero or more `@Produces Service` beans and + exactly one Quarkus HTTP server. +- The `SmithyVertxRecorder` runs at `RUNTIME_INIT`, walks + `Instance`, and either mounts the `SmithyVertxServer` (one + or more Service beans) or short-circuits with an INFO log (zero + beans — e.g. an app that depends on `quarkus-smithy` only for + codegen). +- The `Annotation-discovery model` is named to mark a boundary, not + because it exists. + +## Example dialogue + +> **Dev:** "I have a Smithy service and want it served by my Quarkus +> app. What do I produce?" +> **Domain expert:** "A `@Produces Service` bean. The extension mounts +> every operation on Quarkus's HTTP server automatically — no separate +> port, no `Server.builder()`. See `examples/quarkus-server/`." + +> **Dev:** "Can I have two Smithy services in the same Quarkus app?" +> **Domain expert:** "Yes — produce two `@Produces Service` beans. The +> Vert.x server composes them on the same router. `@http` collisions +> across services are resolved by the matcher's tie-break, not at +> bind time (see ADR-0008)." + +> **Dev:** "Where's the URL of my Smithy server?" +> **Domain expert:** "There isn't a separate one. Smithy operations +> live on Quarkus's HTTP server — `quarkus.http.host`/`quarkus.http.port` +> control the listener. To put Smithy operations under a sub-tree, set +> `quarkus.smithy.server.path-prefix=/api/smithy`." + +## Flagged ambiguities + +- "Server" can mean three things in this codebase: (1) the Quarkus + HTTP server (the listener owning the port); (2) the Smithy Vert.x + server (`SmithyVertxServer`, a `Handler` mounted on + the Quarkus router); (3) the legacy Smithy Netty listener (no + longer used in `quarkus-smithy`). When precision matters say + "Quarkus HTTP server" or "Smithy Vert.x server". +- "Service" can mean a Smithy `service` shape, a Smithy `Service` + generated stub, or a CDI `@ApplicationScoped` bean. Resolution: we + use "Service" only for the generated stub (the type returned by the + user's `@Produces` method); Smithy `service` shape is the model-side + noun; CDI services are referred to as "beans". +- "@Produces Server" was the user-facing producer pattern in earlier + experimental releases. ADR-0003 and ADR-0006 superseded it with + `@Produces Service`. Old references in ADR-0001 are preserved for + historical accuracy; new prose uses the new name. diff --git a/quarkus-smithy/README.md b/quarkus-smithy/README.md new file mode 100644 index 0000000000..f67e7a4ac8 --- /dev/null +++ b/quarkus-smithy/README.md @@ -0,0 +1,163 @@ +# quarkus-smithy + +A Quarkus extension that integrates the Smithy-Java server runtime — +codegen plus mounting — into Quarkus applications. Users produce +`Service` beans; the extension mounts every operation on Quarkus's +HTTP router. + +> **Status:** experimental. APIs and configuration keys may change without +> notice between releases. Do not use in production. + +## What it does + +1. **Codegen.** Replaces the standard `smithy-base` Gradle plugin path. + When `quarkusGenerateCode` runs (as part of `compileJava`, + `quarkusBuild`, or `quarkusDev`), the extension's `CodeGenProvider` + discovers `smithy-build.json`, runs `SmithyBuild` with the + `java-codegen` plugin in-process, and lays generated Java sources + into `build/classes/java/quarkus-generated-sources/smithy/` where + Quarkus's compileJava picks them up automatically. + +2. **Runtime mounting.** Discovers every CDI bean of type + `software.amazon.smithy.java.server.Service` (typically produced by + the user via `@Produces`) and mounts a `SmithyVertxServer` from the + upstream [`:server:server-vertx`](../server/server-vertx/) module + on Quarkus's main HTTP router as a single catch-all route. There is + **no separate Smithy listener**: operations share the Quarkus HTTP + server's port (per ADR-0003) and are reachable at their + protocol-defined paths (`@http(method, uri)` for restJson1; computed + `POST /service//operation/` for rpcv2). + +## Programming model + +`quarkus-smithy` is a **server extension**. The supported user shape is +the **Service-bean model**: declare a CDI producer that returns a built +Smithy `Service`, and the extension mounts every operation on Quarkus's +HTTP server. + +`smithy-build.json`: + +```json +{ + "version": "1.0", + "plugins": { + "java-codegen": { + "service": "com.example#CoffeeShop", + "namespace": "com.example", + "protocol": "aws.protocols#restJson1", + "modes": ["server"] + } + } +} +``` + +User code: + +```java +@ApplicationScoped +class CoffeeShopServerConfig { + @Produces @Singleton + Service coffeeShop() { + return CoffeeShop.builder() + .addCreateOrderOperation(new CreateOrder()) + .addGetMenuOperation(new GetMenu()) + .addGetOrderOperation(new GetOrder()) + .build(); + } +} +``` + +`application.properties`: + +```properties +quarkus.http.port=8080 +``` + +That's it. No `Server.builder().endpoints(...)`. No URL strings. The +extension mounts every operation in `CoffeeShop` on the Quarkus HTTP +server. Operations declared with `@http(method, uri)` are reachable at +that path; rpcv2 operations are reachable at +`/service//operation/`. + +The canonical worked example, with end-to-end Dev & Test guide, lives +at [`examples/quarkus-server`](../examples/quarkus-server/). + +See [`CONTEXT.md`](./CONTEXT.md) for the full glossary. + +## Configuration + +| Key | Default | Notes | +| ------------------------------------ | ------- | ----------------------------------------------------------------------- | +| `quarkus.http.host` / `port` | (Quarkus defaults) | Smithy operations share the Quarkus HTTP server. | +| `quarkus.smithy.server.path-prefix` | (none) | Prepended to every Smithy operation's route. | +| `quarkus.smithy.server.workers` | `procs * 2` | Worker pool size for the orchestrator group. | +| `quarkus.smithy.server.shutdown-grace` | `10s` | Bound applied by the recorder on `SmithyVertxServer.shutdown()` (best-effort). | + +The extension does not configure listener-level concerns (host, port, +TLS, HTTP/2). Use the standard `quarkus.http.*` keys for those — the +Smithy server inherits whatever the Quarkus HTTP server speaks. + +### Mounting under a sub-tree + +To put Smithy operations under `/api/smithy/...` (so REST endpoints can +own the root): + +```properties +quarkus.smithy.server.path-prefix=/api/smithy +``` + +`@http(uri:"/menu")` is then reachable at `/api/smithy/menu`. + +## Modules + +- `runtime/` — runtime classpath, ships with the application. Contains + the `SmithyVertxRecorder` and `SmithyServerConfig`. Depends on + `:server:server-api` and `:server:server-vertx`. +- `deployment/` — deployment classpath, build-time only. Contains + `SmithyCodeGenProvider` and `SmithyProcessor` (the Quarkus + `@BuildStep`s). Bundles `software.amazon.smithy:smithy-build` and + `software.amazon.smithy.java:codegen-plugin` so the user does not + need to declare them. +- `integration-tests/` — exercises `SmithyCodeGenProvider` end-to-end + across the codegen modes the underlying plugin supports. + +## Why "experimental" + +- Depends on `JavaCodegenPlugin`'s settings shape, which is + `@SmithyInternalApi`. A smithy-java release could break this + extension without notice. +- Only the `source` Smithy projection is run. +- Only the `java-codegen` plugin block is honored. +- Native-image support is out of scope for this cut. +- `@streaming` blob operations are unsupported in this release; the + recorder installs Vert.x's `BodyHandler` upstream of the Smithy + server so request bodies are fully buffered before resolution. + Tracked as open question 6 in ADR-0006. + +## Future directions + +The `CodeGenProvider` is intentionally mode-agnostic — it runs whatever +`modes` the user puts in `smithy-build.json`. That leaves room for +programming models the extension does not currently surface as +supported user-facing shapes: + +- **Typed-client.** Generate a Smithy client (`modes: ["client"]`) and + expose it as a CDI bean. Today the runtime side has no extension + support beyond codegen — the recorder's no-Service short-circuit is + the only accommodation. +- **Types-only.** Generate Smithy POJOs (`modes: ["types"]`) and use + them however you like (REST resources, Vert.x routes, internal DTOs) + with Smithy's JSON codec. No runtime mounting, just codegen. + +These are not shipped as documented programming models in this release. +If demand emerges, they can be promoted in a future ADR with their own +examples and integration tests. + +## Design rationale + +See `/docs/adr/` at the workspace root — +[0001](../../docs/adr/0001-produces-server-not-annotation-discovery.md), +[0002](../../docs/adr/0002-bundle-codegen-in-deployment-artifact.md), +[0003](../../docs/adr/0003-adopt-shared-transport-and-interceptor-spi.md), +[0004](../../docs/adr/0004-limit-deployment-bundling-scope.md), +[0006](../../docs/adr/0006-service-bean-model-for-shared-transport.md). diff --git a/quarkus-smithy/build.gradle.kts b/quarkus-smithy/build.gradle.kts new file mode 100644 index 0000000000..e0d005b005 --- /dev/null +++ b/quarkus-smithy/build.gradle.kts @@ -0,0 +1,88 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id("smithy-java.java-conventions") + id("smithy-java.publishing-conventions") + // Generates META-INF/quarkus-extension.properties and quarkus-extension.yaml, + // and enables the quarkus-extension-processor annotation processor that + // emits META-INF/quarkus-build-steps.list (and other extension metadata). + id("io.quarkus.extension") +} + +description = "Experimental Quarkus extension for Smithy-Java :: runtime" + +extra["displayName"] = "Smithy :: Java :: Quarkus :: Runtime" +extra["moduleName"] = "software.amazon.smithy.java.quarkus.runtime" + +val quarkusPlatformVersion = "3.35.3" + +// Override the JDK 21 default from smithy-java.java-conventions: the Quarkus +// extension targets JDK 25. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType { + options.release.set(25) +} + +// SpotBugs 4.8.x cannot parse Java 25 (class file 69) bytecode. Bump locally. +spotbugs { + toolVersion = "4.9.8" +} + +// Tell the io.quarkus.extension plugin which sibling project provides our +// deployment artifact. The plugin writes this into quarkus-extension.properties +// for Quarkus's bootstrap to resolve at build time. +quarkusExtension { + deploymentModule.set(":quarkus-smithy-deployment") +} + +// The io.quarkus.extension plugin's tasks call Task.project at execution +// time and reference Configuration/Project objects in their state, which +// Gradle's configuration cache disallows. Opt those tasks out so the rest +// of the repo (which does work with config cache, enabled in +// gradle.properties) keeps its caching benefit. +tasks.named("extensionDescriptor").configure { + notCompatibleWithConfigurationCache("io.quarkus.extension.gradle.tasks.ExtensionDescriptorTask uses Project at execution time") +} +// validateExtension reads sibling project jars on the runtime classpath to +// validate the extension's dependency graph; under -parallel it races with +// the :jar tasks of those projects and can read partially-written zips. +// Force it to run after the runtime classpath's :jar tasks have all +// completed. +tasks.named("validateExtension").configure { + notCompatibleWithConfigurationCache("io.quarkus.extension.gradle.tasks.ValidateExtensionTask uses Task.project at execution time") + dependsOn( + configurations.runtimeClasspath.map { rc -> + rc.allDependencies + .withType() + .map { dep -> "${dep.path}:jar" } + }, + ) +} + +dependencies { + // Quarkus core + Arc CDI. + implementation(platform("io.quarkus.platform:quarkus-bom:$quarkusPlatformVersion")) + implementation("io.quarkus:quarkus-arc") + // The bridge mounts on quarkus-vertx-http's main Router. This dep + // is what gives users one ingress port (per ADR-0003). + implementation("io.quarkus:quarkus-vertx-http") + + // Smithy-Java server runtime API (Service, Operation, RequestContext) + // and the Vert.x server module that mounts services on a Router. + api(project(":server:server-api")) + api(project(":server:server-vertx")) + + // For InternalLogger used by the recorder. + implementation(project(":logging")) + + // For @SmithyUnstableApi on package-info. + implementation(libs.smithy.utils) +} diff --git a/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyServerConfig.java b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyServerConfig.java new file mode 100644 index 0000000000..6164a36971 --- /dev/null +++ b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyServerConfig.java @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.quarkus.runtime; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.time.Duration; +import java.util.Optional; +// (Optional pathPrefix avoids the empty-string converter trap +// that fires when @WithDefault("") is used with String.) + +/** + * Runtime configuration for the {@code quarkus-smithy} extension. + * + *

All knobs map onto + * {@link software.amazon.smithy.java.server.vertx.ServerOptions}. The + * extension does not configure listener-level concerns ({@code host}, + * {@code port}, TLS) because Smithy operations run on Quarkus's HTTP + * server. Use the standard {@code quarkus.http.*} keys for those. + */ +@ConfigMapping(prefix = "quarkus.smithy.server") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface SmithyServerConfig { + + /** + * Path prefix applied to the Smithy server's catch-all route. Absent + * (default) means operations are reachable at the path their + * {@code @http} trait or rpcv2 path syntax declares. + * + *

Set this when the user wants Smithy operations under a + * sub-tree of the router (e.g., {@code /api/smithy}) so REST + * endpoints at the root are not shadowed. + */ + Optional pathPrefix(); + + /** + * Worker pool size for the orchestrator group. Defaults to + * {@code Runtime.getRuntime().availableProcessors() * 2}. + */ + Optional workers(); + + /** + * Bound applied by the recorder on + * {@link software.amazon.smithy.java.server.vertx.SmithyVertxServer#shutdown()}. + * Default: 10 seconds. + */ + @WithDefault("10s") + Duration shutdownGrace(); +} diff --git a/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyVertxRecorder.java b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyVertxRecorder.java new file mode 100644 index 0000000000..12226a1fb7 --- /dev/null +++ b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/SmithyVertxRecorder.java @@ -0,0 +1,200 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.quarkus.runtime; + +import io.quarkus.arc.Arc; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.ShutdownContext; +import io.quarkus.runtime.annotations.Recorder; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.BodyHandler; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.util.TypeLiteral; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import software.amazon.smithy.java.logging.InternalLogger; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServerProtocolProvider; +import software.amazon.smithy.java.server.vertx.ServerOptions; +import software.amazon.smithy.java.server.vertx.SmithyVertxServer; + +/** + * Quarkus {@link Recorder} that mounts every CDI-discovered + * {@link Service} bean on Quarkus's main Vert.x {@link Router} via + * {@link SmithyVertxServer}. + * + *

The recorder's job is: + *

    + *
  1. Collect {@code Service} beans from Arc.
  2. + *
  3. Load {@link ServerProtocol} providers via {@link ServiceLoader} + * on the thread-context classloader (Quarkus's + * {@code QuarkusClassLoader}, which sees runtime jars) plus the + * recorder's own classloader. Sort by precision.
  4. + *
  5. Translate {@link SmithyServerConfig} into {@link ServerOptions} + * and construct a {@link SmithyVertxServer}.
  6. + *
  7. Mount the server on Quarkus's main Vert.x {@link Router} as a + * single catch-all route under the configured prefix.
  8. + *
  9. Wire route removal and orchestrator drain into Quarkus's + * shutdown sequence so dev-mode hot reload removes the route + * cleanly and graceful shutdown drains workers.
  10. + *
+ * + *

Run-time {@link SmithyServerConfig} is injected via the recorder + * constructor as a {@link RuntimeValue}; build-step methods cannot + * consume run-time config directly (Quarkus enforces that distinction). + */ +@Recorder +public class SmithyVertxRecorder { + + private static final InternalLogger LOG = InternalLogger.getLogger(SmithyVertxRecorder.class); + + private final RuntimeValue config; + + public SmithyVertxRecorder(RuntimeValue config) { + this.config = config; + } + + public void mount( + RuntimeValue mainRouter, + ShutdownContext shutdown + ) { + + // Discover @Produces Service beans via CDI. Multi-Service + // composition is supported. + var instance = Arc.container() + .select( + new TypeLiteral() {}, + Any.Literal.INSTANCE); + + List services = new ArrayList<>(); + for (Service service : instance) { + services.add(service); + LOG.info( + "Discovered Smithy Service '{}' with {} operation(s)", + service.schema().id(), + service.getAllOperations().size()); + } + + if (services.isEmpty()) { + // Apps that depend on quarkus-smithy purely for codegen + // produce no `@Produces Service` beans. The server + // requires at least one; short-circuit so the extension + // is silent in that case. Codegen still ran earlier in + // the build pipeline. + LOG.info( + "No @Produces Service beans found. Skipping the Vert.x " + + "server mount; codegen-only apps will not see any " + + "Smithy operations on the HTTP router."); + return; + } + + List protocols = loadServerProtocols(services); + if (protocols.isEmpty()) { + throw new IllegalStateException( + "No ServerProtocol implementations found on the classpath. " + + "Add the protocol module(s) your services declare (e.g. " + + "aws-server-restjson, server-rpcv2-cbor) to the runtime " + + "classpath."); + } + + SmithyServerConfig cfg = config.getValue(); + var optionsBuilder = ServerOptions.builder() + .pathPrefix(cfg.pathPrefix().orElse("")); + cfg.workers().ifPresent(optionsBuilder::workerCount); + ServerOptions options = optionsBuilder.build(); + + SmithyVertxServer server = SmithyVertxServer.create(services, protocols, options); + + // Mount as a single catch-all route under the configured prefix. + Router router = mainRouter.getValue(); + String mountPath = options.pathPrefix().isEmpty() + ? null + : options.pathPrefix() + "/*"; + Route route = (mountPath == null ? router.route() : router.route(mountPath)) + .handler(BodyHandler.create()) + .handler(server); + + LOG.info( + "Smithy mounted at {} with {} service(s)", + options.pathPrefix().isEmpty() ? "/*" : options.pathPrefix() + "/*", + services.size()); + + // Hot reload + ordered shutdown. Quarkus's ShutdownContext runs + // tasks in *reverse* registration order (LIFO), so the last task + // registered runs first. We want: + // 1) route.remove() — stop accepting new requests + // 2) server.shutdown() — drain the orchestrator pool + // So register server.shutdown() FIRST (runs second) and + // route.remove() SECOND (runs first). + shutdown.addShutdownTask(() -> { + try { + server.shutdown().get(cfg.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException te) { + LOG.warn("Smithy server shutdown timed out after {}", cfg.shutdownGrace()); + } catch (Exception e) { + LOG.warn("Error during Smithy server shutdown", e); + } + }); + shutdown.addShutdownTask(route::remove); + } + + /** + * Discover {@link ServerProtocolProvider}s on the runtime classpath + * and instantiate each one with the recorder's services. The result + * is sorted ascending by {@link ServerProtocolProvider#precision()}, + * so the head of the list is the highest-precision protocol per the + * AWS service-protocol order in the Smithy 2.0 Wire-protocol-selection + * guide. + * + *

Resolves providers from both the thread-context classloader + * (Quarkus's {@code QuarkusClassLoader} at recorder time, which sees + * every runtime jar) and the recorder's own loader, deduped by + * provider class. Required because + * {@code ProtocolResolver}'s static SPI cache (keyed on its own + * classloader) does not see runtime protocol jars under Quarkus's + * partitioned classloader hierarchy. + */ + private static List loadServerProtocols(List services) { + // Single-thread safe: scoped to one mount() invocation, never published. + List providers = new ArrayList<>(); + Set> seen = new HashSet<>(); + ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + if (tccl != null) { + collectProviders(tccl, providers, seen); + } + ClassLoader own = SmithyVertxRecorder.class.getClassLoader(); + if (own != tccl) { + collectProviders(own, providers, seen); + } + providers.sort(Comparator.comparingInt(ServerProtocolProvider::precision)); + List out = new ArrayList<>(providers.size()); + for (ServerProtocolProvider provider : providers) { + out.add(provider.provideProtocolHandler(services)); + } + return Collections.unmodifiableList(out); + } + + private static void collectProviders( + ClassLoader cl, + List providers, + Set> seen + ) { + for (var provider : ServiceLoader.load(ServerProtocolProvider.class, cl)) { + if (seen.add(provider.getClass())) { + providers.add(provider); + } + } + } +} diff --git a/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/package-info.java b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/package-info.java new file mode 100644 index 0000000000..ac710d44e1 --- /dev/null +++ b/quarkus-smithy/src/main/java/software/amazon/smithy/java/quarkus/runtime/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +@SmithyUnstableApi +package software.amazon.smithy.java.quarkus.runtime; + +import software.amazon.smithy.utils.SmithyUnstableApi; diff --git a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/HttpResponseSerializer.java b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/HttpResponseSerializer.java new file mode 100644 index 0000000000..3606e0e5f3 --- /dev/null +++ b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/HttpResponseSerializer.java @@ -0,0 +1,104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.io.datastream.DataStream; + +/** + * Translates a completed {@link HttpJob} (or a failure that prevented + * it from completing) into the headers and body a transport writes + * back to the wire. Transports invoke this from their response-writing + * callback and translate the resulting {@link SerializedResponse} into + * their own response object (Netty {@code DefaultFullHttpResponse}, + * Vert.x {@code HttpServerResponse}, Servlet {@code HttpServletResponse}, + * etc.). + * +

Owns the status-code + header-copy + content-type/length logic + * shared between Netty's response writer and the Vert.x server's + * response writer. + * + *

The serialized body is exposed via {@link SerializedResponse#body()} + * as the original {@link DataStream} so transports that can write + * directly from a {@link java.nio.ByteBuffer} (Netty's + * {@code Unpooled.wrappedBuffer}) avoid the memcpy that + * {@code byte[]}-based transports (Vert.x's {@code Buffer.buffer(byte[])}) + * incur anyway. + */ +public final class HttpResponseSerializer { + + private HttpResponseSerializer() {} + + /** + * Build a wire-ready response from a job and (optional) failure. + * + *

    + *
  • {@code failure != null}: a 500 response with no headers + * and a {@code null} body. Transports typically log; this + * class does not.
  • + *
  • {@code failure == null}: status code from the job + * (defaulting to 200 if unset), headers copied from + * {@link HttpJob#response()}, content-type filled in from + * the serialized payload's media type if not already set, + * content-length filled in from the payload's byte length. + * Body is the payload's {@link DataStream}; may be + * {@code null} if the job set no serialized value.
  • + *
+ */ + public static SerializedResponse from(HttpJob job, Throwable failure) { + if (failure != null) { + return new SerializedResponse(500, HttpHeaders.ofModifiable(), null); + } + + // ArrayHttpHeaders.map() returns an immutable view; copy + // before mutation. Keys are already lowercased by the headers + // impl, so plain containsKey suffices for the auto-fill + // checks below. + Map> headersMap = new LinkedHashMap<>(job.response().headers().map()); + + int statusCode = job.response().getStatusCode(); + if (statusCode <= 0) { + statusCode = 200; + } + + DataStream body = job.response().getSerializedValue(); + if (body != null) { + // If the framework didn't already pin a content-type or + // content-length, take them from the serialized payload's + // metadata. + if (body.contentType() != null && !headersMap.containsKey("content-type")) { + addHeader(headersMap, "content-type", body.contentType()); + } + if (!headersMap.containsKey("content-length")) { + addHeader(headersMap, "content-length", Long.toString(body.contentLength())); + } + } + + return new SerializedResponse(statusCode, HttpHeaders.ofModifiable(headersMap), body); + } + + private static void addHeader(Map> headers, String name, String value) { + List values = new ArrayList<>(1); + values.add(value); + headers.put(name, values); + } + + /** + * The wire-ready shape of an HTTP response. Transports translate + * this into their own framework's response object — the wire + * version (HTTP/1.1 vs HTTP/2) is the transport's responsibility, + * not this record's. The {@code body} field is the serialized + * payload's underlying {@link DataStream} (or {@code null} if + * none); transports read it via + * {@link DataStream#asByteBuffer()} or + * {@link DataStream#asInputStream()}. + */ + public record SerializedResponse(int statusCode, HttpHeaders headers, DataStream body) {} +} diff --git a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ProtocolResolver.java b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ProtocolResolver.java index 80a18ea9bc..0dff5f316d 100644 --- a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ProtocolResolver.java +++ b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ProtocolResolver.java @@ -5,12 +5,54 @@ package software.amazon.smithy.java.server.core; -import java.util.*; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; import java.util.function.Function; import java.util.stream.Collectors; import software.amazon.smithy.java.framework.model.UnknownOperationException; import software.amazon.smithy.model.shapes.ShapeId; +/** + * Iterates a precision-ordered list of {@link ServerProtocol}s and returns + * the first that claims the request, per the + * Smithy 2.0 + * Wire protocol selection guide. + * + *

Two resolution methods are exposed for two caller styles: + * + *

    + *
  • {@link #resolve(ServiceProtocolResolutionRequest)} — throws + * {@link UnknownOperationException} when no protocol claims the request + * or when a protocol claims it but rejects it as malformed. + * Used by transports that always answer 404 in either case + * (e.g. {@code :server:server-netty}).
  • + *
  • {@link #resolveOrEmpty(ServiceProtocolResolutionRequest)} — distinguishes + * no-claim (returns {@link Optional#empty()}, leaving the caller + * free to delegate elsewhere) from claim-and-reject (still throws + * {@code UnknownOperationException}). Used by transports that share a + * router with non-Smithy handlers (e.g. {@code :server:server-vertx} + * falling through via {@code ctx.next()}).
  • + *
+ * + *

Two constructors are exposed for two SPI-loading regimes: + * + *

    + *
  • {@link #ProtocolResolver(ServiceMatcher)} — loads + * {@link ServerProtocolProvider}s from the static class-init + * {@link ServiceLoader} cache (rooted on this class's classloader). + * Suitable for transports that share a classloader with the + * protocol jars (e.g. {@code :server:server-netty} on a flat + * classpath).
  • + *
  • {@link #ProtocolResolver(ServiceMatcher, List)} — accepts a + * caller-supplied, precision-sorted protocol list. Suitable for + * container/framework deployments where runtime jars live in a + * different classloader than the resolver class + * (e.g. {@code :server:server-vertx} under Quarkus).
  • + *
+ */ public final class ProtocolResolver { private static final Map SERVER_PROTOCOL_HANDLERS = ServiceLoader.load( @@ -32,17 +74,60 @@ public ProtocolResolver(ServiceMatcher serviceMatcher) { this.serviceMatcher = serviceMatcher; } + /** + * Construct a resolver from a caller-supplied, precision-sorted list of + * {@link ServerProtocol} instances. Use this overload when the resolver's + * own static {@link ServiceLoader} cannot see the runtime protocol jars + * (e.g. under {@code QuarkusClassLoader}); the caller is responsible for + * loading the providers and sorting them by + * {@link ServerProtocolProvider#precision()} ascending. + */ + public ProtocolResolver(ServiceMatcher serviceMatcher, List serverProtocols) { + if (serverProtocols.isEmpty()) { + // A resolver constructed with no protocols would silently + // turn every request into Optional.empty (no-claim). Catch + // the misconfiguration at construction time instead. + throw new IllegalArgumentException("serverProtocols must not be empty"); + } + this.serverProtocolHandlers = List.copyOf(serverProtocols); + this.serviceMatcher = serviceMatcher; + } + public ServiceProtocolResolutionResult resolve(ServiceProtocolResolutionRequest request) { + return resolveOrEmpty(request).orElseThrow(() -> UnknownOperationException.builder() + .message("No matching operations found for request") + .build()); + } + + /** + * Resolve the request as in {@link #resolve}, but distinguish no + * protocol claimed it (returns {@link Optional#empty()}) from + * a protocol claimed it but rejected the input (throws + * {@link UnknownOperationException}). + * + *

The distinction relies on the + * {@link ServerProtocol#resolveOperation} contract: returning + * {@code null} means "this request is not mine"; throwing + * {@link UnknownOperationException} means "this request is mine but + * malformed". Per the Smithy 2.0 Wire-protocol-selection guide, a + * malformed claim is terminal — later protocols are not consulted — + * so the throw is propagated rather than swallowed. + * + *

Transports sharing a router with non-Smithy handlers use this + * method so that unrecognised requests can be forwarded (e.g. + * {@code ctx.next()}) instead of returning a misleading 404. + */ + public Optional resolveOrEmpty(ServiceProtocolResolutionRequest request) { var candidates = serviceMatcher.getCandidateServices(request); if (candidates.isEmpty()) { - throw UnknownOperationException.builder().message("No matching services found for request").build(); + return Optional.empty(); } for (ServerProtocol protocol : serverProtocolHandlers) { var resolutionResult = protocol.resolveOperation(request, candidates); if (resolutionResult != null) { - return resolutionResult; + return Optional.of(resolutionResult); } } - throw UnknownOperationException.builder().message("No matching operations found for request").build(); + return Optional.empty(); } } diff --git a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ServerProtocolProvider.java b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ServerProtocolProvider.java index 6924a836b0..3c1f754287 100644 --- a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ServerProtocolProvider.java +++ b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/ServerProtocolProvider.java @@ -9,11 +9,47 @@ import software.amazon.smithy.java.server.Service; import software.amazon.smithy.model.shapes.ShapeId; +/** + * SPI for {@link ServerProtocol} discovery via Java's + * {@link java.util.ServiceLoader}. Implementations are advertised via + * {@code META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider} + * resources in each protocol module. + */ public interface ServerProtocolProvider { ServerProtocol provideProtocolHandler(List candidateServices); ShapeId getProtocolId(); + /** + * Precedence rank for this protocol when multiple protocols are + * available on a single service. Lower values are higher + * precedence — sorted ascending by both + * {@link ProtocolResolver} and the Vert.x bridge, so the protocol + * with the smallest {@code precision()} value is tried first. + * + *

The AWS service-protocol precision order from the + * Smithy 2.0 + * Wire protocol selection guide is encoded as the + * canonical scale: + * + * + * + * + * + * + * + * + * + * + * + * + *
AWS protocol precedence values
Protocol{@code precision()}
{@code rpcv2Cbor}{@code 1}
{@code rpcv2Json}{@code 2}
{@code awsJson1_0}{@code 3}
{@code awsJson1_1}{@code 4}
{@code awsQuery}{@code 5}
{@code ec2Query}{@code 6}
{@code restJson1}{@code 7}
{@code restXml}{@code 8}
+ * + *

Third-party protocols not in the AWS list should pick a value + * that places them where they belong on this scale; ties are + * broken by classpath order, which is non-deterministic across + * builds and should be avoided. + */ int precision(); } diff --git a/server/server-core/src/test/java/software/amazon/smithy/java/server/core/HttpResponseSerializerTest.java b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/HttpResponseSerializerTest.java new file mode 100644 index 0000000000..af3ed1776b --- /dev/null +++ b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/HttpResponseSerializerTest.java @@ -0,0 +1,150 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.HttpResponseSerializer.SerializedResponse; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Tests for {@link HttpResponseSerializer}, covering the success path + * (status + headers + body), the failure path (500 with empty body), + * the no-payload path (no body), and the header-precedence rules + * (framework-set content-type/length wins over the serialized + * payload's defaults). + */ +public class HttpResponseSerializerTest { + + private static final ServerProtocol STUB_PROTOCOL = new StubProtocol(); + + private static HttpJob jobWithBody(byte[] body, String contentType) { + Operation op = TestStructs.createMockOperation("Op"); + var request = new HttpRequest(HttpHeaders.ofModifiable(), URI.create("http://localhost/"), "GET"); + var response = new HttpResponse(HttpHeaders.ofModifiable()); + response.setSerializedValue(DataStream.ofBytes(body, contentType)); + @SuppressWarnings({"rawtypes", "unchecked"}) + HttpJob job = new HttpJob((Operation) op, STUB_PROTOCOL, request, response); + return job; + } + + private static final class StubProtocol extends ServerProtocol { + StubProtocol() { + super(List.of()); + } + + @Override + public ShapeId getProtocolId() { + return ShapeId.from("test#Stub"); + } + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + return null; + } + + @Override + public CompletableFuture deserializeInput(Job job) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + return CompletableFuture.completedFuture(null); + } + } + + private static byte[] readBody(SerializedResponse sr) { + if (sr.body() == null) { + return new byte[0]; + } + var bb = sr.body().asByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.get(out); + return out; + } + + @Test + public void failureProducesFiveHundredWithNoBody() { + var sr = HttpResponseSerializer.from(jobWithBody("hello".getBytes(StandardCharsets.UTF_8), "text/plain"), + new RuntimeException("boom")); + assertThat(sr.statusCode()).isEqualTo(500); + assertThat(sr.body()).isNull(); + assertThat(sr.headers().map()).isEmpty(); + } + + @Test + public void successWithBodyEmitsStatusAndContentTypeAndLength() { + var job = jobWithBody("hello".getBytes(StandardCharsets.UTF_8), "text/plain"); + job.response().setStatusCode(200); + + var sr = HttpResponseSerializer.from(job, null); + assertThat(sr.statusCode()).isEqualTo(200); + assertThat(new String(readBody(sr), StandardCharsets.UTF_8)).isEqualTo("hello"); + assertThat(sr.headers().firstValue("content-type")).isEqualTo("text/plain"); + assertThat(sr.headers().firstValue("content-length")).isEqualTo("5"); + } + + @Test + public void successWithoutStatusDefaultsTo200() { + var job = jobWithBody(new byte[0], null); + + var sr = HttpResponseSerializer.from(job, null); + assertThat(sr.statusCode()).isEqualTo(200); + } + + @Test + public void successWithNoSerializedValueProducesNullBodyAndPreservesStatus() { + Operation op = TestStructs.createMockOperation("Op"); + var request = new HttpRequest(HttpHeaders.ofModifiable(), URI.create("http://localhost/"), "GET"); + var response = new HttpResponse(HttpHeaders.ofModifiable()); + response.setStatusCode(204); + @SuppressWarnings({"rawtypes", "unchecked"}) + HttpJob job = new HttpJob((Operation) op, STUB_PROTOCOL, request, response); + + var sr = HttpResponseSerializer.from(job, null); + assertThat(sr.statusCode()).isEqualTo(204); + assertThat(sr.body()).isNull(); + assertThat(sr.headers().firstValue("content-length")).isNull(); + } + + @Test + public void frameworkSetContentTypeWins() { + // A handler that pre-set a different content-type must not be + // overwritten by the serialized payload's media type. + var job = jobWithBody("hello".getBytes(StandardCharsets.UTF_8), "text/plain"); + job.response().setStatusCode(200); + job.response().headers().setHeader("content-type", "application/cbor"); + + var sr = HttpResponseSerializer.from(job, null); + assertThat(sr.headers().firstValue("content-type")).isEqualTo("application/cbor"); + } + + @Test + public void frameworkSetContentLengthWins() { + // A handler that pre-set content-length must not be + // overwritten by the serialized payload's byte length. + var job = jobWithBody("hello".getBytes(StandardCharsets.UTF_8), "text/plain"); + job.response().setStatusCode(200); + job.response().headers().setHeader("content-length", "100"); + + var sr = HttpResponseSerializer.from(job, null); + assertThat(sr.headers().firstValue("content-length")).isEqualTo("100"); + } +} diff --git a/server/server-core/src/test/java/software/amazon/smithy/java/server/core/ProtocolResolverTest.java b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/ProtocolResolverTest.java new file mode 100644 index 0000000000..74a8e38d18 --- /dev/null +++ b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/ProtocolResolverTest.java @@ -0,0 +1,266 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaIndex; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.framework.model.UnknownOperationException; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Tests for {@link ProtocolResolver}, covering both + * {@link ProtocolResolver#resolve(ServiceProtocolResolutionRequest)} and + * {@link ProtocolResolver#resolveOrEmpty(ServiceProtocolResolutionRequest)} + * across the three observable outcomes: claim-and-resolve, no-claim, and + * claim-and-reject. The Vert.x server relies on the second method's + * no-claim/reject distinction to decide between {@code ctx.next()} and 404. + */ +public class ProtocolResolverTest { + + private static ServiceProtocolResolutionRequest request() { + return new ServiceProtocolResolutionRequest( + URI.create("http://localhost/menu"), + new TestStructs.TestModifiableHttpHeaders(), + Context.create(), + "GET"); + } + + private static ServiceMatcher singleServiceMatcher(Service service) { + return new ServiceMatcher(List.of( + Route.builder().pathPrefix("/").services(List.of(service)).build())); + } + + /** A protocol that always returns null (= "this request is not mine"). */ + private static final class NeverClaimsProtocol extends ServerProtocol { + NeverClaimsProtocol() { + super(List.of()); + } + + @Override + public ShapeId getProtocolId() { + return ShapeId.from("test#NeverClaims"); + } + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + return null; + } + + @Override + public CompletableFuture deserializeInput(Job job) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + return CompletableFuture.completedFuture(null); + } + } + + /** + * A protocol that claims every request and returns a fixed result. + * The result's operation/service fields are not exercised in these + * tests; identity comparison on the protocol is enough. + */ + private static final class AlwaysClaimsProtocol extends ServerProtocol { + AlwaysClaimsProtocol() { + super(List.of()); + } + + @Override + public ShapeId getProtocolId() { + return ShapeId.from("test#AlwaysClaims"); + } + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + return new ServiceProtocolResolutionResult(candidates.get(0), null, this); + } + + @Override + public CompletableFuture deserializeInput(Job job) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + return CompletableFuture.completedFuture(null); + } + } + + /** + * A protocol that claims the request (it's mine) but throws because + * the input is malformed — the second of the two failure modes the + * resolver must distinguish. + */ + private static final class ClaimsAndRejectsProtocol extends ServerProtocol { + ClaimsAndRejectsProtocol() { + super(List.of()); + } + + @Override + public ShapeId getProtocolId() { + return ShapeId.from("test#ClaimsAndRejects"); + } + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + throw UnknownOperationException.builder().message("malformed").build(); + } + + @Override + public CompletableFuture deserializeInput(Job job) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + return CompletableFuture.completedFuture(null); + } + } + + private static class StubService implements Service { + @Override + public Operation getOperation(String operationName) { + return null; + } + + @Override + public List> getAllOperations() { + return List.of(); + } + + @Override + public Schema schema() { + return Schema.createService(ShapeId.from("test#Stub")); + } + + @Override + public TypeRegistry typeRegistry() { + return TypeRegistry.EMPTY; + } + + @Override + public SchemaIndex schemaIndex() { + throw new UnsupportedOperationException(); + } + } + + @Test + public void resolveReturnsResultOnClaim() { + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var protocol = new AlwaysClaimsProtocol(); + var resolver = new ProtocolResolver(matcher, List.of(protocol)); + + var result = resolver.resolve(request()); + + assertThat(result).isNotNull(); + assertThat(result.protocol()).isSameAs(protocol); + } + + @Test + public void resolveThrowsOnNoClaim() { + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var resolver = new ProtocolResolver(matcher, List.of(new NeverClaimsProtocol())); + + assertThatThrownBy(() -> resolver.resolve(request())) + .isInstanceOf(UnknownOperationException.class); + } + + @Test + public void resolveThrowsOnClaimAndReject() { + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var resolver = new ProtocolResolver(matcher, + List.of(new ClaimsAndRejectsProtocol())); + + assertThatThrownBy(() -> resolver.resolve(request())) + .isInstanceOf(UnknownOperationException.class) + .hasMessageContaining("malformed"); + } + + @Test + public void resolveOrEmptyReturnsResultOnClaim() { + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var protocol = new AlwaysClaimsProtocol(); + var resolver = new ProtocolResolver(matcher, List.of(protocol)); + + var result = resolver.resolveOrEmpty(request()); + + assertThat(result).isPresent(); + assertThat(result.get().protocol()).isSameAs(protocol); + } + + @Test + public void resolveOrEmptyReturnsEmptyOnNoClaim() { + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var resolver = new ProtocolResolver(matcher, + List.of(new NeverClaimsProtocol())); + + var result = resolver.resolveOrEmpty(request()); + + assertThat(result).isEmpty(); + } + + @Test + public void resolveOrEmptyPropagatesClaimAndRejectAsThrow() { + // The whole point of the tri-state distinction: claim-and-reject + // must NOT collapse into Optional.empty(). Callers downstream + // (e.g. the Vert.x bridge) rely on the throw to translate to 404 + // rather than ctx.next(). + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var resolver = new ProtocolResolver(matcher, + List.of(new ClaimsAndRejectsProtocol())); + + assertThatThrownBy(() -> resolver.resolveOrEmpty(request())) + .isInstanceOf(UnknownOperationException.class); + } + + @Test + public void resolveOrEmptyHonorsListOrderAndStopsOnFirstClaim() { + // The list is treated as precision-ordered. If the first protocol + // claims, no later protocol is consulted — and crucially, a + // claim-and-reject second protocol is never reached. + var service = new StubService(); + var matcher = singleServiceMatcher(service); + var winner = new AlwaysClaimsProtocol(); + var resolver = new ProtocolResolver(matcher, + List.of(winner, new ClaimsAndRejectsProtocol())); + + var result = resolver.resolveOrEmpty(request()); + + assertThat(result).isPresent(); + assertThat(result.get().protocol()).isSameAs(winner); + } +} diff --git a/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java b/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java index e97c62415f..8434cc3f48 100644 --- a/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java +++ b/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java @@ -5,6 +5,7 @@ package software.amazon.smithy.java.server.netty; +import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelDuplexHandler; @@ -23,6 +24,8 @@ import software.amazon.smithy.java.server.core.CorsHeaders; import software.amazon.smithy.java.server.core.HttpJob; import software.amazon.smithy.java.server.core.HttpResponse; +import software.amazon.smithy.java.server.core.HttpResponseSerializer; +import software.amazon.smithy.java.server.core.HttpResponseSerializer.SerializedResponse; import software.amazon.smithy.java.server.core.Orchestrator; import software.amazon.smithy.java.server.core.ProtocolResolver; import software.amazon.smithy.java.server.core.ServiceProtocolResolutionRequest; @@ -81,7 +84,10 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception .setDataStream( DataStream.ofBytes(bodyAccumulator.toByteArray(), job.request().headers().contentType())); - orchestrator.enqueue(job).whenCompleteAsync((r, t) -> writeResponse(channel, job), channel.eventLoop()); + orchestrator.enqueue(job) + .whenCompleteAsync( + (r, t) -> writeResponse(channel, job, t), + channel.eventLoop()); } } @@ -91,19 +97,35 @@ private void reset(Channel channel) { this.job = null; } - private void writeResponse(Channel channel, HttpJob job) { - var serializedValue = job.response().getSerializedValue(); - DefaultFullHttpResponse response = null; + private void writeResponse(Channel channel, HttpJob job, Throwable failure) { + DefaultFullHttpResponse response; try { + // CORS interceptor mutates the Smithy response headers in + // place; must run before HttpResponseSerializer reads them. + // Skip on failure: there is no operation result to interpret. + if (failure == null) { + CorsHeaders.addCorsHeaders(job); + } + SerializedResponse sr = HttpResponseSerializer.from(job, failure); + // Wire version is HTTP/1.1 because :server:server-netty's + // ServerChannelInitializer installs only HttpServerCodec + // (no HTTP/2 framing). The Vert.x bridge avoids this + // hardcode because HttpServerResponse preserves the + // version Vert.x parsed in. + // Zero-copy: wrap the DataStream's ByteBuffer directly + // instead of materialising bytes; matches the behaviour + // pre-Phase-4 (was Unpooled.wrappedBuffer(serializedValue.asByteBuffer())). + ByteBuf bodyBuf = sr.body() == null + ? Unpooled.EMPTY_BUFFER + : Unpooled.wrappedBuffer(sr.body().asByteBuffer()); response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, - HttpResponseStatus.valueOf(job.response().getStatusCode()), - Unpooled.wrappedBuffer(serializedValue.asByteBuffer())); - CorsHeaders.addCorsHeaders(job); - response.headers().set(((NettyHttpHeaders) job.response().headers()).getNettyHeaders()); - response.headers().set("content-length", serializedValue.contentLength()); - if (serializedValue.contentType() != null) { - response.headers().set("content-type", serializedValue.contentType()); + HttpResponseStatus.valueOf(sr.statusCode()), + bodyBuf); + for (var entry : sr.headers().map().entrySet()) { + for (String value : entry.getValue()) { + response.headers().add(entry.getKey(), value); + } } } catch (Throwable e) { response = new DefaultFullHttpResponse( diff --git a/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocolProvider.java b/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocolProvider.java index 3c1d1192c0..60fe332344 100644 --- a/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocolProvider.java +++ b/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocolProvider.java @@ -25,6 +25,6 @@ public ShapeId getProtocolId() { @Override public int precision() { - return 0; + return 1; } } diff --git a/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java index 481532606e..f4029ab005 100644 --- a/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java +++ b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java @@ -27,6 +27,6 @@ public ShapeId getProtocolId() { @Override public int precision() { - return 0; + return 2; } } diff --git a/server/server-vertx/build.gradle.kts b/server/server-vertx/build.gradle.kts new file mode 100644 index 0000000000..fb117359e1 --- /dev/null +++ b/server/server-vertx/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "Smithy Java server that runs on top of Vert.x" + +extra["displayName"] = "Smithy :: Java :: Server :: Vert.x" +extra["moduleName"] = "software.amazon.smithy.java.server.vertx" + +dependencies { + api(project(":server:server-core")) + implementation(project(":logging")) + implementation(project(":context")) + implementation(project(":http:http-api")) + implementation(project(":io")) + + api(libs.vertx.core) + api(libs.vertx.web) + + // The server supports restJson1 + rpcv2 protocols at test time. Production + // consumers add only the protocol jars they actually need. + testImplementation(project(":server:server-rpcv2-cbor")) + testImplementation(project(":server:server-rpcv2-json")) + testImplementation(project(":aws:server:aws-server-restjson")) + testImplementation(project(":codecs:json-codec")) + testImplementation(project(":codecs:cbor-codec")) + testImplementation(project(":core")) + testImplementation(project(":http:http-binding")) + testImplementation(libs.smithy.aws.traits) + testImplementation(libs.smithy.protocol.traits) + + testImplementation(libs.vertx.junit5) + testImplementation(libs.vertx.web.client) + testImplementation(libs.assertj.core) +} diff --git a/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/ServerOptions.java b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/ServerOptions.java new file mode 100644 index 0000000000..2a27cac42a --- /dev/null +++ b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/ServerOptions.java @@ -0,0 +1,95 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import java.util.Objects; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Tunable parameters for {@link SmithyVertxServer}. + * + *

Two knobs: + *

    + *
  • {@link #workerCount()} — orchestrator worker pool size. Default + * {@code Runtime.getRuntime().availableProcessors() * 2}.
  • + *
  • {@link #pathPrefix()} — applied to every operation's URI at + * routing time. Default {@code ""} (no prefix).
  • + *
+ * + *

This class is intentionally narrow. Adding fields requires an ADR; + * we want consumers to be able to read the surface in one screen and + * understand exactly what the server does on their behalf. + * + *

Shutdown deadline. {@link SmithyVertxServer#shutdown()} + * does not impose a deadline; bounding the wait is the caller's + * responsibility. The Quarkus recorder applies + * {@code quarkus.smithy.server.shutdown-grace}; other callers can wrap + * the returned future with {@code orTimeout(...)}. + */ +@SmithyUnstableApi +public final class ServerOptions { + + private static final ServerOptions DEFAULTS = builder().build(); + + private final int workerCount; + private final String pathPrefix; + + private ServerOptions(Builder b) { + this.workerCount = b.workerCount; + this.pathPrefix = b.pathPrefix; + } + + public static ServerOptions defaults() { + return DEFAULTS; + } + + public static Builder builder() { + return new Builder(); + } + + public int workerCount() { + return workerCount; + } + + public String pathPrefix() { + return pathPrefix; + } + + public static final class Builder { + private int workerCount = Runtime.getRuntime().availableProcessors() * 2; + private String pathPrefix = ""; + + private Builder() {} + + public Builder workerCount(int n) { + if (n <= 0) { + throw new IllegalArgumentException("workerCount must be > 0, got " + n); + } + this.workerCount = n; + return this; + } + + public Builder pathPrefix(String prefix) { + Objects.requireNonNull(prefix, "pathPrefix"); + // Normalize: a non-empty prefix must start with "/" and must + // not end with "/". Empty string means "no prefix". + if (prefix.isEmpty()) { + this.pathPrefix = ""; + return this; + } + String p = prefix.startsWith("/") ? prefix : "/" + prefix; + if (p.length() > 1 && p.endsWith("/")) { + p = p.substring(0, p.length() - 1); + } + this.pathPrefix = p; + return this; + } + + public ServerOptions build() { + return new ServerOptions(this); + } + } +} diff --git a/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/SmithyVertxServer.java b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/SmithyVertxServer.java new file mode 100644 index 0000000000..4377c75c89 --- /dev/null +++ b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/SmithyVertxServer.java @@ -0,0 +1,395 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import io.vertx.core.Context; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import software.amazon.smithy.java.framework.model.UnknownOperationException; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.logging.InternalLogger; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.ErrorHandlingOrchestrator; +import software.amazon.smithy.java.server.core.HandlerAssembler; +import software.amazon.smithy.java.server.core.HttpJob; +import software.amazon.smithy.java.server.core.HttpRequest; +import software.amazon.smithy.java.server.core.HttpResponse; +import software.amazon.smithy.java.server.core.HttpResponseSerializer; +import software.amazon.smithy.java.server.core.HttpResponseSerializer.SerializedResponse; +import software.amazon.smithy.java.server.core.OrchestratorGroup; +import software.amazon.smithy.java.server.core.ProtocolResolver; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServiceMatcher; +import software.amazon.smithy.java.server.core.ServiceProtocolResolutionRequest; +import software.amazon.smithy.java.server.core.ServiceProtocolResolutionResult; +import software.amazon.smithy.java.server.core.SingleThreadOrchestrator; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * A Smithy server that runs on top of Vert.x. Implements + * {@link Handler}{@code <}{@link RoutingContext}{@code >} so the + * Quarkus integration can mount it on Quarkus's main {@link Router} as + * a regular Vert.x route handler. + * + *

The server resolves the protocol per request via + * {@link ProtocolResolver}, iterating a precision-ordered list of + * {@link ServerProtocol}s. This is the spec-literal embodiment of + * Smithy 2.0 + * Wire protocol selection. + * + *

Resolution is tri-state: + *

    + *
  • claimed — a protocol returned a result. The server + * enqueues an {@link HttpJob} on its orchestrator and writes the + * response on completion.
  • + *
  • no-claim — every protocol returned {@code null}. The + * server calls {@code ctx.next()} so the request can be served by + * a sibling Vert.x handler (e.g. a Quarkus REST endpoint).
  • + *
  • claim-and-reject — a protocol threw + * {@link UnknownOperationException} (e.g. rpcv2 with a malformed + * URI). The server returns 404 directly; the request is not handed + * to other Vert.x handlers because it would be misinterpreted.
  • + *
+ * + *

The server accepts both HTTP/1.1 and HTTP/2 traffic; wire-version + * negotiation is delegated entirely to the Vert.x server hosting the + * router (so that ALPN, h2c upgrade, and TLS choices live with the + * server, not with us). Response framing flows through Vert.x's + * {@link HttpServerResponse}, preserving the version Vert.x parsed in. + * + *

Body buffering is the caller's responsibility — install Vert.x's + * {@code BodyHandler} upstream of this handler. {@code @streaming Blob} + * operations are not currently supported. + * + *

Lifecycle. The caller owns the {@link io.vertx.ext.web.Route} + * returned by Vert.x; remove it via {@code Route.remove()}. The server + * owns the orchestrator pool; drain it via {@link #shutdown()}. + */ +@SmithyUnstableApi +public final class SmithyVertxServer implements Handler { + + private static final InternalLogger LOG = InternalLogger.getLogger(SmithyVertxServer.class); + + private final List services; + private final ServerOptions options; + private final ProtocolResolver resolver; + private final OrchestratorGroup orchestrator; + private volatile CompletableFuture shutdownFuture; + + private SmithyVertxServer(List services, ServerOptions options, List protocols) { + this.services = List.copyOf(services); + this.options = options; + // A single trivial Route encompasses every service (no + // host/port/protocol/prefix discrimination), so + // ServiceMatcher.getCandidateServices returns every service for + // every request — mirroring server-netty's flat routing. + var matcher = new ServiceMatcher(List.of( + Route.builder() + .pathPrefix("/") + .services(this.services) + .build())); + this.resolver = new ProtocolResolver(matcher, protocols); + this.orchestrator = newOrchestrator(); + logMountSummary(protocols); + } + + /** + * Construct a server with default options. + * + * @param services the Smithy services to serve. Must be non-empty. + * @param protocols precision-sorted list of {@link ServerProtocol}s + * the caller has loaded (typically via {@code ServiceLoader}). + */ + public static SmithyVertxServer create(List services, List protocols) { + return create(services, protocols, ServerOptions.defaults()); + } + + /** + * Construct a server with custom options. + * + * @param services the Smithy services to serve. Must be non-empty. + * @param protocols precision-sorted list of {@link ServerProtocol}s + * the caller has loaded (typically via {@code ServiceLoader}). + * @param options tunable parameters. + */ + public static SmithyVertxServer create( + List services, + List protocols, + ServerOptions options + ) { + Objects.requireNonNull(services, "services"); + Objects.requireNonNull(protocols, "protocols"); + Objects.requireNonNull(options, "options"); + if (services.isEmpty()) { + throw new IllegalArgumentException( + "SmithyVertxServer requires at least one Service. " + + "Add @Produces Service beans (Quarkus) or pass them directly."); + } + if (protocols.isEmpty()) { + throw new IllegalArgumentException( + "SmithyVertxServer requires at least one ServerProtocol. " + + "Load providers via ServiceLoader, sort by precision(), and pass them in."); + } + return new SmithyVertxServer(services, options, protocols); + } + + @Override + public void handle(RoutingContext rc) { + try { + handleInternal(rc); + } catch (Throwable t) { + // Defensive top-level catch: a buggy protocol or a header + // wrapper that throws on a malformed input must not leave + // the Vert.x handler chain in an undefined state. Log and + // 500 — the user's request fails, the server keeps running. + LOG.error("Unhandled exception in Smithy server dispatch", t); + if (!rc.response().headWritten()) { + rc.response().setStatusCode(500).end(); + } + } + } + + /** + * Best-effort drain of the orchestrator. Subsequent calls return + * the same future (idempotent). + * + *

The returned future is the raw orchestrator drain future — + * it carries no built-in deadline and may remain pending if a + * worker is stuck. Callers MUST impose their own bound, e.g. via + * {@code shutdown().get(grace, TimeUnit.MILLISECONDS)} or + * {@code shutdown().orTimeout(...)}. The Quarkus recorder applies + * {@code quarkus.smithy.server.shutdown-grace} this way. + * + *

Caveat: the underlying {@link OrchestratorGroup}'s + * shutdown semantics are inherited from + * {@code SingleThreadOrchestrator.shutdown()}, which currently + * resolves immediately and relies on the worker being a daemon + * thread that stops with the JVM. This API contract is expected to + * tighten when {@code SingleThreadOrchestrator} implements proper + * drain semantics. + */ + public synchronized CompletableFuture shutdown() { + if (shutdownFuture == null) { + shutdownFuture = orchestrator.shutdown(); + } + return shutdownFuture; + } + + private void handleInternal(RoutingContext rc) { + Buffer body = rc.body() == null ? null : rc.body().buffer(); + byte[] bytes = body == null ? new byte[0] : body.getBytes(); + + URI uri = parseRequestUri(rc, options.pathPrefix()); + var requestHeaders = new VertxRequestHeaders(rc.request().headers()); + + // Capture the Vert.x context now (request-side, on the event + // loop) so the writeResponse callback — which runs on the + // orchestrator's worker thread — can hand the response writes + // back to the event loop. Vert.x's HttpServerResponse contract + // is that mutations must run on the request's Context. + Context vertxContext = Vertx.currentContext(); + + dispatch(requestHeaders, uri, rc.request().method().name(), bytes) + .whenComplete((job, t) -> handleCompletion(rc, vertxContext, job, t)); + } + + private CompletableFuture dispatch( + HttpHeaders requestHeaders, + URI uri, + String method, + byte[] body + ) { + var smithyRequest = new HttpRequest(requestHeaders, uri, method); + smithyRequest.setDataStream(DataStream.ofBytes(body, requestHeaders.contentType())); + + var resolutionRequest = new ServiceProtocolResolutionRequest( + uri, + requestHeaders, + smithyRequest.context(), + method); + + Optional resolved; + try { + resolved = resolver.resolveOrEmpty(resolutionRequest); + } catch (UnknownOperationException e) { + return CompletableFuture.failedFuture(e); + } + if (resolved.isEmpty()) { + return CompletableFuture.failedFuture(new NoMatchingProtocol()); + } + var result = resolved.get(); + + var smithyResponse = new HttpResponse(HttpHeaders.ofModifiable()); + + @SuppressWarnings({"rawtypes", "unchecked"}) + HttpJob job = new HttpJob( + (Operation) result.operation(), + result.protocol(), + smithyRequest, + smithyResponse); + + return orchestrator.enqueue(job).thenApply(v -> job); + } + + private static void handleCompletion(RoutingContext rc, Context vertxContext, HttpJob job, Throwable t) { + Throwable cause = unwrap(t); + // All three branches mutate the routing context (next() may run + // user-installed handlers that touch the response; + // setStatusCode/end mutate response state directly), so each + // must run on the request's Vert.x Context per + // HttpServerResponse's threading contract. + if (cause instanceof NoMatchingProtocol) { + runOnContext(vertxContext, rc::next); + return; + } + if (cause instanceof UnknownOperationException) { + runOnContext(vertxContext, () -> { + if (!rc.response().headWritten()) { + rc.response().setStatusCode(404).end(); + } + }); + return; + } + // claim-and-resolve (success) OR an orchestrator-side throw + // (cause != null). writeResponse handles both shapes. + runOnContext(vertxContext, () -> writeResponse(rc, job, cause)); + } + + private static Throwable unwrap(Throwable t) { + // The orchestrator.enqueue(...).thenApply(...) chain wraps user + // exceptions in a single CompletionException; peel it once so + // callers' instanceof checks see the real type. + return (t instanceof CompletionException ce && ce.getCause() != null) ? ce.getCause() : t; + } + + private static void runOnContext(Context vertxContext, Runnable r) { + // vertxContext is captured in handleInternal via Vertx.currentContext(), + // which is non-null when called from a Vert.x route handler — the + // only path that reaches this server. + vertxContext.runOnContext(v -> r.run()); + } + + private static URI parseRequestUri(RoutingContext rc, String pathPrefix) { + // Vert.x's request().uri() returns the path + query as written + // by the client. We reconstruct an absolute-ish URI so Smithy's + // protocol layer sees scheme/host/port consistently with the + // Netty path. The path prefix the server applied at mount time + // is stripped here so the protocol's matcher (which uses the + // model's raw `@http(uri:...)` traits) sees the operation's + // canonical path. + var req = rc.request(); + String stripped = stripPrefix(req.uri(), pathPrefix); + try { + String scheme = req.isSSL() ? "https" : "http"; + String authority = req.host() == null ? "localhost" : req.host(); + return new URI(scheme + "://" + authority + stripped); + } catch (URISyntaxException e) { + return URI.create(stripped); + } + } + + private static String stripPrefix(String rawUri, String pathPrefix) { + if (pathPrefix.isEmpty() || rawUri == null || rawUri.isEmpty()) { + return rawUri; + } + // pathPrefix is normalized to begin with "/" and not end with "/". + if (rawUri.startsWith(pathPrefix)) { + String tail = rawUri.substring(pathPrefix.length()); + return tail.isEmpty() ? "/" : tail; + } + return rawUri; + } + + private static void writeResponse(RoutingContext rc, HttpJob job, Throwable failure) { + var resp = rc.response(); + if (resp.ended()) { + return; + } + if (failure != null) { + LOG.error("Smithy operation {} failed; returning 500", + job == null ? "" : job.operation().name(), + failure); + } + try { + SerializedResponse sr = HttpResponseSerializer.from(job, failure); + resp.setStatusCode(sr.statusCode()); + for (var entry : sr.headers().map().entrySet()) { + for (String value : entry.getValue()) { + resp.headers().add(entry.getKey(), value); + } + } + if (sr.body() == null) { + resp.end(); + } else { + // Vert.x's Buffer constructors only accept byte[]; + // materialise the DataStream's ByteBuffer once. + var bb = sr.body().asByteBuffer(); + byte[] bytes = new byte[bb.remaining()]; + bb.get(bytes); + resp.end(Buffer.buffer(bytes)); + } + } catch (Throwable e) { + LOG.error("Failed to write Smithy response for operation {}", + job == null ? "" : job.operation().name(), + e); + if (!resp.headWritten()) { + resp.setStatusCode(500); + resp.end(); + } + } + } + + private OrchestratorGroup newOrchestrator() { + var handlers = new HandlerAssembler().assembleHandlers(services); + return new OrchestratorGroup( + options.workerCount(), + () -> new ErrorHandlingOrchestrator(new SingleThreadOrchestrator(handlers)), + OrchestratorGroup.Strategy.roundRobin()); + } + + private void logMountSummary(List protocols) { + int operationCount = 0; + for (Service s : services) { + operationCount += s.getAllOperations().size(); + } + var protocolIds = new ArrayList(protocols.size()); + for (ServerProtocol p : protocols) { + protocolIds.add(p.getProtocolId().toString()); + } + LOG.info( + "Smithy server constructed with {} service(s), {} operation(s); protocols (precision order): {}", + services.size(), + operationCount, + protocolIds); + } + + /** + * Marker exception completed when no protocol claimed a request. + * Translated by {@link #handleCompletion} to {@code ctx.next()}. + */ + private static final class NoMatchingProtocol extends RuntimeException { + private static final long serialVersionUID = 1L; + + NoMatchingProtocol() { + super("no protocol claimed the request"); + } + } +} diff --git a/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/VertxRequestHeaders.java b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/VertxRequestHeaders.java new file mode 100644 index 0000000000..673ca8eacf --- /dev/null +++ b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/VertxRequestHeaders.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import io.vertx.core.MultiMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import software.amazon.smithy.java.http.api.HttpHeaders; + +/** + * Read-only adapter from a Vert.x {@link MultiMap} (request headers) to + * Smithy's {@link HttpHeaders}. Header names are normalized to lowercase + * to match Smithy's case-insensitive contract. + */ +final class VertxRequestHeaders implements HttpHeaders { + + private final MultiMap delegate; + + VertxRequestHeaders(MultiMap delegate) { + this.delegate = delegate; + } + + @Override + public List allValues(String name) { + var values = delegate.getAll(name); + return values == null ? List.of() : List.copyOf(values); + } + + @Override + public String firstValue(String name) { + return delegate.get(name); + } + + @Override + public boolean hasHeader(String name) { + return delegate.contains(name); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public Map> map() { + Map> result = new LinkedHashMap<>(); + for (var name : delegate.names()) { + String lower = name.toLowerCase(Locale.ROOT); + List existing = result.get(lower); + if (existing == null) { + existing = new ArrayList<>(delegate.getAll(name)); + } else { + existing.addAll(delegate.getAll(name)); + } + result.put(lower, existing); + } + // Make values unmodifiable for the consumer. + Map> unmodifiable = new LinkedHashMap<>(result.size()); + for (var e : result.entrySet()) { + unmodifiable.put(e.getKey(), Collections.unmodifiableList(e.getValue())); + } + return Collections.unmodifiableMap(unmodifiable); + } +} diff --git a/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/package-info.java b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/package-info.java new file mode 100644 index 0000000000..8f1d356375 --- /dev/null +++ b/server/server-vertx/src/main/java/software/amazon/smithy/java/server/vertx/package-info.java @@ -0,0 +1,15 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Smithy Java server that runs on top of Vert.x. + * + *

Public API: {@link software.amazon.smithy.java.server.vertx.SmithyVertxServer}, + * {@link software.amazon.smithy.java.server.vertx.ServerOptions}. + */ +@SmithyUnstableApi +package software.amazon.smithy.java.server.vertx; + +import software.amazon.smithy.utils.SmithyUnstableApi; diff --git a/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/MenuFixture.java b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/MenuFixture.java new file mode 100644 index 0000000000..3690a4239a --- /dev/null +++ b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/MenuFixture.java @@ -0,0 +1,220 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import software.amazon.smithy.aws.traits.protocols.RestJson1Trait; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaIndex; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.ShapeDeserializer; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.model.pattern.UriPattern; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.traits.Trait; + +/** + * Hand-rolled test fixture: a tiny "Menu" service with one + * {@code GET /menu} operation. Avoids depending on smithy code + * generation in the Vert.x server module's tests. + */ +final class MenuFixture { + + private MenuFixture() {} + + static final ShapeId SERVICE_ID = ShapeId.from("test#Menu"); + static final ShapeId GET_MENU_ID = ShapeId.from("test#GetMenu"); + static final ShapeId GET_ORDER_ID = ShapeId.from("test#GetOrder"); + static final ShapeId PUT_ORDER_ID = ShapeId.from("test#PutOrder"); + + static MenuService menuService() { + return new MenuService(); + } + + /** Empty struct used as input/output for the Menu service's operations. */ + static final class EmptyStruct implements SerializableStruct { + static final EmptyStruct INSTANCE = new EmptyStruct(); + static final Schema SCHEMA = Schema.structureBuilder(ShapeId.from("test#Empty")).build(); + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public void serializeMembers(ShapeSerializer s) { + // no members + } + + @Override + public T getMemberValue(Schema member) { + return null; + } + + static final class Builder implements ShapeBuilder { + @Override + public EmptyStruct build() { + return INSTANCE; + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer d) { + d.readStruct(SCHEMA, this, (state, member, des) -> {}); + return this; + } + } + } + + /** + * Lightweight helper: an {@link ApiOperation} with an empty input + * and empty output, parameterized only by id and HTTP trait. Used + * to add multiple operations to the fixture without writing the + * full ApiOperation boilerplate per op. + */ + static final class StubApiOperation implements ApiOperation { + + private final Schema operationSchema; + + StubApiOperation(ShapeId id, HttpTrait http) { + this.operationSchema = Schema.createOperation(id, (Trait) http); + } + + @Override + public ShapeBuilder inputBuilder() { + return new EmptyStruct.Builder(); + } + + @Override + public ShapeBuilder outputBuilder() { + return new EmptyStruct.Builder(); + } + + @Override + public Schema schema() { + return operationSchema; + } + + @Override + public Schema inputSchema() { + return EmptyStruct.SCHEMA; + } + + @Override + public Schema outputSchema() { + return EmptyStruct.SCHEMA; + } + + @Override + public TypeRegistry errorRegistry() { + return TypeRegistry.builder().build(); + } + + @Override + public List effectiveAuthSchemes() { + return List.of(); + } + + @Override + public List errorSchemas() { + return List.of(); + } + + @Override + public ApiService service() { + return null; + } + } + + private static HttpTrait http(String method, String uri) { + return HttpTrait.builder() + .method(method) + .uri(UriPattern.parse(uri)) + .code(200) + .build(); + } + + static final class MenuService implements Service { + + private static final Schema SCHEMA = Schema.createService(SERVICE_ID, (Trait) RestJson1Trait.builder().build()); + + // Tracks which operations were invoked for tests that care. + final AtomicReference lastInvoked = + new AtomicReference<>(); + + private final Operation getMenu = Operation.of( + "GetMenu", + (input, ctx) -> { + lastInvoked.set("GetMenu"); + return EmptyStruct.INSTANCE; + }, + new StubApiOperation(GET_MENU_ID, http("GET", "/menu")), + this); + + private final Operation getOrder = Operation.of( + "GetOrder", + (input, ctx) -> { + lastInvoked.set("GetOrder"); + return EmptyStruct.INSTANCE; + }, + new StubApiOperation(GET_ORDER_ID, http("GET", "/order/{id}")), + this); + + private final Operation putOrder = Operation.of( + "PutOrder", + (input, ctx) -> { + lastInvoked.set("PutOrder"); + return EmptyStruct.INSTANCE; + }, + new StubApiOperation(PUT_ORDER_ID, http("PUT", "/order")), + this); + + @Override + @SuppressWarnings("unchecked") + public Operation getOperation( + String operationName + ) { + return (Operation) switch (operationName) { + case "GetMenu" -> getMenu; + case "GetOrder" -> getOrder; + case "PutOrder" -> putOrder; + default -> null; + }; + } + + @Override + public List> getAllOperations() { + return List.of(getMenu, getOrder, putOrder); + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public TypeRegistry typeRegistry() { + return TypeRegistry.builder().build(); + } + + @Override + public SchemaIndex schemaIndex() { + return SchemaIndex.compose(); + } + } +} diff --git a/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/PingFixture.java b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/PingFixture.java new file mode 100644 index 0000000000..09a11b2bd6 --- /dev/null +++ b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/PingFixture.java @@ -0,0 +1,177 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaIndex; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.ShapeDeserializer; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.protocol.traits.Rpcv2CborTrait; + +/** + * rpcv2Cbor-flavored service fixture with one no-input/no-output {@code Ping} + * operation. The bridge mounts it at {@code POST /service/Ping/operation/Ping}. + */ +final class PingFixture { + + private PingFixture() {} + + static final ShapeId SERVICE_ID = ShapeId.from("test#Ping"); + static final ShapeId PING_OP_ID = ShapeId.from("test#Ping"); + + static PingService pingService() { + return new PingService(); + } + + /** Empty struct for the rpcv2-cbor fixture. */ + static final class EmptyCbor implements SerializableStruct { + static final EmptyCbor INSTANCE = new EmptyCbor(); + static final Schema SCHEMA = Schema.structureBuilder(ShapeId.from("test#PingEmpty")).build(); + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public void serializeMembers(ShapeSerializer s) { + // empty + } + + @Override + public T getMemberValue(Schema member) { + return null; + } + + static final class Builder implements ShapeBuilder { + @Override + public EmptyCbor build() { + return INSTANCE; + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer d) { + d.readStruct(SCHEMA, this, (state, member, des) -> {}); + return this; + } + } + } + + static final class PingApiOperation implements ApiOperation { + static final PingApiOperation INSTANCE = new PingApiOperation(); + + private static final Schema OPERATION_SCHEMA = Schema.createOperation(PING_OP_ID); + + @Override + public ShapeBuilder inputBuilder() { + return new EmptyCbor.Builder(); + } + + @Override + public ShapeBuilder outputBuilder() { + return new EmptyCbor.Builder(); + } + + @Override + public Schema schema() { + return OPERATION_SCHEMA; + } + + @Override + public Schema inputSchema() { + return EmptyCbor.SCHEMA; + } + + @Override + public Schema outputSchema() { + return EmptyCbor.SCHEMA; + } + + @Override + public TypeRegistry errorRegistry() { + return TypeRegistry.builder().build(); + } + + @Override + public List effectiveAuthSchemes() { + return List.of(); + } + + @Override + public List errorSchemas() { + return List.of(); + } + + @Override + public ApiService service() { + return null; + } + } + + static final class PingService implements Service { + + private static final Schema SCHEMA = + Schema.createService(SERVICE_ID, (Trait) Rpcv2CborTrait.builder().build()); + + final AtomicReference lastInvoked = new AtomicReference<>(); + + private final Operation ping = Operation.of( + "Ping", + (input, ctx) -> { + lastInvoked.set("Ping"); + return EmptyCbor.INSTANCE; + }, + PingApiOperation.INSTANCE, + this); + + @Override + @SuppressWarnings("unchecked") + public Operation getOperation( + String operationName + ) { + if ("Ping".equals(operationName)) { + return (Operation) ping; + } + return null; + } + + @Override + public List> getAllOperations() { + return List.of(ping); + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public TypeRegistry typeRegistry() { + return TypeRegistry.builder().build(); + } + + @Override + public SchemaIndex schemaIndex() { + return SchemaIndex.compose(); + } + } +} diff --git a/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/SmithyVertxServerTest.java b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/SmithyVertxServerTest.java new file mode 100644 index 0000000000..755b851894 --- /dev/null +++ b/server/server-vertx/src/test/java/software/amazon/smithy/java/server/vertx/SmithyVertxServerTest.java @@ -0,0 +1,462 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.vertx; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.ext.web.handler.BodyHandler; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.ServiceLoader; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServerProtocolProvider; + +/** + * Integration tests for {@link SmithyVertxServer}. Each test spins up a + * real Vert.x HTTP server, mounts the Smithy server on its router as a + * Vert.x {@link Route}, and sends real HTTP requests via {@link WebClient}. + */ +@ExtendWith(VertxExtension.class) +class SmithyVertxServerTest { + + private Vertx vertx; + private HttpServer httpServer; + private WebClient client; + private Router router; + private int port; + + private SmithyVertxServer smithyServer; + private Route smithyRoute; + + @BeforeEach + void setUp(Vertx vertx, VertxTestContext ctx) throws InterruptedException { + this.vertx = vertx; + this.router = Router.router(vertx); + this.httpServer = vertx.createHttpServer(new HttpServerOptions().setPort(0)); + httpServer.requestHandler(router) + .listen() + .onComplete(ctx.succeedingThenComplete()); + ctx.awaitCompletion(5, TimeUnit.SECONDS); + this.port = httpServer.actualPort(); + this.client = WebClient.create(vertx); + } + + @AfterEach + void tearDown() throws Exception { + if (smithyRoute != null) { + smithyRoute.remove(); + smithyRoute = null; + } + if (smithyServer != null) { + smithyServer.shutdown().get(5, TimeUnit.SECONDS); + smithyServer = null; + } + if (client != null) { + client.close(); + } + if (httpServer != null) { + httpServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + + /** + * Mount a Smithy server on this test's router with default options. + * Stashes the constructed {@link SmithyVertxServer} and {@link Route} + * for {@link #tearDown()}. + */ + private void mount(List services) { + mount(services, ServerOptions.defaults(), this.router); + } + + private void mount(List services, ServerOptions options) { + mount(services, options, this.router); + } + + private void mount(List services, ServerOptions options, Router target) { + this.smithyServer = SmithyVertxServer.create(services, loadProtocols(services), options); + String mountPath = options.pathPrefix().isEmpty() + ? null + : options.pathPrefix() + "/*"; + var route = (mountPath == null ? target.route() : target.route(mountPath)) + .handler(BodyHandler.create()) + .handler(smithyServer); + if (target == this.router) { + this.smithyRoute = route; + } + } + + private static List loadProtocols(List services) { + var providers = new ArrayList(); + for (var p : ServiceLoader.load( + ServerProtocolProvider.class, + SmithyVertxServerTest.class.getClassLoader())) { + providers.add(p); + } + providers.sort(Comparator.comparingInt(ServerProtocolProvider::precision)); + var protocols = new ArrayList(providers.size()); + for (var p : providers) { + // Some protocols (notably restJson1) build their per-service + // matcher map at construction time and need the real service + // list to route by @http(uri) traits. + protocols.add(p.provideProtocolHandler(services)); + } + return protocols; + } + + @Test + void getMenuRespondsWithSerializedOutput(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + + client.get(port, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp -> ctx.verify(() -> { + assertThat(resp.statusCode()).isEqualTo(200); + // The operation must have been invoked end-to-end, + // not just the route mounted. + assertThat(menu.lastInvoked.get()).isEqualTo("GetMenu"); + ctx.completeNow(); + }))); + } + + @Test + void restJson1PathParametersAndMethodsRouteCorrectly(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + + // GET /order/abc must route to GetOrder (the @http(uri:"/order/{id}") + // operation, translated to Vert.x's /order/:id). + client.get(port, "localhost", "/order/abc") + .send() + .onComplete(ctx.succeeding(resp1 -> ctx.verify(() -> { + assertThat(resp1.statusCode()).isEqualTo(200); + assertThat(menu.lastInvoked.get()).isEqualTo("GetOrder"); + + // PUT /order must route to PutOrder (different method, + // overlapping path prefix with the labeled route). + client.put(port, "localhost", "/order") + .sendBuffer(Buffer.buffer("{}")) + .onComplete(ctx.succeeding(resp2 -> ctx.verify(() -> { + assertThat(resp2.statusCode()).isEqualTo(200); + assertThat(menu.lastInvoked.get()).isEqualTo("PutOrder"); + ctx.completeNow(); + }))); + }))); + } + + @Test + void rpcv2CborRequiresSmithyProtocolHeader(VertxTestContext ctx) { + var ping = PingFixture.pingService(); + mount(List.of(ping)); + + // Request WITHOUT smithy-protocol header: rpcv2-cbor's + // resolveOperation returns null (= "not mine"), so the server + // calls ctx.next(). With no other route installed on this + // test's router, Vert.x produces its default 404 — the + // observable status is still 404, but the source of the 404 + // changed (Vert.x default-route vs Smithy explicit reject). + client.post(port, "localhost", "/service/Ping/operation/Ping") + .sendBuffer(Buffer.buffer(new byte[0])) + .onComplete(ctx.succeeding(resp1 -> ctx.verify(() -> { + assertThat(resp1.statusCode()).isEqualTo(404); + + // Request WITH the correct smithy-protocol header → 200 + var emptyCborMap = Buffer.buffer(new byte[] {(byte) 0xa0}); + client.post(port, "localhost", "/service/Ping/operation/Ping") + .putHeader("smithy-protocol", "rpc-v2-cbor") + .putHeader("content-type", "application/cbor") + .sendBuffer(emptyCborMap) + .onComplete(ctx.succeeding(resp2 -> ctx.verify(() -> { + assertThat(resp2.statusCode()).isEqualTo(200); + assertThat(ping.lastInvoked.get()).isEqualTo("Ping"); + ctx.completeNow(); + }))); + }))); + } + + @Test + void rpcv2CborWithHeaderButMalformedUriReturns404(VertxTestContext ctx) { + // Claim-and-reject branch: a request with the rpc-v2-cbor + // header is unambiguously the rpcv2-cbor protocol's, so a + // malformed URI must NOT fall through via ctx.next() to a + // sibling Vert.x handler — it must 404 from the server directly. + var ping = PingFixture.pingService(); + mount(List.of(ping)); + + // Install a sentinel user route at the same path that would + // be reached if the server incorrectly fell through. + router.post("/service/Ping/operation/") + .handler(rc -> rc.response() + .setStatusCode(200) + .end("should-not-reach")); + + client.post(port, "localhost", "/service/Ping/operation/") + .putHeader("smithy-protocol", "rpc-v2-cbor") + .putHeader("content-type", "application/cbor") + .sendBuffer(Buffer.buffer(new byte[] {(byte) 0xa0})) + .onComplete(ctx.succeeding(resp -> ctx.verify(() -> { + assertThat(resp.statusCode()).isEqualTo(404); + assertThat(resp.bodyAsString()).isNullOrEmpty(); + ctx.completeNow(); + }))); + } + + @Test + void multipleServicesAcrossProtocolsCoexist(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + var ping = PingFixture.pingService(); + mount(List.of(menu, ping)); + + client.get(port, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp1 -> ctx.verify(() -> { + assertThat(resp1.statusCode()).isEqualTo(200); + + client.post(port, "localhost", "/service/Ping/operation/Ping") + .putHeader("smithy-protocol", "rpc-v2-cbor") + .putHeader("content-type", "application/cbor") + .sendBuffer(Buffer.buffer(new byte[] {(byte) 0xa0})) + .onComplete(ctx.succeeding(resp2 -> ctx.verify(() -> { + assertThat(resp2.statusCode()).isEqualTo(200); + assertThat(ping.lastInvoked.get()).isEqualTo("Ping"); + ctx.completeNow(); + }))); + }))); + } + + @Test + void http2RequestGetsHttp2Response(VertxTestContext ctx) throws Exception { + // Bring up a *separate* HTTP server with HTTP/2 (h2c) enabled, + // so we can verify the server does not hard-code HTTP/1.1 in + // its response framing. The setUp() server is HTTP/1-only. + var h2cRouter = Router.router(vertx); + var menu = MenuFixture.menuService(); + mount(List.of(menu), ServerOptions.defaults(), h2cRouter); + + var h2cServer = vertx.createHttpServer(new HttpServerOptions() + .setPort(0) + .setUseAlpn(false) + .setHttp2ClearTextEnabled(true) + .addEnabledSecureTransportProtocol("TLSv1.3")); + var listenFuture = h2cServer.requestHandler(h2cRouter).listen(); + listenFuture.toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + int h2cPort = h2cServer.actualPort(); + + var h2cClient = WebClient.create(vertx, + new WebClientOptions() + .setProtocolVersion(io.vertx.core.http.HttpVersion.HTTP_2) + .setHttp2ClearTextUpgrade(true)); + + h2cClient.get(h2cPort, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp -> ctx.verify(() -> { + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.version()).isEqualTo(io.vertx.core.http.HttpVersion.HTTP_2); + h2cClient.close(); + h2cServer.close() + .toCompletionStage() + .toCompletableFuture() + .whenComplete((v, t) -> ctx.completeNow()); + }))); + } + + @Test + void routeRemovalUnmountsTheServer(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + // Before remove, /menu hits the server. + client.get(port, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp1 -> ctx.verify(() -> { + assertThat(resp1.statusCode()).isEqualTo(200); + + smithyRoute.remove(); + smithyRoute = null; // tearDown won't try to remove it again + // After remove, /menu falls through; no other route + // installed, so Vert.x returns 404. + client.get(port, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp2 -> ctx.verify(() -> { + assertThat(resp2.statusCode()).isEqualTo(404); + ctx.completeNow(); + }))); + }))); + } + + @Test + void shutdownReturnsCompletableFutureAndIsIdempotent() throws Exception { + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + + var f1 = smithyServer.shutdown(); + f1.get(15, TimeUnit.SECONDS); + assertThat(f1).isDone(); + + // Calling shutdown again returns the *same* future (idempotent). + var f2 = smithyServer.shutdown(); + assertThat(f2).isSameAs(f1); + + smithyServer = null; + } + + @Test + void pathPrefixOptionPrependsAllOperationRoutes(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + var options = ServerOptions.builder().pathPrefix("/api").build(); + mount(List.of(menu), options); + + // Operation declared at @http(uri:"/menu") is now reachable at + // /api/menu, NOT at /menu. + client.get(port, "localhost", "/api/menu") + .send() + .onComplete(ctx.succeeding(resp1 -> ctx.verify(() -> { + assertThat(resp1.statusCode()).isEqualTo(200); + assertThat(menu.lastInvoked.get()).isEqualTo("GetMenu"); + + client.get(port, "localhost", "/menu") + .send() + .onComplete(ctx.succeeding(resp2 -> ctx.verify(() -> { + assertThat(resp2.statusCode()).isEqualTo(404); + ctx.completeNow(); + }))); + }))); + } + + @Test + void workerCountOptionAcceptsCustomSize() { + var menu = MenuFixture.menuService(); + var options = ServerOptions.builder().workerCount(2).build(); + mount(List.of(menu), options); + } + + @Test + void idleServerShutdownReturnsPromptly() throws Exception { + // The server's shutdown() returns the orchestrator's drain + // future as-is; bounding it is the caller's responsibility + // (the Quarkus recorder wraps with .get(grace, ms)). This test + // catches the regression where an idle server's drain blocks + // unboundedly — the actual property the server itself owns. + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + + long start = System.nanoTime(); + smithyServer.shutdown().get(5, TimeUnit.SECONDS); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + assertThat(elapsedMs).isLessThan(2_000L); + } + + /** + * Precision-ordering regression: every shipped + * {@code ServerProtocolProvider.precision()} originally returned + * {@code 0}, making the precision sort a no-op against classpath + * order. This test loads every provider visible to the test runtime + * and asserts they declare distinct, positive precisions. + */ + @Test + void shippedProvidersDeclareDistinctPositivePrecisions() { + var providers = ServiceLoader + .load(ServerProtocolProvider.class, SmithyVertxServerTest.class.getClassLoader()) + .stream() + .map(ServiceLoader.Provider::get) + .toList(); + assertThat(providers).as("expected at least rpcv2-cbor + restJson1 on the test classpath") + .hasSizeGreaterThanOrEqualTo(2); + var precisions = providers.stream() + .map(ServerProtocolProvider::precision) + .toList(); + assertThat(precisions) + .as("Providers with the same precision tie and resolve in classpath order") + .doesNotHaveDuplicates(); + assertThat(precisions).allSatisfy(p -> assertThat(p).isPositive()); + } + + /** + * Forward-compat invariant: ServerOptions intentionally exposes no + * interceptor field. Adding one later must remain a non-breaking, + * additive change. + */ + @Test + void serverOptionsHasNoInterceptorField() { + var declaredMethods = ServerOptions.Builder.class.getDeclaredMethods(); + for (var m : declaredMethods) { + String name = m.getName().toLowerCase(Locale.ROOT); + assertThat(name) + .as("ServerOptions.Builder method '%s' must not advertise an interceptor", m.getName()) + .doesNotContain("interceptor"); + } + } + + @Test + void rpcv2EnvelopeReachesOperationOnAnyServiceByName(VertxTestContext ctx) { + var menu = MenuFixture.menuService(); + mount(List.of(menu)); + + client.post(port, "localhost", "/service/Menu/operation/GetMenu") + .putHeader("smithy-protocol", "rpc-v2-cbor") + .putHeader("content-type", "application/cbor") + .sendBuffer(Buffer.buffer(new byte[] {(byte) 0xa0})) + .onComplete(ctx.succeeding(resp -> ctx.verify(() -> { + assertThat(menu.lastInvoked.get()).isEqualTo("GetMenu"); + ctx.completeNow(); + }))); + } + + @Test + void zeroServicesAreRejected() { + assertThatThrownBy(() -> SmithyVertxServer.create(List.of(), loadProtocols(List.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one Service"); + } + + @Test + void zeroProtocolsAreRejected() { + var menu = MenuFixture.menuService(); + assertThatThrownBy(() -> SmithyVertxServer.create(List.of(menu), List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one ServerProtocol"); + } + + @Test + void unrelatedPathFallsThroughToUserRoute(VertxTestContext ctx) { + // Install a user route on the same router *before* mounting + // the server — Vert.x route ordering is registration order. + router.get("/admin/health") + .handler(rc -> rc.response() + .setStatusCode(200) + .putHeader("content-type", "text/plain") + .end("user-handler-ok")); + + Service menu = MenuFixture.menuService(); + mount(List.of(menu)); + + client.get(port, "localhost", "/admin/health") + .send() + .onComplete(ctx.succeeding(resp -> ctx.verify(() -> { + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.bodyAsString()).isEqualTo("user-handler-ok"); + ctx.completeNow(); + }))); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ba398180bf..47ba4f11ab 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,13 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + + // The io.quarkus.extension Gradle plugin is used by the experimental + // quarkus-smithy module to generate Quarkus extension metadata. Pinned + // here so the version is consistent with the Quarkus BOM that module uses. + plugins { + id("io.quarkus.extension") version "3.35.3" + } } rootProject.name = "smithy-java" @@ -59,6 +66,7 @@ include(":client:client-metrics-otel") include(":server:server-api") include(":server:server-core") include(":server:server-netty") +include(":server:server-vertx") include(":server:server-rpcv2") include(":server:server-rpcv2-cbor") include(":server:server-rpcv2-json") @@ -68,6 +76,15 @@ include(":server:server-proxy") include(":codegen:codegen-core") include(":codegen:codegen-plugin") +// Experimental: Quarkus extension (mirrors the structure of quarkus-grpc-zero). +// Flat layout so the published artifact IDs match each project's Gradle name +// and the root build's dependency-substitution rule works for the example. +// Naming follows the canonical Quarkus convention: runtime artifact has no +// suffix (`quarkus-smithy`), deployment artifact has `-deployment` suffix. +include(":quarkus-smithy") +include(":quarkus-smithy-deployment") +include(":quarkus-smithy-integration-tests") + // Utilities include(":jmespath") include(":rulesengine") @@ -109,6 +126,15 @@ include(":examples:restjson-client") include(":examples:standalone-types") include(":examples:mcp-server") include(":examples:mcp-traits-example") +// :examples:quarkus-server is intentionally NOT included here. It is a +// standalone Gradle build that consumes smithy-java only via mavenLocal, +// matching how real customers will use the quarkus-smithy extension. +// Including it as a subproject causes Quarkus dev mode to substitute +// sibling smithy-java projects' raw `build/classes` for their published +// jars, which (a) bypasses json-codec's shadowJar that relocates Jackson 3, +// and (b) splits classloaders so SchemaExtensionKey ids drift between the +// non-reloadable and reloadable classloader buckets, breaking JSON serde. +// Run with: `cd examples/quarkus-server && gradle quarkusDev`. //MCP include(":mcp")