From 2066034fd4a4ee4b3f07675bd18259ed3bb49a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Thu, 20 Aug 2026 18:36:32 +0200 Subject: [PATCH] Add addErrors/removeErrors/removeOperations/removeMembers transformations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addErrors and removeErrors use a single trait each with an `:is(operation, service)` selector, since both shape types carry an `errors` property. Targets are constrained by `@idRef(selector: "structure[trait|error]")`, so non-error or missing targets fail at model-load time rather than during transformation. addErrors skips errors already present, so it is idempotent. removeErrors, removeOperations and removeMembers ignore entries that aren't attached to the shape, keeping the traits usable against upstream models that may or may not declare them. None of the removals delete the referenced shapes themselves — only the containing shape's list is rewritten. removeMembers matches member names case-insensitively, mirroring both Smithy's own member name uniqueness rule and the existing addMembers behaviour. Shape builders have no removeMember, so the container is rebuilt from the surviving members. Test helpers are extracted into a shared TransformationSuite used by the new suites; the pre-existing suites keep their own copies for now. Co-Authored-By: Claude Opus 5 --- README.md | 115 +++++- .../smithytransformations/AddErrorsTest.scala | 312 ++++++++++++++++ .../RemoveErrorsTest.scala | 300 ++++++++++++++++ .../RemoveMembersTest.scala | 337 ++++++++++++++++++ .../RemoveOperationsTest.scala | 335 +++++++++++++++++ .../TransformationSuite.scala | 90 +++++ .../java/smithytransformations/AddErrors.java | 87 +++++ .../smithytransformations/RemoveErrors.java | 81 +++++ .../smithytransformations/RemoveMembers.java | 88 +++++ .../RemoveOperations.java | 66 ++++ ....amazon.smithy.build.ProjectionTransformer | 4 + .../smithy/smithytransformations.smithy | 38 ++ 12 files changed, 1850 insertions(+), 3 deletions(-) create mode 100644 tests/src/test/scala/smithytransformations/AddErrorsTest.scala create mode 100644 tests/src/test/scala/smithytransformations/RemoveErrorsTest.scala create mode 100644 tests/src/test/scala/smithytransformations/RemoveMembersTest.scala create mode 100644 tests/src/test/scala/smithytransformations/RemoveOperationsTest.scala create mode 100644 tests/src/test/scala/smithytransformations/TransformationSuite.scala create mode 100644 transformation/src/main/java/smithytransformations/AddErrors.java create mode 100644 transformation/src/main/java/smithytransformations/RemoveErrors.java create mode 100644 transformation/src/main/java/smithytransformations/RemoveMembers.java create mode 100644 transformation/src/main/java/smithytransformations/RemoveOperations.java 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