diff --git a/docs/source/1.0/guides/building-models/build-config.rst b/docs/source/1.0/guides/building-models/build-config.rst index 38d973a2757..5ca324793ca 100644 --- a/docs/source/1.0/guides/building-models/build-config.rst +++ b/docs/source/1.0/guides/building-models/build-config.rst @@ -862,6 +862,65 @@ key is not in the provided ``keys`` list. } } +.. _flattenNamespaces: + +flattenNamespaces +----------------- + +Flattens namespaces of any shapes connected to a service into a target +namespace. Shapes not connected to a service will not be flattened. + +.. list-table:: + :header-rows: 1 + :widths: 10 20 70 + + * - Property + - Type + - Description + * - namespace + - ``string`` + - **REQUIRED** The target namespace. + * - service + - ``shapeId`` + - **REQUIRED** The service to be flattened. All shapes within this + :ref:`service closure ` will be replaced with equivalent + shapes in the target namespace. + * - includeTagged + - ``[string]`` + - The set of tags that, if found on a shape not connected to the service, + forces the shape to have its namespace flattened into the target + namespace. When additional shapes are included, the shapes are replaced + entirely, along with any references to the shapes which may exist within + separate :ref:`service closures `. + +The following example will flatten the namespaces of the shapes connected to +the ``ns.bar#MyService`` service into the target namespace, ``ns.foo``. Shapes +tagged with ``baz`` or ``qux`` will also be flattened into the ``ns.foo`` +namespace, so long as they don't conflict with a shape within the :ref:`service closure `. + +.. tabs:: + + .. code-tab:: json + + { + "version": "1.0", + "projections": { + "exampleProjection": { + "transforms": [ + { + "name": "flattenNamespaces", + "args": { + "namespace": "ns.foo", + "service": "ns.bar#MyService", + "includeTagged": ["baz", "qux"] + } + } + ] + } + } + } + + .. _removeTraitDefinitions-transform: removeTraitDefinitions diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/transforms/FlattenNamespaces.java b/smithy-build/src/main/java/software/amazon/smithy/build/transforms/FlattenNamespaces.java new file mode 100644 index 00000000000..98f29c36849 --- /dev/null +++ b/smithy-build/src/main/java/software/amazon/smithy/build/transforms/FlattenNamespaces.java @@ -0,0 +1,181 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.build.transforms; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import software.amazon.smithy.build.SmithyBuildException; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.NeighborProviderIndex; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.neighbor.Walker; +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 software.amazon.smithy.utils.FunctionalUtils; +import software.amazon.smithy.utils.Pair; + +/** + * {@code flattenNamespaces} updates a model by flattening the namespaces of + * shapes connected to a service into a single, target namespace. When + * configuring the transformer, a service and target namespace must be + * specified. Optionally, tags can be specified for including any additional + * shapes that should be flattened into the the target namespace. Any shape + * from outside the service closure that is included via the application of a + * tag will not be included if it conflicts with a shape in the service closure. + */ +public final class FlattenNamespaces extends ConfigurableProjectionTransformer { + + /** + * {@code removeTraitShapes} configuration settings. + */ + public static final class Config { + + private String namespace; + private ShapeId service; + private Set tags = Collections.emptySet(); + + /** + * Sets the target namespace that existing namespaces will be flattened + * into. + * + * @param namespace The target namespace to use in the model. + */ + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + /** + * Gets the target namespace that existing namespaces will be flattened + * into. + * + * @return the target namespace to be used in the model. + */ + public String getNamespace() { + return namespace; + } + + /** + * Sets the service ShapeId that will be flattened into the target + * namespace. + * + * @param service The ID of the service. + */ + public void setService(ShapeId service) { + this.service = service; + } + + /** + * @return Gets the service shape ID of the service that will have + * its shape namespaces updated. + */ + public ShapeId getService() { + return service; + } + + /** + * Sets the set of tags that are retained in the model. + * + * @param tags The tags to retain in the model. + */ + public void setIncludeTagged(Set tags) { + this.tags = tags; + } + + /** + * Gets the set of tags that are retained in the model. + * + * @return Returns the tags to retain. + */ + public Set getIncludeTagged() { + return tags; + } + } + + @Override + public Class getConfigType() { + return Config.class; + } + + @Override + protected Model transformWithConfig(TransformContext context, Config config) { + if (config.getService() == null || config.getNamespace() == null) { + throw new SmithyBuildException( + "'namespace' and 'service' properties must be set on flattenNamespace transformer."); + } + Model model = context.getModel(); + Map shapesToRename = getRenamedShapes(config, model); + return ModelTransformer.create().renameShapes(model, shapesToRename); + } + + @Override + public String getName() { + return "flattenNamespaces"; + } + + private Map getRenamedShapes(Config config, Model model) { + if (!model.getShape(config.getService()).isPresent()) { + throw new SmithyBuildException("Configured service, " + config.getService() + + ", not found in model when performing flattenNamespaces transform."); + } + + Map shapesToRename = getRenamedShapesConnectedToService(config, model); + Set taggedShapesToInclude = getTaggedShapesToInclude(config.getIncludeTagged(), model); + + for (ShapeId id : taggedShapesToInclude) { + ShapeId updatedShapeId = updateNamespace(id, config.getNamespace()); + // If new shape ID already exists in map of shapes to rename, skip + // including the additional shape to avoid a conflict. + if (!shapesToRename.containsValue(updatedShapeId)) { + shapesToRename.put(id, updatedShapeId); + } + } + + return shapesToRename; + } + + private ShapeId updateNamespace(ShapeId shapeId, String namespace) { + if (shapeId.getMember().isPresent()) { + return ShapeId.fromParts(namespace, shapeId.getName(), shapeId.getMember().get()); + } + return ShapeId.fromParts(namespace, shapeId.getName()); + } + + private Map getRenamedShapesConnectedToService(Config config, Model model) { + Walker shapeWalker = new Walker(NeighborProviderIndex.of(model).getProvider()); + ServiceShape service = model.expectShape(config.getService(), ServiceShape.class); + return shapeWalker.walkShapes(service).stream() + .filter(FunctionalUtils.not(Prelude::isPreludeShape)) + .map(shape -> Pair.of(shape.getId(), updateNamespace(shape.getId(), config.getNamespace()))) + .collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + } + + private Set getTaggedShapesToInclude(Set tags, Model model) { + return model.shapes() + .filter(FunctionalUtils.not(Prelude::isPreludeShape)) + .filter(shape -> isTagged(tags, shape)) + .map(Shape::getId) + .collect(Collectors.toSet()); + } + + private boolean isTagged(Set tags, Shape shape) { + return shape.getTags().stream().anyMatch(tags::contains); + } +} diff --git a/smithy-build/src/test/java/software/amazon/smithy/build/transforms/FlattenNamespacesTest.java b/smithy-build/src/test/java/software/amazon/smithy/build/transforms/FlattenNamespacesTest.java new file mode 100644 index 00000000000..0f8b391626c --- /dev/null +++ b/smithy-build/src/test/java/software/amazon/smithy/build/transforms/FlattenNamespacesTest.java @@ -0,0 +1,181 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.build.transforms; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.SmithyBuildException; +import software.amazon.smithy.build.SourcesConflictException; +import software.amazon.smithy.build.TransformContext; +import software.amazon.smithy.build.plugins.SourcesPlugin; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.FunctionalUtils; + +public class FlattenNamespacesTest { + + @Test + public void flattenNamespacesOfShapesConnectedToSpecifiedService() throws Exception { + Model model = Model.assembler() + .addImport(Paths.get(getClass().getResource("flatten-namespaces.json").toURI())) + .assemble() + .unwrap(); + ObjectNode config = Node.objectNode() + .withMember("namespace", Node.from("ns.qux")) + .withMember("service", Node.from("ns.foo#MyService")); + TransformContext context = TransformContext.builder() + .model(model) + .settings(config) + .build(); + Model result = new FlattenNamespaces().transform(context); + List ids = result.shapes() + .filter(FunctionalUtils.not(Prelude::isPreludeShape)) + .map(Shape::getId) + .map(Object::toString) + .collect(Collectors.toList()); + + assertThat(ids, containsInAnyOrder("ns.qux#MyService", "ns.qux#MyOperation", "ns.qux#MyOperationOutput", + "ns.qux#MyOperationOutput$foo", "ns.corge#UnconnectedFromService", "ns.grault#MyOperationOutput")); + assertThat(ids, not(containsInAnyOrder("ns.foo#MyService", "ns.bar#MyOperation", "ns.baz#MyOperationOutput", + "ns.baz#MyOperationOutput$foo", "ns.qux#UnconnectedFromService"))); + } + + @Test + public void includesAdditionalTaggedShapes() throws Exception { + Model model = Model.assembler() + .addImport(Paths.get(getClass().getResource("flatten-namespaces.json").toURI())) + .assemble() + .unwrap(); + ObjectNode config = Node.objectNode() + .withMember("namespace", Node.from("ns.qux")) + .withMember("service", Node.from("ns.foo#MyService")) + .withMember("includeTagged", Node.arrayNode().withValue(Node.from("included"))); + TransformContext context = TransformContext.builder() + .model(model) + .settings(config) + .build(); + Model result = new FlattenNamespaces().transform(context); + List ids = result.shapes() + .filter(FunctionalUtils.not(Prelude::isPreludeShape)) + .map(Shape::getId) + .map(Object::toString) + .collect(Collectors.toList()); + + assertThat(ids, containsInAnyOrder("ns.qux#MyService", "ns.qux#MyOperation", "ns.qux#MyOperationOutput", + "ns.qux#MyOperationOutput$foo", "ns.qux#UnconnectedFromService", "ns.grault#MyOperationOutput")); + assertThat(ids, not(containsInAnyOrder("ns.foo#MyService", "ns.bar#MyOperation", "ns.baz#MyOperationOutput", + "ns.baz#MyOperationOutput$foo", "ns.corge#UnconnectedFromService"))); + } + + @Test + public void doesNotIncludeAdditionalTaggedShapesWhenTheyConflict() throws Exception { + Model model = Model.assembler() + .addImport(Paths.get(getClass().getResource("flatten-namespaces.json").toURI())) + .assemble() + .unwrap(); + ObjectNode config = Node.objectNode() + .withMember("namespace", Node.from("ns.qux")) + .withMember("service", Node.from("ns.foo#MyService")) + .withMember("includeTagged", Node.arrayNode().withValue(Node.from("conflicting"))); + TransformContext context = TransformContext.builder() + .model(model) + .settings(config) + .build(); + Model result = new FlattenNamespaces().transform(context); + List ids = result.shapes() + .filter(FunctionalUtils.not(Prelude::isPreludeShape)) + .map(Shape::getId) + .map(Object::toString) + .collect(Collectors.toList()); + + assertThat(ids, containsInAnyOrder("ns.qux#MyService", "ns.qux#MyOperation", "ns.qux#MyOperationOutput", + "ns.qux#MyOperationOutput$foo", "ns.corge#UnconnectedFromService", "ns.grault#MyOperationOutput")); + assertThat(ids, not(containsInAnyOrder("ns.foo#MyService", "ns.bar#MyOperation", "ns.baz#MyOperationOutput", + "ns.baz#MyOperationOutput$foo", "ns.qux#UnconnectedFromService"))); + } + + @Test + public void throwsWhenServiceIsNotConfigured() { + Model model = Model.assembler() + .addUnparsedModel("N/A", "{ \"smithy\": \"1.0\" }") + .assemble() + .unwrap(); + TransformContext context = TransformContext.builder() + .model(model) + .settings(Node.objectNode().withMember("namespace", Node.from("ns.bar"))) + .build(); + Assertions.assertThrows(SmithyBuildException.class, () -> new FlattenNamespaces().transform(context)); + } + + @Test + public void throwsWhenNamespaceIsNotConfigured() { + Model model = Model.assembler() + .addUnparsedModel("N/A", "{ \"smithy\": \"1.0\" }") + .assemble() + .unwrap(); + TransformContext context = TransformContext.builder() + .model(model) + .settings(Node.objectNode().withMember("service", Node.from("ns.foo#MyService"))) + .build(); + Assertions.assertThrows(SmithyBuildException.class, () -> new FlattenNamespaces().transform(context)); + } + + @Test + public void throwsWhenServiceCannotBeFoundInModel() { + Model model = Model.assembler() + .addUnparsedModel("N/A", "{ \"smithy\": \"1.0\" }") + .assemble() + .unwrap(); + ObjectNode config = Node.objectNode() + .withMember("namespace", Node.from("ns.qux")) + .withMember("service", Node.from("ns.foo#MyService")); + TransformContext context = TransformContext.builder() + .model(model) + .settings(config) + .build(); + Assertions.assertThrows(SmithyBuildException.class, () -> new FlattenNamespaces().transform(context)); + } + + @Test + public void throwsWhenServiceIsInvalidInModel() { + Model model = Model.assembler() + .addUnparsedModel("N/A", "{ \"smithy\": \"1.0\", \"shapes\": { \"ns.foo#InvalidService\": { \"type\": \"string\" } } }") + .assemble() + .unwrap(); + ObjectNode config = Node.objectNode() + .withMember("namespace", Node.from("ns.qux")) + .withMember("service", Node.from("ns.foo#InvalidService")); + TransformContext context = TransformContext.builder() + .model(model) + .settings(config) + .build(); + Assertions.assertThrows(ExpectationNotMetException.class, () -> new FlattenNamespaces().transform(context)); + } +} diff --git a/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/flatten-namespaces.json b/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/flatten-namespaces.json new file mode 100644 index 00000000000..7b2d1f1a2d1 --- /dev/null +++ b/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/flatten-namespaces.json @@ -0,0 +1,40 @@ +{ + "smithy": "1.0", + "shapes": { + "ns.foo#MyService": { + "type": "service", + "version": "2017-01-19", + "operations": [ + { + "target": "ns.bar#MyOperation" + } + ] + }, + "ns.bar#MyOperation": { + "type": "operation", + "output": { + "target": "ns.baz#MyOperationOutput" + } + }, + "ns.baz#MyOperationOutput": { + "type": "structure", + "members": { + "foo": { + "target": "smithy.api#String" + } + } + }, + "ns.corge#UnconnectedFromService": { + "type": "string", + "traits": { + "smithy.api#tags": ["included"] + } + }, + "ns.grault#MyOperationOutput": { + "type": "string", + "traits": { + "smithy.api#tags": ["conflicting"] + } + } + } +} diff --git a/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/no-service.json b/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/no-service.json new file mode 100644 index 00000000000..55d21962f81 --- /dev/null +++ b/smithy-build/src/test/resources/software/amazon/smithy/build/transforms/no-service.json @@ -0,0 +1,8 @@ +{ + "smithy": "1.0", + "shapes": { + "ns.foo#MyString": { + "type": "string" + } + } +}