diff --git a/.changes/next-release/feature-d0d9dd98dfc2595a63c97eca17c24604e402c1e5.json b/.changes/next-release/feature-d0d9dd98dfc2595a63c97eca17c24604e402c1e5.json new file mode 100644 index 00000000000..655ff8b6d35 --- /dev/null +++ b/.changes/next-release/feature-d0d9dd98dfc2595a63c97eca17c24604e402c1e5.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "description": "Added support for services to declare non-default operation names (e.g., AddTagsToResource) for tagging operations at the service level in the `tagEnabled` trait. Tag API discovery and validation honor the override before falling back to default-named operations.", + "pull_requests": [ + "[#3130](https://github.com/smithy-lang/smithy/pull/3130)" + ] +} diff --git a/docs/source-2.0/aws/aws-core.rst b/docs/source-2.0/aws/aws-core.rst index da49119eb04..4c289de5839 100644 --- a/docs/source-2.0/aws/aws-core.rst +++ b/docs/source-2.0/aws/aws-core.rst @@ -1320,11 +1320,17 @@ members: - ``boolean`` - Set to true to indicate that the service does not have default tagging operations that create, read, update, and delete tags on resources. + * - apiConfig + - :ref:`Taggable service API config structure ` + - Specifies non-default operation names for the service-wide tagging + APIs. Any unset member falls back to the default-named operation. The default operations for resource tagging operations are named TagResource, UntagResource, and ListTagsForResource. All three operations are required to be in the service, and each operation must satisfy corresponding validation -constraints unless ``disableDefaultOperations`` is set to **true**. +constraints unless ``disableDefaultOperations`` is set to **true**. The +``apiConfig`` member allows a service to use non-default operation names for +any of the three slots; an unset slot falls back to the default name. The following is a minimal snippet showing the inclusion of the named required operations for the ``aws.api#tagEnabled`` Weather service. @@ -1461,6 +1467,58 @@ through the operates attached to the service. } } +A service that uses non-default operation names can specify them via +``apiConfig``. Each member is independently optional; a slot left unset falls +back to the default-named operation. + +.. code-block:: smithy + + @tagEnabled(apiConfig: { + tagApi: AddTagsToResource + untagApi: RemoveTagsFromResource + listTagsApi: DescribeTagsForResource + }) + service Weather { + resources: [Forecast] + operations: [AddTagsToResource, RemoveTagsFromResource, DescribeTagsForResource] + } + + +.. _service-taggable-apiconfig-structure: + +Taggable service API config structure +===================================== + +Configuration structure for specifying service-wide tagging operations when +non-default operation names are used. Any unset member falls back to the +default-named operation (``TagResource``, ``UntagResource``, +``ListTagsForResource`` respectively). + +**Properties** + +.. list-table:: + :header-rows: 1 + :widths: 30 10 60 + + * - Property + - Type + - Description + * - tagApi + - ``ShapeID`` + - **Optional** Defines the service-wide operation that creates and + updates tags on resources. The value MUST be a valid :ref:`shape-id` + that targets an ``operation`` shape bound to the service. + * - untagApi + - ``ShapeID`` + - **Optional** Defines the service-wide operation that removes tags + from resources. The value MUST be a valid :ref:`shape-id` that + targets an ``operation`` shape bound to the service. + * - listTagsApi + - ``ShapeID`` + - **Optional** Defines the service-wide operation that lists tags on + resources. The value MUST be a valid :ref:`shape-id` that targets an + ``operation`` shape bound to the service. + .. smithy-trait:: aws.api#taggable .. _taggable-trait: diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndex.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndex.java index 24f6bc0a260..6ca992a88df 100644 --- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndex.java +++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndex.java @@ -9,6 +9,8 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.BiPredicate; +import java.util.function.Function; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.knowledge.KnowledgeIndex; import software.amazon.smithy.model.knowledge.OperationIndex; @@ -126,9 +128,12 @@ public boolean serviceHasTagApis(ToShapeId serviceShapeId) { } /** - * Gets the ShapeID of the TagResource operation on the shape if one is found by name. - * If a resource ID is passed in, it will return the qualifiying service wide TagResource operation ID rather than - * the operation ID specified by the tagApi property. + * Gets the ShapeID of the TagResource operation on the shape if one is found. + * For services, this returns the operation referenced by the service-level + * {@link TagEnabledTrait} apiConfig if set, otherwise the default-named + * {@code TagResource} operation if bound to the service. For resources, this + * returns the resource-level {@link TaggableTrait} apiConfig if set, otherwise + * the service-wide resolved operation. * * @param serviceOrResourceId ShapeID of the service shape to retrieve the qualifying TagResource operation for. * @return The ShapeID of a qualifying TagResource operation if one is found. Returns an empty optional otherwise. @@ -138,9 +143,12 @@ public Optional getTagResourceOperation(ToShapeId serviceOrResourceId) } /** - * Gets the ShapeID of the UntagResource operation on the shape if one is found meeting the criteria. - * If a resource ID is passed in, it will return the qualifiying service wide TagResource operation ID rather than - * the operation ID specified by the tagApi property. + * Gets the ShapeID of the UntagResource operation on the shape if one is found. + * For services, this returns the operation referenced by the service-level + * {@link TagEnabledTrait} apiConfig if set, otherwise the default-named + * {@code UntagResource} operation if bound to the service. For resources, this + * returns the resource-level {@link TaggableTrait} apiConfig if set, otherwise + * the service-wide resolved operation. * * @param serviceOrResourceId ShapeID of the service shape to retrieve the qualifying UntagResource operation for. * @return The ShapeID of a qualifying UntagResource operation if one is found. Returns an empty optional @@ -151,9 +159,12 @@ public Optional getUntagResourceOperation(ToShapeId serviceOrResourceId } /** - * Gets the ShapeID of the ListTagsForResource operation on the service shape if one is found meeting the criteria. - * If a resource ID is passed in, it will return the qualifiying service wide TagResource operation ID rather than - * the operation ID specified by the tagApi property. + * Gets the ShapeID of the ListTagsForResource operation on the shape if one is found. + * For services, this returns the operation referenced by the service-level + * {@link TagEnabledTrait} apiConfig if set, otherwise the default-named + * {@code ListTagsForResource} operation if bound to the service. For resources, + * this returns the resource-level {@link TaggableTrait} apiConfig if set, + * otherwise the service-wide resolved operation. * * @param serviceOrResourceId ShapeID of the service shape to retrieve the qualifying ListTagsForResource * operation for. @@ -205,84 +216,80 @@ private void computeTaggingApis(Model model, ServiceShape service) { for (ShapeId operationId : service.getOperations()) { operationMap.put(operationId.getName(), operationId); } - - calculateTagApi(model, service, operationMap); - calculateUntagApi(model, service, operationMap); - calculateListTagsApi(model, service, operationMap); - } - - private void calculateTagApi( - Model model, - ServiceShape service, - Map operationMap - ) { - TopDownIndex topDownIndex = TopDownIndex.of(model); OperationIndex operationIndex = OperationIndex.of(model); - - if (operationMap.containsKey(TaggingShapeUtils.TAG_RESOURCE_OPNAME)) { - ShapeId tagOperationId = operationMap.get(TaggingShapeUtils.TAG_RESOURCE_OPNAME); - shapeToTagOperation.put(service.getId(), tagOperationId); - OperationShape tagOperation = model.expectShape(tagOperationId, OperationShape.class); - if (TaggingShapeUtils.verifyTagResourceOperation(model, tagOperation, operationIndex)) { - serviceTagOperationIsValid.add(service.getId()); - } - } - for (ResourceShape resourceShape : topDownIndex.getContainedResources(service)) { - shapeToTagOperation.put(resourceShape.getId(), - resourceShape.getTrait(TaggableTrait.class) - .flatMap(TaggableTrait::getApiConfig) - .map(TaggableApiConfig::getTagApi) - .orElse(shapeToTagOperation.get(service.getId()))); - } - } - - private void calculateUntagApi( - Model model, - ServiceShape service, - Map operationMap - ) { TopDownIndex topDownIndex = TopDownIndex.of(model); - OperationIndex operationIndex = OperationIndex.of(model); - if (operationMap.containsKey(TaggingShapeUtils.UNTAG_RESOURCE_OPNAME)) { - ShapeId untagOperationId = operationMap.get(TaggingShapeUtils.UNTAG_RESOURCE_OPNAME); - shapeToUntagOperation.put(service.getId(), untagOperationId); - OperationShape untagOperation = model.expectShape(untagOperationId, OperationShape.class); - if (TaggingShapeUtils.verifyUntagResourceOperation(model, untagOperation, operationIndex)) { - serviceUntagOperationIsValid.add(service.getId()); - } - } - for (ResourceShape resourceShape : topDownIndex.getContainedResources(service)) { - shapeToUntagOperation.put(resourceShape.getId(), - resourceShape.getTrait(TaggableTrait.class) - .flatMap(TaggableTrait::getApiConfig) - .map(TaggableApiConfig::getUntagApi) - .orElse(shapeToUntagOperation.get(service.getId()))); - } + calculateServiceTagOp(model, + service, + operationMap, + operationIndex, + topDownIndex, + TaggingShapeUtils.TAG_RESOURCE_OPNAME, + TaggableServiceApiConfig::getTagApi, + TaggableApiConfig::getTagApi, + (m, op) -> TaggingShapeUtils.verifyTagResourceOperation(m, op, operationIndex), + shapeToTagOperation, + serviceTagOperationIsValid); + calculateServiceTagOp(model, + service, + operationMap, + operationIndex, + topDownIndex, + TaggingShapeUtils.UNTAG_RESOURCE_OPNAME, + TaggableServiceApiConfig::getUntagApi, + TaggableApiConfig::getUntagApi, + (m, op) -> TaggingShapeUtils.verifyUntagResourceOperation(m, op, operationIndex), + shapeToUntagOperation, + serviceUntagOperationIsValid); + calculateServiceTagOp(model, + service, + operationMap, + operationIndex, + topDownIndex, + TaggingShapeUtils.LIST_TAGS_OPNAME, + TaggableServiceApiConfig::getListTagsApi, + TaggableApiConfig::getListTagsApi, + (m, op) -> TaggingShapeUtils.verifyListTagsOperation(m, op, operationIndex), + shapeToListTagsOperation, + serviceListTagsOperationIsValid); } - private void calculateListTagsApi( + private void calculateServiceTagOp( Model model, ServiceShape service, - Map operationMap + Map operationMap, + OperationIndex operationIndex, + TopDownIndex topDownIndex, + String defaultOpName, + Function> serviceCfg, + Function resourceCfg, + BiPredicate verifier, + Map shapeToOpMap, + Set validitySet ) { - TopDownIndex topDownIndex = TopDownIndex.of(model); - OperationIndex operationIndex = OperationIndex.of(model); + // Service-level apiConfig wins; otherwise fall back to the default-named operation if bound. + Optional resolved = service.getTrait(TagEnabledTrait.class) + .flatMap(TagEnabledTrait::getApiConfig) + .flatMap(serviceCfg) + .filter(opId -> service.getOperations().contains(opId)) + .map(Optional::of) + .orElseGet(() -> Optional.ofNullable(operationMap.get(defaultOpName))); - if (operationMap.containsKey(TaggingShapeUtils.LIST_TAGS_OPNAME)) { - ShapeId listTagsOperationId = operationMap.get(TaggingShapeUtils.LIST_TAGS_OPNAME); - shapeToListTagsOperation.put(service.getId(), listTagsOperationId); - OperationShape listTagsOperation = model.expectShape(listTagsOperationId, OperationShape.class); - if (TaggingShapeUtils.verifyListTagsOperation(model, listTagsOperation, operationIndex)) { - serviceListTagsOperationIsValid.add(service.getId()); + resolved.ifPresent(opId -> { + shapeToOpMap.put(service.getId(), opId); + OperationShape op = model.expectShape(opId, OperationShape.class); + if (verifier.test(model, op)) { + validitySet.add(service.getId()); } - } + }); + + // Resource-level apiConfig still wins; otherwise fall through to the (possibly renamed) service-level op. for (ResourceShape resourceShape : topDownIndex.getContainedResources(service)) { - shapeToListTagsOperation.put(resourceShape.getId(), + shapeToOpMap.put(resourceShape.getId(), resourceShape.getTrait(TaggableTrait.class) .flatMap(TaggableTrait::getApiConfig) - .map(TaggableApiConfig::getListTagsApi) - .orElse(shapeToListTagsOperation.get(service.getId()))); + .map(resourceCfg) + .orElse(shapeToOpMap.get(service.getId()))); } } diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/ServiceTaggingValidator.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/ServiceTaggingValidator.java index c0aad92f26c..3e65905cf71 100644 --- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/ServiceTaggingValidator.java +++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/ServiceTaggingValidator.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Function; import software.amazon.smithy.model.FromSourceLocation; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.ServiceShape; @@ -42,7 +43,10 @@ private List validateService(ServiceShape service, AwsTagIndex events.add(getInvalidOperationEvent(service, trait, tagResourceId.get(), TAG_RESOURCE_OPNAME)); } } else { - events.add(getMissingOperationEvent(service, trait, TAG_RESOURCE_OPNAME)); + events.add(getMissingOperationEvent(service, + trait, + TAG_RESOURCE_OPNAME, + TaggableServiceApiConfig::getTagApi)); } Optional untagResourceId = awsTagIndex.getUntagResourceOperation(service.getId()); @@ -51,7 +55,10 @@ private List validateService(ServiceShape service, AwsTagIndex events.add(getInvalidOperationEvent(service, trait, untagResourceId.get(), UNTAG_RESOURCE_OPNAME)); } } else { - events.add(getMissingOperationEvent(service, trait, UNTAG_RESOURCE_OPNAME)); + events.add(getMissingOperationEvent(service, + trait, + UNTAG_RESOURCE_OPNAME, + TaggableServiceApiConfig::getUntagApi)); } Optional listTagsId = awsTagIndex.getListTagsForResourceOperation(service.getId()); @@ -60,17 +67,32 @@ private List validateService(ServiceShape service, AwsTagIndex events.add(getInvalidOperationEvent(service, trait, listTagsId.get(), LIST_TAGS_OPNAME)); } } else { - events.add(getMissingOperationEvent(service, trait, LIST_TAGS_OPNAME)); + events.add(getMissingOperationEvent(service, + trait, + LIST_TAGS_OPNAME, + TaggableServiceApiConfig::getListTagsApi)); } return events; } - private ValidationEvent getMissingOperationEvent(ServiceShape service, FromSourceLocation location, String opName) { + private ValidationEvent getMissingOperationEvent( + ServiceShape service, + TagEnabledTrait trait, + String defaultOpName, + Function> accessor + ) { + Optional configured = trait.getApiConfig().flatMap(accessor); + if (configured.isPresent()) { + return warning(service, + trait, + "Service marked `aws.api#TagEnabled` is missing an operation named '" + + configured.get().getName() + "' (specified by `apiConfig`)."); + } return warning(service, - location, + trait, "Service marked `aws.api#TagEnabled` is missing an operation named " - + "'" + opName + ".'"); + + "'" + defaultOpName + ".'"); } private ValidationEvent getInvalidOperationEvent( diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledServiceValidator.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledServiceValidator.java index 9930861b7b1..0a413021c8e 100644 --- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledServiceValidator.java +++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledServiceValidator.java @@ -5,11 +5,15 @@ package software.amazon.smithy.aws.traits.tagging; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.knowledge.TopDownIndex; import software.amazon.smithy.model.shapes.ResourceShape; import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.validation.AbstractValidator; import software.amazon.smithy.model.validation.ValidationEvent; @@ -54,6 +58,62 @@ private List validateService( + " consistent tagging operations implemented: {TagResource, UntagResource, and" + " ListTagsForResource}.")); } + + trait.getApiConfig().ifPresent(cfg -> { + Set opNames = new HashSet<>(); + for (ShapeId opId : service.getOperations()) { + opNames.add(opId.getName()); + } + checkApiConfigConflict(events, + service, + trait, + cfg.getTagApi(), + opNames, + TaggingShapeUtils.TAG_RESOURCE_OPNAME, + "tagApi"); + checkApiConfigConflict(events, + service, + trait, + cfg.getUntagApi(), + opNames, + TaggingShapeUtils.UNTAG_RESOURCE_OPNAME, + "untagApi"); + checkApiConfigConflict(events, + service, + trait, + cfg.getListTagsApi(), + opNames, + TaggingShapeUtils.LIST_TAGS_OPNAME, + "listTagsApi"); + }); + return events; } + + private void checkApiConfigConflict( + List events, + ServiceShape service, + TagEnabledTrait trait, + Optional configured, + Set opNames, + String defaultOpName, + String apiConfigMember + ) { + if (!configured.isPresent()) { + return; + } + if (configured.get().getName().equals(defaultOpName)) { + return; + } + if (!opNames.contains(defaultOpName)) { + return; + } + events.add(warning(service, + trait, + "Service has `apiConfig." + apiConfigMember + "` set to `" + configured.get() + + "` but also has a service-bound operation named `" + defaultOpName + + "`. The default-named operation will be ignored for tagging API discovery." + + " Remove either the `apiConfig` override or the `" + defaultOpName + + "` operation to avoid ambiguity.")); + } } diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTrait.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTrait.java index 03610cc30db..2827135d167 100644 --- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTrait.java +++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTrait.java @@ -4,6 +4,7 @@ */ package software.amazon.smithy.aws.traits.tagging; +import java.util.Optional; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.ObjectNode; import software.amazon.smithy.model.shapes.ShapeId; @@ -19,10 +20,12 @@ public final class TagEnabledTrait extends AbstractTrait implements ToSmithyBuil public static final ShapeId ID = ShapeId.from("aws.api#tagEnabled"); private final boolean disableDefaultOperations; + private final TaggableServiceApiConfig apiConfig; public TagEnabledTrait(Builder builder) { super(ID, builder.getSourceLocation()); disableDefaultOperations = builder.disableDefaultOperations; + apiConfig = builder.apiConfig; } @Override @@ -32,6 +35,7 @@ protected Node createNode() { if (disableDefaultOperations) { builder.withMember("disableDefaultOperations", true); } + builder.withOptionalMember("apiConfig", getApiConfig().map(TaggableServiceApiConfig::toNode)); return builder.build(); } @@ -39,23 +43,40 @@ public boolean getDisableDefaultOperations() { return disableDefaultOperations; } + /** + * Gets the TaggableServiceApiConfig if the service overrides the default tagging operation names. + * + * @return the TaggableServiceApiConfig for the service. + */ + public Optional getApiConfig() { + return Optional.ofNullable(apiConfig); + } + public static Builder builder() { return new Builder(); } @Override public Builder toBuilder() { - return builder().disableDefaultOperations(disableDefaultOperations); + return builder() + .disableDefaultOperations(disableDefaultOperations) + .apiConfig(apiConfig); } public static final class Builder extends AbstractTraitBuilder { private Boolean disableDefaultOperations = false; + private TaggableServiceApiConfig apiConfig; public Builder disableDefaultOperations(Boolean disableDefaultOperations) { this.disableDefaultOperations = disableDefaultOperations; return this; } + public Builder apiConfig(TaggableServiceApiConfig apiConfig) { + this.apiConfig = apiConfig; + return this; + } + @Override public TagEnabledTrait build() { return new TagEnabledTrait(this); @@ -71,8 +92,21 @@ public ShapeId getShapeId() { @Override public TagEnabledTrait createTrait(ShapeId target, Node value) { ObjectNode objectNode = value.expectObjectNode(); - Boolean name = objectNode.getBooleanMemberOrDefault("disableDefaultOperations", false); - TagEnabledTrait result = builder().sourceLocation(value).disableDefaultOperations(name).build(); + Builder builder = builder().sourceLocation(value); + builder.disableDefaultOperations( + objectNode.getBooleanMemberOrDefault("disableDefaultOperations", false)); + objectNode.getObjectMember("apiConfig").ifPresent(apiNode -> { + TaggableServiceApiConfig.Builder cfg = TaggableServiceApiConfig.builder() + .sourceLocation(apiNode.getSourceLocation()); + apiNode.getStringMember("tagApi") + .ifPresent(s -> cfg.tagApi(ShapeId.from(s.getValue()))); + apiNode.getStringMember("untagApi") + .ifPresent(s -> cfg.untagApi(ShapeId.from(s.getValue()))); + apiNode.getStringMember("listTagsApi") + .ifPresent(s -> cfg.listTagsApi(ShapeId.from(s.getValue()))); + builder.apiConfig(cfg.build()); + }); + TagEnabledTrait result = builder.build(); result.setNodeCache(value); return result; } diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfig.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfig.java new file mode 100644 index 00000000000..33892e31cf6 --- /dev/null +++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfig.java @@ -0,0 +1,138 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.aws.traits.tagging; + +import java.util.Objects; +import java.util.Optional; +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.ToNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +/** + * Structure representing the configuration of service-wide tagging APIs when non-default operation names are used. + * All members are optional; an unset member implies the default-named operation + * (TagResource, UntagResource, ListTagsForResource respectively) is used. + */ +public final class TaggableServiceApiConfig + implements FromSourceLocation, ToNode, ToSmithyBuilder { + private final ShapeId tagApi; + private final ShapeId untagApi; + private final ShapeId listTagsApi; + private final SourceLocation sourceLocation; + + private TaggableServiceApiConfig(Builder builder) { + tagApi = builder.tagApi; + untagApi = builder.untagApi; + listTagsApi = builder.listTagsApi; + sourceLocation = Objects.requireNonNull(builder.sourceLocation); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the ShapeId of the operation that implements service-wide TagResource behavior, if specified. + * + * @return Optional ShapeId of the configured tag operation. + */ + public Optional getTagApi() { + return Optional.ofNullable(tagApi); + } + + /** + * Gets the ShapeId of the operation that implements service-wide UntagResource behavior, if specified. + * + * @return Optional ShapeId of the configured untag operation. + */ + public Optional getUntagApi() { + return Optional.ofNullable(untagApi); + } + + /** + * Gets the ShapeId of the operation that implements service-wide ListTagsForResource behavior, if specified. + * + * @return Optional ShapeId of the configured list tags operation. + */ + public Optional getListTagsApi() { + return Optional.ofNullable(listTagsApi); + } + + @Override + public SourceLocation getSourceLocation() { + return sourceLocation; + } + + @Override + public Builder toBuilder() { + return builder() + .tagApi(tagApi) + .untagApi(untagApi) + .listTagsApi(listTagsApi) + .sourceLocation(sourceLocation); + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .sourceLocation(getSourceLocation()) + .withOptionalMember("tagApi", getTagApi().map(id -> Node.from(id.toString()))) + .withOptionalMember("untagApi", getUntagApi().map(id -> Node.from(id.toString()))) + .withOptionalMember("listTagsApi", getListTagsApi().map(id -> Node.from(id.toString()))) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof TaggableServiceApiConfig)) { + return false; + } + TaggableServiceApiConfig other = (TaggableServiceApiConfig) o; + return toNode().equals(other.toNode()); + } + + @Override + public int hashCode() { + return toNode().hashCode(); + } + + public static final class Builder implements SmithyBuilder { + private ShapeId tagApi; + private ShapeId untagApi; + private ShapeId listTagsApi; + private SourceLocation sourceLocation = SourceLocation.none(); + + public Builder tagApi(ShapeId tagApi) { + this.tagApi = tagApi; + return this; + } + + public Builder untagApi(ShapeId untagApi) { + this.untagApi = untagApi; + return this; + } + + public Builder listTagsApi(ShapeId listTagsApi) { + this.listTagsApi = listTagsApi; + return this; + } + + public Builder sourceLocation(SourceLocation sourceLocation) { + this.sourceLocation = sourceLocation; + return this; + } + + @Override + public TaggableServiceApiConfig build() { + return new TaggableServiceApiConfig(this); + } + } +} diff --git a/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy b/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy index 10dd0ab3c70..c3fd7e8088c 100644 --- a/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy +++ b/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy @@ -239,6 +239,33 @@ structure tagEnabled { /// if the service does not have the standard tag operations supporting all /// resources on the service. Default value is `false` disableDefaultOperations: Boolean + + /// Specifies non-default operation names for the service-wide tagging APIs. + /// Any unset member falls back to the default-named operation + /// (TagResource, UntagResource, ListTagsForResource respectively). + apiConfig: TaggableServiceApiConfig +} + +/// Points to a service-bound operation designated for a service-wide tagging API. +@idRef( + failWhenMissing: true + selector: "service > operation" +) +string ServiceTagOperationReference + +/// Structure representing the configuration of service-wide tagging APIs when +/// non-default operation names are used. All members are optional; an unset +/// member implies the default-named operation (TagResource, UntagResource, +/// ListTagsForResource respectively) is used. +structure TaggableServiceApiConfig { + /// The operation that creates or updates tags on resources for this service. + tagApi: ServiceTagOperationReference + + /// The operation that removes tags from resources for this service. + untagApi: ServiceTagOperationReference + + /// The operation that lists tags on resources for this service. + listTagsApi: ServiceTagOperationReference } /// Points to an operation designated for a tagging APi diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndexTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndexTest.java index 693875ada6c..63e467362d6 100644 --- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndexTest.java +++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/AwsTagIndexTest.java @@ -24,6 +24,8 @@ public final class AwsTagIndexTest { private static final ShapeId WEATHER_SERVICE_ID = ShapeId.fromParts(NAMESPACE, "Weather"); private static final ShapeId UNTAGGED_SERVICE_ID = ShapeId.fromParts(NAMESPACE, "UntaggedService"); private static final ShapeId CITY_RESOURCE_ID = ShapeId.fromParts(NAMESPACE, "City"); + private static final ShapeId RENAMED_SERVICE_ID = ShapeId.fromParts(NAMESPACE, "RenamedTaggingService"); + private static final ShapeId PLOT_RESOURCE_ID = ShapeId.fromParts(NAMESPACE, "Plot"); private static Model model; private static AwsTagIndex tagIndex; @@ -100,6 +102,34 @@ public static Stream resourceTagMutabilities() { Arguments.of(ShapeId.fromParts(NAMESPACE, "Silo"), true, true)); } + @Test + public void detectsServiceApiConfigRenamedTagOperations() { + Optional tagOptional = tagIndex.getTagResourceOperation(RENAMED_SERVICE_ID); + assertTrue(tagOptional.isPresent()); + assertEquals(ShapeId.fromParts(NAMESPACE, "AddTagsToResource"), tagOptional.get()); + + Optional untagOptional = tagIndex.getUntagResourceOperation(RENAMED_SERVICE_ID); + assertTrue(untagOptional.isPresent()); + assertEquals(ShapeId.fromParts(NAMESPACE, "RemoveTagsFromResource"), untagOptional.get()); + + Optional listTagsOptional = tagIndex.getListTagsForResourceOperation(RENAMED_SERVICE_ID); + assertTrue(listTagsOptional.isPresent()); + assertEquals(ShapeId.fromParts(NAMESPACE, "DescribeTagsForResource"), listTagsOptional.get()); + + assertTrue(tagIndex.serviceHasTagApis(RENAMED_SERVICE_ID)); + assertTrue(tagIndex.serviceHasValidTagResourceOperation(RENAMED_SERVICE_ID)); + assertTrue(tagIndex.serviceHasValidUntagResourceOperation(RENAMED_SERVICE_ID)); + assertTrue(tagIndex.serviceHasValidListTagsForResourceOperation(RENAMED_SERVICE_ID)); + } + + @Test + public void resourceFallsThroughToServiceApiConfig() { + // Plot resource has no resource-level apiConfig; should resolve to the service-level renamed op. + Optional tagOptional = tagIndex.getTagResourceOperation(PLOT_RESOURCE_ID); + assertTrue(tagOptional.isPresent()); + assertEquals(ShapeId.fromParts(NAMESPACE, "AddTagsToResource"), tagOptional.get()); + } + @Test public void resolvesTagMember() { assertFalse(tagIndex.getTagsMember(ShapeId.fromParts(NAMESPACE, "GetCity")).isPresent()); diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTraitTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTraitTest.java index 6ab2f924517..5eb2217e532 100644 --- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTraitTest.java +++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TagEnabledTraitTest.java @@ -5,7 +5,9 @@ package software.amazon.smithy.aws.traits.tagging; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,6 +16,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; import software.amazon.smithy.model.node.StringNode; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.traits.Trait; @@ -34,6 +37,7 @@ public void loadsTrait() { assertThat(trait.get(), instanceOf(TagEnabledTrait.class)); TagEnabledTrait typedTrait = (TagEnabledTrait) trait.get(); assertTrue(typedTrait.getDisableDefaultOperations()); + assertFalse(typedTrait.getApiConfig().isPresent()); } @Test @@ -48,5 +52,59 @@ public void loadsTraitDefaultCheck() { assertThat(trait.get(), instanceOf(TagEnabledTrait.class)); TagEnabledTrait typedTrait = (TagEnabledTrait) trait.get(); assertFalse(typedTrait.getDisableDefaultOperations()); + assertFalse(typedTrait.getApiConfig().isPresent()); + } + + @Test + public void loadsTraitWithApiConfig() { + TraitFactory provider = TraitFactory.createServiceFactory(); + ObjectNode apiConfigNode = Node.objectNodeBuilder() + .withMember("tagApi", "ns.qux#AddTagsToResource") + .withMember("untagApi", "ns.qux#RemoveTagsFromResource") + .withMember("listTagsApi", "ns.qux#DescribeTagsForResource") + .build(); + ObjectNode objectNode = Node.objectNodeBuilder() + .withMember("apiConfig", apiConfigNode) + .build(); + + Optional trait = provider.createTrait( + ShapeId.from("aws.api#tagEnabled"), + ShapeId.from("ns.qux#foo"), + objectNode); + + assertTrue(trait.isPresent()); + TagEnabledTrait typedTrait = (TagEnabledTrait) trait.get(); + assertFalse(typedTrait.getDisableDefaultOperations()); + assertTrue(typedTrait.getApiConfig().isPresent()); + TaggableServiceApiConfig cfg = typedTrait.getApiConfig().get(); + assertEquals(Optional.of(ShapeId.from("ns.qux#AddTagsToResource")), cfg.getTagApi()); + assertEquals(Optional.of(ShapeId.from("ns.qux#RemoveTagsFromResource")), cfg.getUntagApi()); + assertEquals(Optional.of(ShapeId.from("ns.qux#DescribeTagsForResource")), cfg.getListTagsApi()); + assertThat(typedTrait.toNode(), equalTo(objectNode)); + } + + @Test + public void loadsTraitWithPartialApiConfig() { + TraitFactory provider = TraitFactory.createServiceFactory(); + ObjectNode apiConfigNode = Node.objectNodeBuilder() + .withMember("tagApi", "ns.qux#AddTagsToResource") + .build(); + ObjectNode objectNode = Node.objectNodeBuilder() + .withMember("apiConfig", apiConfigNode) + .build(); + + Optional trait = provider.createTrait( + ShapeId.from("aws.api#tagEnabled"), + ShapeId.from("ns.qux#foo"), + objectNode); + + assertTrue(trait.isPresent()); + TagEnabledTrait typedTrait = (TagEnabledTrait) trait.get(); + TaggableServiceApiConfig cfg = typedTrait.getApiConfig().get(); + assertEquals(Optional.of(ShapeId.from("ns.qux#AddTagsToResource")), cfg.getTagApi()); + assertFalse(cfg.getUntagApi().isPresent()); + assertFalse(cfg.getListTagsApi().isPresent()); + assertThat(typedTrait.toNode(), equalTo(objectNode)); + assertThat(typedTrait.toBuilder().build(), equalTo(typedTrait)); } } diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfigTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfigTest.java new file mode 100644 index 00000000000..2d2d6038bd0 --- /dev/null +++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/tagging/TaggableServiceApiConfigTest.java @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.aws.traits.tagging; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.ShapeId; + +public class TaggableServiceApiConfigTest { + @Test + public void roundTripsAllMembers() { + TaggableServiceApiConfig cfg = TaggableServiceApiConfig.builder() + .tagApi(ShapeId.from("ns.qux#TagIt")) + .untagApi(ShapeId.from("ns.qux#Untag")) + .listTagsApi(ShapeId.from("ns.qux#ListTags")) + .build(); + + Node node = cfg.toNode(); + assertThat(node, + equalTo(Node.objectNodeBuilder() + .withMember("tagApi", "ns.qux#TagIt") + .withMember("untagApi", "ns.qux#Untag") + .withMember("listTagsApi", "ns.qux#ListTags") + .build())); + assertThat(cfg.toBuilder().build(), equalTo(cfg)); + } + + @Test + public void allMembersOptional() { + TaggableServiceApiConfig cfg = TaggableServiceApiConfig.builder().build(); + assertFalse(cfg.getTagApi().isPresent()); + assertFalse(cfg.getUntagApi().isPresent()); + assertFalse(cfg.getListTagsApi().isPresent()); + assertThat(cfg.toNode(), equalTo(Node.objectNode())); + } + + @Test + public void supportsPartialConfiguration() { + TaggableServiceApiConfig cfg = TaggableServiceApiConfig.builder() + .tagApi(ShapeId.from("ns.qux#TagIt")) + .build(); + + assertEquals(Optional.of(ShapeId.from("ns.qux#TagIt")), cfg.getTagApi()); + assertFalse(cfg.getUntagApi().isPresent()); + assertFalse(cfg.getListTagsApi().isPresent()); + + Node node = cfg.toNode(); + assertThat(node, + equalTo(Node.objectNodeBuilder() + .withMember("tagApi", "ns.qux#TagIt") + .build())); + } +} diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.errors b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.errors new file mode 100644 index 00000000000..4a3e5ce8593 --- /dev/null +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.errors @@ -0,0 +1 @@ +[WARNING] example.weather#Weather: Service has `apiConfig.tagApi` set to `example.weather#AddTagsToResource` but also has a service-bound operation named `TagResource`. The default-named operation will be ignored for tagging API discovery. Remove either the `apiConfig` override or the `TagResource` operation to avoid ambiguity. | TagEnabledService diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.smithy new file mode 100644 index 00000000000..dc2097e2607 --- /dev/null +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-conflict.smithy @@ -0,0 +1,103 @@ +$version: "2.0" + +metadata suppressions = [ + { + id: "UnstableTrait", + namespace: "example.weather" + } +] + +namespace example.weather + +use aws.api#arn +use aws.api#taggable +use aws.api#tagEnabled + +// `apiConfig.tagApi` is set to `AddTagsToResource`, but the service also binds an +// operation named `TagResource`. The default-named op is dead code in this model +// and the validator should warn about the ambiguity. +@tagEnabled(apiConfig: { + tagApi: AddTagsToResource +}) +service Weather { + version: "2006-03-01" + resources: [City] + operations: [AddTagsToResource, TagResource, UntagResource, ListTagsForResource] +} + +structure Tag { + key: String + value: String +} + +list TagList { + member: Tag +} + +list TagKeys { + member: String +} + +operation AddTagsToResource { + input := { + @required + resourceArn: String + @length(max: 128) + tags: TagList + } + output := { } +} + +operation TagResource { + input := { + @required + resourceArn: String + @length(max: 128) + tags: TagList + } + output := { } +} + +operation UntagResource { + input := { + @required + resourceArn: String + @required + tagKeys: TagKeys + } + output := { } +} + +@readonly +operation ListTagsForResource { + input := { + @required + resourceArn: String + } + output := { + @length(max: 128) + tags: TagList + } +} + +@arn(template: "city/{cityId}") +@taggable(property: "tags") +resource City { + identifiers: { cityId: String } + properties: { + name: String + tags: TagList + } + create: CreateCity +} + +operation CreateCity { + input := for City { + $name + $tags + } + output := for City { + @required + $cityId + } +} diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.errors b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.errors new file mode 100644 index 00000000000..b304e1ce6f2 --- /dev/null +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.errors @@ -0,0 +1,4 @@ +[DANGER] example.weather#City: Resource does not have tagging CRUD operations and is not compatible with service-wide tagging operations for service `example.weather#Weather`. | TaggableResource +[WARNING] example.weather#Weather: Service marked `aws.api#TagEnabled` is missing an operation named 'ListTagsForResource.' | ServiceTagging +[WARNING] example.weather#Weather: Service marked `aws.api#TagEnabled` is missing an operation named 'UntagResource.' | ServiceTagging +[WARNING] example.weather#Weather: Service marked `aws.api#tagEnabled` trait does not have consistent tagging operations implemented: {TagResource, UntagResource, and ListTagsForResource}. | TagEnabledService diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.smithy new file mode 100644 index 00000000000..94d307ead27 --- /dev/null +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/invalid-service-api-config-missing-op.smithy @@ -0,0 +1,67 @@ +$version: "2.0" + +metadata suppressions = [ + { + id: "UnstableTrait", + namespace: "example.weather" + } +] + +namespace example.weather + +use aws.api#arn +use aws.api#taggable +use aws.api#tagEnabled + +// Service overrides only TagResource via apiConfig; UntagResource and ListTagsForResource +// are missing entirely. Expect ServiceTagging warnings for the missing slots and a +// TagEnabledService aggregate warning. +@tagEnabled(apiConfig: { + tagApi: AddTagsToResource +}) +service Weather { + version: "2006-03-01" + resources: [City] + operations: [AddTagsToResource] +} + +structure Tag { + key: String + value: String +} + +list TagList { + member: Tag +} + +operation AddTagsToResource { + input := { + @required + resourceArn: String + @length(max: 128) + tags: TagList + } + output := { } +} + +@arn(template: "city/{cityId}") +@taggable(property: "tags") +resource City { + identifiers: { cityId: String } + properties: { + name: String + tags: TagList + } + create: CreateCity +} + +operation CreateCity { + input := for City { + $name + $tags + } + output := for City { + @required + $cityId + } +} diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/valid-service-api-config.errors b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/valid-service-api-config.errors new file mode 100644 index 00000000000..e69de29bb2d diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/valid-service-api-config.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/valid-service-api-config.smithy new file mode 100644 index 00000000000..a0e45ed6592 --- /dev/null +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/tagging/valid-service-api-config.smithy @@ -0,0 +1,92 @@ +$version: "2.0" + +metadata suppressions = [ + { + id: "UnstableTrait", + namespace: "example.weather" + } +] + +namespace example.weather + +use aws.api#arn +use aws.api#taggable +use aws.api#tagEnabled + +@tagEnabled(apiConfig: { + tagApi: AddTagsToResource + untagApi: RemoveTagsFromResource + listTagsApi: DescribeTagsForResource +}) +service Weather { + version: "2006-03-01" + resources: [City] + operations: [AddTagsToResource, RemoveTagsFromResource, DescribeTagsForResource] +} + +structure Tag { + key: String + value: String +} + +list TagList { + member: Tag +} + +list TagKeys { + member: String +} + +operation AddTagsToResource { + input := { + @required + resourceArn: String + @length(max: 128) + tags: TagList + } + output := { } +} + +operation RemoveTagsFromResource { + input := { + @required + resourceArn: String + @required + tagKeys: TagKeys + } + output := { } +} + +@readonly +operation DescribeTagsForResource { + input := { + @required + resourceArn: String + } + output := { + @length(max: 128) + tags: TagList + } +} + +@arn(template: "city/{cityId}") +@taggable(property: "tags") +resource City { + identifiers: { cityId: String } + properties: { + name: String + tags: TagList + } + create: CreateCity +} + +operation CreateCity { + input := for City { + $name + $tags + } + output := for City { + @required + $cityId + } +} diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/tagging/aws-tag-index-test-model.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/tagging/aws-tag-index-test-model.smithy index 990a788f24e..00d93647d24 100644 --- a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/tagging/aws-tag-index-test-model.smithy +++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/tagging/aws-tag-index-test-model.smithy @@ -27,6 +27,71 @@ service Weather { service UntaggedService {} +@tagEnabled(apiConfig: { + tagApi: AddTagsToResource + untagApi: RemoveTagsFromResource + listTagsApi: DescribeTagsForResource +}) +service RenamedTaggingService { + version: "2006-03-01" + resources: [Plot] + operations: [AddTagsToResource, RemoveTagsFromResource, DescribeTagsForResource] +} + +operation AddTagsToResource { + input := { + @required + resourceArn: String + @length(max: 128) + tags: TagList + } + output := { } +} + +operation RemoveTagsFromResource { + input := { + @required + resourceArn: String + @required + tagKeys: TagKeys + } + output := { } +} + +@readonly +operation DescribeTagsForResource { + input := { + @required + resourceArn: String + } + output := { + @length(max: 128) + tags: TagList + } +} + +@arn(template: "plot/{plotId}") +@taggable(property: "tags") +resource Plot { + identifiers: { plotId: String } + properties: { + name: String + tags: TagList + } + create: CreatePlot +} + +operation CreatePlot { + input := for Plot { + $name + $tags + } + output := for Plot { + @required + $plotId + } +} + structure Tag { key: String value: String