diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadata.java b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadata.java new file mode 100644 index 000000000000..82105501bc7f --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadata.java @@ -0,0 +1,256 @@ +/* + * Copyright 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.awssdk.codegen.smithy; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import software.amazon.awssdk.codegen.internal.Constant; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.Metadata; +import software.amazon.awssdk.codegen.model.intermediate.Protocol; +import software.amazon.awssdk.codegen.model.service.AuthType; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.awssdk.codegen.utils.ProtocolUtils; +import software.amazon.awssdk.utils.Pair; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.smithy.aws.traits.ServiceTrait; +import software.amazon.smithy.aws.traits.auth.SigV4Trait; +import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait; +import software.amazon.smithy.aws.traits.protocols.AwsQueryCompatibleTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AuthTrait; +import software.amazon.smithy.model.traits.DocumentationTrait; +import software.amazon.smithy.model.traits.HttpBearerAuthTrait; +import software.amazon.smithy.model.traits.TitleTrait; +import software.amazon.smithy.model.traits.Trait; + +/** + * Constructs the {@link Metadata} for the intermediate model from a Smithy service. The Smithy + * counterpart to C2J's {@code AddMetadata}. + * + *

{@code serviceAbbreviation} has no Smithy equivalent and is left null, so + * {@link Metadata#getDescriptiveServiceName()} falls back to the service full name. Javadoc difference + * only. + */ +final class AddSmithyMetadata { + private static final String AWS_PACKAGE_PREFIX = "software.amazon.awssdk.services"; + private static final String HTTP = "http"; + private static final String EVENT_STREAM_HTTP = "eventStreamHttp"; + private static final String H2 = "h2"; + + private AddSmithyMetadata() { + } + + static Metadata constructMetadata(Model model, + ServiceShape service, + ServiceIndex serviceIndex, + NamingStrategy namingStrategy, + CustomizationConfig customizationConfig) { + Metadata metadata = new Metadata(); + + String serviceName = namingStrategy.getServiceName(); + configurePackageName(metadata, namingStrategy, customizationConfig); + + String protocol = ProtocolUtils.resolveProtocol(serviceIndex, service); + + metadata.withApiVersion(service.getVersion()) + .withAsyncClient(String.format(Constant.ASYNC_CLIENT_CLASS_NAME_PATTERN, serviceName)) + .withAsyncInterface(String.format(Constant.ASYNC_CLIENT_INTERFACE_NAME_PATTERN, serviceName)) + .withAsyncBuilder(String.format(Constant.ASYNC_BUILDER_CLASS_NAME_PATTERN, serviceName)) + .withAsyncBuilderInterface(String.format(Constant.ASYNC_BUILDER_INTERFACE_NAME_PATTERN, serviceName)) + .withBaseBuilderInterface(String.format(Constant.BASE_BUILDER_INTERFACE_NAME_PATTERN, serviceName)) + .withBaseBuilder(String.format(Constant.BASE_BUILDER_CLASS_NAME_PATTERN, serviceName)) + .withDocumentation(documentation(service)) + .withServiceAbbreviation(null) + .withBatchmanagerPackageName(namingStrategy.getBatchManagerPackageName(serviceName)) + .withPresignedUrlPackageName(namingStrategy.getPresignedUrlPackageName(serviceName)) + .withServiceFullName(serviceFullName(service)) + .withServiceName(serviceName) + .withSyncClient(String.format(Constant.SYNC_CLIENT_CLASS_NAME_PATTERN, serviceName)) + .withSyncInterface(String.format(Constant.SYNC_CLIENT_INTERFACE_NAME_PATTERN, serviceName)) + .withSyncBuilder(String.format(Constant.SYNC_BUILDER_CLASS_NAME_PATTERN, serviceName)) + .withSyncBuilderInterface(String.format(Constant.SYNC_BUILDER_INTERFACE_NAME_PATTERN, serviceName)) + .withBaseExceptionName(String.format(Constant.BASE_EXCEPTION_NAME_PATTERN, serviceName)) + .withBaseRequestName(String.format(Constant.BASE_REQUEST_NAME_PATTERN, serviceName)) + .withBaseResponseName(String.format(Constant.BASE_RESPONSE_NAME_PATTERN, serviceName)) + .withProtocol(Protocol.fromValue(protocol)) + .withEndpointPrefix(endpointPrefix(service)) + .withSigningName(namingStrategy.getSigningName()) + .withAuthType(authType(service)) + .withUid(uid(service)) + .withServiceId(serviceId(service)) + .withSupportsH2(supportsH2(serviceIndex, service)) + .withAwsQueryCompatible(awsQueryCompatible(service)) + .withAuth(auth(service)); + + metadata.withJsonVersion(jsonVersion(metadata, service)); + + return metadata; + } + + private static void configurePackageName(Metadata metadata, + NamingStrategy namingStrategy, + CustomizationConfig customizationConfig) { + String packageName = customizationConfig.getRootPackageName(); + + Optional> packageNamePair = splitCustomRootPackageName(packageName); + String rootPackageWithoutServiceId = packageNamePair.map(pkg -> StringUtils.lowerCase(pkg.left())) + .orElse(AWS_PACKAGE_PREFIX); + + String service = packageNamePair.map(pkg -> StringUtils.lowerCase(pkg.right())) + .orElse(namingStrategy.getServiceName()); + + metadata.withRootPackageName(rootPackageWithoutServiceId) + .withClientPackageName(namingStrategy.getClientPackageName(service)) + .withModelPackageName(namingStrategy.getModelPackageName(service)) + .withTransformPackageName(namingStrategy.getTransformPackageName(service)) + .withRequestTransformPackageName(namingStrategy.getRequestTransformPackageName(service)) + .withPaginatorsPackageName(namingStrategy.getPaginatorsPackageName(service)) + .withWaitersPackageName(namingStrategy.getWaitersPackageName(service)) + .withEndpointRulesPackageName(namingStrategy.getEndpointRulesPackageName(service)) + .withAuthSchemePackageName(namingStrategy.getAuthSchemePackageName(service)) + .withJmesPathPackageName(namingStrategy.getJmesPathPackageName(service)); + } + + private static Optional> splitCustomRootPackageName(String rootPackageName) { + if (rootPackageName == null) { + return Optional.empty(); + } + int i = rootPackageName.lastIndexOf('.'); + return Optional.of(Pair.of(rootPackageName.substring(0, i), rootPackageName.substring(i + 1))); + } + + /** + * Smithy has no {@code jsonVersion} field; the version is encoded in the protocol trait. So + * awsJson1_0 is the only case that differs from C2J's {@code 1.1} default for JSON protocols. + */ + private static String jsonVersion(Metadata metadata, ServiceShape service) { + if (!metadata.isJsonProtocol()) { + return null; + } + if (service.hasTrait(AwsJson1_0Trait.class)) { + return "1.0"; + } + return "1.1"; + } + + /** + * C2J derives this from {@code protocolSettings.containsKey("h2")}. Smithy carries the equivalent + * on the protocol trait: {@code http} lists supported HTTP versions, {@code eventStreamHttp} + * those required for event streams. + * + *

Read off the trait node rather than via {@code AwsProtocolTrait} because + * {@code Rpcv2CborTrait} inherits these members from a different base class, in a jar that is + * only test-scoped here. + */ + private static boolean supportsH2(ServiceIndex serviceIndex, ServiceShape service) { + for (Trait protocolTrait : serviceIndex.getProtocols(service).values()) { + Optional node = protocolTrait.toNode().asObjectNode(); + if (!node.isPresent()) { + continue; + } + if (listsH2(node.get(), HTTP) || listsH2(node.get(), EVENT_STREAM_HTTP)) { + return true; + } + } + return false; + } + + private static boolean listsH2(ObjectNode protocolTrait, String member) { + return protocolTrait.getArrayMember(member) + .map(versions -> versions.getElementsAs(StringNode.class).stream() + .anyMatch(v -> H2.equals(v.getValue()))) + .orElse(false); + } + + /** + * C2J carries an always-empty map as a marker; the Smithy equivalent is an annotation trait, + * which carries no value at all. + */ + private static Map awsQueryCompatible(ServiceShape service) { + return service.hasTrait(AwsQueryCompatibleTrait.class) ? new LinkedHashMap<>() : null; + } + + private static String documentation(ServiceShape service) { + return service.getTrait(DocumentationTrait.class).map(DocumentationTrait::getValue).orElse(null); + } + + private static String serviceFullName(ServiceShape service) { + return service.getTrait(TitleTrait.class).map(TitleTrait::getValue).orElse(null); + } + + private static String serviceId(ServiceShape service) { + return service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getSdkId) + .orElseThrow(() -> new IllegalStateException( + "Service is missing @aws.api#service trait: " + service.getId())); + } + + /** + * C2J's {@code uid} builds the generated API-doc cross-links; Smithy's {@code docId} is declared + * for that purpose. {@code resolveDocId} falls back to sdkId-plus-version, which models override + * explicitly when that does not match. + */ + private static String uid(ServiceShape service) { + return service.getTrait(ServiceTrait.class) + .map(trait -> trait.resolveDocId(service)) + .orElse(null); + } + + private static String endpointPrefix(ServiceShape service) { + return service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getEndpointPrefix) + .orElse(null); + } + + /** + * The legacy single {@code authType}; C2J derives it from the service {@code signatureVersion}. + */ + private static AuthType authType(ServiceShape service) { + if (service.hasTrait(SigV4Trait.class)) { + return AuthType.V4; + } + return null; + } + + /** + * C2J's {@code metadata.auth} is the service's effective auth, so the fallback matters: reading + * only {@code @auth} would leave this empty for services that never declare it, such as EC2. + */ + private static List auth(ServiceShape service) { + List auth = new ArrayList<>(); + if (service.hasTrait(AuthTrait.class)) { + for (ShapeId schemeId : service.expectTrait(AuthTrait.class).getValues()) { + auth.add(AuthType.fromValue(schemeId.toString())); + } + return auth; + } + if (service.hasTrait(SigV4Trait.class)) { + auth.add(AuthType.V4); + } else if (service.hasTrait(HttpBearerAuthTrait.class)) { + auth.add(AuthType.BEARER); + } + return auth; + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadataTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadataTest.java new file mode 100644 index 000000000000..54933bfdcfd1 --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyMetadataTest.java @@ -0,0 +1,247 @@ +/* + * Copyright 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.awssdk.codegen.smithy; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.Metadata; +import software.amazon.awssdk.codegen.model.service.AuthType; +import software.amazon.awssdk.codegen.naming.DefaultSmithyNamingStrategy; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.shapes.ServiceShape; + +/** + * Unit tests for {@link AddSmithyMetadata}, covering the metadata fields whose Smithy source is not + * a straight copy of a single service trait. + */ +class AddSmithyMetadataTest { + + private static Model modelOf(String protocolUse, String serviceTraits, String extraShapes) { + String src = + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + protocolUse + + "\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@sigv4(name: \"demo\")\n" + + serviceTraits + + "service DemoService { version: \"2024-01-01\", operations: [Op] }\n\n" + + "operation Op { input: OpRequest, output: OpResponse }\n" + + "structure OpRequest {}\n" + + "structure OpResponse {}\n" + + extraShapes; + return Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", src) + .assemble() + .unwrap(); + } + + private static Metadata metadataOf(Model model) { + ServiceShape service = model.getServiceShapes().iterator().next(); + NamingStrategy naming = new DefaultSmithyNamingStrategy(model, service, CustomizationConfig.create()); + return AddSmithyMetadata.constructMetadata(model, service, ServiceIndex.of(model), naming, + CustomizationConfig.create()); + } + + // ---- jsonVersion ------------------------------------------------------ + + @Test + void awsJson1_0_jsonVersionIs10() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#awsJson1_0\n", "@awsJson1_0\n", "")); + assertThat(metadata.getJsonVersion()).isEqualTo("1.0"); + } + + @Test + void awsJson1_1_jsonVersionIs11() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#awsJson1_1\n", "@awsJson1_1\n", "")); + assertThat(metadata.getJsonVersion()).isEqualTo("1.1"); + } + + @Test + void restJson_jsonVersionDefaultsTo11() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", "@restJson1\n", "")); + assertThat(metadata.getJsonVersion()).isEqualTo("1.1"); + } + + @Test + void queryProtocol_hasNoJsonVersion() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#awsQuery\nuse aws.protocols#awsQueryError\n", + "@awsQuery\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n", "")); + assertThat(metadata.getJsonVersion()).isNull(); + } + + // ---- awsQueryCompatible ----------------------------------------------- + + @Test + void awsQueryCompatibleTrait_mapsToEmptyMap() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#awsJson1_0\nuse aws.protocols#awsQueryCompatible\n", + "@awsJson1_0\n@awsQueryCompatible\n", "")); + assertThat(metadata.getAwsQueryCompatible()).isNotNull().isEmpty(); + } + + @Test + void noAwsQueryCompatibleTrait_isNull() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#awsJson1_0\n", "@awsJson1_0\n", "")); + assertThat(metadata.getAwsQueryCompatible()).isNull(); + } + + // ---- uid -------------------------------------------------------------- + + @Test + void noDocId_uidDerivedFromSdkIdAndVersion() { + Metadata metadata = metadataOf(modelWithServiceTrait( + "@service(sdkId: \"Demo Widget\", arnNamespace: \"demo\")")); + assertThat(metadata.getUid()).isEqualTo("demo-widget-2024-01-01"); + } + + @Test + void explicitDocId_overridesDerivedUid() { + Metadata metadata = metadataOf(modelWithServiceTrait( + "@service(sdkId: \"Demo Widget\", arnNamespace: \"demo\", docId: \"demowidget-2024-01-01\")")); + assertThat(metadata.getUid()).isEqualTo("demowidget-2024-01-01"); + } + + // ---- supportsH2 ------------------------------------------------------- + + // C2J equivalent: "h2": "eventstream". + @Test + void eventStreamHttpListsH2_supportsH2() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", + "@restJson1(http: [\"http/1.1\", \"h2\"], eventStreamHttp: [\"h2\"])\n", "")); + assertThat(metadata.supportsH2()).isTrue(); + } + + // C2J equivalent: "h2": "required". + @Test + void httpListsOnlyH2_supportsH2() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", "@restJson1(http: [\"h2\"])\n", "")); + assertThat(metadata.supportsH2()).isTrue(); + } + + @Test + void httpListsWithoutH2_doesNotSupportH2() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", + "@restJson1(http: [\"http/1.1\"], eventStreamHttp: [\"http/1.1\"])\n", "")); + assertThat(metadata.supportsH2()).isFalse(); + } + + @Test + void noHttpConfiguration_doesNotSupportH2() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", "@restJson1\n", "")); + assertThat(metadata.supportsH2()).isFalse(); + } + + // rpcv2Cbor inherits the two members from a different base trait class than the aws.protocols + // traits, so this only passes when they are read off the trait node. + @Test + void rpcV2CborEventStreamHttpListsH2_supportsH2() { + Metadata metadata = metadataOf(modelOf( + "use smithy.protocols#rpcv2Cbor\n", + "@rpcv2Cbor(http: [\"http/1.1\", \"h2\"], eventStreamHttp: [\"h2\"])\n", "")); + assertThat(metadata.supportsH2()).isTrue(); + } + + // ---- auth / authType -------------------------------------------------- + + // The EC2 case: no @auth, so the value comes from the applied auth trait. + @Test + void noAuthTrait_fallsBackToAppliedSigV4() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", "@restJson1\n", "")); + assertThat(metadata.getAuth()).containsExactly(AuthType.V4); + assertThat(metadata.getAuthType()).isEqualTo(AuthType.V4); + } + + // Order is load-bearing, so assert it against the reverse of the declaration order. + @Test + void explicitAuthTrait_mappedInDeclaredOrder() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", + "@restJson1\n@httpBearerAuth\n@auth([httpBearerAuth, sigv4])\n", "")); + assertThat(metadata.getAuth()).containsExactly(AuthType.BEARER, AuthType.V4); + } + + // An empty @auth is a different input from no @auth, and takes the other branch. + @Test + void emptyAuthTrait_isEmpty() { + Metadata metadata = metadataOf(modelOf( + "use aws.protocols#restJson1\n", "@restJson1\n@auth([])\n", "")); + assertThat(metadata.getAuth()).isEmpty(); + } + + // authType only looks for sigv4, so a bearer-only service has none. + @Test + void noSigV4_bearerOnly_fallsBackToBearer() { + Metadata metadata = metadataOf(modelWithoutSigV4("@httpBearerAuth\n")); + assertThat(metadata.getAuth()).containsExactly(AuthType.BEARER); + assertThat(metadata.getAuthType()).isNull(); + } + + private static Model modelWithoutSigV4(String serviceTraits) { + String src = + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.protocols#restJson1\n\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@restJson1\n" + + serviceTraits + + "service DemoService { version: \"2024-01-01\", operations: [Op] }\n\n" + + "operation Op { input: OpRequest, output: OpResponse }\n" + + "structure OpRequest {}\n" + + "structure OpResponse {}\n"; + return Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", src) + .assemble() + .unwrap(); + } + + private static Model modelWithServiceTrait(String serviceTrait) { + String src = + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + "use aws.protocols#awsJson1_0\n\n" + + serviceTrait + "\n" + + "@sigv4(name: \"demo\")\n" + + "@awsJson1_0\n" + + "service DemoService { version: \"2024-01-01\", operations: [Op] }\n\n" + + "operation Op { input: OpRequest, output: OpResponse }\n" + + "structure OpRequest {}\n" + + "structure OpResponse {}\n"; + return Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", src) + .assemble() + .unwrap(); + } +}