diff --git a/README.md b/README.md index d8eee5a..c2af513 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ A collection of reusable [Smithy](https://smithy.io/) `ProjectionTransformer`s, Currently provides: - [`@addOperations`](#addoperations) — append operations to an existing service. +- [`@removeOperations`](#removeoperations) — detach operations from an existing service. - [`@addMembers`](#addmembers) — append members to an existing aggregate shape (structure or union). +- [`@removeMembers`](#removemembers) — detach members from an existing aggregate shape. +- [`@addErrors`](#adderrors) — append errors to an existing operation or service. +- [`@removeErrors`](#removeerrors) — detach errors from an existing operation or service. - [`removeTraits`](#removetraits) — strip traits matching a selector off every shape in the model. -This transformation exists as a workaround / replacement for [smithy-lang/smithy#3105](https://github.com/smithy-lang/smithy/issues/3105). +These exist as a workaround / replacement for [smithy-lang/smithy#3105](https://github.com/smithy-lang/smithy/issues/3105). ## Installation @@ -37,13 +41,13 @@ lazy val myProject = project ) ``` -`smithy4sModelTransformers` is processed in order — list `addOperations` before any transformer that depends on the final `operations` list. +`smithy4sModelTransformers` is processed in order — list each transformer before any that depends on the shape of the model it produces. If you use both an `add*` and the matching `remove*` transformer on the same shape, the later one wins for anything they both name. A working example lives in [`smithy4sExample/`](smithy4sExample/). ## Use with the Smithy CLI / `smithy-build` -The transformation is registered as a `software.amazon.smithy.build.ProjectionTransformer` SPI, so adding the jar to your build classpath is enough — reference it by name from `smithy-build.json`: +Each transformation is registered as a `software.amazon.smithy.build.ProjectionTransformer` SPI, so adding the jar to your build classpath is enough — reference them by name from `smithy-build.json`: ```json { @@ -52,7 +56,11 @@ The transformation is registered as a `software.amazon.smithy.build.ProjectionTr "default": { "transforms": [ { "name": "addOperations" }, + { "name": "removeOperations" }, { "name": "addMembers" }, + { "name": "removeMembers" }, + { "name": "addErrors" }, + { "name": "removeErrors" }, { "name": "removeTraits" } ] } @@ -94,6 +102,25 @@ operation Another {} After the `addOperations` transformer runs, `MyService.operations` becomes `[A, Another]`. You can have multiple `apply MyService @addOperations(...)` blocks across files — Smithy's loader [concatenates](https://smithy.io/2.0/spec/model.html#trait-conflict-resolution) them, so several consumers can each contribute their own operations. +## `removeOperations` + +The inverse of `@addOperations` — detach operations from a service you don't own: + +```smithy +// ours.smithy +$version: "2" + +namespace example + +use smithytransformations#removeOperations + +apply MyService @removeOperations([A]) +``` + +After the `removeOperations` transformer runs, `A` is gone from `MyService.operations`. Only the service's `operations` list is touched — the `A` shape itself stays in the model, so another service can still bind it. Pair it with smithy-build's own [`removeUnusedShapes`](https://smithy.io/2.0/guides/smithy-build-json.html#removeunusedshapes) if you want the now-orphaned operation gone too. + +Naming an operation the service doesn't have is ignored rather than an error, which keeps the trait usable against an upstream model that may or may not bind it. + ## `addMembers` Given an upstream aggregate shape (structure or union) you don't own: @@ -134,6 +161,88 @@ apply MyStruct @addMembers([ After the `addMembers` transformer runs, `MyStruct` has the original `original` member plus `extra: String` and a required `withTraits: Integer` with documentation. Each entry is `{ name, target, traits? }`; `traits` is an optional map from trait shape id to that trait's node value. The selector accepts both structures and unions, and multiple `apply ... @addMembers(...)` blocks are concatenated like `@addOperations`. +## `removeMembers` + +The inverse of `@addMembers` — detach members from an aggregate shape you don't own: + +```smithy +// ours.smithy +$version: "2" + +namespace example + +use smithytransformations#removeMembers + +apply MyStruct @removeMembers(["original"]) +``` + +After the `removeMembers` transformer runs, `MyStruct` no longer has an `original` member. Entries are plain member names, not shape ids, and they're matched **case-insensitively** — matching how Smithy itself treats member name uniqueness. Naming a member the shape doesn't have is ignored rather than an error. + +Only the container is rewritten, so removing a member that something else still depends on is your responsibility to avoid — dropping a member referenced by an `@httpLabel` binding or a `@required` contract elsewhere will surface as a validation failure downstream, not here. + +## `addErrors` + +Both operations and services have an `errors` property, so one trait covers both. On an operation the errors are specific to that operation; on a service they apply to every operation it contains. + +Given an upstream operation you don't own: + +```smithy +// upstream.smithy — provided by someone else +$version: "2" + +namespace example + +operation A { + errors: [ExistingError] +} + +@error("client") +structure ExistingError {} +``` + +attach `@addErrors` from your own file to append errors to it: + +```smithy +// ours.smithy +$version: "2" + +namespace example + +use smithytransformations#addErrors + +apply A @addErrors([BoomError]) + +@error("server") +structure BoomError {} +``` + +After the `addErrors` transformer runs, `A.errors` becomes `[ExistingError, BoomError]`. Apply it to a service instead to add an error to every operation at once: + +```smithy +apply MyService @addErrors([BoomError]) +``` + +Targets must be structures marked with [`@error`](https://smithy.io/2.0/spec/type-refinement-traits.html#error-trait) — the trait's `@idRef` selector enforces that at model-load time, so a typo or a non-error target fails before the transformation runs. Errors already present on the shape are skipped rather than duplicated, so running the transformation twice is a no-op the second time. Multiple `apply ... @addErrors(...)` blocks are concatenated like `@addOperations`. + +## `removeErrors` + +The inverse of `@addErrors`, on operations and services alike: + +```smithy +// ours.smithy +$version: "2" + +namespace example + +use smithytransformations#removeErrors + +apply A @removeErrors([ExistingError]) +``` + +After the `removeErrors` transformer runs, `ExistingError` is gone from `A.errors`. The error structure itself stays in the model — other operations may still declare it. Naming an error the shape doesn't declare is ignored rather than an error. + +One asymmetry worth knowing: removing an error from an *operation* cannot cancel out one inherited from the enclosing *service*. Service-level `errors` apply to every operation the service contains and Smithy offers no per-operation opt-out, so if the error comes from the service, remove it from the service. + ## `removeTraits` Unlike the others, this one isn't driven by a trait — it's model-wide, so there's no shape to attach it to. It's configured by the `smithytransformations#removeTraits` metadata key, whose type is declared with Smithy's [`@metadata` trait](https://smithy.io/2.0/spec/model.html#metadata-trait), so the value is validated as part of loading the model. diff --git a/tests/src/test/scala/smithytransformations/AddErrorsTest.scala b/tests/src/test/scala/smithytransformations/AddErrorsTest.scala new file mode 100644 index 0000000..348b6db --- /dev/null +++ b/tests/src/test/scala/smithytransformations/AddErrorsTest.scala @@ -0,0 +1,312 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations + +class AddErrorsTest extends TransformationSuite(() => new AddErrors) { + + test("operation: adds @addErrors targets to the operation's errors") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A { + | errors: [ExistingError] + |} + | + |@error("client") + |structure ExistingError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A { + | errors: [ExistingError, BoomError] + |} + | + |@error("client") + |structure ExistingError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("service: adds @addErrors targets to the service's errors") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |service MyService { + | operations: [A] + | errors: [ExistingError] + |} + | + |operation A {} + | + |@error("client") + |structure ExistingError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |service MyService { + | operations: [A] + | errors: [ExistingError, BoomError] + |} + | + |operation A {} + | + |@error("client") + |structure ExistingError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("operation without errors: @addErrors introduces the errors list") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A { + | errors: [BoomError] + |} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("idempotent: an error already on the shape is not duplicated") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A { + | errors: [BoomError] + |} + | + |@error("server") + |structure BoomError {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("cross-namespace: @addErrors resolves errors from a different namespace") { + val otherNs = + """|$version: "2" + | + |namespace other + | + |@error("server") + |structure BoomError {} + |""".stripMargin + transformationComparisonTestMulti( + input = Seq( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + |use other#BoomError + | + |@addErrors([BoomError]) + |operation A {} + |""".stripMargin, + otherNs, + ), + expected = Seq( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + |use other#BoomError + | + |@addErrors([BoomError]) + |operation A { + | errors: [BoomError] + |} + |""".stripMargin, + otherNs, + ), + ) + } + + test("empty: @addErrors([]) is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([]) + |operation A { + | errors: [BoomError] + |} + | + |@error("server") + |structure BoomError {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("apply: a second `apply ... @addErrors(...)` is concatenated with the original") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |operation A {} + | + |apply A @addErrors([OtherError]) + | + |@error("server") + |structure BoomError {} + | + |@error("client") + |structure OtherError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError, OtherError]) + |operation A { + | errors: [BoomError, OtherError] + |} + | + |@error("server") + |structure BoomError {} + | + |@error("client") + |structure OtherError {} + |""".stripMargin, + ) + } + + test("validation: target shape must be an @error structure") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([NotAnError]) + |operation A {} + | + |structure NotAnError {} + |""".stripMargin + ) + assert(errors.exists(_.contains("NotAnError")), errors.mkString("\n")) + } + + test("validation: @addErrors cannot be applied to a structure") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([BoomError]) + |structure NotAnOperation {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin + ) + assert(errors.exists(_.contains("addErrors")), errors.mkString("\n")) + } + + test("validation: target shape must exist") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addErrors + | + |@addErrors([DoesNotExist]) + |operation A {} + |""".stripMargin + ) + assert(errors.exists(_.contains("DoesNotExist")), errors.mkString("\n")) + } + +} diff --git a/tests/src/test/scala/smithytransformations/RemoveErrorsTest.scala b/tests/src/test/scala/smithytransformations/RemoveErrorsTest.scala new file mode 100644 index 0000000..ea53bdc --- /dev/null +++ b/tests/src/test/scala/smithytransformations/RemoveErrorsTest.scala @@ -0,0 +1,300 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations + +class RemoveErrorsTest extends TransformationSuite(() => new RemoveErrors) { + + test("operation: removes @removeErrors targets from the operation's errors") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A { + | errors: [KeepError, BoomError] + |} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A { + | errors: [KeepError] + |} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("service: removes @removeErrors targets from the service's errors") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |service MyService { + | operations: [A] + | errors: [KeepError, BoomError] + |} + | + |operation A {} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |service MyService { + | operations: [A] + | errors: [KeepError] + |} + | + |operation A {} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("removing the only error leaves the operation with no errors") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A { + | errors: [BoomError] + |} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin, + ) + } + + test("the error structure itself stays in the model") { + val result = new RemoveErrors().transform( + software + .amazon + .smithy + .build + .TransformContext + .builder() + .model( + loadModel( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A { + | errors: [BoomError] + |} + | + |@error("server") + |structure BoomError {} + |""".stripMargin + ) + ) + .build() + ) + assert( + result + .getShape(software.amazon.smithy.model.shapes.ShapeId.from("example#BoomError")) + .isPresent + ) + } + + test("not attached: naming an error the shape doesn't have is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([NotAttachedError]) + |operation A { + | errors: [KeepError] + |} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure NotAttachedError {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("empty: @removeErrors([]) is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([]) + |operation A { + | errors: [KeepError] + |} + | + |@error("client") + |structure KeepError {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("apply: a second `apply ... @removeErrors(...)` is concatenated with the original") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |operation A { + | errors: [KeepError, BoomError, OtherError] + |} + | + |apply A @removeErrors([OtherError]) + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + | + |@error("client") + |structure OtherError {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError, OtherError]) + |operation A { + | errors: [KeepError] + |} + | + |@error("client") + |structure KeepError {} + | + |@error("server") + |structure BoomError {} + | + |@error("client") + |structure OtherError {} + |""".stripMargin, + ) + } + + test("validation: @removeErrors cannot be applied to a structure") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([BoomError]) + |structure NotAnOperation {} + | + |@error("server") + |structure BoomError {} + |""".stripMargin + ) + assert(errors.exists(_.contains("removeErrors")), errors.mkString("\n")) + } + + test("validation: target shape must exist") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeErrors + | + |@removeErrors([DoesNotExist]) + |operation A {} + |""".stripMargin + ) + assert(errors.exists(_.contains("DoesNotExist")), errors.mkString("\n")) + } + +} diff --git a/tests/src/test/scala/smithytransformations/RemoveMembersTest.scala b/tests/src/test/scala/smithytransformations/RemoveMembersTest.scala new file mode 100644 index 0000000..7f5b020 --- /dev/null +++ b/tests/src/test/scala/smithytransformations/RemoveMembersTest.scala @@ -0,0 +1,337 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations + +class RemoveMembersTest extends TransformationSuite(() => new RemoveMembers) { + + test("structure: removes the named members") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |structure MyStruct { + | kept: String + | gone: Integer + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin, + ) + } + + test("union: removes the named members") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |union MyUnion { + | kept: String + | gone: Integer + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |union MyUnion { + | kept: String + |} + |""".stripMargin, + ) + } + + test("multiple members are removed at once") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone", "alsoGone"]) + |structure MyStruct { + | kept: String + | gone: Integer + | alsoGone: Boolean + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone", "alsoGone"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin, + ) + } + + test("member traits do not prevent removal") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |structure MyStruct { + | kept: String + | + | @required + | @documentation("doomed") + | gone: Integer + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin, + ) + } + + test("case-insensitive: member names match regardless of case") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["GONE"]) + |structure MyStruct { + | kept: String + | gone: Integer + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["GONE"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin, + ) + } + + test("nonexistent: naming a member the shape doesn't have is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["notThere"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("empty: @removeMembers([]) is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers([]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("removing every member leaves an empty structure") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["only"]) + |structure MyStruct { + | only: String + |} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["only"]) + |structure MyStruct {} + |""".stripMargin, + ) + } + + test("apply: a second `apply ... @removeMembers(...)` is concatenated with the original") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone"]) + |structure MyStruct { + | kept: String + | gone: Integer + | alsoGone: Boolean + |} + | + |apply MyStruct @removeMembers(["alsoGone"]) + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["gone", "alsoGone"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin, + ) + } + + test("interaction: addMembers then removeMembers cancel out") { + import software.amazon.smithy.build.TransformContext + import software.amazon.smithy.model.shapes.ShapeId + import scala.jdk.CollectionConverters.* + + val input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addMembers + |use smithytransformations#removeMembers + | + |@addMembers([{ name: "extra", target: String }]) + |@removeMembers(["extra"]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin + + val added = new AddMembers().transform( + TransformContext.builder().model(loadModel(input)).build() + ) + val removed = new RemoveMembers().transform( + TransformContext.builder().model(added).build() + ) + + val members = + removed + .expectShape(ShapeId.from("example#MyStruct")) + .members() + .asScala + .map(_.getMemberName) + .toList + + assertEquals(members, List("kept")) + } + + test("validation: @removeMembers cannot be applied to an operation") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers(["whatever"]) + |operation NotAnAggregate {} + |""".stripMargin + ) + assert(errors.exists(_.contains("removeMembers")), errors.mkString("\n")) + } + + test("validation: member names must be non-empty") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeMembers + | + |@removeMembers([""]) + |structure MyStruct { + | kept: String + |} + |""".stripMargin + ) + assert(errors.nonEmpty, errors.mkString("\n")) + } + +} diff --git a/tests/src/test/scala/smithytransformations/RemoveOperationsTest.scala b/tests/src/test/scala/smithytransformations/RemoveOperationsTest.scala new file mode 100644 index 0000000..2cc236d --- /dev/null +++ b/tests/src/test/scala/smithytransformations/RemoveOperationsTest.scala @@ -0,0 +1,335 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations + +import software.amazon.smithy.build.TransformContext +import software.amazon.smithy.model.shapes.ShapeId + +import scala.jdk.CollectionConverters.* + +class RemoveOperationsTest extends TransformationSuite(() => new RemoveOperations) { + + test("basic: removes @removeOperations targets from the service's operations") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A, Gone] + |} + | + |operation A {} + |operation Gone {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |operation Gone {} + |""".stripMargin, + ) + } + + test("the operation shape itself stays in the model") { + val result = new RemoveOperations().transform( + TransformContext + .builder() + .model( + loadModel( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A, Gone] + |} + | + |operation A {} + |operation Gone {} + |""".stripMargin + ) + ) + .build() + ) + assert(result.getShape(ShapeId.from("example#Gone")).isPresent) + } + + test("cross-namespace: @removeOperations resolves operations from a different namespace") { + val otherNs = + """|$version: "2" + | + |namespace other + | + |operation Gone {} + |""".stripMargin + transformationComparisonTestMulti( + input = Seq( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + |use other#Gone + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A, Gone] + |} + | + |operation A {} + |""".stripMargin, + otherNs, + ), + expected = Seq( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + |use other#Gone + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |""".stripMargin, + otherNs, + ), + ) + } + + test("not attached: naming an operation the service doesn't have is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Unbound]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |operation Unbound {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("empty: @removeOperations([]) is a no-op") { + val model = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |""".stripMargin + transformationComparisonTest(input = model, expected = model) + } + + test("removing every operation leaves an empty service") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([A]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([A]) + |service MyService {} + | + |operation A {} + |""".stripMargin, + ) + } + + test("apply: a second `apply ... @removeOperations(...)` is concatenated with the original") { + transformationComparisonTest( + input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Gone]) + |service MyService { + | operations: [A, Gone, AlsoGone] + |} + | + |apply MyService @removeOperations([AlsoGone]) + | + |operation A {} + |operation Gone {} + |operation AlsoGone {} + |""".stripMargin, + expected = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([Gone, AlsoGone]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |operation Gone {} + |operation AlsoGone {} + |""".stripMargin, + ) + } + + test("interaction: addOperations then removeOperations cancel out") { + val input = + """|$version: "2" + | + |namespace example + | + |use smithytransformations#addOperations + |use smithytransformations#removeOperations + | + |@addOperations([Another]) + |@removeOperations([Another]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |operation Another {} + |""".stripMargin + + val added = new AddOperations().transform( + TransformContext.builder().model(loadModel(input)).build() + ) + val removed = new RemoveOperations().transform( + TransformContext.builder().model(added).build() + ) + + val service = removed + .expectShape(ShapeId.from("example#MyService")) + .asServiceShape() + .get() + + assertEquals( + service.getOperations().asScala.toList, + List(ShapeId.from("example#A")), + ) + } + + test("validation: target shape must be an operation") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([NotAnOperation]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + | + |structure NotAnOperation {} + |""".stripMargin + ) + assert(errors.exists(_.contains("NotAnOperation")), errors.mkString("\n")) + } + + test("validation: @removeOperations cannot be applied to a non-service shape") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([A]) + |structure NotAService {} + | + |operation A {} + |""".stripMargin + ) + assert(errors.exists(_.contains("removeOperations")), errors.mkString("\n")) + } + + test("validation: target shape must exist") { + val errors = validationErrorsFor( + """|$version: "2" + | + |namespace example + | + |use smithytransformations#removeOperations + | + |@removeOperations([DoesNotExist]) + |service MyService { + | operations: [A] + |} + | + |operation A {} + |""".stripMargin + ) + assert(errors.exists(_.contains("DoesNotExist")), errors.mkString("\n")) + } + +} diff --git a/tests/src/test/scala/smithytransformations/TransformationSuite.scala b/tests/src/test/scala/smithytransformations/TransformationSuite.scala new file mode 100644 index 0000000..b120bbf --- /dev/null +++ b/tests/src/test/scala/smithytransformations/TransformationSuite.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations + +import munit.FunSuite +import software.amazon.smithy.build.ProjectionTransformer +import software.amazon.smithy.build.TransformContext +import software.amazon.smithy.diff.ModelDiff +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.loader.ModelAssembler +import software.amazon.smithy.model.validation.Severity + +import scala.jdk.CollectionConverters.* + +/** Shared plumbing for transformer tests: run the transformer over a model, then assert the result + * is model-equivalent (via smithy-diff, so formatting and member order don't matter) to an + * expected model. + */ +abstract class TransformationSuite(transformer: () => ProjectionTransformer) extends FunSuite { + + protected def transformationComparisonTest(input: String, expected: String): Unit = + transformationComparisonTestMulti(Seq(input), Seq(expected)) + + protected def transformationComparisonTestMulti( + input: Seq[String], + expected: Seq[String], + ): Unit = { + val result = transformer().transform( + TransformContext + .builder() + .model(loadModel(input*)) + .build() + ) + + val diff = + ModelDiff + .builder() + .oldModel(loadModel(expected*)) + .newModel(result) + .compare() + .getDiffEvents + .asScala + .toList + + assert(diff.isEmpty, diff.map(_.toString).mkString("\n")) + } + + protected def loadModel(contents: String*): Model = { + val assembler = Model + .assembler() + .discoverModels() + .putProperty(ModelAssembler.DISABLE_JAR_CACHE, true) + contents.zipWithIndex.foreach { case (c, i) => + assembler.addUnparsedModel(s"test-$i.smithy", c) + } + assembler.assemble().unwrap() + } + + protected def validationErrorsFor(content: String): List[String] = { + val result = Model + .assembler() + .discoverModels() + .putProperty(ModelAssembler.DISABLE_JAR_CACHE, true) + .addUnparsedModel("test.smithy", content) + .assemble() + val errors = result + .getValidationEvents + .asScala + .toList + .filter(_.getSeverity == Severity.ERROR) + .map(_.getMessage) + assert(errors.nonEmpty, "expected validation errors but got none") + errors + } + +} diff --git a/transformation/src/main/java/smithytransformations/AddErrors.java b/transformation/src/main/java/smithytransformations/AddErrors.java new file mode 100644 index 0000000..271f163 --- /dev/null +++ b/transformation/src/main/java/smithytransformations/AddErrors.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations; + +import software.amazon.smithy.build.ProjectionTransformer; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.transform.ModelTransformer; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Appends error shapes to the operations and services carrying {@code @addErrors}. + * + *

Both shape types have an {@code errors} property, so one trait covers both: on an operation the + * errors are specific to that operation, on a service they apply to every operation it contains. + * Errors already present on the shape are left alone, so applying the transformation twice is a + * no-op the second time. + */ +public final class AddErrors implements ProjectionTransformer { + + @Override + public String getName() { + return "addErrors"; + } + + @Override + public Model transform(TransformContext context) { + Model model = context.getModel(); + Set updated = new HashSet<>(); + + for (Shape shape : model.getShapesWithTrait(AddErrorsTrait.class)) { + List toAdd = shape.expectTrait(AddErrorsTrait.class).getValues(); + if (toAdd.isEmpty()) { + continue; + } + + if (shape.isOperationShape()) { + OperationShape operation = shape.asOperationShape().get(); + Set existing = operation.getErrorsSet(); + OperationShape.Builder builder = operation.toBuilder(); + for (ShapeId error : toAdd) { + if (!existing.contains(error)) { + builder.addError(error); + } + } + updated.add(builder.build()); + } else if (shape.isServiceShape()) { + ServiceShape service = shape.asServiceShape().get(); + Set existing = service.getErrorsSet(); + ServiceShape.Builder builder = service.toBuilder(); + for (ShapeId error : toAdd) { + if (!existing.contains(error)) { + builder.addError(error); + } + } + updated.add(builder.build()); + } + } + + if (updated.isEmpty()) { + return model; + } + + return ModelTransformer.create().replaceShapes(model, updated); + } +} diff --git a/transformation/src/main/java/smithytransformations/RemoveErrors.java b/transformation/src/main/java/smithytransformations/RemoveErrors.java new file mode 100644 index 0000000..8bd4bdc --- /dev/null +++ b/transformation/src/main/java/smithytransformations/RemoveErrors.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations; + +import software.amazon.smithy.build.ProjectionTransformer; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.transform.ModelTransformer; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Detaches error shapes from the operations and services carrying {@code @removeErrors}. + * + *

Only the {@code errors} property is touched — the error structures themselves stay in the + * model, since other shapes may still reference them. Listing an error that isn't attached is + * ignored rather than an error, which keeps the trait usable against upstream models that may or + * may not declare it. + * + *

Note that removing an error from an operation cannot cancel out one inherited from the + * enclosing service: on a service, {@code errors} applies to every operation it contains, and + * Smithy has no per-operation opt-out. Remove it from the service instead. + */ +public final class RemoveErrors implements ProjectionTransformer { + + @Override + public String getName() { + return "removeErrors"; + } + + @Override + public Model transform(TransformContext context) { + Model model = context.getModel(); + Set updated = new HashSet<>(); + + for (Shape shape : model.getShapesWithTrait(RemoveErrorsTrait.class)) { + List toRemove = shape.expectTrait(RemoveErrorsTrait.class).getValues(); + if (toRemove.isEmpty()) { + continue; + } + + if (shape.isOperationShape()) { + OperationShape operation = shape.asOperationShape().get(); + OperationShape.Builder builder = operation.toBuilder(); + toRemove.forEach(builder::removeError); + updated.add(builder.build()); + } else if (shape.isServiceShape()) { + ServiceShape service = shape.asServiceShape().get(); + ServiceShape.Builder builder = service.toBuilder(); + toRemove.forEach(builder::removeError); + updated.add(builder.build()); + } + } + + if (updated.isEmpty()) { + return model; + } + + return ModelTransformer.create().replaceShapes(model, updated); + } +} diff --git a/transformation/src/main/java/smithytransformations/RemoveMembers.java b/transformation/src/main/java/smithytransformations/RemoveMembers.java new file mode 100644 index 0000000..a865b8a --- /dev/null +++ b/transformation/src/main/java/smithytransformations/RemoveMembers.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations; + +import software.amazon.smithy.build.ProjectionTransformer; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.AbstractShapeBuilder; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.transform.ModelTransformer; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Removes named members from the aggregate shapes carrying {@code @removeMembers}. + * + *

Names are matched case-insensitively, matching how Smithy itself treats member name + * uniqueness. Naming a member the shape doesn't have is ignored rather than an error, so the trait + * stays usable against an upstream model that may or may not declare it. + * + *

The container is rebuilt from the members that survive, since shape builders have no + * {@code removeMember}. Only the container is touched: removing a member that other shapes still + * reference (through a {@code @required} target elsewhere, say) is the caller's problem to avoid. + */ +public final class RemoveMembers implements ProjectionTransformer { + + @Override + public String getName() { + return "removeMembers"; + } + + @Override + public Model transform(TransformContext context) { + Model model = context.getModel(); + Set updated = new HashSet<>(); + + for (Shape container : model.getShapesWithTrait(RemoveMembersTrait.class)) { + List names = container.expectTrait(RemoveMembersTrait.class).getValues(); + if (names.isEmpty()) { + continue; + } + + Set toRemoveLower = new HashSet<>(); + for (String name : names) { + toRemoveLower.add(name.toLowerCase(Locale.ROOT)); + } + + AbstractShapeBuilder builder = Shape.shapeToBuilder(container); + builder.clearMembers(); + boolean changed = false; + for (MemberShape member : container.members()) { + if (toRemoveLower.contains(member.getMemberName().toLowerCase(Locale.ROOT))) { + changed = true; + } else { + builder.addMember(member); + } + } + + if (changed) { + updated.add(builder.build()); + } + } + + if (updated.isEmpty()) { + return model; + } + + return ModelTransformer.create().replaceShapes(model, updated); + } +} diff --git a/transformation/src/main/java/smithytransformations/RemoveOperations.java b/transformation/src/main/java/smithytransformations/RemoveOperations.java new file mode 100644 index 0000000..ec1d8e8 --- /dev/null +++ b/transformation/src/main/java/smithytransformations/RemoveOperations.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithytransformations; + +import software.amazon.smithy.build.ProjectionTransformer; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.transform.ModelTransformer; + +import java.util.HashSet; +import java.util.Set; + +/** + * Detaches operations from the services carrying {@code @removeOperations}. + * + *

Only the service's {@code operations} list is touched — the operation shapes themselves stay in + * the model, so another service can still bind them. Combine with smithy-build's own {@code + * removeUnusedShapes} if you want the now-orphaned operations gone as well. + * + *

Listing an operation the service doesn't have is ignored rather than an error. + */ +public final class RemoveOperations implements ProjectionTransformer { + + @Override + public String getName() { + return "removeOperations"; + } + + @Override + public Model transform(TransformContext context) { + Model model = context.getModel(); + Set updated = new HashSet<>(); + + for (ServiceShape service : model.getServiceShapesWithTrait(RemoveOperationsTrait.class)) { + RemoveOperationsTrait trait = service.expectTrait(RemoveOperationsTrait.class); + if (trait.getValues().isEmpty()) { + continue; + } + ServiceShape.Builder builder = service.toBuilder(); + trait.getValues().forEach(builder::removeOperation); + updated.add(builder.build()); + } + + if (updated.isEmpty()) { + return model; + } + + return ModelTransformer.create().replaceShapes(model, updated); + } +} diff --git a/transformation/src/main/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer b/transformation/src/main/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer index 84bd4ac..b62b375 100644 --- a/transformation/src/main/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer +++ b/transformation/src/main/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer @@ -1,3 +1,7 @@ smithytransformations.AddOperations smithytransformations.AddMembers smithytransformations.RemoveTraits +smithytransformations.AddErrors +smithytransformations.RemoveErrors +smithytransformations.RemoveOperations +smithytransformations.RemoveMembers diff --git a/transformation/src/main/resources/META-INF/smithy/smithytransformations.smithy b/transformation/src/main/resources/META-INF/smithy/smithytransformations.smithy index 4f81474..a45b53a 100644 --- a/transformation/src/main/resources/META-INF/smithy/smithytransformations.smithy +++ b/transformation/src/main/resources/META-INF/smithy/smithytransformations.smithy @@ -46,3 +46,41 @@ list RemoveTraits { @private @length(min: 1) string RemoveTraitsSelector + +/// Adds the listed error shapes to the operation or service this trait is applied to. +@trait(selector: ":is(operation, service)") +list addErrors { + @idRef(failWhenMissing: true, selector: "structure[trait|error]") + member: String +} + +/// Removes the listed error shapes from the operation or service this trait is applied to. +/// +/// Errors that aren't attached to the shape are ignored. +@trait(selector: ":is(operation, service)") +list removeErrors { + @idRef(failWhenMissing: true, selector: "structure[trait|error]") + member: String +} + +/// Removes the listed operations from the service this trait is applied to. +/// +/// Operations that aren't attached to the service are ignored. The operation shapes +/// themselves are left in the model; only the service's `operations` list is affected. +@trait(selector: "service") +list removeOperations { + @idRef(failWhenMissing: true, selector: "operation") + member: String +} + +/// Removes the named members from the aggregate (structure or union) this trait is applied to. +/// +/// Members that don't exist on the shape are ignored. +@trait(selector: ":is(structure, union)") +list removeMembers { + member: RemoveMembersEntry +} + +@private +@length(min: 1) +string RemoveMembersEntry