From 8ceea0ef86f45daceefbc1ba069a768ff6f7c8fe Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Fri, 7 Aug 2026 15:34:26 +0200 Subject: [PATCH 1/2] Update closure types example This updates the closure types example to show not only combined mode, but also types only and no types. Having every type in one example is useful to show how the same model may be used in multiple ways. This example will be referenced in a blog post announcing closure-based codegen, so keeping it all in one is particularly important. --- examples/closure-types/README.md | 72 ++++- examples/closure-types/build.gradle.kts | 65 ++--- .../closure-types/client/build.gradle.kts | 34 +++ .../closure-types/client/smithy-build.json | 12 + .../audubon/client/ClientGenerationTest.java | 34 +++ .../closure-types/consumer/build.gradle.kts | 13 + .../audubon/consumer/BandedBirdNotifier.java | 74 +++++ .../consumer/BandedBirdNotifierTest.java | 89 ++++++ examples/closure-types/model/events.smithy | 120 ++------ examples/closure-types/model/service.smithy | 263 +++++++----------- .../closure-types/server/build.gradle.kts | 38 +++ .../closure-types/server/smithy-build.json | 13 + .../audubon/server/BirdWatcherHandlers.java | 119 ++++++++ .../audubon/server/BirdWatcherService.java | 34 +++ .../audubon/server/EventPublisher.java | 43 +++ .../server/BirdWatcherServiceTest.java | 99 +++++++ .../example/audubon/server/FakeSnsClient.java | 29 ++ examples/closure-types/settings.gradle.kts | 13 +- examples/closure-types/smithy-build.json | 12 - .../closure/CombinedGenerationTest.java | 64 ----- examples/closure-types/types/build.gradle.kts | 33 +++ .../closure-types/types/smithy-build.json | 13 + .../audubon/events/EventTypesTest.java | 48 ++++ settings.gradle.kts | 4 + smithy-templates.json | 2 +- 25 files changed, 941 insertions(+), 399 deletions(-) create mode 100644 examples/closure-types/client/build.gradle.kts create mode 100644 examples/closure-types/client/smithy-build.json create mode 100644 examples/closure-types/client/src/test/java/com/example/audubon/client/ClientGenerationTest.java create mode 100644 examples/closure-types/consumer/build.gradle.kts create mode 100644 examples/closure-types/consumer/src/main/java/com/example/audubon/consumer/BandedBirdNotifier.java create mode 100644 examples/closure-types/consumer/src/test/java/com/example/audubon/consumer/BandedBirdNotifierTest.java create mode 100644 examples/closure-types/server/build.gradle.kts create mode 100644 examples/closure-types/server/smithy-build.json create mode 100644 examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherHandlers.java create mode 100644 examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherService.java create mode 100644 examples/closure-types/server/src/main/java/com/example/audubon/server/EventPublisher.java create mode 100644 examples/closure-types/server/src/test/java/com/example/audubon/server/BirdWatcherServiceTest.java create mode 100644 examples/closure-types/server/src/test/java/com/example/audubon/server/FakeSnsClient.java delete mode 100644 examples/closure-types/smithy-build.json delete mode 100644 examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java create mode 100644 examples/closure-types/types/build.gradle.kts create mode 100644 examples/closure-types/types/smithy-build.json create mode 100644 examples/closure-types/types/src/test/java/com/example/audubon/events/EventTypesTest.java diff --git a/examples/closure-types/README.md b/examples/closure-types/README.md index b6d37b185..4667624a1 100644 --- a/examples/closure-types/README.md +++ b/examples/closure-types/README.md @@ -1,16 +1,59 @@ -## Examples: Closure-Driven Combined Generation +## Examples: Closure-Driven Code Generation -This example demonstrates driving code generation from a shape closure defined in the model rather -than from a service shape alone. It uses "combined mode": a service is generated as a server, and -the data shapes in a modeled shape closure are generated alongside it. +This example drives code generation from a shape closure declared in the model instead of from a +service shape. A shape closure is a named set of shapes that does not have to be rooted in a service, +which makes it possible to generate types for shapes no operation refers to. Asynchronous events that +a service publishes are the motivating case. -The bird-watching service (`smithy.example.birds#iBird`) is generated as an RPC v2 CBOR server. The -`smithy.example.birds#fullService` closure includes the whole service namespace, so the event types -(such as `VerifiedSighting`) are generated alongside the server even though some are not reachable -from the service's operations. +The model describes a bird-watching club. `com.example.audubon#BirdWatcher` manages a +`SightingResource`, and publishes `SightingReported` and `SightingWithdrawn` events so subscribers +hear about changes without polling. Neither event appears in any operation, so neither is in the +service closure. Both are tagged `event`, and `model/events.smithy` declares a closure that selects +them by that tag: -The `closure` setting in `smithy-build.json` references the `shapeClosures` entry authored in the -model, so the closure definition travels with the model rather than living in build configuration. +```smithy +metadata shapeClosures = [ + { + id: "com.example.audubon#events" + includeBySelector: "structure[trait|tags|(values) = event]" + } +] +``` + +Four subprojects build from that one model, each showing a different way to configure the plugin: + +| Subproject | Modes | Closure | What it generates | +|---|---|---|---| +| `types` | `["types"]` | `com.example.audubon#events` | Only the event types. No service is involved, so no `service` setting is needed. | +| `server` | `["server", "types"]` | `com.example.audubon#all` | The service as a server, plus the event types it publishes. | +| `client` | `["client"]` | none | Only the service client. | +| `consumer` | none | none | Nothing. It depends on `types` for the event classes. | + +### types + +The shared package, and the reason for the feature. It generates the two event structures and the +shapes they reference, and nothing else. Publishing this as an artifact is what lets a producer and +its subscribers agree on a payload without either one regenerating it. + +### server + +Combined mode, which generates a service and standalone types together. The service is what publishes +the events, so it needs those types alongside its own. + +Combined mode requires the primary service to be a member of the closure it generates, so this +subproject drives generation from `com.example.audubon#all`, a closure over the whole namespace. +Pointing it at the events-only closure fails with a message saying the service is not part of it. + +### client + +A plain client build, driven by the service with no closure at all. The `closure` setting requires +`types` mode, so leaving both out is what makes this an ordinary client. A client calls the service +and has no reason to know about the events, so it neither generates them nor depends on them. + +### consumer + +A subscriber, which runs no code generator. It depends on `types` and decodes events with the same +classes the service used to encode them. ### Usage @@ -20,3 +63,12 @@ To use this example as a template, run the following command with the ```console smithy init -t closure-types --url git@github.com:smithy-lang/smithy-java.git ``` + +Then build it and run the tests: + +```console +cd closure-types +gradle build +``` + +Each subproject's tests show what its configuration produces. diff --git a/examples/closure-types/build.gradle.kts b/examples/closure-types/build.gradle.kts index 5c61633a7..c3fa94bc7 100644 --- a/examples/closure-types/build.gradle.kts +++ b/examples/closure-types/build.gradle.kts @@ -1,55 +1,26 @@ +/** + * Configuration shared by every subproject. The Smithy plugin itself is applied per + * subproject, since each one is configured by its own `smithy-build.json`. + */ +subprojects { + apply(plugin = "java-library") -plugins { - `java-library` - id("software.amazon.smithy.gradle.smithy-base") -} - -dependencies { - val smithyJavaVersion: String by project - - smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") - // Combined mode generates a server, so server-api must be on the codegen classpath (the plugin - // validates it) and on the runtime classpath for the generated server. - smithyBuild("software.amazon.smithy.java:server-api:$smithyJavaVersion") - api("software.amazon.smithy.java:server-api:$smithyJavaVersion") - // The RPC v2 CBOR server protocol the generated service is served with at runtime. - implementation("software.amazon.smithy.java:server-rpcv2-cbor:$smithyJavaVersion") - - testImplementation("org.hamcrest:hamcrest:3.0") - testImplementation("org.junit.jupiter:junit-jupiter:6.1.2") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.assertj:assertj-core:3.27.7") -} - -// Add the generated Java sources and resources to the main source set so they compile. -afterEvaluate { - val generatedPath = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() - sourceSets { - main { - java { - srcDir("$generatedPath/java") - } - resources { - srcDir("$generatedPath/resources") - } - } + repositories { + mavenLocal() + mavenCentral() } -} -tasks { - val smithyBuild by getting - compileJava { - dependsOn(smithyBuild) + the().toolchain { + languageVersion = JavaLanguageVersion.of(21) } - processResources { - dependsOn(smithyBuild) + + dependencies { + "testImplementation"("org.junit.jupiter:junit-jupiter:6.1.2") + "testRuntimeOnly"("org.junit.platform:junit-platform-launcher") + "testImplementation"("org.assertj:assertj-core:3.27.7") } - withType { + + tasks.withType { useJUnitPlatform() } } - -repositories { - mavenLocal() - mavenCentral() -} diff --git a/examples/closure-types/client/build.gradle.kts b/examples/closure-types/client/build.gradle.kts new file mode 100644 index 000000000..024014a86 --- /dev/null +++ b/examples/closure-types/client/build.gradle.kts @@ -0,0 +1,34 @@ +/** + * Generates a BirdWatcher client and nothing else. There is no `closure` setting, so + * generation is driven by the service in the usual way. A client calls the service and + * has no reason to know about the events it publishes. + */ + +plugins { + id("software.amazon.smithy.gradle.smithy-base") +} + +dependencies { + val smithyJavaVersion: String by project + val smithyVersion: String by project + + smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") + implementation("software.amazon.smithy:smithy-protocol-traits:$smithyVersion") + + // Client mode needs client-core on the codegen classpath and at runtime. + smithyBuild("software.amazon.smithy.java:client-core:$smithyJavaVersion") + api("software.amazon.smithy.java:client-core:$smithyJavaVersion") + + implementation("software.amazon.smithy.java:client-rpcv2-cbor:$smithyJavaVersion") +} + +// Compile the generated sources and package the generated resources, which hold the +// service file used to discover the schemas at runtime. +afterEvaluate { + val generated = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() + sourceSets["main"].java.srcDir("$generated/java") + sourceSets["main"].resources.srcDir("$generated/resources") +} + +tasks.compileJava { dependsOn(tasks.named("smithyBuild")) } +tasks.processResources { dependsOn(tasks.named("smithyBuild")) } diff --git a/examples/closure-types/client/smithy-build.json b/examples/closure-types/client/smithy-build.json new file mode 100644 index 000000000..0d7b56193 --- /dev/null +++ b/examples/closure-types/client/smithy-build.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "sources": ["../model"], + "plugins": { + "java-codegen": { + "service": "com.example.audubon#BirdWatcher", + "namespace": "com.example.audubon.client", + "headerFile": "../license.txt", + "modes": ["client"] + } + } +} diff --git a/examples/closure-types/client/src/test/java/com/example/audubon/client/ClientGenerationTest.java b/examples/closure-types/client/src/test/java/com/example/audubon/client/ClientGenerationTest.java new file mode 100644 index 000000000..a668ce0a4 --- /dev/null +++ b/examples/closure-types/client/src/test/java/com/example/audubon/client/ClientGenerationTest.java @@ -0,0 +1,34 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.audubon.client.client.BirdWatcherClient; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * A client generated from the service alone, with no {@code closure} setting and no + * {@code types} mode, so it gets the operation shapes and nothing else. + */ +class ClientGenerationTest { + + @Test + void generatesAMethodPerOperation() { + assertThat(BirdWatcherClient.class.getMethods()) + .extracting(Method::getName) + .contains("reportSighting", "getSighting", "listSightings", "withdrawSighting"); + } + + @Test + void generatesNoEventTypes() { + // No operation reaches the events and no closure is set, so they are absent. + assertThatThrownBy(() -> Class.forName("com.example.audubon.client.model.SightingReported")) + .isInstanceOf(ClassNotFoundException.class); + } +} diff --git a/examples/closure-types/consumer/build.gradle.kts b/examples/closure-types/consumer/build.gradle.kts new file mode 100644 index 000000000..b43e0942c --- /dev/null +++ b/examples/closure-types/consumer/build.gradle.kts @@ -0,0 +1,13 @@ +/** + * A subscriber, which runs no code generator. It depends on the `types` project for the + * generated event classes, which is the point of publishing them separately. + * + * The project is referenced relative to the parent so this works both standalone and + * when the example is built inside the smithy-java repository. + */ +dependencies { + val smithyJavaVersion: String by project + + implementation(project(parent!!.path + ":types")) + implementation("software.amazon.smithy.java:cbor-codec:$smithyJavaVersion") +} diff --git a/examples/closure-types/consumer/src/main/java/com/example/audubon/consumer/BandedBirdNotifier.java b/examples/closure-types/consumer/src/main/java/com/example/audubon/consumer/BandedBirdNotifier.java new file mode 100644 index 000000000..3c6636ada --- /dev/null +++ b/examples/closure-types/consumer/src/main/java/com/example/audubon/consumer/BandedBirdNotifier.java @@ -0,0 +1,74 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.consumer; + +import com.example.audubon.events.model.SightingReported; +import com.example.audubon.events.model.SightingWithdrawn; +import java.util.Base64; +import java.util.function.Consumer; +import java.util.function.Predicate; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.serde.Codec; + +/** + * Notifies an ornithologist when a banded bird is sighted and nobody has read its band + * yet. + * + *

Decodes events with the same generated types the service encoded them with, so + * nothing here parses a map or casts an untyped value. + */ +public final class BandedBirdNotifier { + + // Matches the publisher. + private static final Codec CODEC = Rpcv2CborCodec.builder().build(); + + private final Predicate hasBand; + private final Consumer sendSms; + + /** + * @param hasBand decides whether a photo shows a banded bird; a photo can show that + * a band is present but not what it says, since the code wraps around the leg. + */ + public BandedBirdNotifier(Predicate hasBand, Consumer sendSms) { + this.hasBand = hasBand; + this.sendSms = sendSms; + } + + /** + * Handles one message from the subscription. An SNS filter policy could reject + * unwanted events before they arrive. + * + * @param subject the event's shape name + * @param message a Base64-encoded CBOR payload + */ + public void onMessage(String subject, String message) { + byte[] payload = Base64.getDecoder().decode(message); + + switch (subject) { + case "SightingReported" -> onSightingReported( + CODEC.deserializeShape(payload, SightingReported.builder())); + case "SightingWithdrawn" -> onSightingWithdrawn( + CODEC.deserializeShape(payload, SightingWithdrawn.builder())); + // Ignoring unknown events keeps working when the service adds new ones. + } + } + + private void onSightingReported(SightingReported event) { + // Worth a trip only if a band is visible and nobody has read it yet. + if (event.getBandCode() != null || event.getPhotoUrl() == null + || !hasBand.test(event.getPhotoUrl())) { + return; + } + + sendSms.accept("Bird " + event.getBirdId() + " sighted at " + + event.getLocation().getLatitude() + ", " + event.getLocation().getLongitude() + + " wearing an unread band."); + } + + private void onSightingWithdrawn(SightingWithdrawn event) { + sendSms.accept("Sighting " + event.getSightingId() + " was withdrawn."); + } +} diff --git a/examples/closure-types/consumer/src/test/java/com/example/audubon/consumer/BandedBirdNotifierTest.java b/examples/closure-types/consumer/src/test/java/com/example/audubon/consumer/BandedBirdNotifierTest.java new file mode 100644 index 000000000..eb90b3ad6 --- /dev/null +++ b/examples/closure-types/consumer/src/test/java/com/example/audubon/consumer/BandedBirdNotifierTest.java @@ -0,0 +1,89 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.audubon.events.model.Coordinates; +import com.example.audubon.events.model.SightingReported; +import com.example.audubon.events.model.SightingWithdrawn; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.io.ByteBufferUtils; + +/** + * The subscriber decodes events with the shared generated types, and only alerts about + * birds worth a trip. + */ +class BandedBirdNotifierTest { + + private static final String BIRD = "bird-1"; + private static final String SIGHTING = "sighting-1"; + private static final Codec CODEC = Rpcv2CborCodec.builder().build(); + + private final List sent = new ArrayList<>(); + + private static SightingReported.Builder reported() { + return SightingReported.builder() + .sightingId(SIGHTING) + .birdId(BIRD) + .sightedAt(Instant.EPOCH) + .location(Coordinates.builder().latitude(1.0).longitude(2.0).build()) + .photoUrl("https://photos.example.com/audubon/1.jpg"); + } + + @Test + void alertsWhenAPhotoShowsAnUnreadBand() { + notifier(photoUrl -> true).onMessage("SightingReported", encode(reported().build())); + + assertThat(sent).hasSize(1); + assertThat(sent.get(0)).contains(BIRD).contains("unread band"); + } + + @Test + void staysQuietWhenThereIsNothingToGoSee() { + BandedBirdNotifier notifier = notifier(photoUrl -> true); + + // Already read, so there is no trip to make. + notifier.onMessage("SightingReported", encode(reported().bandCode("AB-1247").build())); + // No photo to inspect. + notifier.onMessage("SightingReported", + encode(SightingReported.builder() + .sightingId(SIGHTING) + .birdId(BIRD) + .sightedAt(Instant.EPOCH) + .location(Coordinates.builder().latitude(1.0).longitude(2.0).build()) + .build())); + // An event this subscriber does not handle. + notifier.onMessage("SomethingElse", encode(reported().build())); + + assertThat(sent).isEmpty(); + } + + @Test + void retractsAnAlertWhenTheSightingIsWithdrawn() { + notifier(photoUrl -> true).onMessage("SightingWithdrawn", + encode(SightingWithdrawn.builder().sightingId(SIGHTING).birdId(BIRD).build())); + + assertThat(sent).hasSize(1); + assertThat(sent.get(0)).contains("withdrawn"); + } + + private BandedBirdNotifier notifier(Predicate hasBand) { + return new BandedBirdNotifier(hasBand, sent::add); + } + + /** Encodes an event the way the service does. */ + private static String encode(SerializableStruct event) { + return ByteBufferUtils.base64Encode(CODEC.serialize(event)); + } +} diff --git a/examples/closure-types/model/events.smithy b/examples/closure-types/model/events.smithy index 17aff635d..f5afff5dc 100644 --- a/examples/closure-types/model/events.smithy +++ b/examples/closure-types/model/events.smithy @@ -1,120 +1,50 @@ $version: "2" metadata shapeClosures = [ - // A closure that only includes shapes tagged as events. { - id: "smithy.example.birds#events" - includeBySelector: "[trait|tags|(values) = event]" + id: "com.example.audubon#events" + includeBySelector: "structure[trait|tags|(values) = event]" } ] -namespace smithy.example.birds +namespace com.example.audubon -/// Reports an unclassified potential sighting of a bird from a video stream, -/// detected by a simple computer vision application. -/// -/// These events are sent to a queue where a more robust algorithm is applied -/// to verify the sighting and classify the species. -/// -/// This is an internal event. -@internal +/// Published when a member reports a new sighting. @tags(["event"]) -structure UnclassifiedStreamSighting { - /// The ID of the camera stream where the bird was detected. +@references([{resource: SightingResource}]) +structure SightingReported { + /// The sighting that was reported. @required - streamId: UUID + sightingId: Uuid - /// The timestamp of the stream where the bird was first detected. + /// The bird that was sighted. @required - start: Timestamp + birdId: Uuid - /// The timestamp of the stream where the bird was last detected. + /// When the bird was sighted. @required - end: Timestamp -} - -/// Reports a sighting from a stream that has been classified. -/// -/// If confidence is high, these may be automatically added to the list -/// of verified sightings. Otherwise these are sent to a queue for -/// review. -/// -/// This is an internal event. -@internal -@tags(["event"]) -structure ClassifiedStreamSighting { - /// The ID of the camera stream where the bird was detected. - @required - streamId: UUID - - /// The timestamp of the stream where the bird was first detected. - @required - start: Timestamp - - /// The timestamp of the stream where the bird was last detected. - @required - end: Timestamp + sightedAt: Timestamp - /// The proposed classification of the bird. + /// Where the bird was sighted. @required - classification: Classification + location: Coordinates - /// The confidence in the classification as a percentage. - @required - @range(min: 0, max: 100) - confidence: Float -} - -/// Reports an unverified sighting submitted by a user. -/// -/// These are sent to a queue for review. -/// -/// This is an internal event -@internal -@tags(["event"]) -@references([ - { - resource: Bird - } - { - resource: Sighting - } -]) -structure UnverifiedSighting for Sighting { - @required - $birdId + /// A URL to the photo submitted with the sighting, if there was one. + photoUrl: String - @required - $sightingId + /// The code on the bird's identification band, if the member read one. + bandCode: String } -// This is a public event. -/// Reports a verified sighting of a bird. +/// Published when a sighting is withdrawn. @tags(["event"]) -@references([ - { - resource: Bird - } - { - resource: Sighting - } -]) -structure VerifiedSighting for Sighting { - @required - $birdId - - @required - classification: Classification - - @required - $sightingId - - @required - $timestamp - +@references([{resource: SightingResource}]) +structure SightingWithdrawn { + /// The sighting that was withdrawn. @required - $location + sightingId: Uuid + /// The bird the withdrawn sighting referred to. @required - $verified + birdId: Uuid } diff --git a/examples/closure-types/model/service.smithy b/examples/closure-types/model/service.smithy index dc4d6e980..7ea01784e 100644 --- a/examples/closure-types/model/service.smithy +++ b/examples/closure-types/model/service.smithy @@ -1,255 +1,180 @@ $version: "2" metadata shapeClosures = [ - // A closure that includes every shape in the service namespace, - // including events. + // Everything in the namespace: the service and the events it publishes. Combined + // mode requires the primary service to be a member of the closure it generates, + // so the server subproject drives generation from this one. { - id: "smithy.example.birds#fullService" - includeNamespaces: ["smithy.example.birds"] + id: "com.example.audubon#all" + includeNamespaces: ["com.example.audubon"] } ] -namespace smithy.example.birds +namespace com.example.audubon use smithy.protocols#rpcv2Cbor -/// A service that tracks bird sightings for research purposes. +/// Tracks bird sightings reported by members of a bird-watching club. @rpcv2Cbor -@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize") -service iBird { +service BirdWatcher { + version: "2026-08-05" resources: [ - Bird + SightingResource + ] + errors: [ + SightingNotFound ] } -/// A resource representing the bird itself. -/// -/// These may only be created by the service operators. -resource Bird { +/// A single report of a bird, submitted by a club member. +resource SightingResource { identifiers: { - birdId: UUID + sightingId: Uuid } properties: { - classification: Classification + birdId: Uuid + sightedAt: Timestamp + location: Coordinates + photoUrl: String + bandCode: String } - resources: [ - Sighting - ] - create: CreateBird - read: GetBird - list: ListBirds + create: ReportSighting + read: GetSighting + list: ListSightings + delete: WithdrawSighting } -/// The taxonomic classification of a bird. -/// -/// Ranks above order are shared by all birds, so they are omitted. -structure Classification { +/// A sighting as the service stores and returns it. +structure Sighting for SightingResource { + /// The identifier assigned to this sighting. @required - order: NonEmptyString + $sightingId + /// The bird that was sighted. @required - family: NonEmptyString + $birdId + /// When the bird was sighted. @required - genus: NonEmptyString + $sightedAt + /// Where the bird was sighted. @required - species: NonEmptyString - - subspecies: NonEmptyString -} - -/// Adds a bird to the database. This is for internal use only. -@internal -operation CreateBird { - input := for Bird { - @required - $classification - } -} - -/// Retrieves information about a specific bird. -@readonly -operation GetBird { - input := for Bird { - @required - $birdId - } - - output := for Bird { - @required - $birdId - - @required - $classification - } -} - -/// Lists birds present in the database. -@paginated(items: "birds") -@readonly -operation ListBirds { - input := with [PaginatedInput] {} - - output := with [PaginatedOutput] { - @required - birds: BirdList - } -} - -list BirdList { - member: BirdSummary -} + $location -/// A summary of a bird's properties. -structure BirdSummary for Bird { - $birdId - $classification -} + /// A URL to the photo submitted with the sighting, if there was one. + $photoUrl -/// A resource representing a bird sighting. -/// -/// These may be created either by user submission or by automated monitoring -/// systems. Sightings are verified before appearing in listings. -resource Sighting { - identifiers: { - birdId: UUID - sightingId: UUID - } - properties: { - timestamp: Timestamp - location: Coordinates - image: Image - verified: Boolean - } - create: CreateSighting - read: GetSighting - list: ListSightings + /// The code on the bird's identification band, if the member read one. + $bandCode } -/// Creates a sighting. -operation CreateSighting { - input := for Sighting { +/// Records a member's sighting of a bird. +operation ReportSighting { + input := for SightingResource { + /// The bird that was sighted. @required $birdId + /// When the bird was sighted. @required - $timestamp + $sightedAt + /// Where the bird was sighted. @required $location - @required - image: Image + /// A photo of the bird. + @notProperty + photo: Photo - // For internal use only. Automated sightings from a stream may set - // this to true if their confidence is high. - @internal - verified: Boolean + /// The code on the bird's identification band, if the member read one. + $bandCode } - output := for Sighting { + output := for SightingResource { + /// The identifier assigned to the new sighting. @required $sightingId } } -/// Gets a sighting. -/// -/// Unverified sightings may be retrieved here, even if they don't appear in -/// listings. +/// Retrieves a single sighting. @readonly operation GetSighting { - input := for Sighting { - @required - $birdId - + input := for SightingResource { @required $sightingId } - output := for Sighting { + output := for SightingResource { @required - $timestamp + $sightingId @required - $location + $birdId @required - $image + $sightedAt @required - $verified + $location + + $photoUrl + + $bandCode } } -/// List verified sightings for a particular bird. -@paginated(items: "sightings") +/// Lists every sighting. @readonly operation ListSightings { - input := for Bird with [PaginatedInput] { + input := {} + + output := { @required - $birdId + sightings: Sightings } +} - output := with [PaginatedOutput] { +/// Withdraws a sighting that was reported in error. +/// +/// The sighting is deleted. Subscribers that acted on it find out through the +/// `SightingWithdrawn` event. +@idempotent +operation WithdrawSighting { + input := for SightingResource { @required - sightings: SightingSummaryList + $sightingId } -} -/// Geographical coordinates from where a sighting took place. -structure Coordinates { - latitude: BigDecimal - longitude: BigDecimal + output := {} } -list SightingSummaryList { - member: SightingSummary +list Sightings { + member: Sighting } -/// A summary of a sighting's properties. -structure SightingSummary for Sighting { - @required - $birdId - - @required - $sightingId - +/// Returned when no sighting has the requested identifier. +@error("client") +structure SightingNotFound { @required - $timestamp + message: String +} +/// Where a sighting took place. +structure Coordinates { @required - $location + latitude: Double @required - $verified -} - -// A mixin to share input pagination parameters. -@mixin -@private -structure PaginatedInput { - nextToken: NonEmptyString - - @range(min: 1, max: 1000) - pageSize: Integer = 100 + longitude: Double } -// A mixin to share output pagination parameters. -@mixin -@private -structure PaginatedOutput { - nextToken: NonEmptyString -} - -/// A UUID-v4 string. +/// A UUID, used for every identifier in this model. @pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") -string UUID - -@length(min: 1) -string NonEmptyString +string Uuid -/// A JPEG image. +/// A JPEG photo of a bird. @mediaType("image/jpeg") -blob Image +blob Photo diff --git a/examples/closure-types/server/build.gradle.kts b/examples/closure-types/server/build.gradle.kts new file mode 100644 index 000000000..4f94a94ee --- /dev/null +++ b/examples/closure-types/server/build.gradle.kts @@ -0,0 +1,38 @@ +/** + * Combined mode: the service generates as a server, and the event types from the + * closure generate alongside it. The service publishes those events, so it needs them + * even though no operation refers to them. + */ + +plugins { + id("software.amazon.smithy.gradle.smithy-base") +} + +dependencies { + val smithyJavaVersion: String by project + val smithyVersion: String by project + + smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") + implementation("software.amazon.smithy:smithy-protocol-traits:$smithyVersion") + + // Server mode needs server-api on the codegen classpath and at runtime. + smithyBuild("software.amazon.smithy.java:server-api:$smithyJavaVersion") + api("software.amazon.smithy.java:server-api:$smithyJavaVersion") + + implementation("software.amazon.smithy.java:server-rpcv2-cbor:$smithyJavaVersion") + implementation("software.amazon.smithy.java:cbor-codec:$smithyJavaVersion") + + // Publishes events to an SNS topic. + implementation("software.amazon.awssdk:sns:2.47.6") +} + +// Compile the generated sources and package the generated resources, which hold the +// service file used to discover the schemas at runtime. +afterEvaluate { + val generated = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() + sourceSets["main"].java.srcDir("$generated/java") + sourceSets["main"].resources.srcDir("$generated/resources") +} + +tasks.compileJava { dependsOn(tasks.named("smithyBuild")) } +tasks.processResources { dependsOn(tasks.named("smithyBuild")) } diff --git a/examples/closure-types/server/smithy-build.json b/examples/closure-types/server/smithy-build.json new file mode 100644 index 000000000..e852d7019 --- /dev/null +++ b/examples/closure-types/server/smithy-build.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "sources": ["../model"], + "plugins": { + "java-codegen": { + "service": "com.example.audubon#BirdWatcher", + "namespace": "com.example.audubon.server", + "headerFile": "../license.txt", + "modes": ["server", "types"], + "closure": "com.example.audubon#all" + } + } +} diff --git a/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherHandlers.java b/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherHandlers.java new file mode 100644 index 000000000..0c53ba0dc --- /dev/null +++ b/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherHandlers.java @@ -0,0 +1,119 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.server; + +import com.example.audubon.server.model.GetSightingOutput; +import com.example.audubon.server.model.ListSightingsOutput; +import com.example.audubon.server.model.ReportSightingOutput; +import com.example.audubon.server.model.Sighting; +import com.example.audubon.server.model.SightingNotFound; +import com.example.audubon.server.model.SightingReported; +import com.example.audubon.server.model.SightingWithdrawn; +import com.example.audubon.server.model.WithdrawSightingOutput; +import com.example.audubon.server.service.GetSightingOperation; +import com.example.audubon.server.service.ListSightingsOperation; +import com.example.audubon.server.service.ReportSightingOperation; +import com.example.audubon.server.service.WithdrawSightingOperation; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Implementations of the four BirdWatcher operations. + * + *

Sightings live in a map. A real service would use a durable store such as Amazon + * DynamoDB, keyed on the sighting ID with a secondary index on {@code birdId}. + */ +public final class BirdWatcherHandlers { + + private final Map sightings = new ConcurrentHashMap<>(); + private final EventPublisher events; + + public BirdWatcherHandlers(EventPublisher events) { + this.events = events; + } + + /** + * Stores the sighting and announces it. No operation returns + * {@code SightingReported}, so without a shape closure it would have no type. + */ + public ReportSightingOperation reportSighting() { + return (input, context) -> { + String sightingId = UUID.randomUUID().toString(); + + // A real service would upload to Amazon S3. Events carry the URL, not bytes. + String photoUrl = input.getPhoto() == null + ? null + : "https://photos.example.com/audubon/" + sightingId + ".jpg"; + + Sighting sighting = Sighting.builder() + .sightingId(sightingId) + .birdId(input.getBirdId()) + .sightedAt(input.getSightedAt()) + .location(input.getLocation()) + .photoUrl(photoUrl) + .bandCode(input.getBandCode()) + .build(); + sightings.put(sightingId, sighting); + + events.publish(SightingReported.builder() + .sightingId(sightingId) + .birdId(sighting.getBirdId()) + .sightedAt(sighting.getSightedAt()) + .location(sighting.getLocation()) + .photoUrl(sighting.getPhotoUrl()) + .bandCode(sighting.getBandCode()) + .build()); + + return ReportSightingOutput.builder().sightingId(sightingId).build(); + }; + } + + public GetSightingOperation getSighting() { + return (input, context) -> { + Sighting sighting = require(input.getSightingId()); + return GetSightingOutput.builder() + .sightingId(sighting.getSightingId()) + .birdId(sighting.getBirdId()) + .sightedAt(sighting.getSightedAt()) + .location(sighting.getLocation()) + .photoUrl(sighting.getPhotoUrl()) + .bandCode(sighting.getBandCode()) + .build(); + }; + } + + public ListSightingsOperation listSightings() { + return (input, context) -> ListSightingsOutput.builder() + .sightings(List.copyOf(sightings.values())) + .build(); + } + + /** Deletes the sighting and announces it, so subscribers can discard it. */ + public WithdrawSightingOperation withdrawSighting() { + return (input, context) -> { + Sighting withdrawn = require(input.getSightingId()); + sightings.remove(withdrawn.getSightingId()); + + events.publish(SightingWithdrawn.builder() + .sightingId(withdrawn.getSightingId()) + .birdId(withdrawn.getBirdId()) + .build()); + + return WithdrawSightingOutput.builder().build(); + }; + } + + /** Returns the sighting, or throws the modeled error. */ + private Sighting require(String sightingId) { + Sighting sighting = sightings.get(sightingId); + if (sighting == null) { + throw SightingNotFound.builder().message("No sighting " + sightingId).build(); + } + return sighting; + } +} diff --git a/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherService.java b/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherService.java new file mode 100644 index 000000000..c6b04b667 --- /dev/null +++ b/examples/closure-types/server/src/main/java/com/example/audubon/server/BirdWatcherService.java @@ -0,0 +1,34 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.server; + +import com.example.audubon.server.service.BirdWatcher; +import software.amazon.awssdk.services.sns.SnsClient; + +/** + * Assembles the generated service from the operation implementations. + */ +public final class BirdWatcherService { + + private BirdWatcherService() {} + + /** + * Builds the service, publishing events to the given SNS topic. Serve the result + * with a smithy-java server such as Netty or an AWS Lambda endpoint. + */ + public static BirdWatcher create(SnsClient sns, String topicArn) { + return create(new BirdWatcherHandlers(new EventPublisher(sns, topicArn))); + } + + static BirdWatcher create(BirdWatcherHandlers handlers) { + return BirdWatcher.builder() + .addGetSightingOperation(handlers.getSighting()) + .addListSightingsOperation(handlers.listSightings()) + .addReportSightingOperation(handlers.reportSighting()) + .addWithdrawSightingOperation(handlers.withdrawSighting()) + .build(); + } +} diff --git a/examples/closure-types/server/src/main/java/com/example/audubon/server/EventPublisher.java b/examples/closure-types/server/src/main/java/com/example/audubon/server/EventPublisher.java new file mode 100644 index 000000000..6c27ca8f7 --- /dev/null +++ b/examples/closure-types/server/src/main/java/com/example/audubon/server/EventPublisher.java @@ -0,0 +1,43 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.server; + +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.io.ByteBufferUtils; + +/** + * Publishes generated event types to an SNS topic. + * + *

Events are ordinary generated structures, so one method serializes any of them. + */ +public final class EventPublisher { + + // Matches the protocol the service speaks. + private static final Codec CODEC = Rpcv2CborCodec.builder().build(); + + private final SnsClient sns; + private final String topicArn; + + public EventPublisher(SnsClient sns, String topicArn) { + this.sns = sns; + this.topicArn = topicArn; + } + + /** Serializes an event to CBOR and publishes it. */ + public void publish(SerializableStruct event) { + sns.publish(PublishRequest.builder() + .topicArn(topicArn) + // An SNS message body must be a UTF-8 string, so CBOR needs encoding. + .message(ByteBufferUtils.base64Encode(CODEC.serialize(event))) + // Lets subscribers filter without decoding the body. + .subject(event.schema().id().getName()) + .build()); + } +} diff --git a/examples/closure-types/server/src/test/java/com/example/audubon/server/BirdWatcherServiceTest.java b/examples/closure-types/server/src/test/java/com/example/audubon/server/BirdWatcherServiceTest.java new file mode 100644 index 000000000..45914b0f0 --- /dev/null +++ b/examples/closure-types/server/src/test/java/com/example/audubon/server/BirdWatcherServiceTest.java @@ -0,0 +1,99 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.audubon.server.model.Coordinates; +import com.example.audubon.server.model.GetSightingInput; +import com.example.audubon.server.model.ListSightingsInput; +import com.example.audubon.server.model.ReportSightingInput; +import com.example.audubon.server.model.SightingNotFound; +import com.example.audubon.server.model.SightingReported; +import com.example.audubon.server.model.SightingWithdrawn; +import com.example.audubon.server.model.WithdrawSightingInput; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.server.Service; + +/** + * Reporting and withdrawing a sighting publish events, using types the shape closure + * brought into generation. + */ +class BirdWatcherServiceTest { + + private static final String BIRD = "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + private static final Codec CODEC = Rpcv2CborCodec.builder().build(); + + private final List published = new ArrayList<>(); + private final BirdWatcherHandlers handlers = new BirdWatcherHandlers( + new EventPublisher(new FakeSnsClient(published), "arn:aws:sns:us-west-2:1:sightings")); + + private String report() { + return handlers.reportSighting().reportSighting( + ReportSightingInput.builder() + .birdId(BIRD) + .sightedAt(Instant.parse("2026-08-05T14:32:00Z")) + .location(Coordinates.builder().latitude(47.6062).longitude(-122.3321).build()) + .build(), + null) + .getSightingId(); + } + + @Test + void buildsAServiceFromTheGeneratedInterfaces() { + assertThat(BirdWatcherService.create(handlers)).isInstanceOf(Service.class); + } + + @Test + void reportingASightingStoresItAndPublishesAnEvent() { + String id = report(); + + assertThat(handlers.getSighting() + .getSighting(GetSightingInput.builder().sightingId(id).build(), null) + .getBirdId()) + .isEqualTo(BIRD); + assertThat(handlers.listSightings() + .listSightings(ListSightingsInput.builder().build(), null) + .getSightings()) + .hasSize(1); + + // Subscribers filter on the subject without decoding the body. + assertThat(published).hasSize(1); + assertThat(published.get(0).subject()).isEqualTo("SightingReported"); + assertThat(decode(SightingReported.builder()).getLocation().getLatitude()).isEqualTo(47.6062); + } + + @Test + void withdrawingASightingDeletesItAndPublishesAnEvent() { + String id = report(); + published.clear(); + + handlers.withdrawSighting() + .withdrawSighting(WithdrawSightingInput.builder().sightingId(id).build(), null); + + assertThat(decode(SightingWithdrawn.builder()).getSightingId()).isEqualTo(id); + + // Gone. Subscribers learn about it from the event. + assertThatThrownBy(() -> handlers.getSighting() + .getSighting(GetSightingInput.builder().sightingId(id).build(), null)) + .isInstanceOf(SightingNotFound.class); + } + + private T decode(ShapeBuilder builder) { + return CODEC.deserializeShape( + Base64.getDecoder().decode(published.get(0).message()), builder); + } +} diff --git a/examples/closure-types/server/src/test/java/com/example/audubon/server/FakeSnsClient.java b/examples/closure-types/server/src/test/java/com/example/audubon/server/FakeSnsClient.java new file mode 100644 index 000000000..174f00bd4 --- /dev/null +++ b/examples/closure-types/server/src/test/java/com/example/audubon/server/FakeSnsClient.java @@ -0,0 +1,29 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.server; + +import java.util.List; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +/** Collects published messages instead of reaching SNS. */ +record FakeSnsClient(List published) implements SnsClient { + + @Override + public PublishResponse publish(PublishRequest request) { + published.add(request); + return PublishResponse.builder().messageId("id").build(); + } + + @Override + public String serviceName() { + return SnsClient.SERVICE_NAME; + } + + @Override + public void close() {} +} diff --git a/examples/closure-types/settings.gradle.kts b/examples/closure-types/settings.gradle.kts index f2d2f8859..ed08ab7e7 100644 --- a/examples/closure-types/settings.gradle.kts +++ b/examples/closure-types/settings.gradle.kts @@ -1,5 +1,11 @@ /** - * Combined generation of a server plus standalone types, driven by a shape closure. + * Code generation driven by a modeled shape closure. + * + * The `types` subproject generates only the event types in the closure, with no + * service. The `server` subproject generates the service alongside those same types + * using combined mode, and publishes events to SNS. The `client` subproject generates + * only a client. The `consumer` subproject generates nothing and depends on `types` + * to decode the published events. */ pluginManagement { @@ -17,3 +23,8 @@ pluginManagement { } rootProject.name = "ClosureTypes" + +include("types") +include("server") +include("client") +include("consumer") diff --git a/examples/closure-types/smithy-build.json b/examples/closure-types/smithy-build.json deleted file mode 100644 index f44222ad6..000000000 --- a/examples/closure-types/smithy-build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "1.0", - "plugins": { - "java-codegen": { - "service": "smithy.example.birds#iBird", - "namespace": "software.amazon.smithy.java.example.closure", - "headerFile": "license.txt", - "modes": ["server", "types"], - "closure": "smithy.example.birds#fullService" - } - } -} diff --git a/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java b/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java deleted file mode 100644 index 0a72c4f0c..000000000 --- a/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.example.closure; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Instant; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.java.example.closure.model.Classification; -import software.amazon.smithy.java.example.closure.model.Coordinates; -import software.amazon.smithy.java.example.closure.model.VerifiedSighting; -import software.amazon.smithy.java.example.closure.service.IBird; -import software.amazon.smithy.java.server.Service; - -/** - * Verifies combined-mode generation driven by the {@code fullService} shape closure: the bird - * service is generated as a server (see {@code IBird} under the {@code service} package) and the - * service closure's data shapes, including the event types, are generated alongside it. - */ -public class CombinedGenerationTest { - - @Test - void generatesServerForService() { - // The service is generated as a server Service implementation. - assertThat(Service.class).isAssignableFrom(IBird.class); - } - - @Test - void generatesServiceClosureTypes() { - // Classification is part of the service closure and generates as a normal data shape. - var classification = Classification.builder() - .order("Passeriformes") - .family("Corvidae") - .genus("Corvus") - .species("corax") - .build(); - assertThat(classification.getGenus()).isEqualTo("Corvus"); - assertThat(classification.getSpecies()).isEqualTo("corax"); - } - - @Test - void generatesEventTypesFromClosure() { - // VerifiedSighting is a public event tagged "event"; the closure pulls it into generation - // alongside the server. All of its members are required, so populate them all. - var sighting = VerifiedSighting.builder() - .birdId("bird-1") - .sightingId("sighting-1") - .timestamp(Instant.EPOCH) - .location(Coordinates.builder().build()) - .verified(true) - .classification(Classification.builder() - .order("Passeriformes") - .family("Corvidae") - .genus("Corvus") - .species("corax") - .build()) - .build(); - assertThat(sighting.getBirdId()).isEqualTo("bird-1"); - assertThat(sighting.isVerified()).isTrue(); - } -} diff --git a/examples/closure-types/types/build.gradle.kts b/examples/closure-types/types/build.gradle.kts new file mode 100644 index 000000000..3b73f5e36 --- /dev/null +++ b/examples/closure-types/types/build.gradle.kts @@ -0,0 +1,33 @@ +/** + * Generates only the event types in the `com.example.audubon#events` closure. No + * service is involved, so no `service` setting is needed. + */ + +plugins { + id("software.amazon.smithy.gradle.smithy-base") +} + +dependencies { + val smithyJavaVersion: String by project + val smithyVersion: String by project + + smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") + + // Defines the @rpcv2Cbor trait on the service. The model needs it on its own + // classpath to load. + implementation("software.amazon.smithy:smithy-protocol-traits:$smithyVersion") + + api("software.amazon.smithy.java:core:$smithyJavaVersion") + testImplementation("software.amazon.smithy.java:cbor-codec:$smithyJavaVersion") +} + +// Compile the generated sources and package the generated resources, which hold the +// service file used to discover the schemas at runtime. +afterEvaluate { + val generated = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() + sourceSets["main"].java.srcDir("$generated/java") + sourceSets["main"].resources.srcDir("$generated/resources") +} + +tasks.compileJava { dependsOn(tasks.named("smithyBuild")) } +tasks.processResources { dependsOn(tasks.named("smithyBuild")) } diff --git a/examples/closure-types/types/smithy-build.json b/examples/closure-types/types/smithy-build.json new file mode 100644 index 000000000..c808842ae --- /dev/null +++ b/examples/closure-types/types/smithy-build.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "sources": ["../model"], + "plugins": { + "java-codegen": { + "namespace": "com.example.audubon.events", + "name": "AudubonEvents", + "headerFile": "../license.txt", + "modes": ["types"], + "closure": "com.example.audubon#events" + } + } +} diff --git a/examples/closure-types/types/src/test/java/com/example/audubon/events/EventTypesTest.java b/examples/closure-types/types/src/test/java/com/example/audubon/events/EventTypesTest.java new file mode 100644 index 000000000..7b7bb0d8a --- /dev/null +++ b/examples/closure-types/types/src/test/java/com/example/audubon/events/EventTypesTest.java @@ -0,0 +1,48 @@ +/* + * Example license header. + * File header line two + */ + +package com.example.audubon.events; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.audubon.events.model.Coordinates; +import com.example.audubon.events.model.SightingReported; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.serde.Codec; + +/** + * The event types generate from the closure even though no operation refers to them, + * and they round-trip through a codec. + */ +class EventTypesTest { + + private final Codec codec = Rpcv2CborCodec.builder().build(); + + @Test + void roundTripsAnEvent() { + SightingReported event = SightingReported.builder() + .sightingId("sighting-1") + .birdId("bird-1") + .sightedAt(Instant.EPOCH) + .location(Coordinates.builder().latitude(1.0).longitude(2.0).build()) + .build(); + + SightingReported decoded = codec.deserializeShape(codec.serialize(event), SightingReported.builder()); + + assertThat(decoded).isEqualTo(event); + // Optional members are absent rather than defaulted. + assertThat(decoded.getBandCode()).isNull(); + } + + @Test + void generatesNoServiceTypes() { + // Types mode has no service, so operation shapes are not generated. + assertThatThrownBy(() -> Class.forName("com.example.audubon.events.model.ReportSightingInput")) + .isInstanceOf(ClassNotFoundException.class); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0e57486b3..3c3677332 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -117,6 +117,10 @@ include(":examples:lambda") include(":examples:restjson-client") include(":examples:standalone-types") include(":examples:closure-types") +include(":examples:closure-types:types") +include(":examples:closure-types:server") +include(":examples:closure-types:client") +include(":examples:closure-types:consumer") include(":examples:mcp-server") include(":examples:mcp-traits-example") diff --git a/smithy-templates.json b/smithy-templates.json index 0ccdf3cb2..92fcb78b2 100644 --- a/smithy-templates.json +++ b/smithy-templates.json @@ -58,7 +58,7 @@ ] }, "closure-types": { - "documentation": "Code generation of both standalone types and mixed service + standalone types, driven by a modeled shape closure.", + "documentation": "Types-only, server, and client code generation driven by a modeled shape closure, including types for events outside the service closure.", "path": "examples/closure-types", "include": [ "examples/gradle.properties", From bbbbe5533c7ab224c83d79e976be271f6820ee8d Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Wed, 12 Aug 2026 15:33:00 +0200 Subject: [PATCH 2/2] bump versions in example properties --- examples/gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/gradle.properties b/examples/gradle.properties index 0af8556dc..59b2a148f 100644 --- a/examples/gradle.properties +++ b/examples/gradle.properties @@ -1,3 +1,3 @@ -smithyJavaVersion=[0,1] -smithyGradleVersion=1.1.0 +smithyJavaVersion=[1,2] +smithyGradleVersion=1.5.0 smithyVersion=[1,2]