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/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] 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",