diff --git a/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService index a3221e9a..5cf1bc34 100644 --- a/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService +++ b/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -26,3 +26,5 @@ alloy.UrlFormNameTrait$Provider alloy.UncheckedExamplesTrait$Provider alloy.UntaggedUnionTrait$Provider alloy.UuidFormatTrait$Provider +alloy.HttpPolymorphicResponseTrait$Provider +alloy.HttpSuccessTrait$Provider diff --git a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index dba61705..ee7ae589 100644 --- a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -11,3 +11,4 @@ alloy.validation.DiscriminatedUnionValidator alloy.validation.SimpleRestJsonHttpHeaderValidator alloy.validation.SimpleRestJsonValidator alloy.validation.StructurePatternTraitValidator +alloy.validation.HttpPolymorphicResponseValidator diff --git a/modules/core/resources/META-INF/smithy/http.smithy b/modules/core/resources/META-INF/smithy/http.smithy new file mode 100644 index 00000000..3d22f929 --- /dev/null +++ b/modules/core/resources/META-INF/smithy/http.smithy @@ -0,0 +1,12 @@ +$version: "2" + +namespace alloy + +@trait( + selector: "structure > member[trait|required] :test(> :test(union))", + structurallyExclusive: "member" +) +structure httpPolymorphicResponse {} + +@trait(selector: "structure") +integer httpSuccess diff --git a/modules/core/resources/META-INF/smithy/manifest b/modules/core/resources/META-INF/smithy/manifest index ec4c0303..634ef150 100644 --- a/modules/core/resources/META-INF/smithy/manifest +++ b/modules/core/resources/META-INF/smithy/manifest @@ -12,3 +12,4 @@ unions.smithy urlform.smithy uuid.smithy metadata.smithy +http.smithy diff --git a/modules/core/resources/META-INF/smithy/uuid.smithy b/modules/core/resources/META-INF/smithy/uuid.smithy index b6ee0399..c6a36918 100644 --- a/modules/core/resources/META-INF/smithy/uuid.smithy +++ b/modules/core/resources/META-INF/smithy/uuid.smithy @@ -8,3 +8,4 @@ structure uuidFormat {} @uuidFormat string UUID + diff --git a/modules/core/src/alloy/HttpPolymorphicResponseTrait.java b/modules/core/src/alloy/HttpPolymorphicResponseTrait.java new file mode 100644 index 00000000..85a5899a --- /dev/null +++ b/modules/core/src/alloy/HttpPolymorphicResponseTrait.java @@ -0,0 +1,46 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * 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 alloy; + +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AnnotationTrait; +import software.amazon.smithy.model.traits.AbstractTrait; + +public class HttpPolymorphicResponseTrait extends AnnotationTrait { + + public static ShapeId ID = ShapeId.from("alloy#httpPolymorphicResponse"); + + public HttpPolymorphicResponseTrait(ObjectNode node) { + super(ID, node); + } + + public HttpPolymorphicResponseTrait() { + super(ID, Node.objectNode()); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public HttpPolymorphicResponseTrait createTrait(ShapeId target, Node node) { + return new HttpPolymorphicResponseTrait(node.expectObjectNode()); + } + } +} diff --git a/modules/core/src/alloy/HttpSuccessTrait.java b/modules/core/src/alloy/HttpSuccessTrait.java new file mode 100644 index 00000000..ce2d4a4a --- /dev/null +++ b/modules/core/src/alloy/HttpSuccessTrait.java @@ -0,0 +1,59 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * 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 alloy; + +import software.amazon.smithy.model.FromSourceLocation; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.shapes.ShapeId; + +public final class HttpSuccessTrait extends AbstractTrait { + public static final ShapeId ID = ShapeId.from("alloy#httpSuccess"); + + private final int code; + + public HttpSuccessTrait(int code, FromSourceLocation sourceLocation) { + super(ID, sourceLocation); + this.code = code; + } + + public HttpSuccessTrait(int code) { + this(code, SourceLocation.NONE); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public Trait createTrait(ShapeId target, Node value) { + return new HttpSuccessTrait(value.expectNumberNode().getValue().intValue(), value.getSourceLocation()); + } + } + + public int getCode() { + return code; + } + + @Override + protected Node createNode() { + return new NumberNode(code, getSourceLocation()); + } +} diff --git a/modules/core/src/alloy/validation/HttpPolymorphicResponseValidator.java b/modules/core/src/alloy/validation/HttpPolymorphicResponseValidator.java new file mode 100644 index 00000000..c8a75ea9 --- /dev/null +++ b/modules/core/src/alloy/validation/HttpPolymorphicResponseValidator.java @@ -0,0 +1,64 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * 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 alloy.validation; + +import alloy.HttpPolymorphicResponseTrait; +import alloy.HttpSuccessTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.*; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ArrayList; +import java.util.Collections; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class HttpPolymorphicResponseValidator extends AbstractValidator { + + protected static String EXPECTED_SINGLE_MEMBER = "The httpPolymorphicResponse trait can only be used in a structure that has a single required member, and this member must target a union"; + protected static String EXPECTED_HTTP_SUCCESS_ON_ALL_MEMBER_TARGETS = "The members of a union targeted by httpPolymorphicResponse trait must all target a structure annotated with @alloy.httpSuccess"; + protected static String EXPECTED_DISTINCT_HTTP_SUCCESS = "The targets of the members of this union must have distinct httpSuccess values"; + + @Override + public List validate(Model model) { + return model.getMemberShapesWithTrait(HttpPolymorphicResponseTrait.class).stream().flatMap(structureMember -> { + StructureShape container = model.expectShape(structureMember.getContainer(), StructureShape.class); + List errors = new ArrayList(); + if (container.getAllMembers().size() != 1) { + errors.add(error(container, EXPECTED_SINGLE_MEMBER)); + } + UnionShape union = model.expectShape(structureMember.getTarget(), UnionShape.class); + union.members().stream().collect(Collectors.groupingBy(member -> { + return model.expectShape(member.getTarget()).getTrait(HttpSuccessTrait.class) + .map(httpSuccess -> httpSuccess.getCode()); + })).entrySet().stream().forEach(entry -> { + if (!entry.getKey().isPresent()) { + errors.add(error(structureMember, EXPECTED_HTTP_SUCCESS_ON_ALL_MEMBER_TARGETS)); + } else if (entry.getValue().size() > 1) { + Optional statusCode = entry.getKey(); + errors.add(error(union, EXPECTED_DISTINCT_HTTP_SUCCESS)); + } + }); + + return errors.stream(); + }).collect(Collectors.toList()); + } +} diff --git a/modules/core/test/resources/META-INF/smithy/traits.smithy b/modules/core/test/resources/META-INF/smithy/traits.smithy index 0a079fa1..5c8053bc 100644 --- a/modules/core/test/resources/META-INF/smithy/traits.smithy +++ b/modules/core/test/resources/META-INF/smithy/traits.smithy @@ -15,6 +15,8 @@ use alloy#untagged use alloy#urlFormFlattened use alloy#urlFormName use alloy#uuidFormat +use alloy#httpSuccess +use alloy#httpPolymorphicResponse use alloy.common#countryCodeFormat use alloy.common#emailFormat use alloy.common#hexColorCodeFormat @@ -211,3 +213,24 @@ structure TestUrlFormName { @urlFormName("Test") test: String } + +operation TestHttpPolymporhicResponse { + input:= { + @httpPolymorphicResponse + @required + response: PolymporhicResponseUnion + } +} + +union PolymporhicResponseUnion{ + created: TestCreated + okay: TestOkay +} + +@httpSuccess(201) +structure TestCreated { +} + +@httpSuccess(200) +structure TestOkay { +} diff --git a/modules/core/test/src/alloy/validation/HttpPolymorphicResponseValidatorSpec.scala b/modules/core/test/src/alloy/validation/HttpPolymorphicResponseValidatorSpec.scala new file mode 100644 index 00000000..b6a82eeb --- /dev/null +++ b/modules/core/test/src/alloy/validation/HttpPolymorphicResponseValidatorSpec.scala @@ -0,0 +1,167 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * 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 alloy.validation + +import software.amazon.smithy.model.Model + +import scala.jdk.CollectionConverters._ +import software.amazon.smithy.model.validation.Severity + +final class HttpPolymorphicResponseValidatorSpec extends munit.FunSuite { + + test( + "Validator checks that all targets of the polymorphic response union have @httpSuccess" + ) { + val modelString = + """|$version: "2" + | + |namespace foo + | + |use alloy#httpPolymorphicResponse + | + |operation Test { + | output := { + | @required + | @httpPolymorphicResponse + | response: Response + | } + |} + | + |union Response { + | created: Created + |} + | + |structure Created { + |} + | + |""".stripMargin + + val events = Model + .assembler(this.getClass().getClassLoader()) + .addUnparsedModel("foo.smithy", modelString) + .discoverModels() + .assemble() + .getValidationEvents() + .asScala + .filter(_.getSeverity() == Severity.ERROR) + + assertEquals(events.size, 1) + assertEquals( + events.head.getMessage(), + HttpPolymorphicResponseValidator.EXPECTED_HTTP_SUCCESS_ON_ALL_MEMBER_TARGETS + ) + } + + test( + "Validator checks that @httpPolymorphicResponse annotates the only member of a structure" + ) { + val modelString = + """|$version: "2" + | + |namespace foo + | + |use alloy#httpPolymorphicResponse + |use alloy#httpSuccess + | + |operation Test { + | output := { + | @required + | @httpPolymorphicResponse + | response: Response + | + | illegalMember: String + | } + |} + | + |union Response { + | created: Created + |} + | + | + |@httpSuccess(201) + |structure Created { + |} + | + |""".stripMargin + + val events = Model + .assembler(this.getClass().getClassLoader()) + .addUnparsedModel("foo.smithy", modelString) + .discoverModels() + .assemble() + .getValidationEvents() + .asScala + .filter(_.getSeverity() == Severity.ERROR) + + assertEquals(events.size, 1) + assertEquals( + events.head.getMessage(), + HttpPolymorphicResponseValidator.EXPECTED_SINGLE_MEMBER + ) + } + + test( + "Validator checks that union-members target shapes with distinct @httpSuccess values" + ) { + val modelString = + """|$version: "2" + | + |namespace foo + | + |use alloy#httpPolymorphicResponse + |use alloy#httpSuccess + | + |operation Test { + | output := { + | @required + | @httpPolymorphicResponse + | response: Response + | } + |} + | + |union Response { + | created: Created + | okay: Okay + |} + | + | + |@httpSuccess(201) + |structure Created { + |} + | + |@httpSuccess(201) + |structure Okay { + |} + | + |""".stripMargin + + val events = Model + .assembler(this.getClass().getClassLoader()) + .addUnparsedModel("foo.smithy", modelString) + .discoverModels() + .assemble() + .getValidationEvents() + .asScala + .filter(_.getSeverity() == Severity.ERROR) + + assertEquals(events.size, 1) + assertEquals( + events.head.getMessage(), + HttpPolymorphicResponseValidator.EXPECTED_DISTINCT_HTTP_SUCCESS + ) + } + +}