From 4c6d80cdfcc730e69dda936a49156a9d44913e4c Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:00:45 -0500 Subject: [PATCH] Implement endpoint bdd resolution --- .../src/awsjson10/_private/endpoints.py | 63 ++ .../src/awsjson10/_private/schemas.py | 256 ------- .../aws-json-1-0/src/awsjson10/config.py | 20 +- .../src/awsjson11/_private/endpoints.py | 63 ++ .../src/awsjson11/_private/schemas.py | 256 ------- .../aws-json-1-1/src/awsjson11/config.py | 20 +- .../src/awsquery/_private/endpoints.py | 63 ++ .../src/awsquery/_private/schemas.py | 256 ------- .../aws-query/src/awsquery/config.py | 20 +- .../src/ec2query/_private/endpoints.py | 63 ++ .../src/ec2query/_private/schemas.py | 256 ------- .../ec2-query/src/ec2query/config.py | 20 +- .../_private/endpoints.py | 31 + .../my_handwritten_model/_private/schemas.py | 55 +- .../src/my_handwritten_model/config.py | 12 +- .../src/restjson/_private/endpoints.py | 63 ++ .../src/restjson/_private/schemas.py | 256 ------- .../rest-json-1/src/restjson/config.py | 20 +- .../_private/endpoints.py | 63 ++ .../restxmlwithnamespace/_private/schemas.py | 256 ------- .../src/restxmlwithnamespace/config.py | 20 +- .../src/restxml/_private/endpoints.py | 63 ++ .../rest-xml/src/restxml/_private/schemas.py | 256 ------- codegen-output/rest-xml/src/restxml/config.py | 20 +- codegen-output/rpcv2-cbor/pyproject.toml | 1 - .../src/rpcv2cbor/_private/endpoints.py | 31 + .../src/rpcv2cbor/_private/schemas.py | 60 +- .../rpcv2-cbor/src/rpcv2cbor/config.py | 10 +- .../AwsClientContextParamsCodegenTest.java | 92 +++ .../python/codegen/test/AwsCodegenTest.java | 21 +- .../test/aws-account-id-endpoint-mode.smithy | 42 ++ .../test/aws-client-context-params.smithy | 52 ++ .../codegen/AwsAsyncConfigIntegration.java | 68 +- .../codegen/AwsEndpointRulesIntegration.java | 227 +++++++ .../python/aws/codegen/AwsRuntimeTypes.java | 13 +- ...sStandardRegionalEndpointsIntegration.java | 69 -- ...hon.codegen.integrations.PythonIntegration | 2 +- .../test/ClientContextParamsCodegenTest.java | 56 ++ .../codegen/test/PythonCodegenTest.java | 10 +- .../codegen/test/client-context-params.smithy | 42 ++ .../codegen/DirectedPythonClientCodegen.java | 3 + .../python/codegen/EndpointTestGenerator.java | 437 ++++++++++++ .../codegen/HttpProtocolTestGenerator.java | 222 +----- .../smithy/python/codegen/RuntimeTypes.java | 5 +- .../codegen/SchemaTraitFilterIndex.java | 8 +- .../python/codegen/ValueNodeVisitor.java | 236 +++++++ .../codegen/generators/ConfigGenerator.java | 116 +++- .../generators/EndpointBddGenerator.java | 575 ++++++++++++++++ .../integrations/EndpointRulesExtension.java | 61 ++ .../integrations/RuntimeClientPlugin.java | 23 + .../generators/EndpointBddGeneratorTest.java | 352 ++++++++++ designs/endpoints.md | 476 +++++++++++++ ...king-f922f4a4a4d24323ae5c5cd8e36df83a.json | 4 + ...ture-83b7bf9294df4bbcb37bb27f5bad88c2.json | 4 + .../smithy_aws_core/aio/endpoints/__init__.py | 2 - .../aio/endpoints/standard_regional.py | 35 - .../src/smithy_aws_core/endpoints/__init__.py | 19 +- .../smithy_aws_core/endpoints/_aws_library.py | 168 +++++ .../smithy_aws_core/endpoints/partitions.json | 267 ++++++++ .../tests/unit/endpoints/__init__.py | 2 + .../tests/unit/endpoints/test_aws_library.py | 209 ++++++ .../unit/endpoints/test_standard_regional.py | 96 --- ...king-17d1b930e94f48f187aedf3016c4e907.json | 4 + ...king-34ac73b42e58473ab63a15959a66880c.json | 4 + ...ture-eb933edb101d4c5680d8a2c90642f1ef.json | 4 + .../smithy-core/src/smithy_core/__init__.py | 11 +- .../smithy-core/src/smithy_core/_jmespath.py | 277 ++++++++ .../smithy-core/src/smithy_core/aio/client.py | 52 +- .../src/smithy_core/aio/endpoints.py | 40 +- packages/smithy-core/src/smithy_core/auth.py | 18 +- .../smithy-core/src/smithy_core/endpoints.py | 99 --- .../src/smithy_core/endpoints/__init__.py | 34 + .../src/smithy_core/endpoints/_engine.py | 643 ++++++++++++++++++ .../src/smithy_core/endpoints/_params.py | 289 ++++++++ .../src/smithy_core/endpoints/_resolver.py | 91 +++ .../endpoints/_standard_library.py | 125 ++++ .../src/smithy_core/endpoints/_types.py | 205 ++++++ .../src/smithy_core/interfaces/__init__.py | 28 +- .../smithy-core/src/smithy_core/rfc3986.py | 8 + .../smithy-core/src/smithy_core/traits.py | 49 ++ packages/smithy-core/src/smithy_core/utils.py | 47 ++ .../tests/unit/aio/_pipeline_harness.py | 3 +- .../smithy-core/tests/unit/aio/test_client.py | 86 ++- .../tests/unit/endpoints/__init__.py | 2 + .../smithy-core/tests/unit/endpoints/_bdds.py | 167 +++++ .../tests/unit/endpoints/test_engine.py | 332 +++++++++ .../unit/endpoints/test_resolve_static_uri.py | 19 + .../tests/unit/endpoints/test_resolver.py | 350 ++++++++++ .../unit/endpoints/test_standard_library.py | 123 ++++ .../smithy-core/tests/unit/test_jmespath.py | 115 ++++ .../smithy-core/tests/unit/test_traits.py | 27 + packages/smithy-core/tests/unit/test_utils.py | 43 ++ ...ture-3b00eb4d722846cc9a89f3181a541464.json | 4 + .../smithy-test/src/smithy_test/__init__.py | 7 +- .../src/smithy_test/_private/endpoints.py | 145 ++++ .../src/smithy_test/aio/__init__.py | 6 + .../src/smithy_test/aio/_endpoints.py | 104 +++ .../smithy-test/tests/unit/test_endpoints.py | 187 +++++ scripts/api-snapshot/api.json | 59 +- 99 files changed, 7802 insertions(+), 2592 deletions(-) create mode 100644 codegen-output/aws-json-1-0/src/awsjson10/_private/endpoints.py create mode 100644 codegen-output/aws-json-1-1/src/awsjson11/_private/endpoints.py create mode 100644 codegen-output/aws-query/src/awsquery/_private/endpoints.py create mode 100644 codegen-output/ec2-query/src/ec2query/_private/endpoints.py create mode 100644 codegen-output/my-handwritten-model/src/my_handwritten_model/_private/endpoints.py create mode 100644 codegen-output/rest-json-1/src/restjson/_private/endpoints.py create mode 100644 codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/endpoints.py create mode 100644 codegen-output/rest-xml/src/restxml/_private/endpoints.py create mode 100644 codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/endpoints.py create mode 100644 codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsClientContextParamsCodegenTest.java create mode 100644 codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-account-id-endpoint-mode.smithy create mode 100644 codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-client-context-params.smithy create mode 100644 codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegration.java delete mode 100644 codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsStandardRegionalEndpointsIntegration.java create mode 100644 codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/ClientContextParamsCodegenTest.java create mode 100644 codegen/core/src/it/resources/software/amazon/smithy/python/codegen/test/client-context-params.smithy create mode 100644 codegen/core/src/main/java/software/amazon/smithy/python/codegen/EndpointTestGenerator.java create mode 100644 codegen/core/src/main/java/software/amazon/smithy/python/codegen/ValueNodeVisitor.java create mode 100644 codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EndpointBddGenerator.java create mode 100644 codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/EndpointRulesExtension.java create mode 100644 codegen/core/src/test/java/software/amazon/smithy/python/codegen/generators/EndpointBddGeneratorTest.java create mode 100644 designs/endpoints.md create mode 100644 packages/smithy-aws-core/.changes/next-release/smithy-aws-core-breaking-f922f4a4a4d24323ae5c5cd8e36df83a.json create mode 100644 packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-83b7bf9294df4bbcb37bb27f5bad88c2.json delete mode 100644 packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/__init__.py delete mode 100644 packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/standard_regional.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/endpoints/_aws_library.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/endpoints/partitions.json create mode 100644 packages/smithy-aws-core/tests/unit/endpoints/__init__.py create mode 100644 packages/smithy-aws-core/tests/unit/endpoints/test_aws_library.py delete mode 100644 packages/smithy-aws-core/tests/unit/endpoints/test_standard_regional.py create mode 100644 packages/smithy-core/.changes/next-release/smithy-core-breaking-17d1b930e94f48f187aedf3016c4e907.json create mode 100644 packages/smithy-core/.changes/next-release/smithy-core-breaking-34ac73b42e58473ab63a15959a66880c.json create mode 100644 packages/smithy-core/.changes/next-release/smithy-core-feature-eb933edb101d4c5680d8a2c90642f1ef.json create mode 100644 packages/smithy-core/src/smithy_core/_jmespath.py delete mode 100644 packages/smithy-core/src/smithy_core/endpoints.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/__init__.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/_engine.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/_params.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/_resolver.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/_standard_library.py create mode 100644 packages/smithy-core/src/smithy_core/endpoints/_types.py create mode 100644 packages/smithy-core/tests/unit/endpoints/__init__.py create mode 100644 packages/smithy-core/tests/unit/endpoints/_bdds.py create mode 100644 packages/smithy-core/tests/unit/endpoints/test_engine.py create mode 100644 packages/smithy-core/tests/unit/endpoints/test_resolve_static_uri.py create mode 100644 packages/smithy-core/tests/unit/endpoints/test_resolver.py create mode 100644 packages/smithy-core/tests/unit/endpoints/test_standard_library.py create mode 100644 packages/smithy-core/tests/unit/test_jmespath.py create mode 100644 packages/smithy-test/.changes/next-release/smithy-test-feature-3b00eb4d722846cc9a89f3181a541464.json create mode 100644 packages/smithy-test/src/smithy_test/_private/endpoints.py create mode 100644 packages/smithy-test/src/smithy_test/aio/__init__.py create mode 100644 packages/smithy-test/src/smithy_test/aio/_endpoints.py create mode 100644 packages/smithy-test/tests/unit/test_endpoints.py diff --git a/codegen-output/aws-json-1-0/src/awsjson10/_private/endpoints.py b/codegen-output/aws-json-1-0/src/awsjson10/_private/endpoints.py new file mode 100644 index 000000000..afc00a573 --- /dev/null +++ b/codegen-output/aws-json-1-0/src/awsjson10/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.json10#JsonRpc10.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://jsonrpc10-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://jsonrpc10.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://jsonrpc10-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://jsonrpc10.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://jsonrpc10.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/aws-json-1-0/src/awsjson10/_private/schemas.py b/codegen-output/aws-json-1-0/src/awsjson10/_private/schemas.py index eb59791f8..839c07771 100644 --- a/codegen-output/aws-json-1-0/src/awsjson10/_private/schemas.py +++ b/codegen-output/aws-json-1-0/src/awsjson10/_private/schemas.py @@ -194,7 +194,6 @@ _JR = "JsonRpc10" _a = "aws.auth#sigv4" _a_ = "aws.protocols#awsJson1_0" -_sB = "smithy.rules#endpointBdd" _aw = "aws.api#service" CLIENT_OPTIONAL_DEFAULTS: StaticSchema = (18, n0, _COD, 0, (_m,), ((7, {_s: 0}),)) @@ -862,261 +861,6 @@ { _a: MappingProxyType({"name": "jsonrpc10"}), _a_: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonrpc10-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonrpc10.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonrpc10-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonrpc10.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonrpc10.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aw: MappingProxyType({"sdkId": "JSON RPC 10"}), }, ) diff --git a/codegen-output/aws-json-1-0/src/awsjson10/config.py b/codegen-output/aws-json-1-0/src/awsjson10/config.py index 7f3fb48d6..487e4aaa2 100644 --- a/codegen-output/aws-json-1-0/src/awsjson10/config.py +++ b/codegen-output/aws-json-1-0/src/awsjson10/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncAwsJson10ClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import JSON_RPC10 from .auth import HTTPAuthSchemeResolver @@ -136,6 +136,8 @@ class _AsyncJSONRPC10ConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -170,6 +172,12 @@ class AsyncJSONRPC10Config(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -200,6 +208,8 @@ class AsyncJSONRPC10Config(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -212,8 +222,8 @@ class AsyncJSONRPC10Config(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("json_rpc_10") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="jsonrpc10" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/aws-json-1-1/src/awsjson11/_private/endpoints.py b/codegen-output/aws-json-1-1/src/awsjson11/_private/endpoints.py new file mode 100644 index 000000000..f85fe866f --- /dev/null +++ b/codegen-output/aws-json-1-1/src/awsjson11/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.json#JsonProtocol.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://jsonprotocol-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://jsonprotocol.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://jsonprotocol-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://jsonprotocol.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://jsonprotocol.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/aws-json-1-1/src/awsjson11/_private/schemas.py b/codegen-output/aws-json-1-1/src/awsjson11/_private/schemas.py index 27ec863a0..f8482fd3e 100644 --- a/codegen-output/aws-json-1-1/src/awsjson11/_private/schemas.py +++ b/codegen-output/aws-json-1-1/src/awsjson11/_private/schemas.py @@ -210,7 +210,6 @@ _JP = "JsonProtocol" _aw = "aws.auth#sigv4" _a_ = "aws.protocols#awsJson1_1" -_sB = "smithy.rules#endpointBdd" _aws = "aws.api#service" COMPLEX_NESTED_ERROR_DATA: StaticSchema = (18, n0, _CNED, 0, (_F,), ((3, {_sN: _Fo}),)) @@ -1034,261 +1033,6 @@ { _aw: MappingProxyType({"name": "jsonprotocol"}), _a_: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonprotocol-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonprotocol.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonprotocol-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonprotocol.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://jsonprotocol.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aws: MappingProxyType( { "sdkId": "Json Protocol", diff --git a/codegen-output/aws-json-1-1/src/awsjson11/config.py b/codegen-output/aws-json-1-1/src/awsjson11/config.py index 6c7352e1c..6ad6440ff 100644 --- a/codegen-output/aws-json-1-1/src/awsjson11/config.py +++ b/codegen-output/aws-json-1-1/src/awsjson11/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncAwsJson11ClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import JSON_PROTOCOL from .auth import HTTPAuthSchemeResolver @@ -130,6 +130,8 @@ class _AsyncJsonProtocolConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -164,6 +166,12 @@ class AsyncJsonProtocolConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -194,6 +202,8 @@ class AsyncJsonProtocolConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -206,8 +216,8 @@ class AsyncJsonProtocolConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("json_protocol") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="jsonprotocol" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/aws-query/src/awsquery/_private/endpoints.py b/codegen-output/aws-query/src/awsquery/_private/endpoints.py new file mode 100644 index 000000000..1d4e51f30 --- /dev/null +++ b/codegen-output/aws-query/src/awsquery/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.query#AwsQuery.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://awsquery-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://awsquery.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://awsquery-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://awsquery.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://awsquery.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/aws-query/src/awsquery/_private/schemas.py b/codegen-output/aws-query/src/awsquery/_private/schemas.py index 40da232a9..641d8e81d 100644 --- a/codegen-output/aws-query/src/awsquery/_private/schemas.py +++ b/codegen-output/aws-query/src/awsquery/_private/schemas.py @@ -290,7 +290,6 @@ _AQ = "AwsQuery" _aw = "aws.auth#sigv4" _aQ = "aws.protocols#awsQuery" -_sB = "smithy.rules#endpointBdd" _aws = "aws.api#service" DATETIME_OFFSETS_INPUT: StaticSchema = (18, n0, _DOI, {_sSI: _sU, _s: None}) @@ -1325,261 +1324,6 @@ { _aw: MappingProxyType({"name": "awsquery"}), _aQ: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsquery-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsquery.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsquery-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsquery.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsquery.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aws: MappingProxyType({"sdkId": "Query Protocol"}), _sNm: MappingProxyType({"uri": "https://example.com/"}), }, diff --git a/codegen-output/aws-query/src/awsquery/config.py b/codegen-output/aws-query/src/awsquery/config.py index 14c3f17b7..ccb949239 100644 --- a/codegen-output/aws-query/src/awsquery/config.py +++ b/codegen-output/aws-query/src/awsquery/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncAwsQueryClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import AWS_QUERY from .auth import HTTPAuthSchemeResolver @@ -185,6 +185,8 @@ class _AsyncQueryProtocolConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -219,6 +221,12 @@ class AsyncQueryProtocolConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -249,6 +257,8 @@ class AsyncQueryProtocolConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -261,8 +271,8 @@ class AsyncQueryProtocolConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("query_protocol") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="awsquery" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/ec2-query/src/ec2query/_private/endpoints.py b/codegen-output/ec2-query/src/ec2query/_private/endpoints.py new file mode 100644 index 000000000..39dca475b --- /dev/null +++ b/codegen-output/ec2-query/src/ec2query/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.ec2#AwsEc2.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://awsec2-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://awsec2.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://awsec2-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://awsec2.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://awsec2.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/ec2-query/src/ec2query/_private/schemas.py b/codegen-output/ec2-query/src/ec2query/_private/schemas.py index bb71665f3..325d671f5 100644 --- a/codegen-output/ec2-query/src/ec2query/_private/schemas.py +++ b/codegen-output/ec2-query/src/ec2query/_private/schemas.py @@ -254,7 +254,6 @@ _AE = "AwsEc2" _aw = "aws.auth#sigv4" _aQ = "aws.protocols#ec2Query" -_sB = "smithy.rules#endpointBdd" _aws = "aws.api#service" DATETIME_OFFSETS_INPUT: StaticSchema = (18, n0, _DOI, {_sSI: _sU, _s: None}) @@ -1033,261 +1032,6 @@ { _aw: MappingProxyType({"name": "ec2query"}), _aQ: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsec2-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsec2.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsec2-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsec2.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://awsec2.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aws: MappingProxyType({"sdkId": "EC2 Protocol"}), _sNm: MappingProxyType({"uri": "https://example.com/"}), }, diff --git a/codegen-output/ec2-query/src/ec2query/config.py b/codegen-output/ec2-query/src/ec2query/config.py index b3d75e3b2..a945cc83f 100644 --- a/codegen-output/ec2-query/src/ec2query/config.py +++ b/codegen-output/ec2-query/src/ec2query/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncEc2QueryClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import AWS_EC2 from .auth import HTTPAuthSchemeResolver @@ -154,6 +154,8 @@ class _AsyncEC2ProtocolConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -188,6 +190,12 @@ class AsyncEC2ProtocolConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -218,6 +226,8 @@ class AsyncEC2ProtocolConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -230,8 +240,8 @@ class AsyncEC2ProtocolConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("ec2_protocol") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="awsec2" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/endpoints.py b/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/endpoints.py new file mode 100644 index 000000000..7eb096e1e --- /dev/null +++ b/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/endpoints.py @@ -0,0 +1,31 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for smithy.python.tests.handwritten#MyHandwrittenModel.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("isSet",), + conditions=( + ((2, "isSet", (0, "endpoint")), None), # 0: isSet(endpoint) + ), + results=( + None, # 0: no match + (6, (0, "endpoint"), None, ()), # 1 + (5, "(default endpointRuleSet) endpoint is not set - you must configure an endpoint."), # 2 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 100_000_001, 100_000_002, + ), +) +# fmt: on diff --git a/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/schemas.py b/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/schemas.py index f9ef0c3bb..c7b2d38cd 100644 --- a/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/schemas.py +++ b/codegen-output/my-handwritten-model/src/my_handwritten_model/_private/schemas.py @@ -67,7 +67,6 @@ _TES = "TradeEventStream" _MHM = "MyHandwrittenModel" _sC = "smithy.protocols#rpcv2Cbor" -_sB = "smithy.rules#endpointBdd" ALPHA: StaticSchema = (18, n0, _A, 0, (_i, _t), ((3, 0), (4, 0))) @@ -213,57 +212,5 @@ 21, n0, _MHM, - { - _sC: MappingProxyType({"http": ("h2", "http/1.1"), "eventStreamHttp": ("h2",)}), - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Endpoint used for making requests. Should be formatted as a URI.", - "type": "string", - } - ) - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "endpoint"}),), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "(default endpointRuleSet) endpoint is not set - you must configure an endpoint.", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 2, - "nodes": "/////wAAAAH/////AAAAAAX14QEF9eEC", - } - ), - }, + {_sC: MappingProxyType({"http": ("h2", "http/1.1"), "eventStreamHttp": ("h2",)})}, ) diff --git a/codegen-output/my-handwritten-model/src/my_handwritten_model/config.py b/codegen-output/my-handwritten-model/src/my_handwritten_model/config.py index 7e5faf843..52f35e521 100644 --- a/codegen-output/my-handwritten-model/src/my_handwritten_model/config.py +++ b/codegen-output/my-handwritten-model/src/my_handwritten_model/config.py @@ -3,9 +3,7 @@ from dataclasses import dataclass from typing import Any, Callable, TYPE_CHECKING, TypeAlias, Union, cast -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import ( AsyncClientProtocol, AsyncClientTransport, @@ -22,6 +20,7 @@ from smithy_http.aio.protocols import AsyncRpcV2CborClientProtocol from smithy_http.interfaces import HTTPRequestConfiguration +from ._private.endpoints import BDD from ._private.schemas import MY_HANDWRITTEN_MODEL @@ -138,12 +137,7 @@ def __init__( ): self.auth_scheme_resolver = auth_scheme_resolver or DefaultAuthResolver() self.auth_schemes = auth_schemes or {} - self.endpoint_resolver = ( - endpoint_resolver - or AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="MyHandwrittenModel" - ) - ) + self.endpoint_resolver = endpoint_resolver or AsyncBddEndpointResolver(BDD) self.endpoint_uri = endpoint_uri self.http_request_config = http_request_config self.interceptors = interceptors or [] diff --git a/codegen-output/rest-json-1/src/restjson/_private/endpoints.py b/codegen-output/rest-json-1/src/restjson/_private/endpoints.py new file mode 100644 index 000000000..de6112cda --- /dev/null +++ b/codegen-output/rest-json-1/src/restjson/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.restjson#RestJson.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://restjson-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://restjson.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://restjson-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://restjson.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://restjson.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/rest-json-1/src/restjson/_private/schemas.py b/codegen-output/rest-json-1/src/restjson/_private/schemas.py index 8d28de71d..d894dbf35 100644 --- a/codegen-output/rest-json-1/src/restjson/_private/schemas.py +++ b/codegen-output/rest-json-1/src/restjson/_private/schemas.py @@ -844,7 +844,6 @@ _RS = "RecursiveShapes" _RJ = "RestJson" _aw = "aws.auth#sigv4" -_sB = "smithy.rules#endpointBdd" _aws = "aws.api#service" _aJ = "aws.protocols#restJson1" @@ -4624,261 +4623,6 @@ _RJ, { _aw: MappingProxyType({"name": "restjson"}), - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restjson-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restjson.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restjson-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restjson.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restjson.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aws: MappingProxyType({"sdkId": "Rest Json Protocol"}), _aJ: None, }, diff --git a/codegen-output/rest-json-1/src/restjson/config.py b/codegen-output/rest-json-1/src/restjson/config.py index 2ca59effa..0735ccd99 100644 --- a/codegen-output/rest-json-1/src/restjson/config.py +++ b/codegen-output/rest-json-1/src/restjson/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncRestJsonClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import REST_JSON from .auth import HTTPAuthSchemeResolver @@ -615,6 +615,8 @@ class _AsyncRestJsonProtocolConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -649,6 +651,12 @@ class AsyncRestJsonProtocolConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -679,6 +687,8 @@ class AsyncRestJsonProtocolConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -691,8 +701,8 @@ class AsyncRestJsonProtocolConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("rest_json_protocol") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="restjson" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/endpoints.py b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/endpoints.py new file mode 100644 index 000000000..e72fedb1d --- /dev/null +++ b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.restxml.xmlns#RestXmlWithNamespace.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://restxmlwithnamespace-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://restxmlwithnamespace.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://restxmlwithnamespace-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://restxmlwithnamespace.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://restxmlwithnamespace.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/schemas.py b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/schemas.py index 01797616d..f7b81bcd3 100644 --- a/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/schemas.py +++ b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/_private/schemas.py @@ -38,7 +38,6 @@ _RXWN = "RestXmlWithNamespace" _a = "aws.auth#sigv4" _aX = "aws.protocols#restXml" -_sB = "smithy.rules#endpointBdd" _aw = "aws.api#service" NESTED_WITH_NAMESPACE: StaticSchema = ( @@ -115,261 +114,6 @@ { _a: MappingProxyType({"name": "restxmlwithnamespace"}), _aX: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxmlwithnamespace-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxmlwithnamespace.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxmlwithnamespace-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxmlwithnamespace.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxmlwithnamespace.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aw: MappingProxyType({"sdkId": "Rest Xml Protocol Namespace"}), _sNm: MappingProxyType({"uri": "https://example.com"}), }, diff --git a/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/config.py b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/config.py index a4dade1d9..22099d00d 100644 --- a/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/config.py +++ b/codegen-output/rest-xml-with-namespace/src/restxmlwithnamespace/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncRestXmlClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import REST_XML_WITH_NAMESPACE from .auth import HTTPAuthSchemeResolver @@ -63,6 +63,8 @@ class _AsyncRestXmlProtocolNamespaceConfigOverrides(AwsConfigOverrides, total=Fa ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -97,6 +99,12 @@ class AsyncRestXmlProtocolNamespaceConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -127,6 +135,8 @@ class AsyncRestXmlProtocolNamespaceConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -139,8 +149,8 @@ class AsyncRestXmlProtocolNamespaceConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("rest_xml_protocol_namespace") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="restxmlwithnamespace" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/rest-xml/src/restxml/_private/endpoints.py b/codegen-output/rest-xml/src/restxml/_private/endpoints.py new file mode 100644 index 000000000..84b872f59 --- /dev/null +++ b/codegen-output/rest-xml/src/restxml/_private/endpoints.py @@ -0,0 +1,63 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for aws.protocoltests.restxml#RestXml.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ((2, "booleanEquals", (0, "UseFIPS"), True), None), # 3: booleanEquals(UseFIPS, true) + ((2, "booleanEquals", (0, "UseDualStack"), True), None), # 4: booleanEquals(UseDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsDualStack"), True), None), # 5: booleanEquals(PartitionResult#supportsDualStack, true) + ((2, "booleanEquals", (1, (0, "PartitionResult"), "supportsFIPS"), True), None), # 6: booleanEquals(PartitionResult#supportsFIPS, true) + ((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None), # 7: stringEquals(PartitionResult#name, "aws-us-gov") + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + (5, "Invalid Configuration: Dualstack and custom endpoint are not supported"), # 2 + (6, (0, "Endpoint"), None, ()), # 3 + (6, "https://restxml-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 4 + (5, "FIPS and DualStack are enabled, but this partition does not support one or both"), # 5 + (6, "https://restxml.{Region}.amazonaws.com", None, ()), # 6 + (6, "https://restxml-fips.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 7 + (5, "FIPS is enabled but this partition does not support FIPS"), # 8 + (6, "https://restxml.{Region}.{PartitionResult#dualStackDnsSuffix}", None, ()), # 9 + (5, "DualStack is enabled but this partition does not support DualStack"), # 10 + (6, "https://restxml.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 13, 3, + 1, 4, 100_000_012, + 2, 5, 100_000_012, + 3, 8, 6, + 4, 7, 100_000_011, + 5, 100_000_009, 100_000_010, + 4, 11, 9, + 6, 10, 100_000_008, + 7, 100_000_006, 100_000_007, + 5, 12, 100_000_005, + 6, 100_000_004, 100_000_005, + 3, 100_000_001, 14, + 4, 100_000_002, 100_000_003, + ), +) +# fmt: on diff --git a/codegen-output/rest-xml/src/restxml/_private/schemas.py b/codegen-output/rest-xml/src/restxml/_private/schemas.py index 01b7c37d3..44e1d8c00 100644 --- a/codegen-output/rest-xml/src/restxml/_private/schemas.py +++ b/codegen-output/rest-xml/src/restxml/_private/schemas.py @@ -575,7 +575,6 @@ _RX = "RestXml" _aw = "aws.auth#sigv4" _aX = "aws.protocols#restXml" -_sB = "smithy.rules#endpointBdd" _aws = "aws.api#service" BOOLEAN_LIST: StaticSchema = (16, n0, _BL, 0, (_m,), ((2, 0),)) @@ -3021,261 +3020,6 @@ { _aw: MappingProxyType({"name": "restxml"}), _aX: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "Region": MappingProxyType( - { - "builtIn": "AWS::Region", - "required": False, - "documentation": "The AWS Region. This is a default regional AWS endpointRuleSet.", - "type": "string", - } - ), - "UseDualStack": MappingProxyType( - { - "builtIn": "AWS::UseDualStack", - "required": True, - "default": False, - "documentation": "Whether to use dual-stack.", - "type": "boolean", - } - ), - "UseFIPS": MappingProxyType( - { - "builtIn": "AWS::UseFIPS", - "required": True, - "default": False, - "documentation": "Whether to use FIPS-compliant regional endpoint.", - "type": "boolean", - } - ), - "Endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Override the endpoint.", - "type": "string", - } - ), - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "Endpoint"}),), - } - ), - MappingProxyType( - {"fn": "isSet", "argv": (MappingProxyType({"ref": "Region"}),)} - ), - MappingProxyType( - { - "fn": "aws.partition", - "argv": (MappingProxyType({"ref": "Region"}),), - "assign": "PartitionResult", - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseFIPS"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": (MappingProxyType({"ref": "UseDualStack"}), True), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsDualStack", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "booleanEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "supportsFIPS", - ), - } - ), - True, - ), - } - ), - MappingProxyType( - { - "fn": "stringEquals", - "argv": ( - MappingProxyType( - { - "fn": "getAttr", - "argv": ( - MappingProxyType( - {"ref": "PartitionResult"} - ), - "name", - ), - } - ), - "aws-us-gov", - ), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "Endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxml-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxml.{Region}.amazonaws.com", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxml-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxml.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error", - } - ), - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": "https://restxml.{Region}.{PartitionResult#dnsSuffix}", - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "Invalid Configuration: Missing Region", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 14, - "nodes": "/////wAAAAH/////AAAAAAAAAA0AAAADAAAAAQAAAAQF9eEMAAAAAgAAAAUF9eEMAAAAAwAAAAgAAAAGAAAABAAAAAcF9eELAAAABQX14QkF9eEKAAAABAAAAAsAAAAJAAAABgAAAAoF9eEIAAAABwX14QYF9eEHAAAABQAAAAwF9eEFAAAABgX14QQF9eEFAAAAAwX14QEAAAAOAAAABAX14QIF9eED", - } - ), _aws: MappingProxyType({"sdkId": "Rest Xml Protocol"}), }, ) diff --git a/codegen-output/rest-xml/src/restxml/config.py b/codegen-output/rest-xml/src/restxml/config.py index 217db9cf5..de302c70c 100644 --- a/codegen-output/rest-xml/src/restxml/config.py +++ b/codegen-output/rest-xml/src/restxml/config.py @@ -16,12 +16,11 @@ from smithy_aws_core.aio.config import AsyncFileSystem from smithy_aws_core.aio.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.aio.config.resolvers import EndpointUriResolver -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) from smithy_aws_core.aio.protocols import AsyncRestXmlClientProtocol from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import AsyncClientProtocol, AsyncEndpointResolver from smithy_core.aio.interfaces.auth import AsyncAuthScheme from smithy_core.aio.interfaces.identity import AsyncIdentityResolver @@ -30,6 +29,7 @@ from smithy_core.shapes import ShapeID from smithy_http.aio.aiohttp import AIOHTTPClient +from ._private.endpoints import BDD from ._private.schemas import REST_XML from .auth import HTTPAuthSchemeResolver @@ -352,6 +352,8 @@ class _AsyncRestXmlProtocolConfigOverrides(AwsConfigOverrides, total=False): ) auth_schemes: dict[ShapeID, AsyncAuthScheme[Any, Any, Any, Any]] | None auth_scheme_resolver: AuthSchemeResolver | None + use_dualstack_endpoint: bool | None + use_fips_endpoint: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -386,6 +388,12 @@ class AsyncRestXmlProtocolConfig(AsyncAwsConfig): operation. """ + use_dualstack_endpoint: bool | None = None + """Whether to use dual-stack.""" + + use_fips_endpoint: bool | None = None + """Whether to use FIPS-compliant regional endpoint.""" + aws_credentials_identity_resolver: ( AsyncIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None ) = None @@ -416,6 +424,8 @@ class AsyncRestXmlProtocolConfig(AsyncAwsConfig): """ _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "use_dualstack_endpoint": FieldSpec(default=None), + "use_fips_endpoint": FieldSpec(default=None), "aws_credentials_identity_resolver": FieldSpec(default=None), "region": FieldSpec(default=None), "aws_access_key_id": FieldSpec(default=None), @@ -428,8 +438,8 @@ class AsyncRestXmlProtocolConfig(AsyncAwsConfig): default=None, resolver=EndpointUriResolver("rest_xml_protocol") ), "endpoint_resolver": FieldSpec( - default_factory=lambda: AsyncStandardRegionalEndpointsResolver( - endpoint_prefix="restxml" + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] ) ), "protocol": FieldSpec( diff --git a/codegen-output/rpcv2-cbor/pyproject.toml b/codegen-output/rpcv2-cbor/pyproject.toml index 302fe53a7..14cb6dda1 100644 --- a/codegen-output/rpcv2-cbor/pyproject.toml +++ b/codegen-output/rpcv2-cbor/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ ] dependencies = [ - "smithy_aws_core~=0.12.0", "smithy_core~=0.9.0", "smithy_http[cbor, aiohttp]~=0.6.0" ] diff --git a/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/endpoints.py b/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/endpoints.py new file mode 100644 index 000000000..5a0e09687 --- /dev/null +++ b/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/endpoints.py @@ -0,0 +1,31 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +"""Endpoint BDD for smithy.protocoltests.rpcv2Cbor#RpcV2Protocol.""" + +from smithy_core.endpoints import EndpointBdd + +# fmt: off +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("isSet",), + conditions=( + ((2, "isSet", (0, "endpoint")), None), # 0: isSet(endpoint) + ), + results=( + None, # 0: no match + (6, (0, "endpoint"), None, ()), # 1 + (5, "(default endpointRuleSet) endpoint is not set - you must configure an endpoint."), # 2 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 100_000_001, 100_000_002, + ), +) +# fmt: on diff --git a/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/schemas.py b/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/schemas.py index 7fb248372..fdeb7210f 100644 --- a/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/schemas.py +++ b/codegen-output/rpcv2-cbor/src/rpcv2cbor/_private/schemas.py @@ -210,7 +210,6 @@ _RS = "RecursiveShapes" _RVP = "RpcV2Protocol" _sC = "smithy.protocols#rpcv2Cbor" -_sB = "smithy.rules#endpointBdd" VALIDATION_EXCEPTION_FIELD: StaticSchema = (18, n0, _VEF, 0, (_p, _m), ((3, 1), (3, 1))) @@ -863,61 +862,4 @@ (), ) -RPC_V2_PROTOCOL: StaticSchema = ( - 21, - n1, - _RVP, - { - _sC: None, - _sB: MappingProxyType( - { - "version": "1.1", - "parameters": MappingProxyType( - { - "endpoint": MappingProxyType( - { - "builtIn": "SDK::Endpoint", - "required": False, - "documentation": "Endpoint used for making requests. Should be formatted as a URI.", - "type": "string", - } - ) - } - ), - "conditions": ( - MappingProxyType( - { - "fn": "isSet", - "argv": (MappingProxyType({"ref": "endpoint"}),), - } - ), - ), - "results": ( - MappingProxyType( - { - "conditions": (), - "endpoint": MappingProxyType( - { - "url": MappingProxyType({"ref": "endpoint"}), - "properties": MappingProxyType[str, object]({}), - "headers": MappingProxyType[str, object]({}), - } - ), - "type": "endpoint", - } - ), - MappingProxyType( - { - "conditions": (), - "error": "(default endpointRuleSet) endpoint is not set - you must configure an endpoint.", - "type": "error", - } - ), - ), - "root": 2, - "nodeCount": 2, - "nodes": "/////wAAAAH/////AAAAAAX14QEF9eEC", - } - ), - }, -) +RPC_V2_PROTOCOL: StaticSchema = (21, n1, _RVP, {_sC: None}) diff --git a/codegen-output/rpcv2-cbor/src/rpcv2cbor/config.py b/codegen-output/rpcv2-cbor/src/rpcv2cbor/config.py index 5cf736f42..6db9e260f 100644 --- a/codegen-output/rpcv2-cbor/src/rpcv2cbor/config.py +++ b/codegen-output/rpcv2-cbor/src/rpcv2cbor/config.py @@ -3,9 +3,7 @@ from dataclasses import dataclass from typing import Any, Callable, TYPE_CHECKING, TypeAlias, Union, cast -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) +from smithy_core.aio.endpoints import AsyncBddEndpointResolver from smithy_core.aio.interfaces import ( AsyncClientProtocol, AsyncClientTransport, @@ -22,6 +20,7 @@ from smithy_http.aio.protocols import AsyncRpcV2CborClientProtocol from smithy_http.interfaces import HTTPRequestConfiguration +from ._private.endpoints import BDD from ._private.schemas import RPC_V2_PROTOCOL @@ -173,10 +172,7 @@ def __init__( ): self.auth_scheme_resolver = auth_scheme_resolver or DefaultAuthResolver() self.auth_schemes = auth_schemes or {} - self.endpoint_resolver = ( - endpoint_resolver - or AsyncStandardRegionalEndpointsResolver(endpoint_prefix="RpcV2Protocol") - ) + self.endpoint_resolver = endpoint_resolver or AsyncBddEndpointResolver(BDD) self.endpoint_uri = endpoint_uri self.http_request_config = http_request_config self.interceptors = interceptors or [] diff --git a/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsClientContextParamsCodegenTest.java b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsClientContextParamsCodegenTest.java new file mode 100644 index 000000000..496fcc79d --- /dev/null +++ b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsClientContextParamsCodegenTest.java @@ -0,0 +1,92 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.build.FileManifest; +import software.amazon.smithy.build.PluginContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.python.codegen.PythonClientCodegenPlugin; + +public class AwsClientContextParamsCodegenTest { + + private static String generateConfig(Path tempDir, String resource, String service, String module) + throws IOException { + var loader = AwsClientContextParamsCodegenTest.class.getClassLoader(); + Model model = Model.assembler(loader) + .discoverModels(loader) + .addImport(AwsClientContextParamsCodegenTest.class.getResource(resource)) + .assemble() + .unwrap(); + PluginContext context = PluginContext.builder() + .fileManifest(FileManifest.create(tempDir)) + .settings(ObjectNode.builder() + .withMember("service", service) + .withMember("module", module) + .withMember("moduleVersion", "0.0.1") + .build()) + .model(model) + .build(); + new PythonClientCodegenPlugin().execute(context); + return Files.readString(tempDir.resolve("src/" + module + "/config.py")); + } + + @Test + public void addsAsyncConfigFieldsForClientContextParams(@TempDir Path tempDir) throws IOException { + var config = generateConfig( + tempDir, + "aws-client-context-params.smithy", + "example.awsccp#ClientContextService", + "awsccp"); + // Overrides, dataclass fields and FieldSpecs without resolvers. + assertTrue(config.contains(" force_path_style: bool | None\n")); + assertTrue(config.contains("force_path_style: bool | None = None")); + assertTrue(config.contains("Forces path-style addressing.")); + assertTrue(config.contains("stage: str | None = None")); + assertTrue(config.contains("\"force_path_style\": FieldSpec(default=None),")); + assertTrue(config.contains("\"disable_s3_express_session_auth\": FieldSpec(default=None),")); + assertTrue(config.contains("\"stage\": FieldSpec(default=None),")); + // ForcePathStyle is both a built-in and a client context parameter: one field. + assertEquals(1, count(config, "force_path_style: bool | None = None")); + assertEquals(1, count(config, "\"force_path_style\": FieldSpec(")); + // This service's rules declare none of the AWS built-ins with generated fields. + assertFalse(config.contains("use_fips_endpoint")); + assertFalse(config.contains("use_dualstack_endpoint")); + assertFalse(config.contains("account_id_endpoint_mode")); + } + + @Test + public void addsAccountIdEndpointModeWhenRulesDeclareIt(@TempDir Path tempDir) throws IOException { + var config = generateConfig( + tempDir, + "aws-account-id-endpoint-mode.smithy", + "example.accountid#AccountIdService", + "accountid"); + assertTrue(config.contains(" account_id_endpoint_mode: str | None\n")); + assertEquals(1, count(config, "account_id_endpoint_mode: str | None = None")); + assertTrue(config.contains("Defaults to preferred.")); + assertTrue(config.contains("\"account_id_endpoint_mode\": FieldSpec(default=\"preferred\"),")); + // Its rules declare no FIPS or dual-stack built-ins. + assertFalse(config.contains("use_fips_endpoint")); + assertFalse(config.contains("use_dualstack_endpoint")); + } + + private static int count(String text, String fragment) { + int count = 0; + for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + 1)) { + count++; + } + return count; + } +} diff --git a/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java index e447fa3d0..9162ca4f0 100644 --- a/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java +++ b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java @@ -84,8 +84,25 @@ public void testCodegen(@TempDir Path tempDir) throws IOException { assertTrue(schemas.contains("\"arnNamespace\": \"rest-json\"")); assertTrue(schemas.contains("\"cloudFormationName\": \"RestJson\"")); assertTrue(schemas.contains("\"cloudTrailEventSource\": \"rest-json.amazonaws.com\"")); - assertTrue(schemas.contains("smithy.rules#endpointBdd")); - assertTrue(schemas.contains("rest-json-1.{Region}.{PartitionResult#dnsSuffix}")); + // Lowering consumes the endpoint rules; the schemas no longer carry them. + assertFalse(schemas.contains("smithy.rules#endpointBdd")); + + var endpoints = Files.readString(tempDir.resolve("src/restjson/_private/endpoints.py")); + assertTrue(endpoints.contains("\"https://rest-json-1.{Region}.{PartitionResult#dnsSuffix}\"")); + // Formatting may wrap the call, so match its parts. + assertTrue(config.contains("AsyncBddEndpointResolver(")); + assertTrue(config.contains("BDD, extensions=[AWS_RULES_EXTENSION]")); + assertTrue(config.contains("from ._private.endpoints import BDD")); + assertTrue(config.contains("from smithy_aws_core.endpoints import AWS_RULES_EXTENSION")); + // The default rules declare the FIPS and dual-stack built-ins, so the config gets + // their fields; they don't declare the account ID endpoint mode. + assertTrue(config.contains(" use_fips_endpoint: bool | None\n")); + assertTrue(config.contains(" use_dualstack_endpoint: bool | None\n")); + assertTrue(config.contains("use_fips_endpoint: bool | None = None")); + assertTrue(config.contains("use_dualstack_endpoint: bool | None = None")); + assertTrue(config.contains("\"use_fips_endpoint\": FieldSpec(default=None),")); + assertTrue(config.contains("\"use_dualstack_endpoint\": FieldSpec(default=None),")); + assertFalse(config.contains("account_id_endpoint_mode")); } private static void assertInOrder(String value, String... fragments) { diff --git a/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-account-id-endpoint-mode.smithy b/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-account-id-endpoint-mode.smithy new file mode 100644 index 000000000..f490f536f --- /dev/null +++ b/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-account-id-endpoint-mode.smithy @@ -0,0 +1,42 @@ +$version: "2.0" + +namespace example.accountid + +use aws.api#service +use aws.auth#sigv4 +use aws.protocols#restJson1 +use smithy.rules#endpointRuleSet + +@restJson1 +@sigv4(name: "account-id") +@service(sdkId: "Account Id", endpointPrefix: "account-id") +@suppress(["RuleSetAwsBuiltIn.AWS::Auth::AccountIdEndpointMode"]) +@endpointRuleSet({ + version: "1.0" + parameters: { + Region: { type: "string", builtIn: "AWS::Region", documentation: "The region." } + AccountIdEndpointMode: { + type: "string" + builtIn: "AWS::Auth::AccountIdEndpointMode" + documentation: "The account ID endpoint mode." + } + } + rules: [ + { + conditions: [ + { fn: "isSet", argv: [{ ref: "AccountIdEndpointMode" }] } + { fn: "isSet", argv: [{ ref: "Region" }] } + ] + endpoint: { url: "https://{AccountIdEndpointMode}.{Region}.example.com" } + type: "endpoint" + } + { conditions: [], error: "No mode", type: "error" } + ] +}) +service AccountIdService { + version: "2026-10-22" + operations: [Ping] +} + +@http(method: "POST", uri: "/ping") +operation Ping {} diff --git a/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-client-context-params.smithy b/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-client-context-params.smithy new file mode 100644 index 000000000..95b1c065f --- /dev/null +++ b/codegen/aws/core/src/it/resources/software/amazon/smithy/python/codegen/test/aws-client-context-params.smithy @@ -0,0 +1,52 @@ +$version: "2.0" + +namespace example.awsccp + +use aws.api#service +use aws.auth#sigv4 +use aws.protocols#restJson1 +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet + +@restJson1 +@sigv4(name: "client-context") +@service(sdkId: "Client Context", endpointPrefix: "client-context") +@clientContextParams( + ForcePathStyle: { type: "boolean", documentation: "Forces path-style addressing." } + DisableS3ExpressSessionAuth: { type: "boolean" } + Stage: { type: "string", documentation: "The deployment stage." } +) +@endpointRuleSet({ + version: "1.0" + parameters: { + ForcePathStyle: { + type: "boolean" + required: true + default: false + builtIn: "AWS::S3::ForcePathStyle" + documentation: "Forces path-style addressing." + } + DisableS3ExpressSessionAuth: { type: "boolean", documentation: "Disables session auth." } + Stage: { type: "string", documentation: "The deployment stage." } + } + rules: [ + { + conditions: [{ fn: "isSet", argv: [{ ref: "Stage" }] }] + endpoint: { url: "https://{Stage}.example.com" } + type: "endpoint" + } + { + conditions: [{ fn: "booleanEquals", argv: [{ ref: "ForcePathStyle" }, true] }] + endpoint: { url: "https://path.example.com" } + type: "endpoint" + } + { conditions: [], error: "No stage", type: "error" } + ] +}) +service ClientContextService { + version: "2026-10-21" + operations: [Ping] +} + +@http(method: "POST", uri: "/ping") +operation Ping {} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java index 6fc34c56a..38b9286e5 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java @@ -4,21 +4,31 @@ */ package software.amazon.smithy.python.aws.codegen; +import static software.amazon.smithy.python.aws.codegen.AwsEndpointRulesIntegration.BUILT_IN_FIELDS; + +import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Set; import software.amazon.smithy.aws.traits.ServiceTrait; +import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.python.aws.codegen.AwsEndpointRulesIntegration.BuiltInField; import software.amazon.smithy.python.codegen.CodegenUtils; import software.amazon.smithy.python.codegen.ConfigProperty; import software.amazon.smithy.python.codegen.GenerationContext; import software.amazon.smithy.python.codegen.RuntimeTypes; import software.amazon.smithy.python.codegen.SmithyPythonDependency; +import software.amazon.smithy.python.codegen.generators.ConfigGenerator; import software.amazon.smithy.python.codegen.integrations.PythonIntegration; import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; import software.amazon.smithy.python.codegen.sections.AsyncConfigSection; import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameter; +import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; import software.amazon.smithy.utils.CodeInterceptor; import software.amazon.smithy.utils.CodeSection; import software.amazon.smithy.utils.SmithyInternalApi; @@ -51,6 +61,35 @@ public class AwsAsyncConfigIntegration implements PythonIntegration { "auth_schemes", "auth_scheme_resolver"); + // The service's endpoint rules parameters, in trait order. + private static List endpointParameters(ServiceShape service) { + var parameters = new ArrayList(); + service.getTrait(EndpointBddTrait.class).ifPresent(trait -> trait.getParameters().forEach(parameters::add)); + return parameters; + } + + // A built-in's config field, typed and documented by its rules parameter. + private static ConfigProperty builtInProperty(Parameter parameter, BuiltInField field) { + var type = switch (parameter.getType()) { + case BOOLEAN -> "bool"; + case STRING -> "str"; + default -> throw new CodegenException(String.format( + "Unsupported type %s for built-in %s", + parameter.getType(), + parameter.getBuiltIn().orElseThrow())); + }; + var documentation = parameter.getDocumentation() + .orElse("The " + parameter.getName() + " endpoint parameter."); + if (field.defaultValue() != null) { + documentation += " Defaults to " + field.defaultValue() + "."; + } + return ConfigProperty.builder() + .name(field.name()) + .type(Symbol.builder().name(type).build()) + .documentation(documentation) + .build(); + } + @Override public List> interceptors( GenerationContext context @@ -97,6 +136,20 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s var hasAuth = !serviceIndex.getAuthSchemes(context.settings().service()).isEmpty(); // Preserve the first declaration when plugins contribute duplicate properties. var pluginProperties = new LinkedHashMap(); + for (ConfigProperty property : ConfigGenerator.clientContextProperties(context)) { + pluginProperties.putIfAbsent(property.name(), property); + } + // A field for each built-in the rules declare that the AWS rules extension binds + // to config, and the non-None defaults of those fields by name. + var builtInDefaults = new HashMap(); + for (Parameter parameter : endpointParameters(service)) { + var field = parameter.getBuiltIn().map(BUILT_IN_FIELDS::get).orElse(null); + if (field != null + && pluginProperties.putIfAbsent(field.name(), builtInProperty(parameter, field)) == null + && field.defaultValue() != null) { + builtInDefaults.put(field.name(), field.defaultValue()); + } + } for (PythonIntegration integration : context.integrations()) { for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { if (plugin.matchesService(model, service)) { @@ -249,7 +302,12 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s // below. Fields unique to this service (e.g., api_key from @httpApiKeyAuth) // survive and participate in the resolution pipeline. for (String propertyName : pluginProperties.keySet()) { - writer.write("\"$L\": $T(default=None),", propertyName, fieldSpecSymbol); + var builtInDefault = builtInDefaults.get(propertyName); + if (builtInDefault != null) { + writer.write("\"$L\": $T(default=$S),", propertyName, fieldSpecSymbol, builtInDefault); + } else { + writer.write("\"$L\": $T(default=None),", propertyName, fieldSpecSymbol); + } } writer.write("**$T._FIELDS,", asyncAwsConfigSymbol); @@ -272,14 +330,10 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s writer.write("),"); // endpoint_resolver FieldSpec - var endpointPrefix = service.getTrait(ServiceTrait.class) - .map(ServiceTrait::getEndpointPrefix) - .orElse(context.settings().service().getName()); writer.write("\"endpoint_resolver\": $T(", fieldSpecSymbol); writer.indent(); - writer.write("default_factory=lambda: $T(endpoint_prefix=$S),", - AwsRuntimeTypes.STANDARD_REGIONAL_ENDPOINTS_RESOLVER, - endpointPrefix); + writer.write("default_factory=lambda: $C,", + writer.consumer(w -> ConfigGenerator.writeDefaultEndpointResolver(context, w))); writer.dedent(); writer.write("),"); diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegration.java new file mode 100644 index 000000000..ae14540bb --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegration.java @@ -0,0 +1,227 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.REGION; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import software.amazon.smithy.aws.traits.ServiceTrait; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.pattern.UriPattern; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.transform.ModelTransformer; +import software.amazon.smithy.python.codegen.CodegenUtils; +import software.amazon.smithy.python.codegen.EndpointTestGenerator; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.PythonSettings; +import software.amazon.smithy.python.codegen.RuntimeTypes; +import software.amazon.smithy.python.codegen.integrations.EndpointRulesExtension; +import software.amazon.smithy.python.codegen.integrations.PythonIntegration; +import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.LibraryFunction; +import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameter; +import software.amazon.smithy.rulesengine.language.syntax.rule.Condition; +import software.amazon.smithy.rulesengine.traits.ContextParamTrait; +import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Adds the region config property and registers the AWS rules extension for AWS services + * and for services whose endpoint rules use AWS functions or built-ins. In generated + * endpoint tests, the extension sets the AWS built-ins and test credentials. For S3, it + * strips the bucket from the operation URIs because the rules place it. + */ +@SmithyInternalApi +public final class AwsEndpointRulesIntegration implements PythonIntegration { + /** + * The config fields the AWS rules extension binds built-ins to, besides the base + * region field, by built-in. A service's config gets a field for each one its rules + * declare. Keep in sync with {@code AWS_RULES_EXTENSION} in smithy_aws_core.endpoints. + */ + static final Map BUILT_IN_FIELDS = Map.of( + "AWS::UseFIPS", + new BuiltInField("use_fips_endpoint", null), + "AWS::UseDualStack", + new BuiltInField("use_dualstack_endpoint", null), + // The SDKs and Tools Reference Guide default. + "AWS::Auth::AccountIdEndpointMode", + new BuiltInField("account_id_endpoint_mode", "preferred")); + private static final String REGION_BUILT_IN = "AWS::Region"; + private static final String ACCOUNT_ID = "AWS::Auth::AccountId"; + private static final String TEST_ACCESS_KEY_ID = "test-access-key-id"; + private static final String TEST_SECRET_ACCESS_KEY = "test-secret-access-key"; + private static final String S3_SDK_ID = "S3"; + private static final String BUCKET = "Bucket"; + private static final String BUCKET_LABEL = "/{" + BUCKET + "}"; + + /** + * Strips the leading {@code /{Bucket}} from S3's operation URIs. S3's rules place the + * bucket in the endpoint host or path, so a serialized path that also starts with the + * bucket would send it twice. The {@code Bucket} member keeps its {@code @httpLabel}, + * so it isn't serialized into the body; the path serializer ignores a label that isn't + * in the URI. + */ + @Override + public Model preprocessModel(Model model, PythonSettings settings) { + var service = settings.service(model); + if (!service.getTrait(ServiceTrait.class).map(ServiceTrait::getSdkId).orElse("").equals(S3_SDK_ID)) { + return model; + } + var replaced = new ArrayList(); + for (OperationShape operation : TopDownIndex.of(model).getContainedOperations(service)) { + var http = operation.getTrait(HttpTrait.class); + if (http.isPresent() && placesBucket(model, operation, http.get())) { + var uri = http.get().getUri().toString().substring(BUCKET_LABEL.length()); + if (uri.isEmpty() || uri.startsWith("?")) { + uri = "/" + uri; + } + replaced.add(operation.toBuilder() + .addTrait(http.get().toBuilder().uri(UriPattern.parse(uri)).build()) + .build()); + } + } + return ModelTransformer.create().replaceShapes(model, replaced); + } + + // Whether the URI starts with the Bucket label and the rules read Bucket. + private static boolean placesBucket(Model model, OperationShape operation, HttpTrait http) { + var uri = http.getUri().toString(); + if (!uri.equals(BUCKET_LABEL) && !uri.startsWith(BUCKET_LABEL + "/") && !uri.startsWith(BUCKET_LABEL + "?")) { + return false; + } + return model.expectShape(operation.getInputShape()) + .getMember(BUCKET) + .filter(member -> member.hasTrait(ContextParamTrait.class)) + .isPresent(); + } + + @Override + public List getClientPlugins(GenerationContext context) { + var plugins = new ArrayList(); + if (context.applicationProtocol().isHttpProtocol()) { + plugins.add(RuntimeClientPlugin.builder() + .addConfigProperty(REGION) + .build()); + } + plugins.add(RuntimeClientPlugin.builder() + .servicePredicate((model, service) -> service.hasTrait(ServiceTrait.class) || usesAwsRules(service)) + .endpointRulesExtension(new AwsRulesExtension()) + .build()); + return plugins; + } + + /** + * A config field that the AWS rules extension binds a built-in to. + * + * @param name the config field name. + * @param defaultValue the field's default, or null for None. + */ + record BuiltInField(String name, String defaultValue) {} + + /** + * Whether a service's endpoint rules call AWS functions or read AWS built-ins. + * + * @param service the service. + * @return true if the rules need the AWS rules extension. + */ + static boolean usesAwsRules(ServiceShape service) { + var trait = service.getTrait(EndpointBddTrait.class); + if (trait.isEmpty()) { + return false; + } + for (Parameter parameter : trait.get().getParameters()) { + if (parameter.getBuiltIn().filter(b -> b.startsWith("AWS::")).isPresent()) { + return true; + } + } + for (Condition condition : trait.get().getConditions()) { + if (callsAwsFunction(condition.getFunction())) { + return true; + } + } + return false; + } + + private static boolean callsAwsFunction(Expression expression) { + if (expression instanceof LibraryFunction function) { + if (function.getName().startsWith("aws.")) { + return true; + } + for (Expression argument : function.getArguments()) { + if (callsAwsFunction(argument)) { + return true; + } + } + } + return false; + } + + private static final class AwsRulesExtension implements EndpointRulesExtension { + + @Override + public Symbol getRulesExtensionSymbol(GenerationContext context) { + return AwsRuntimeTypes.AWS_RULES_EXTENSION; + } + + @Override + public void writeEndpointTestConfig(GenerationContext context, PythonWriter writer) { + if (CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()).isEmpty()) { + return; + } + // Config resolution requires a region. Explicit values keep endpoint_uri and the + // credentials from being read from the environment. + writer.write(""" + "region": "us-east-1", + "endpoint_uri": None, + "aws_credentials_identity_resolver": $T($T(access_key_id=$S, secret_access_key=$S)),""", + RuntimeTypes.STATIC_CREDENTIALS_RESOLVER, + AwsRuntimeTypes.AWS_CREDENTIALS_IDENTITY, + TEST_ACCESS_KEY_ID, + TEST_SECRET_ACCESS_KEY); + } + + @Override + public Optional> getEndpointTestBuiltIn( + GenerationContext context, + String builtIn, + Node value + ) { + if (CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()).isEmpty()) { + return Optional.empty(); + } + var field = builtIn.equals(REGION_BUILT_IN) + ? REGION.name() + : Optional.ofNullable(BUILT_IN_FIELDS.get(builtIn)).map(BuiltInField::name).orElse(null); + if (field != null) { + return Optional.of(writer -> writer.write("$S: $L,", field, EndpointTestGenerator.literal(value))); + } + // The account ID is read from the identity, so the test credentials carry it. + if (builtIn.equals(ACCOUNT_ID)) { + return Optional + .of(writer -> writer.write( + "$S: $T($T(access_key_id=$S, secret_access_key=$S, account_id=$S)),", + "aws_credentials_identity_resolver", + RuntimeTypes.STATIC_CREDENTIALS_RESOLVER, + AwsRuntimeTypes.AWS_CREDENTIALS_IDENTITY, + TEST_ACCESS_KEY_ID, + TEST_SECRET_ACCESS_KEY, + value.expectStringNode().getValue())); + } + return Optional.empty(); + } + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsRuntimeTypes.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsRuntimeTypes.java index b226c8337..42766b78b 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsRuntimeTypes.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsRuntimeTypes.java @@ -31,10 +31,15 @@ public final class AwsRuntimeTypes { "aio.protocols", "AsyncRestXmlClientProtocol"); - // smithy_aws_core.aio.endpoints.standard_regional - public static final Symbol STANDARD_REGIONAL_ENDPOINTS_RESOLVER = createSymbol( - "aio.endpoints.standard_regional", - "AsyncStandardRegionalEndpointsResolver"); + // smithy_aws_core.endpoints + public static final Symbol AWS_RULES_EXTENSION = createSymbol( + "endpoints", + "AWS_RULES_EXTENSION"); + + // smithy_aws_core.identity + public static final Symbol AWS_CREDENTIALS_IDENTITY = createSymbol( + "identity", + "AWSCredentialsIdentity"); private AwsRuntimeTypes() {} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsStandardRegionalEndpointsIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsStandardRegionalEndpointsIntegration.java deleted file mode 100644 index 346f94fe3..000000000 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsStandardRegionalEndpointsIntegration.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -package software.amazon.smithy.python.aws.codegen; - -import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.REGION; - -import java.util.List; -import software.amazon.smithy.aws.traits.ServiceTrait; -import software.amazon.smithy.python.codegen.GenerationContext; -import software.amazon.smithy.python.codegen.integrations.PythonIntegration; -import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; -import software.amazon.smithy.python.codegen.sections.InitDefaultEndpointResolverSection; -import software.amazon.smithy.python.codegen.writer.PythonWriter; -import software.amazon.smithy.utils.CodeInterceptor; -import software.amazon.smithy.utils.CodeSection; - -public class AwsStandardRegionalEndpointsIntegration implements PythonIntegration { - @Override - public List getClientPlugins(GenerationContext context) { - if (context.applicationProtocol().isHttpProtocol()) { - return List.of( - RuntimeClientPlugin.builder() - .addConfigProperty(REGION) - .build()); - } else { - return List.of(); - } - } - - @Override - public List> interceptors( - GenerationContext context - ) { - return List.of( - new RegionalInitEndpointResolverInterceptor(context)); - } - - private static final class RegionalInitEndpointResolverInterceptor - implements CodeInterceptor { - - private final GenerationContext context; - - public RegionalInitEndpointResolverInterceptor(GenerationContext context) { - this.context = context; - } - - @Override - public Class sectionType() { - return InitDefaultEndpointResolverSection.class; - } - - @Override - public void write(PythonWriter writer, String previousText, InitDefaultEndpointResolverSection section) { - String endpointPrefix = context.settings() - .service(context.model()) - .getTrait(ServiceTrait.class) - .map(ServiceTrait::getEndpointPrefix) - .orElse(context.settings().service().getName()); - - writer.write( - "self.endpoint_resolver = endpoint_resolver or $1T(endpoint_prefix=$2S)", - AwsRuntimeTypes.STANDARD_REGIONAL_ENDPOINTS_RESOLVER, - endpointPrefix); - - } - } -} diff --git a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration index 2504b47a9..6330be370 100644 --- a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration +++ b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration @@ -9,7 +9,7 @@ software.amazon.smithy.python.aws.codegen.AwsIdentityIntegration software.amazon.smithy.python.aws.codegen.AwsProtocolsIntegration software.amazon.smithy.python.aws.codegen.AwsServiceIdIntegration software.amazon.smithy.python.aws.codegen.AwsUserAgentIntegration -software.amazon.smithy.python.aws.codegen.AwsStandardRegionalEndpointsIntegration +software.amazon.smithy.python.aws.codegen.AwsEndpointRulesIntegration software.amazon.smithy.python.aws.codegen.AwsDefaultEndpointRuleSetIntegration software.amazon.smithy.python.aws.codegen.customizations.dynamodb.AwsDynamoDbRetryIntegration software.amazon.smithy.python.aws.codegen.AwsAsyncConfigIntegration diff --git a/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/ClientContextParamsCodegenTest.java b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/ClientContextParamsCodegenTest.java new file mode 100644 index 000000000..840d42dd0 --- /dev/null +++ b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/ClientContextParamsCodegenTest.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen.test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.build.FileManifest; +import software.amazon.smithy.build.PluginContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.python.codegen.PythonClientCodegenPlugin; + +public class ClientContextParamsCodegenTest { + + @Test + public void addsConfigFieldsForClientContextParams(@TempDir Path tempDir) throws IOException { + var loader = ClientContextParamsCodegenTest.class.getClassLoader(); + Model model = Model.assembler(loader) + .discoverModels(loader) + .addImport(ClientContextParamsCodegenTest.class.getResource("client-context-params.smithy")) + .assemble() + .unwrap(); + PluginContext context = PluginContext.builder() + .fileManifest(FileManifest.create(tempDir)) + .settings(ObjectNode.builder() + .withMember("service", "example.ccp#ClientContextService") + .withMember("module", "ccp") + .withMember("moduleVersion", "0.0.1") + .build()) + .model(model) + .build(); + new PythonClientCodegenPlugin().execute(context); + + var config = Files.readString(tempDir.resolve("src/ccp/config.py")); + // Named to_snake_case(name), typed from the trait, documented from it, defaulting to None. + assertTrue(config.contains("force_path_style: bool | None")); + assertTrue(config.contains("Forces path-style addressing.")); + assertTrue(config.contains("disable_s3_express_session_auth: bool | None")); + assertTrue(config.contains("The DisableS3ExpressSessionAuth endpoint parameter.")); + assertTrue(config.contains("stage: str | None")); + assertTrue(config.contains("The deployment stage.")); + assertTrue(config.contains("force_path_style: bool | None = None,")); + assertTrue(config.contains("self.force_path_style = force_path_style")); + + var endpoints = Files.readString(tempDir.resolve("src/ccp/_private/endpoints.py")); + assertTrue(endpoints.contains("(\"ForcePathStyle\", \"boolean\", True, False, None, True)")); + assertTrue(endpoints.contains("(\"Stage\", \"string\", False, None, None, True)")); + } +} diff --git a/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java index f92fbefde..161ee3c07 100644 --- a/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java +++ b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java @@ -71,8 +71,14 @@ public void testCodegen(@TempDir Path tempDir) throws IOException { assertTrue(schemas.contains("aws.protocols#restJson1")); assertTrue(schemas.contains("smithy.api#httpApiKeyAuth")); assertTrue(schemas.contains("\"name\": \"weather-auth\"")); - assertTrue(schemas.contains("smithy.rules#endpointBdd")); - assertTrue(schemas.contains("(default endpointRuleSet) endpoint is not set")); + // Lowering consumes the endpoint rules; the schemas no longer carry them. + assertFalse(schemas.contains("smithy.rules#endpointBdd")); + + var endpoints = Files.readString(tempDir.resolve("src/weather/_private/endpoints.py")); + assertTrue(endpoints.contains("(default endpointRuleSet) endpoint is not set")); + assertTrue(config.contains( + "self.endpoint_resolver = endpoint_resolver or AsyncBddEndpointResolver(BDD)")); + assertTrue(config.contains("from ._private.endpoints import BDD")); assertTrue(schemas.contains("smithy.api#longPoll")); assertTrue(schemas.contains("\"timeoutMillis\": 20000")); } diff --git a/codegen/core/src/it/resources/software/amazon/smithy/python/codegen/test/client-context-params.smithy b/codegen/core/src/it/resources/software/amazon/smithy/python/codegen/test/client-context-params.smithy new file mode 100644 index 000000000..b0dead175 --- /dev/null +++ b/codegen/core/src/it/resources/software/amazon/smithy/python/codegen/test/client-context-params.smithy @@ -0,0 +1,42 @@ +$version: "2.0" + +namespace example.ccp + +use aws.protocols#restJson1 +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet + +@restJson1 +@clientContextParams( + ForcePathStyle: { type: "boolean", documentation: "Forces path-style addressing." } + DisableS3ExpressSessionAuth: { type: "boolean" } + Stage: { type: "string", documentation: "The deployment stage." } +) +@endpointRuleSet({ + version: "1.0" + parameters: { + ForcePathStyle: { type: "boolean", required: true, default: false, documentation: "Forces path-style addressing." } + DisableS3ExpressSessionAuth: { type: "boolean", documentation: "Disables session auth." } + Stage: { type: "string", documentation: "The deployment stage." } + } + rules: [ + { + conditions: [{ fn: "isSet", argv: [{ ref: "Stage" }] }] + endpoint: { url: "https://{Stage}.example.com" } + type: "endpoint" + } + { + conditions: [{ fn: "booleanEquals", argv: [{ ref: "ForcePathStyle" }, true] }] + endpoint: { url: "https://path.example.com" } + type: "endpoint" + } + { conditions: [], error: "No stage", type: "error" } + ] +}) +service ClientContextService { + version: "2026-10-21" + operations: [Ping] +} + +@http(method: "POST", uri: "/ping") +operation Ping {} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/DirectedPythonClientCodegen.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/DirectedPythonClientCodegen.java index 1840033b2..2fc29dccf 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/DirectedPythonClientCodegen.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/DirectedPythonClientCodegen.java @@ -30,6 +30,7 @@ import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.python.codegen.generators.ConfigGenerator; import software.amazon.smithy.python.codegen.generators.ConformanceTestGenerator; +import software.amazon.smithy.python.codegen.generators.EndpointBddGenerator; import software.amazon.smithy.python.codegen.generators.InitGenerator; import software.amazon.smithy.python.codegen.generators.ModelModuleGenerator; import software.amazon.smithy.python.codegen.generators.ModelStubGenerator; @@ -111,6 +112,7 @@ public void customizeBeforeShapeGeneration(CustomizeDirective directive) { new ClientGenerator(directive.context(), directive.service()).run(); + new EndpointTestGenerator(directive.context()).run(); var protocolGenerator = directive.context().protocolGenerator(); if (protocolGenerator == null) { diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/EndpointTestGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/EndpointTestGenerator.java new file mode 100644 index 000000000..3323cb95c --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/EndpointTestGenerator.java @@ -0,0 +1,437 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.EndpointTrait; +import software.amazon.smithy.python.codegen.generators.ConfigGenerator; +import software.amazon.smithy.python.codegen.generators.EndpointBddGenerator; +import software.amazon.smithy.python.codegen.integrations.EndpointRulesExtension; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameter; +import software.amazon.smithy.rulesengine.traits.ClientContextParamsTrait; +import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; +import software.amazon.smithy.rulesengine.traits.EndpointTestCase; +import software.amazon.smithy.rulesengine.traits.EndpointTestExpectation; +import software.amazon.smithy.rulesengine.traits.EndpointTestOperationInput; +import software.amazon.smithy.rulesengine.traits.EndpointTestsTrait; +import software.amazon.smithy.utils.CaseUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Generates {@code tests/test_endpoints.py} from a service's {@code smithy.rules#endpointTests} + * cases. + * + *

Every case becomes a rules test, which resolves the case's parameter values with the + * client's own endpoint resolver. Every operation input of a case also becomes a client test, + * which calls the operation through the client with config from the input's built-in and + * client parameters and checks the endpoint the client resolves. A client test the client + * can't express is generated as skipped, with the reason. Cases keep the trait's {@code params} + * and {@code expect} objects as they are. + */ +@SmithyInternalApi +public final class EndpointTestGenerator implements Runnable { + + private static final Symbol CHECK_RULES_CASE = testSymbol("check_rules_case_async"); + private static final Symbol CHECK_CLIENT_CASE = testSymbol("check_client_case_async"); + + private final GenerationContext context; + private final ServiceShape service; + private final Map parametersByBuiltIn = new HashMap<>(); + private final Set clientContextParams; + private final List rulesExtensions; + + /** + * @param context The generation context. + */ + public EndpointTestGenerator(GenerationContext context) { + this.context = context; + this.service = context.settings().service(context.model()); + service.getTrait(EndpointBddTrait.class).ifPresent(bdd -> { + for (Parameter parameter : bdd.getParameters()) { + parameter.getBuiltIn().ifPresent(builtIn -> parametersByBuiltIn.put(builtIn, parameter)); + } + }); + this.clientContextParams = service.getTrait(ClientContextParamsTrait.class) + .map(trait -> trait.getParameters().keySet()) + .orElse(Set.of()); + this.rulesExtensions = ConfigGenerator.endpointRulesExtensions(context); + } + + @Override + public void run() { + var cases = service.getTrait(EndpointTestsTrait.class) + .map(EndpointTestsTrait::getTestCases) + .orElse(List.of()); + if (cases.isEmpty() || !service.hasTrait(EndpointBddTrait.class)) { + return; + } + context.writerDelegator().useFileWriter("./tests/test_endpoints.py", "tests.test_endpoints", writer -> { + writer.addDependency(SmithyPythonDependency.PYTEST); + writer.addDependency(SmithyPythonDependency.PYTEST_ASYNCIO); + writer.addDependency(SmithyPythonDependency.SMITHY_TEST); + writer.addStdlibImport("collections.abc", "Mapping"); + writer.addStdlibImport("typing", "Any"); + writer.addImport(SmithyPythonDependency.PYTEST.packageName(), "mark"); + writer.addImport(SmithyPythonDependency.PYTEST.packageName(), "param"); + writeClientFactory(writer); + writeRulesExtensions(writer); + writeRulesTests(writer, cases); + writeClientTests(writer, cases); + }); + } + + // _config builds the client's config with the test config, _endpoint_resolver gets one + // default endpoint resolver for every rules test, and _client builds a client with an + // interceptor added to it. + private void writeClientFactory(PythonWriter writer) { + var settings = context.settings(); + var asyncConfig = CodegenUtils.getAsyncConfigSymbol(settings, context.model()); + var config = asyncConfig.orElse(CodegenUtils.getConfigSymbol(settings)); + var construct = asyncConfig.isPresent() + ? writer.format("await $T.resolve", config) + : writer.format("$T", config); + var client = context.symbolProvider().toSymbol(service); + writer.write(""" + # Endpoint tests from the endpointTests trait of $L. + + _TEST_CONFIG: dict[str, Any] = { + ${C|} + } + + + async def _config(**fields: Any) -> $T: + return $L( + **(_TEST_CONFIG | fields), + retry_strategy=$T(max_attempts=1), + ) + + + # Every rules test shares one resolver, so its caches see changing parameters. + _resolver: $T | None = None + + + async def _endpoint_resolver() -> $T: + global _resolver + if _resolver is None: + config = await _config() + # Only the resolver is used, so the transport is closed right away. + await $T(config.transport) + assert config.endpoint_resolver is not None + _resolver = config.endpoint_resolver + return _resolver + + + async def _client( + config: Mapping[str, Any], capture: $T[Any, Any, Any, Any] + ) -> $T: + return $T(config=await _config(**config, interceptors=[capture])) + """, + service.getId(), + writer.consumer(w -> { + for (EndpointRulesExtension extension : rulesExtensions) { + extension.writeEndpointTestConfig(context, w); + } + }), + config, + construct, + RuntimeTypes.SIMPLE_RETRY_STRATEGY, + RuntimeTypes.ENDPOINT_RESOLVER, + RuntimeTypes.ENDPOINT_RESOLVER, + RuntimeTypes.ASYNC_CLOSE, + RuntimeTypes.INTERCEPTOR, + client, + client); + } + + // _RULES_EXTENSIONS lets the checks map the auth scheme names a case expects to the + // scheme IDs the client's endpoint resolver gives. + private void writeRulesExtensions(PythonWriter writer) { + if (rulesExtensions.isEmpty()) { + return; + } + writer.write(""" + + + _RULES_EXTENSIONS = [ + ${C|} + ] + """, + writer.consumer(w -> { + for (EndpointRulesExtension extension : rulesExtensions) { + w.write("$T,", extension.getRulesExtensionSymbol(context)); + } + })); + } + + private String extensionsArgument() { + return rulesExtensions.isEmpty() ? "" : ", extensions=_RULES_EXTENSIONS"; + } + + private void writeRulesTests(PythonWriter writer, List cases) { + var ids = new CaseIds(); + writer.write(""" + + + RULES_CASES = [ + ${C|} + ] + + + @mark.parametrize(("params", "expect"), RULES_CASES) + async def test_endpoint_rules(params: dict[str, Any], expect: dict[str, Any]) -> None: + await $T(await _endpoint_resolver(), params, expect$L) + """, + writer.consumer(w -> { + for (EndpointTestCase testCase : cases) { + w.write(""" + param( + $L, + $L, + id=$S, + ),""", + literal(testCase.getParams()), + expectation(testCase.getExpect()), + ids.next(testCase)); + } + }), + CHECK_RULES_CASE, + extensionsArgument()); + } + + private void writeClientTests(PythonWriter writer, List cases) { + if (cases.stream().allMatch(testCase -> testCase.getOperationInputs().isEmpty())) { + return; + } + writer.addStdlibImport("collections.abc", "Awaitable"); + writer.addStdlibImport("collections.abc", "Callable"); + var client = context.symbolProvider().toSymbol(service); + var ids = new CaseIds(); + writer.write(""" + + + CLIENT_CASES = [ + ${C|} + ] + + + @mark.parametrize(("config", "operation", "input", "expect"), CLIENT_CASES) + async def test_endpoint_client( + config: dict[str, Any], + operation: Callable[[$T, Any], Awaitable[Any]], + input: Any, + expect: dict[str, Any], + ) -> None: + await $T(lambda capture: _client(config, capture), operation, input, expect$L) + """, + writer.consumer(w -> { + for (EndpointTestCase testCase : cases) { + for (EndpointTestOperationInput input : testCase.getOperationInputs()) { + writeClientCase(w, testCase, input, ids.next(testCase)); + } + } + }), + client, + CHECK_CLIENT_CASE, + extensionsArgument()); + } + + private void writeClientCase( + PythonWriter writer, + EndpointTestCase testCase, + EndpointTestOperationInput input, + String id + ) { + var operation = operation(input.getOperationName()); + var inputShape = context.model().expectShape(operation.getInputShape(), StructureShape.class); + var skips = new ArrayList(); + var entries = configEntries(input, skips); + writer.write(""" + param( + { + ${C|} + }, + $T.$L, + ${C|}, + $L, + id=$S,$L + ),""", + writer.consumer(w -> entries.forEach(entry -> entry.accept(w))), + context.symbolProvider().toSymbol(service), + context.symbolProvider() + .toSymbol(operation) + .expectProperty(SymbolProperties.OPERATION_METHOD) + .getName(), + writer.consumer(w -> input.getOperationParams() + .accept(new ValueNodeVisitor(context, w, inputShape))), + expectation(testCase.getExpect()), + id, + marks(writer, operation, skips)); + } + + // A skip for a case the client can't express, or an expected failure for a known gap. + private static String marks(PythonWriter writer, OperationShape operation, List skips) { + if (!skips.isEmpty()) { + return writer.format("\n marks=mark.skip(reason=$S),", String.join("; ", skips)); + } + var hostLabels = operation.getTrait(EndpointTrait.class) + .map(trait -> !trait.getHostPrefix().getLabels().isEmpty()) + .orElse(false); + if (hostLabels) { + return writer.format("\n marks=mark.xfail(reason=$S, strict=True),", + "Host labels in an endpoint trait's hostPrefix aren't serialized yet"); + } + return ""; + } + + // The config entries an operation input's built-in and client parameters set. A parameter + // the client has no config field for adds the reason the case is skipped. + private List> configEntries(EndpointTestOperationInput input, List skips) { + var entries = new ArrayList>(); + input.getBuiltInParams().getMembers().forEach((name, value) -> { + var builtIn = name.getValue(); + var entry = builtInEntry(builtIn, value); + if (entry.isPresent()) { + entries.add(entry.get()); + return; + } + var parameter = parametersByBuiltIn.get(builtIn); + boolean isDefault = parameter == null + || parameter.getDefault().map(d -> d.toNode().equals(value)).orElse(false); + if (!isDefault) { + skips.add("No config field sets the built-in " + builtIn); + } + }); + input.getClientParams().getMembers().forEach((name, value) -> { + if (clientContextParams.contains(name.getValue())) { + entries.add(entry(CaseUtils.toSnakeCase(name.getValue()), value)); + } else { + skips.add(name.getValue() + " is not a client context parameter"); + } + }); + return entries; + } + + private Optional> builtInEntry(String builtIn, Node value) { + var parameter = parametersByBuiltIn.get(builtIn); + // A built-in that is also a client context parameter is read from its config field. + if (parameter != null && clientContextParams.contains(parameter.getNameString())) { + return Optional.of(entry(CaseUtils.toSnakeCase(parameter.getNameString()), value)); + } + if (builtIn.equals("SDK::Endpoint")) { + return Optional.of(entry("endpoint_uri", value)); + } + for (EndpointRulesExtension extension : rulesExtensions) { + var entry = extension.getEndpointTestBuiltIn(context, builtIn, value); + if (entry.isPresent()) { + return entry; + } + } + return Optional.empty(); + } + + private static Consumer entry(String field, Node value) { + return writer -> writer.write("$S: $L,", field, literal(value)); + } + + private OperationShape operation(String name) { + return TopDownIndex.of(context.model()) + .getContainedOperations(service) + .stream() + .filter(operation -> operation.getId().getName().equals(name)) + .findFirst() + .orElseThrow(() -> new CodegenException(String.format( + "Endpoint test operation %s is not in service %s", + name, + service.getId()))); + } + + // The case's expect object as the trait models it. + private static String expectation(EndpointTestExpectation expect) { + var error = expect.getError(); + if (error.isPresent()) { + return literal(Node.objectNode().withMember("error", error.get())); + } + var endpoint = expect.getEndpoint() + .orElseThrow(() -> new CodegenException("An endpoint test case expects neither an endpoint" + + " nor an error")); + var expected = Node.objectNodeBuilder().withMember("url", endpoint.getUrl()); + if (!endpoint.getHeaders().isEmpty()) { + var headers = Node.objectNodeBuilder(); + endpoint.getHeaders().forEach((name, values) -> headers.withMember(name, Node.fromStrings(values))); + expected.withMember("headers", headers.build()); + } + if (!endpoint.getProperties().isEmpty()) { + var properties = Node.objectNodeBuilder(); + endpoint.getProperties().forEach(properties::withMember); + expected.withMember("properties", properties.build()); + } + return literal(Node.objectNode().withMember("endpoint", expected.build())); + } + + /** + * Renders a node as a Python literal on one line: lists for arrays and dicts for objects. + * + * @param node The node. + * @return Returns the Python literal. + */ + public static String literal(Node node) { + return switch (node.getType()) { + case NULL -> "None"; + case BOOLEAN -> node.expectBooleanNode().getValue() ? "True" : "False"; + case NUMBER -> node.expectNumberNode().getValue().toString(); + case STRING -> EndpointBddGenerator.literal(node.expectStringNode().getValue()); + case ARRAY -> node.expectArrayNode() + .getElements() + .stream() + .map(EndpointTestGenerator::literal) + .collect(Collectors.joining(", ", "[", "]")); + case OBJECT -> node.expectObjectNode() + .getMembers() + .entrySet() + .stream() + .map(member -> EndpointBddGenerator.literal(member.getKey().getValue()) + ": " + + literal(member.getValue())) + .collect(Collectors.joining(", ", "{", "}")); + }; + } + + private static Symbol testSymbol(String name) { + return Symbol.builder() + .name(name) + .namespace(SmithyPythonDependency.SMITHY_TEST.packageName() + ".aio", ".") + .addDependency(SmithyPythonDependency.SMITHY_TEST) + .build(); + } + + // Test ids from case documentation, numbered when a description repeats. + private static final class CaseIds { + private final Set used = new HashSet<>(); + + String next(EndpointTestCase testCase) { + var base = testCase.getDocumentation().orElse("undocumented"); + var id = base; + for (int n = 2; !used.add(id); n++) { + id = base + " (" + n + ")"; + } + return id; + } + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java index 10c0fdf05..6cd118b3a 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java @@ -25,27 +25,15 @@ import software.amazon.smithy.model.knowledge.HttpBindingIndex; import software.amazon.smithy.model.knowledge.OperationIndex; import software.amazon.smithy.model.knowledge.TopDownIndex; -import software.amazon.smithy.model.node.ArrayNode; -import software.amazon.smithy.model.node.BooleanNode; import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.NodeVisitor; -import software.amazon.smithy.model.node.NullNode; -import software.amazon.smithy.model.node.NumberNode; import software.amazon.smithy.model.node.ObjectNode; -import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.model.shapes.CollectionShape; -import software.amazon.smithy.model.shapes.DocumentShape; import software.amazon.smithy.model.shapes.ListShape; -import software.amazon.smithy.model.shapes.MapShape; import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.OperationShape; import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.StructureShape; -import software.amazon.smithy.model.shapes.UnionShape; -import software.amazon.smithy.model.traits.HttpPayloadTrait; -import software.amazon.smithy.model.traits.OutputTrait; import software.amazon.smithy.model.traits.StreamingTrait; import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; import software.amazon.smithy.protocoltests.traits.AppliesTo; @@ -203,7 +191,8 @@ private void generateRequestTest(OperationShape operation, HttpRequestTestCase t // Generate the input using the expected shape and params var inputShape = model.expectShape(operation.getInputShape(), StructureShape.class); writer.write("input_ = $C\n", - (Runnable) () -> testCase.getParams().accept(new ValueNodeVisitor(inputShape))); + (Runnable) () -> testCase.getParams() + .accept(new ValueNodeVisitor(context, writer, inputShape))); // Execute the command, and catch the expected exception writer.addImport(SmithyPythonDependency.PYTEST.packageName(), "fail"); @@ -508,7 +497,8 @@ private void generateResponseTest(OperationShape operation, HttpResponseTestCase var inputShape = model.expectShape(operation.getInputShape(), StructureShape.class); var outputShape = model.expectShape(operation.getOutputShape(), StructureShape.class); writer.write("input_ = $C\n", - (Runnable) () -> (ObjectNode.builder().build()).accept(new ValueNodeVisitor(inputShape))); + (Runnable) () -> (ObjectNode.builder().build()) + .accept(new ValueNodeVisitor(context, writer, inputShape))); // Execute the command, fail if unexpected exception writer.addImport(SmithyPythonDependency.PYTEST.packageName(), "fail", "fail"); @@ -525,7 +515,8 @@ private void generateResponseTest(OperationShape operation, HttpResponseTestCase context.symbolProvider() .toSymbol(operation) .expectProperty(SymbolProperties.OPERATION_METHOD), - (Runnable) () -> testCase.getParams().accept(new ValueNodeVisitor(outputShape)), + (Runnable) () -> testCase.getParams() + .accept(new ValueNodeVisitor(context, writer, outputShape)), (Runnable) () -> assertResponseEqual(testCase, operation)); }); } @@ -563,7 +554,8 @@ private void generateErrorResponseTest( // Create an empty input object to pass var inputShape = model.expectShape(operation.getInputShape(), StructureShape.class); writer.write("input_ = $C\n", - (Runnable) () -> (Node.objectNode()).accept(new ValueNodeVisitor(inputShape))); + (Runnable) () -> (Node.objectNode()) + .accept(new ValueNodeVisitor(context, writer, inputShape))); // Execute the command, fail if unexpected exception writer.addImport(SmithyPythonDependency.PYTEST.packageName(), "fail", "fail"); writer.write( @@ -773,204 +765,6 @@ async def send( RuntimeTypes.ASYNC_LIST); } - /** - * NodeVisitor implementation for converting node values for - * input shape(s) to proper Python values in the generated code. - */ - private final class ValueNodeVisitor implements NodeVisitor { - private final Shape inputShape; - - private ValueNodeVisitor(Shape inputShape) { - this.inputShape = inputShape; - } - - @Override - public Void arrayNode(ArrayNode node) { - var openList = "["; - var closeList = "]"; - - if (inputShape.isListShape()) { - var target = model.expectShape(inputShape.asListShape().get().getMember().getTarget()); - if (target.isDocumentShape()) { - var docName = writer.format("$T", RuntimeTypes.DOCUMENT); - openList = docName + "(["; - closeList = "])"; - } - } - - writer.openBlock(openList, closeList, () -> { - // The target visitor won't change if the input shape is a union - ValueNodeVisitor targetVisitor; - if (inputShape instanceof CollectionShape) { - var target = model.expectShape(((CollectionShape) inputShape).getMember().getTarget()); - targetVisitor = new ValueNodeVisitor(target); - } else { - targetVisitor = this; - } - - node.getElements().forEach(elementNode -> { - writer.write("$C, ", (Runnable) () -> elementNode.accept(targetVisitor)); - }); - }); - return null; - } - - @Override - public Void booleanNode(BooleanNode node) { - writer.writeInline(node.getValue() ? "True" : "False"); - return null; - } - - @Override - public Void nullNode(NullNode node) { - writer.writeInline("None"); - return null; - } - - @Override - public Void numberNode(NumberNode node) { - if (inputShape.isTimestampShape()) { - var parsed = CodegenUtils.parseTimestampNode(model, inputShape, node); - writer.writeInline(CodegenUtils.getDatetimeConstructor(writer, parsed)); - } else if (inputShape.isFloatShape() || inputShape.isDoubleShape()) { - writer.writeInline("float($L)", node.getValue()); - } else { - writer.writeInline("$L", node.getValue()); - } - return null; - } - - @Override - public Void objectNode(ObjectNode node) { - switch (inputShape.getType()) { - case STRUCTURE -> structureShape((StructureShape) inputShape, node); - case MAP -> mapShape((MapShape) inputShape, node); - case UNION -> unionShape((UnionShape) inputShape, node); - case DOCUMENT -> documentShape((DocumentShape) inputShape, node); - default -> throw new CodegenException("unexpected input shape: " + inputShape.getType()); - } - return null; - } - - @Override - public Void stringNode(StringNode node) { - if (inputShape.isBlobShape()) { - writer.writeInline("b$S", node.getValue()); - } else if (inputShape.isFloatShape() || inputShape.isDoubleShape()) { - var value = switch (node.getValue()) { - case "NaN" -> "nan"; - case "Infinity" -> "inf"; - case "-Infinity" -> "-inf"; - default -> throw new CodegenException("Invalid value: " + node.getValue()); - }; - - writer.writeInline("float($S)", value); - } else { - writer.writeInline("$S", node.getValue()); - } - return null; - } - - private Void structureShape(StructureShape shape, ObjectNode node) { - writer.openBlock("$T(", - ")", - context.symbolProvider().toSymbol(shape), - () -> structureMemberShapes(shape, node)); - return null; - } - - private Void structureMemberShapes(StructureShape container, ObjectNode node) { - for (MemberShape member : container.members()) { - var optionalValueNode = node.getMember(member.getMemberName()); - if (optionalValueNode.isEmpty()) { - if (isStringLikeOutputPayload(container, member)) { - // Massage these outputs to always be present because it will generally be impossible to - // determine the difference between an empty body and a missing body. - optionalValueNode = Optional.of(Node.from("")); - } else { - continue; - } - } - var valueNode = optionalValueNode.get(); - - var targetShape = model.expectShape(member.getTarget()); - - var formatString = "$L = $C,"; - if (targetShape.isDocumentShape()) { - var docName = writer.format("$T", RuntimeTypes.DOCUMENT); - formatString = "$L = " + docName + "($C),"; - } - - writer.write(formatString, - context.symbolProvider().toMemberName(member), - (Runnable) () -> valueNode.accept(new ValueNodeVisitor(targetShape))); - - } - return null; - } - - private boolean isStringLikeOutputPayload(StructureShape container, MemberShape member) { - if (container.hasTrait(OutputTrait.class) && member.hasTrait(HttpPayloadTrait.class)) { - var target = model.expectShape(member.getTarget()); - return target.isBlobShape() || target.isStringShape(); - } - return false; - } - - private Void mapShape(MapShape shape, ObjectNode node) { - writer.openBlock("{", - "}", - () -> node.getMembers().forEach((keyNode, valueNode) -> { - var targetShape = model.expectShape(shape.getValue().getTarget()); - - var formatString = "$S: $C,"; - if (targetShape.isDocumentShape()) { - var docName = writer.format("$T", RuntimeTypes.DOCUMENT); - formatString = "$S: " + docName + "($C),"; - } - - writer.write(formatString, - keyNode.getValue(), - (Runnable) () -> valueNode.accept(new ValueNodeVisitor(targetShape))); - })); - return null; - } - - private Void documentShape(DocumentShape shape, ObjectNode node) { - writer.openBlock("{", - "}", - () -> node.getMembers().forEach((keyNode, valueNode) -> { - writer.write("$S: $C,", - keyNode.getValue(), - (Runnable) () -> valueNode.accept(this)); - })); - return null; - } - - private Void unionShape(UnionShape shape, ObjectNode node) { - if (node.getMembers().size() == 1) { - node.getMembers().forEach((keyNode, valueNode) -> { - var memberShape = shape.getMember(keyNode.getValue()) - .orElseThrow(() -> new CodegenException("unknown member: " + keyNode.getValue())); - var targetShape = model.expectShape(memberShape.getTarget()); - unionShape(memberShape, targetShape, valueNode); - }); - } else { - throw new CodegenException("exactly 1 named member must be set."); - } - return null; - } - - private Void unionShape(MemberShape memberShape, Shape targetShape, Node node) { - writer.openBlock("$T(", - ")", - context.symbolProvider().toSymbol(memberShape), - () -> writer.write("value = $C", - (Runnable) () -> node.accept(new ValueNodeVisitor(targetShape)))); - return null; - } - } - /** * Implements Python formatting for the {@code J} formatter. * diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/RuntimeTypes.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/RuntimeTypes.java index 89eded9fd..78fbbfa14 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/RuntimeTypes.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/RuntimeTypes.java @@ -122,8 +122,8 @@ public final class RuntimeTypes { createSymbol("aio.interfaces", "AsyncEndpointResolver", SmithyPythonDependency.SMITHY_CORE); // smithy_core.aio.endpoints - public static final Symbol STATIC_ENDPOINT_RESOLVER = - createSymbol("aio.endpoints", "AsyncStaticEndpointResolver", SmithyPythonDependency.SMITHY_CORE); + public static final Symbol ASYNC_BDD_ENDPOINT_RESOLVER = + createSymbol("aio.endpoints", "AsyncBddEndpointResolver", SmithyPythonDependency.SMITHY_CORE); // smithy_core.aio.types public static final Symbol ASYNC_BYTES_READER = @@ -131,6 +131,7 @@ public final class RuntimeTypes { // smithy_core.aio.utils public static final Symbol ASYNC_LIST = createSymbol("aio.utils", "async_list", SmithyPythonDependency.SMITHY_CORE); + public static final Symbol ASYNC_CLOSE = createSymbol("aio.utils", "close", SmithyPythonDependency.SMITHY_CORE); // smithy_http public static final Symbol TUPLES_TO_FIELDS = diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/SchemaTraitFilterIndex.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/SchemaTraitFilterIndex.java index 9e68cdb5b..1b4efcf83 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/SchemaTraitFilterIndex.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/SchemaTraitFilterIndex.java @@ -55,7 +55,9 @@ import software.amazon.smithy.model.traits.XmlFlattenedTrait; import software.amazon.smithy.model.traits.XmlNameTrait; import software.amazon.smithy.model.traits.XmlNamespaceTrait; -import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; +import software.amazon.smithy.rulesengine.traits.ContextParamTrait; +import software.amazon.smithy.rulesengine.traits.OperationContextParamsTrait; +import software.amazon.smithy.rulesengine.traits.StaticContextParamsTrait; import software.amazon.smithy.utils.SetUtils; import software.amazon.smithy.utils.SmithyInternalApi; @@ -95,7 +97,9 @@ public final class SchemaTraitFilterIndex implements KnowledgeIndex { TimestampFormatTrait.ID, // read per-member by smithy_http / smithy_aws_core serde ORIGINAL_SHAPE_ID, // rpcv2Cbor unit detection ServiceTrait.ID, // service metadata, including sdkId used by package docs - EndpointBddTrait.ID, // endpoint resolution data prepared by EndpointBddIntegration + ContextParamTrait.ID, // endpoint parameter plans read these at runtime + StaticContextParamsTrait.ID, + OperationContextParamsTrait.ID, LongPollTrait.ID, // retry backoff when a long-polling operation exhausts its quota RequestCompressionTrait.ID, // read at runtime JsonNameTrait.ID, diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ValueNodeVisitor.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ValueNodeVisitor.java new file mode 100644 index 000000000..96556445f --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ValueNodeVisitor.java @@ -0,0 +1,236 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen; + +import java.util.Optional; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeVisitor; +import software.amazon.smithy.model.node.NullNode; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.CollectionShape; +import software.amazon.smithy.model.shapes.DocumentShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.shapes.UnionShape; +import software.amazon.smithy.model.traits.HttpPayloadTrait; +import software.amazon.smithy.model.traits.OutputTrait; +import software.amazon.smithy.python.codegen.writer.PythonWriter; + +/** + * NodeVisitor implementation for converting node values for + * input shape(s) to proper Python values in the generated code. + */ +final class ValueNodeVisitor implements NodeVisitor { + private final GenerationContext context; + private final Model model; + private final PythonWriter writer; + private final Shape inputShape; + + /** + * @param context The generation context. + * @param writer The writer to write the value with. + * @param inputShape The shape of the value. + */ + ValueNodeVisitor(GenerationContext context, PythonWriter writer, Shape inputShape) { + this.context = context; + this.model = context.model(); + this.writer = writer; + this.inputShape = inputShape; + } + + @Override + public Void arrayNode(ArrayNode node) { + var openList = "["; + var closeList = "]"; + + if (inputShape.isListShape()) { + var target = model.expectShape(inputShape.asListShape().get().getMember().getTarget()); + if (target.isDocumentShape()) { + var docName = writer.format("$T", RuntimeTypes.DOCUMENT); + openList = docName + "(["; + closeList = "])"; + } + } + + writer.openBlock(openList, closeList, () -> { + // The target visitor won't change if the input shape is a union + ValueNodeVisitor targetVisitor; + if (inputShape instanceof CollectionShape) { + var target = model.expectShape(((CollectionShape) inputShape).getMember().getTarget()); + targetVisitor = new ValueNodeVisitor(context, writer, target); + } else { + targetVisitor = this; + } + + node.getElements().forEach(elementNode -> { + writer.write("$C, ", (Runnable) () -> elementNode.accept(targetVisitor)); + }); + }); + return null; + } + + @Override + public Void booleanNode(BooleanNode node) { + writer.writeInline(node.getValue() ? "True" : "False"); + return null; + } + + @Override + public Void nullNode(NullNode node) { + writer.writeInline("None"); + return null; + } + + @Override + public Void numberNode(NumberNode node) { + if (inputShape.isTimestampShape()) { + var parsed = CodegenUtils.parseTimestampNode(model, inputShape, node); + writer.writeInline(CodegenUtils.getDatetimeConstructor(writer, parsed)); + } else if (inputShape.isFloatShape() || inputShape.isDoubleShape()) { + writer.writeInline("float($L)", node.getValue()); + } else { + writer.writeInline("$L", node.getValue()); + } + return null; + } + + @Override + public Void objectNode(ObjectNode node) { + switch (inputShape.getType()) { + case STRUCTURE -> structureShape((StructureShape) inputShape, node); + case MAP -> mapShape((MapShape) inputShape, node); + case UNION -> unionShape((UnionShape) inputShape, node); + case DOCUMENT -> documentShape((DocumentShape) inputShape, node); + default -> throw new CodegenException("unexpected input shape: " + inputShape.getType()); + } + return null; + } + + @Override + public Void stringNode(StringNode node) { + if (inputShape.isBlobShape()) { + writer.writeInline("b$S", node.getValue()); + } else if (inputShape.isFloatShape() || inputShape.isDoubleShape()) { + var value = switch (node.getValue()) { + case "NaN" -> "nan"; + case "Infinity" -> "inf"; + case "-Infinity" -> "-inf"; + default -> throw new CodegenException("Invalid value: " + node.getValue()); + }; + + writer.writeInline("float($S)", value); + } else { + writer.writeInline("$S", node.getValue()); + } + return null; + } + + private Void structureShape(StructureShape shape, ObjectNode node) { + writer.openBlock("$T(", + ")", + context.symbolProvider().toSymbol(shape), + () -> structureMemberShapes(shape, node)); + return null; + } + + private Void structureMemberShapes(StructureShape container, ObjectNode node) { + for (MemberShape member : container.members()) { + var optionalValueNode = node.getMember(member.getMemberName()); + if (optionalValueNode.isEmpty()) { + if (isStringLikeOutputPayload(container, member)) { + // Massage these outputs to always be present because it will generally be impossible to + // determine the difference between an empty body and a missing body. + optionalValueNode = Optional.of(Node.from("")); + } else { + continue; + } + } + var valueNode = optionalValueNode.get(); + + var targetShape = model.expectShape(member.getTarget()); + + var formatString = "$L = $C,"; + if (targetShape.isDocumentShape()) { + var docName = writer.format("$T", RuntimeTypes.DOCUMENT); + formatString = "$L = " + docName + "($C),"; + } + + writer.write(formatString, + context.symbolProvider().toMemberName(member), + (Runnable) () -> valueNode.accept(new ValueNodeVisitor(context, writer, targetShape))); + + } + return null; + } + + private boolean isStringLikeOutputPayload(StructureShape container, MemberShape member) { + if (container.hasTrait(OutputTrait.class) && member.hasTrait(HttpPayloadTrait.class)) { + var target = model.expectShape(member.getTarget()); + return target.isBlobShape() || target.isStringShape(); + } + return false; + } + + private Void mapShape(MapShape shape, ObjectNode node) { + writer.openBlock("{", + "}", + () -> node.getMembers().forEach((keyNode, valueNode) -> { + var targetShape = model.expectShape(shape.getValue().getTarget()); + + var formatString = "$S: $C,"; + if (targetShape.isDocumentShape()) { + var docName = writer.format("$T", RuntimeTypes.DOCUMENT); + formatString = "$S: " + docName + "($C),"; + } + + writer.write(formatString, + keyNode.getValue(), + (Runnable) () -> valueNode.accept(new ValueNodeVisitor(context, writer, targetShape))); + })); + return null; + } + + private Void documentShape(DocumentShape shape, ObjectNode node) { + writer.openBlock("{", + "}", + () -> node.getMembers().forEach((keyNode, valueNode) -> { + writer.write("$S: $C,", + keyNode.getValue(), + (Runnable) () -> valueNode.accept(this)); + })); + return null; + } + + private Void unionShape(UnionShape shape, ObjectNode node) { + if (node.getMembers().size() == 1) { + node.getMembers().forEach((keyNode, valueNode) -> { + var memberShape = shape.getMember(keyNode.getValue()) + .orElseThrow(() -> new CodegenException("unknown member: " + keyNode.getValue())); + var targetShape = model.expectShape(memberShape.getTarget()); + unionShape(memberShape, targetShape, valueNode); + }); + } else { + throw new CodegenException("exactly 1 named member must be set."); + } + return null; + } + + private Void unionShape(MemberShape memberShape, Shape targetShape, Node node) { + writer.openBlock("$T(", + ")", + context.symbolProvider().toSymbol(memberShape), + () -> writer.write("value = $C", + (Runnable) () -> node.accept(new ValueNodeVisitor(context, writer, targetShape)))); + return null; + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java index 2a388c7f8..95e6160d5 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java @@ -11,6 +11,7 @@ import java.util.TreeSet; import java.util.function.Consumer; import software.amazon.smithy.aws.traits.ServiceTrait; +import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.model.knowledge.ServiceIndex; import software.amazon.smithy.model.knowledge.TopDownIndex; @@ -23,12 +24,15 @@ import software.amazon.smithy.python.codegen.RuntimeTypes; import software.amazon.smithy.python.codegen.SmithyPythonDependency; import software.amazon.smithy.python.codegen.SymbolProperties; +import software.amazon.smithy.python.codegen.integrations.EndpointRulesExtension; import software.amazon.smithy.python.codegen.integrations.PythonIntegration; import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; import software.amazon.smithy.python.codegen.sections.AsyncConfigSection; import software.amazon.smithy.python.codegen.sections.ConfigSection; import software.amazon.smithy.python.codegen.sections.InitDefaultEndpointResolverSection; import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.rulesengine.traits.ClientContextParamsTrait; +import software.amazon.smithy.utils.CaseUtils; import software.amazon.smithy.utils.CodeInterceptor; import software.amazon.smithy.utils.SmithyInternalApi; @@ -81,20 +85,6 @@ public final class ConfigGenerator implements Runnable { .build()) .build()) .documentation("A static URI to route requests to.") - .build(), - ConfigProperty.builder() - .name("endpoint_resolver") - .type(RuntimeTypes.ENDPOINT_RESOLVER) - .documentation(""" - The endpoint resolver used to resolve the final endpoint per-operation based on the \ - configuration.""") - .nullable(false) - .initialize(writer -> { - writer.pushState(new InitDefaultEndpointResolverSection()); - writer.write("self.endpoint_resolver = endpoint_resolver or $T()", - RuntimeTypes.STATIC_ENDPOINT_RESOLVER); - writer.popState(); - }) .build()); // This list contains any properties that must be added to any http-based @@ -118,6 +108,102 @@ public ConfigGenerator(PythonSettings settings, GenerationContext context) { this.settings = settings; } + /** + * One config field per {@code clientContextParams} entry, named {@code to_snake_case(name)}, + * which is the attribute endpoint parameter plans read. Fields default to None and have no + * environment or profile sources. + * + * @param context the generation context. + * @return the fields, in trait order. + */ + public static List clientContextProperties(GenerationContext context) { + // TODO: when porting to Python codegen, fail on a field name that is a Python keyword or + // collides with another config field. No model does today, but a collision is dropped + // silently and the rules read the other field's value. + var service = context.settings().service(context.model()); + var trait = service.getTrait(ClientContextParamsTrait.class); + if (trait.isEmpty()) { + return List.of(); + } + var properties = new ArrayList(); + for (var entry : trait.get().getParameters().entrySet()) { + var definition = entry.getValue(); + var type = switch (definition.getType()) { + case BOOLEAN -> "bool"; + case STRING -> "str"; + default -> throw new CodegenException(String.format( + "Unsupported type %s for client context parameter %s", + definition.getType(), + entry.getKey())); + }; + properties.add(ConfigProperty.builder() + .name(CaseUtils.toSnakeCase(entry.getKey())) + .type(Symbol.builder().name(type).build()) + .documentation(definition.getDocumentation() + .orElse("The " + entry.getKey() + " endpoint parameter.")) + .build()); + } + return properties; + } + + /** + * The endpoint rules extensions that client plugins register for the service, in + * integration order. The default endpoint resolver gets them. + * + * @param context the generation context. + * @return the extensions. + */ + public static List endpointRulesExtensions(GenerationContext context) { + var service = context.settings().service(context.model()); + var extensions = new ArrayList(); + for (PythonIntegration integration : context.integrations()) { + for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { + if (plugin.matchesService(context.model(), service)) { + plugin.getEndpointRulesExtension().ifPresent(extensions::add); + } + } + } + return extensions; + } + + /** + * Writes the expression that builds the default endpoint resolver, which evaluates + * the service's endpoint BDD with the registered rules extensions. + * + * @param context the generation context. + * @param writer the writer to write the expression with, inline. + */ + public static void writeDefaultEndpointResolver(GenerationContext context, PythonWriter writer) { + var bdd = EndpointBddGenerator.bddSymbol(context.settings()); + var extensions = endpointRulesExtensions(context); + if (extensions.isEmpty()) { + writer.writeInline("$T($T)", RuntimeTypes.ASYNC_BDD_ENDPOINT_RESOLVER, bdd); + return; + } + writer.writeInline("$T($T, extensions=[", RuntimeTypes.ASYNC_BDD_ENDPOINT_RESOLVER, bdd); + for (int i = 0; i < extensions.size(); i++) { + writer.writeInline(i == 0 ? "$T" : ", $T", extensions.get(i).getRulesExtensionSymbol(context)); + } + writer.writeInline("])"); + } + + private static ConfigProperty endpointResolverProperty(GenerationContext context) { + return ConfigProperty.builder() + .name("endpoint_resolver") + .type(RuntimeTypes.ENDPOINT_RESOLVER) + .documentation(""" + The endpoint resolver used to resolve the final endpoint per-operation based on the \ + configuration.""") + .nullable(false) + .initialize(writer -> { + writer.pushState(new InitDefaultEndpointResolverSection()); + writer.write("self.endpoint_resolver = endpoint_resolver or $C", + writer.consumer(w -> writeDefaultEndpointResolver(context, w))); + writer.popState(); + }) + .build(); + } + private static List getProtocolProperties(GenerationContext context) { var properties = new ArrayList(); var protocolBuilder = ConfigProperty.builder() @@ -413,7 +499,9 @@ private void generateConfig(GenerationContext context, PythonWriter writer) { // Initialize a set of config properties with our base properties. var properties = new TreeSet<>(Comparator.comparing(ConfigProperty::name)); properties.addAll(BASE_PROPERTIES); + properties.add(endpointResolverProperty(context)); properties.addAll(getProtocolProperties(context)); + properties.addAll(clientContextProperties(context)); var serviceIndex = ServiceIndex.of(context.model()); boolean hasAuth = !serviceIndex.getAuthSchemes(settings.service()).isEmpty(); diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EndpointBddGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EndpointBddGenerator.java new file mode 100644 index 000000000..fe7237f26 --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EndpointBddGenerator.java @@ -0,0 +1,575 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen.generators; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.PythonSettings; +import software.amazon.smithy.rulesengine.language.Endpoint; +import software.amazon.smithy.rulesengine.language.RulesVersion; +import software.amazon.smithy.rulesengine.language.evaluation.value.Value; +import software.amazon.smithy.rulesengine.language.syntax.Identifier; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Reference; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Template; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.GetAttr; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.LibraryFunction; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.Literal; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.LiteralVisitor; +import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameter; +import software.amazon.smithy.rulesengine.language.syntax.parameters.ParameterType; +import software.amazon.smithy.rulesengine.language.syntax.rule.Condition; +import software.amazon.smithy.rulesengine.language.syntax.rule.EndpointRule; +import software.amazon.smithy.rulesengine.language.syntax.rule.ErrorRule; +import software.amazon.smithy.rulesengine.language.syntax.rule.Rule; +import software.amazon.smithy.rulesengine.logic.bdd.Bdd; +import software.amazon.smithy.rulesengine.traits.ClientContextParamsTrait; +import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Lowers a service's {@code smithy.rules#endpointBdd} trait into an endpoint BDD and writes it + * to {@code /_private/endpoints.py}. + * + *

The BDD is a {@code smithy_core.endpoints.EndpointBdd} of Python literals that + * re-encodes the trait. Lowering keeps no engine decisions: names stay names, tags are + * small ints, and the trait's nodes are copied verbatim. Python values are modeled here + * as {@code String}, {@code Boolean}, {@code Integer}, {@code null} and {@link List} + * (a tuple). + */ +@SmithyInternalApi +public final class EndpointBddGenerator implements Runnable { + + private static final int RESULT_OFFSET = Bdd.RESULT_OFFSET; + + // Tags lead expression and result tuples; smithy_core.endpoints._types.Tag has the same values. + private static final int REF = 0; + private static final int ATTR = 1; + private static final int CALL = 2; + private static final int LIST = 3; + private static final int MAP = 4; + private static final int ERROR = 5; + private static final int ENDPOINT = 6; + private static final String TAG_LEGEND = "# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6.\n"; + private static final Comparator CODE_POINT_ORDER = (a, b) -> Arrays.compare( + a.codePoints().toArray(), + b.codePoints().toArray()); + + private final GenerationContext context; + + public EndpointBddGenerator(GenerationContext context) { + this.context = context; + } + + /** + * A lowered endpoint BDD: the fields of a Python {@code EndpointBdd}. + * + * @param parameters the parameters field. + * @param functions the functions field. + * @param conditions the conditions field. + * @param results the results field. + * @param root the root field. + * @param nodes the nodes field. + */ + public record EndpointBdd( + List parameters, + List functions, + List conditions, + List results, + int root, + List nodes) {} + + @Override + public void run() { + var service = context.settings().service(context.model()); + var filename = String.format("src/%s/_private/endpoints.py", context.settings().moduleName()); + context.fileManifest().writeFile(filename, renderModule(service)); + } + + /** + * The {@code BDD} constant of a client's generated endpoints module. + * + * @param settings the client's settings. + * @return the symbol. + */ + public static Symbol bddSymbol(PythonSettings settings) { + var namespace = String.format("%s._private.endpoints", settings.moduleName()); + return Symbol.builder() + .name("BDD") + .namespace(namespace, ".") + .definitionFile(String.format("./src/%s/_private/endpoints.py", settings.moduleName())) + .build(); + } + + /** + * Lowers the service's endpointBdd trait and renders the endpoints module source. + * + * @param service the service carrying the endpointBdd trait. + * @return the module source. + */ + private static String renderModule(ServiceShape service) { + var trait = service.getTrait(EndpointBddTrait.class) + .orElseThrow(() -> new CodegenException(service.getId() + " has no endpointBdd trait")); + var clientContext = service.getTrait(ClientContextParamsTrait.class) + .map(t -> t.getParameters().keySet()) + .orElse(Set.of()); + return render(service.getId().toString(), trait, lower(trait, clientContext)); + } + + /** + * Lowers an endpointBdd trait into an endpoint BDD. + * + * @param trait the trait to lower. + * @param clientContextParams the names of the service's clientContextParams. + * @return the BDD. + */ + public static EndpointBdd lower(EndpointBddTrait trait, Collection clientContextParams) { + return new Lowering(trait, Set.copyOf(clientContextParams)).bdd(); + } + + /** + * Renders a lowered BDD as the source of a generated endpoints module. + * + * @param serviceId the service shape ID, used in the module docstring. + * @param trait the trait the BDD was lowered from, used for comments. + * @param bdd the lowered BDD. + * @return the module source. + */ + public static String render(String serviceId, EndpointBddTrait trait, EndpointBdd bdd) { + var out = new StringBuilder(); + out.append("# Code generated by smithy-python-codegen DO NOT EDIT.\n\n"); + out.append("\"\"\"Endpoint BDD for ").append(serviceId).append(".\"\"\"\n\n"); + out.append("from smithy_core.endpoints import EndpointBdd\n\n"); + // The generator lays the literal out one entry per line; keep formatters from exploding it. + out.append("# fmt: off\n"); + out.append(TAG_LEGEND); + out.append("BDD = EndpointBdd(\n"); + if (!bdd.parameters().isEmpty()) { + out.append(" # (name, type, required, default, builtin, client_context)\n"); + } + writeLines(out, "parameters", bdd.parameters(), null); + out.append(" functions=").append(literal(bdd.functions())).append(",\n"); + var conditionComments = new ArrayList(); + for (var condition : trait.getConditions()) { + conditionComments.add(condition.toString()); + } + writeLines(out, "conditions", bdd.conditions(), conditionComments); + // Nodes point at results by index, so each result carries its index. + var resultComments = new ArrayList(); + resultComments.add("no match"); + while (resultComments.size() < bdd.results().size()) { + resultComments.add(""); + } + writeLines(out, "results", bdd.results(), resultComments); + out.append(" root=").append(formatReference(bdd.root())).append(",\n"); + writeNodes(out, bdd.nodes()); + out.append(")\n"); + out.append("# fmt: on\n"); + return out.toString(); + } + + private static void writeLines(StringBuilder out, String field, List items, List comments) { + out.append(" ").append(field).append('='); + if (items.isEmpty()) { + out.append("(),\n"); + return; + } + out.append("(\n"); + for (int i = 0; i < items.size(); i++) { + out.append(" ").append(literal(items.get(i))).append(','); + if (comments != null) { + out.append(" # ").append(i); + var comment = comments.get(i); + if (!comment.isEmpty() && isPrintableAscii(comment)) { + out.append(": ").append(comment); + } + } + out.append('\n'); + } + out.append(" ),\n"); + } + + private static void writeNodes(StringBuilder out, List nodes) { + if (!nodes.isEmpty()) { + out.append(" # (condition, high, low); 1 and -1 are terminals, a negative reference is a\n"); + out.append(" # complement edge, and 100_000_000 + k is result k.\n"); + } + out.append(" nodes="); + if (nodes.isEmpty()) { + out.append("(),\n"); + return; + } + out.append("(\n"); + for (int i = 0; i < nodes.size(); i += 3) { + out.append(" ") + .append(nodes.get(i)) + .append(", ") + .append(formatReference((Integer) nodes.get(i + 1))) + .append(", ") + .append(formatReference((Integer) nodes.get(i + 2))) + .append(",\n"); + } + out.append(" ),\n"); + } + + // A node or result reference; result references are written 100_000_000 + k, so k is easy to read. + private static String formatReference(int ref) { + if (ref < RESULT_OFFSET) { + return Integer.toString(ref); + } + return String.format(Locale.ROOT, "%,d", ref).replace(',', '_'); + } + + private static boolean isPrintableAscii(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < 0x20 || c > 0x7e) { + return false; + } + } + return true; + } + + /** + * Renders a lowered value as a Python literal on one line. + * + * @param value a String, Boolean, Integer, null or List (a tuple). + * @return the Python literal. + */ + public static String literal(Object value) { + var out = new StringBuilder(); + writeLiteral(out, value); + return out.toString(); + } + + private static void writeLiteral(StringBuilder out, Object value) { + if (value == null) { + out.append("None"); + } else if (value instanceof Boolean b) { + out.append(b ? "True" : "False"); + } else if (value instanceof Integer i) { + out.append(i); + } else if (value instanceof String s) { + writeString(out, s); + } else if (value instanceof List items) { + out.append('('); + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + out.append(", "); + } + writeLiteral(out, items.get(i)); + } + if (items.size() == 1) { + out.append(','); + } + out.append(')'); + } else { + throw new CodegenException("Unexpected endpoint BDD value: " + value); + } + } + + // Writes an ASCII-only, double-quoted Python str literal. + private static void writeString(StringBuilder out, String s) { + out.append('"'); + s.codePoints().forEach(c -> { + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20 || c == 0x7f) { + out.append(String.format("\\x%02x", c)); + } else if (c < 0x7f) { + out.append((char) c); + } else if (c <= 0xffff) { + out.append(String.format("\\u%04x", c)); + } else { + out.append(String.format("\\U%08x", c)); + } + } + } + }); + out.append('"'); + } + + private static List tuple(Object... items) { + return Arrays.asList(items); + } + + private static final class Lowering { + private final EndpointBddTrait trait; + private final Set clientContext; + private final Map parameters = new LinkedHashMap<>(); + private final Set references = new HashSet<>(); + private final Set functions = new TreeSet<>(CODE_POINT_ORDER); + + Lowering(EndpointBddTrait trait, Set clientContext) { + this.trait = trait; + this.clientContext = clientContext; + } + + EndpointBdd bdd() { + // One trait version. + if (!RulesVersion.V1_1.equals(trait.getVersion())) { + throw invalid("endpointBdd version " + trait.getVersion() + " is not supported; expected 1.1"); + } + for (Parameter parameter : trait.getParameters()) { + parameters.put(parameter.getNameString(), parameter); + } + var conditions = new ArrayList(); + for (Condition condition : trait.getConditions()) { + conditions.add(tuple( + expression(condition.getFunction()), + condition.getResult().map(Identifier::toString).orElse(null))); + } + var results = new ArrayList(); + List rules = trait.getResults(); + for (int i = 0; i < rules.size(); i++) { + results.add(result(i, rules.get(i))); + } + + var nodes = nodes(); + return new EndpointBdd( + parameters(), + new ArrayList(functions), + conditions, + results, + trait.getBdd().getRootRef(), + nodes); + } + + // Normalized parameter fields; unread optional parameters are dropped. + private List parameters() { + var out = new ArrayList(); + for (Parameter parameter : parameters.values()) { + String name = parameter.getNameString(); + if (!parameter.isRequired() && !references.contains(name)) { + continue; + } + out.add(tuple( + name, + type(parameter.getType()), + parameter.isRequired(), + parameter.getDefault().map(this::defaultValue).orElse(null), + parameter.getBuiltIn().orElse(null), + clientContext.contains(name))); + } + return out; + } + + private static String type(ParameterType type) { + return switch (type) { + case STRING -> "string"; + case BOOLEAN -> "boolean"; + case STRING_ARRAY -> "stringArray"; + }; + } + + private Object defaultValue(Value value) { + Node node = value.toNode(); + if (node.isStringNode()) { + return node.expectStringNode().getValue(); + } else if (node.isBooleanNode()) { + return node.expectBooleanNode().getValue(); + } else if (node.isArrayNode()) { + var out = new ArrayList(); + for (Node element : node.expectArrayNode().getElements()) { + out.add(element.expectStringNode().getValue()); + } + return out; + } + throw invalid("unsupported parameter default " + Node.printJson(node)); + } + + // Results, with the no-match result at index 0. + private Object result(int index, Rule rule) { + if (index == 0) { + return null; + } + if (rule instanceof ErrorRule error) { + return tuple(ERROR, expression(error.getError())); + } else if (rule instanceof EndpointRule endpointRule) { + Endpoint endpoint = endpointRule.getEndpoint(); + Object properties = null; + if (!endpoint.getProperties().isEmpty()) { + properties = map(endpoint.getProperties()); + } + var headers = new ArrayList(); + for (Map.Entry> header : endpoint.getHeaders().entrySet()) { + var values = new ArrayList(); + for (Expression value : header.getValue()) { + values.add(expression(value)); + } + headers.add(tuple(header.getKey(), values)); + } + return tuple(ENDPOINT, expression(endpoint.getUrl()), properties, headers); + } + throw invalid("result " + index + " is not an endpoint or error result"); + } + + private Object expression(Expression expression) { + if (expression instanceof Literal literal) { + return literal(literal); + } else if (expression instanceof Reference reference) { + return reference(reference); + } else if (expression instanceof GetAttr getAttr) { + return tuple(ATTR, expression(getAttr.getTarget()), path(getAttr)); + } else if (expression instanceof LibraryFunction function) { + functions.add(function.getName()); + var call = new ArrayList(); + call.add(CALL); + call.add(function.getName()); + for (Expression argument : function.getArguments()) { + call.add(expression(argument)); + } + return call; + } + throw invalid("unsupported expression " + expression); + } + + private Object reference(Reference reference) { + return tuple(REF, name(reference)); + } + + private String name(Reference reference) { + String name = reference.getName().toString(); + references.add(name); + return name; + } + + // getAttr paths, parsed by Smithy and written back as text: keys joined by '.', each index + // as [i]. + private String path(GetAttr getAttr) { + var path = new StringBuilder(); + for (GetAttr.Part part : getAttr.getPath()) { + if (part instanceof GetAttr.Part.Key key) { + path.append(path.isEmpty() ? "" : ".").append(key.key()); + } else if (part instanceof GetAttr.Part.Index index) { + path.append('[').append(index.index()).append(']'); + } else { + throw invalid("unsupported getAttr path part " + part); + } + } + if (path.isEmpty()) { + throw invalid("empty getAttr path in " + getAttr); + } + return path.toString(); + } + + private Object literal(Literal literal) { + return literal.accept(new LiteralVisitor() { + @Override + public Object visitBoolean(boolean b) { + return b; + } + + @Override + public Object visitString(Template value) { + return template(value); + } + + @Override + public Object visitRecord(Map members) { + return map(members); + } + + @Override + public Object visitTuple(List members) { + var list = new ArrayList(); + list.add(LIST); + for (Literal member : members) { + list.add(literal(member)); + } + return list; + } + + @Override + public Object visitInteger(int value) { + return value; + } + }); + } + + private List map(Map members) { + var keys = new ArrayList(); + var map = new ArrayList(); + map.add(MAP); + map.add(keys); + for (Map.Entry member : members.entrySet()) { + keys.add(member.getKey().toString()); + map.add(literal(member.getValue())); + } + return map; + } + + // Every string in an expression position is a template, kept as a template string. + // It is rendered back from Smithy's parts, because Template.toNode() drops the escapes. + private String template(Template template) { + var out = new StringBuilder(); + for (Template.Part part : template.getParts()) { + if (part instanceof Template.Literal literal) { + out.append(literal.getValue().replace("{", "{{").replace("}", "}}")); + } else if (part instanceof Template.Dynamic placeholder) { + out.append('{').append(placeholder(template, placeholder.toExpression())).append('}'); + } else { + throw invalid("unsupported template part in " + template); + } + } + return out.toString(); + } + + private String placeholder(Template template, Expression expression) { + if (expression instanceof Reference reference) { + return name(reference); + } else if (expression instanceof GetAttr getAttr && getAttr.getTarget() instanceof Reference base) { + return name(base) + "#" + path(getAttr); + } + throw invalid("malformed template placeholder in " + template); + } + + // Nodes copied verbatim. Smithy's validator checks the indexes and references, but + // not that node 0 is the terminal the runtime relies on, nor that no edge + // complements a result reference. + private List nodes() { + Bdd bdd = trait.getBdd(); + int nodeCount = bdd.getNodeCount(); + if (nodeCount < 1 || bdd.getVariable(0) != -1 || bdd.getHigh(0) != 1 || bdd.getLow(0) != -1) { + throw invalid("node 0 must be the terminal (-1, 1, -1)"); + } + var nodes = new ArrayList(nodeCount * 3); + for (int i = 0; i < nodeCount; i++) { + int high = bdd.getHigh(i); + int low = bdd.getLow(i); + if (high <= -RESULT_OFFSET || low <= -RESULT_OFFSET) { + throw invalid("node " + i + " complements a result reference"); + } + nodes.add(bdd.getVariable(i)); + nodes.add(high); + nodes.add(low); + } + return nodes; + } + + private static CodegenException invalid(String message) { + return new CodegenException("Invalid endpointBdd trait: " + message); + } + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/EndpointRulesExtension.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/EndpointRulesExtension.java new file mode 100644 index 000000000..eb2295683 --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/EndpointRulesExtension.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.python.codegen.integrations; + +import java.util.Optional; +import java.util.function.Consumer; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Provides details for generating code that uses an endpoint rules extension. + * + *

The service's default endpoint resolver gets the extension, and generated endpoint + * tests configure their clients for the built-ins it binds. + */ +@SmithyUnstableApi +public interface EndpointRulesExtension { + + /** + * @param context The code generation context. + * @return Returns the symbol of the {@code smithy_core.endpoints.RulesExtension}. + */ + Symbol getRulesExtensionSymbol(GenerationContext context); + + /** + * Writes the config entries every generated endpoint test sets, such as test + * credentials, as {@code "field": value,} lines of a dict literal. A test case's own + * entries replace them. + * + * @param context The code generation context. + * @param writer The writer to write the entries with. + */ + default void writeEndpointTestConfig(GenerationContext context, PythonWriter writer) {} + + /** + * Gets the config entry that sets a built-in this extension binds in generated + * endpoint tests, written as one {@code "field": value,} line of a dict literal. + * + *

Code generation itself sets {@code SDK::Endpoint} and built-ins that are also + * client context parameters. A test case that sets any other built-in to a value + * other than its default is skipped unless an extension sets it. + * + * @param context The code generation context. + * @param builtIn The built-in, such as {@code AWS::Region}. + * @param value The value the test case gives it. + * @return Returns the entry's writer, or empty if this extension doesn't set the + * built-in. + */ + default Optional> getEndpointTestBuiltIn( + GenerationContext context, + String builtIn, + Node value + ) { + return Optional.empty(); + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RuntimeClientPlugin.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RuntimeClientPlugin.java index 540cdf50b..487415ead 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RuntimeClientPlugin.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RuntimeClientPlugin.java @@ -42,6 +42,7 @@ public final class RuntimeClientPlugin implements ToSmithyBuilder getAuthScheme() { return Optional.ofNullable(authScheme); } + /** + * @return Returns an optional endpoint rules extension to register. + */ + public Optional getEndpointRulesExtension() { + return Optional.ofNullable(endpointRulesExtension); + } + /** * Write additional files required by this plugin. * @@ -159,6 +168,7 @@ public SmithyBuilder toBuilder() { var builder = builder() .pythonPlugin(pythonPlugin) .authScheme(authScheme) + .endpointRulesExtension(endpointRulesExtension) .configProperties(configProperties) .writeAdditionalFiles(writeAdditionalFiles); @@ -180,6 +190,7 @@ public static final class Builder implements SmithyBuilder private List configProperties = new ArrayList<>(); private SymbolReference pythonPlugin = null; private AuthScheme authScheme = null; + private EndpointRulesExtension endpointRulesExtension = null; private WriteAdditionalFiles writeAdditionalFiles = (context) -> Collections.emptyList(); Builder() {} @@ -294,6 +305,18 @@ public Builder authScheme(AuthScheme authScheme) { return this; } + /** + * Configures an endpoint rules extension which will be passed to the client's + * default endpoint resolver. + * + * @param endpointRulesExtension The endpoint rules extension to register. + * @return Returns the builder. + */ + public Builder endpointRulesExtension(EndpointRulesExtension endpointRulesExtension) { + this.endpointRulesExtension = endpointRulesExtension; + return this; + } + /** * Write additional files required by this plugin. * diff --git a/codegen/core/src/test/java/software/amazon/smithy/python/codegen/generators/EndpointBddGeneratorTest.java b/codegen/core/src/test/java/software/amazon/smithy/python/codegen/generators/EndpointBddGeneratorTest.java new file mode 100644 index 000000000..22c65e199 --- /dev/null +++ b/codegen/core/src/test/java/software/amazon/smithy/python/codegen/generators/EndpointBddGeneratorTest.java @@ -0,0 +1,352 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen.generators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.rulesengine.traits.EndpointBddTrait; + +public class EndpointBddGeneratorTest { + + private static final String PARAMETERS = """ + { + "Region": {"type": "String", "builtIn": "AWS::Region", "documentation": "The region."}, + "UseFIPS": {"type": "Boolean", "required": true, "default": false, "builtIn": "AWS::UseFIPS", + "deprecated": {"message": "old"}}, + "Endpoint": {"type": "string", "builtIn": "SDK::Endpoint"}, + "Key": {"type": "string"}, + "Bucket": {"type": "String", "required": true}, + "ForcePathStyle": {"type": "boolean", "builtIn": "AWS::S3::ForcePathStyle"}, + "Tables": {"type": "stringArray", "required": true, "default": ["a", "b"]}, + "Unused": {"type": "boolean", "default": true, "required": true} + } + """; + + private static final String CONDITIONS = """ + [ + {"fn": "isSet", "argv": [{"ref": "Endpoint"}]}, + {"fn": "parseURL", "argv": ["{Endpoint}"], "assign": "url"}, + {"fn": "booleanEquals", "argv": [{"ref": "UseFIPS"}, true]}, + {"fn": "stringEquals", "argv": [{"fn": "getAttr", "argv": [{"ref": "url"}, "scheme"]}, "http"]}, + {"fn": "isSet", "argv": [{"ref": "Region"}]}, + {"fn": "isValidHostLabel", "argv": [{"ref": "Region"}, false]}, + {"fn": "stringEquals", "argv": [ + {"fn": "coalesce", "argv": [{"fn": "substring", "argv": [{"ref": "Bucket"}, 0, 4, false]}, ""]}, + "ab{{c}}"]}, + {"fn": "split", "argv": [{"ref": "Bucket"}, "--", 0], "assign": "parts"}, + {"fn": "isSet", "argv": [{"fn": "getAttr", "argv": [{"ref": "parts"}, "[-1]"]}]}, + {"fn": "ite", "argv": [{"fn": "coalesce", "argv": [{"ref": "ForcePathStyle"}, false]}, "path", "vhost"], + "assign": "style"} + ] + """; + + private static final String RESULTS = """ + [ + {"conditions": [], "type": "error", "error": "Invalid {Region}: {{escaped}} {Bucket}{Bucket}"}, + {"conditions": [], "type": "endpoint", "endpoint": { + "url": "{url#scheme}://{url#authority}{url#path}", + "properties": { + "authSchemes": [{"name": "sigv4", "signingRegion": "{Region}", "disableDoubleEncoding": true}], + "literal": {"ref": "Region", "fn": "isSet", "n": 2, "list": ["{style}", ["x"]]} + }, + "headers": {"x-b": ["{Region}", "b"], "x-a": []} + }}, + {"conditions": [], "type": "endpoint", "endpoint": {"url": {"ref": "Endpoint"}, "properties": {}, + "headers": {}}}, + {"conditions": [], "type": "endpoint", "endpoint": {"url": "https://{Bucket}.example.com"}} + ] + """; + + private static final int[] NODES = { + -1, + 1, + -1, + 0, + 3, + 100_000_003, + 1, + 100_000_002, + 100_000_001, + }; + + @Test + public void rejectsOtherTraitVersions() { + var e = assertThrows(CodegenException.class, + () -> lower(trait("1.2", PARAMETERS, CONDITIONS, RESULTS, 2, NODES))); + assertTrue(e.getMessage().contains("version 1.2")); + } + + @Test + public void normalizesAndDropsParameters() { + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, NODES), Set.of("ForcePathStyle")); + // Key is optional and unread, so it is dropped; Unused and Tables are required, so they stay. + assertEquals( + "((\"Region\", \"string\", False, None, \"AWS::Region\", False), " + + "(\"UseFIPS\", \"boolean\", True, False, \"AWS::UseFIPS\", False), " + + "(\"Endpoint\", \"string\", False, None, \"SDK::Endpoint\", False), " + + "(\"Bucket\", \"string\", True, None, None, False), " + + "(\"ForcePathStyle\", \"boolean\", False, None, \"AWS::S3::ForcePathStyle\", True), " + + "(\"Tables\", \"stringArray\", True, (\"a\", \"b\"), None, False), " + + "(\"Unused\", \"boolean\", True, True, None, False))", + EndpointBddGenerator.literal(bdd.parameters())); + } + + @Test + public void keepsParametersReadAsGetAttrBases() { + var parameters = """ + {"A": {"type": "string"}, "T": {"type": "stringArray"}, "C": {"type": "string"}} + """; + var conditions = """ + [{"fn": "isSet", "argv": [{"ref": "A"}]}, + {"fn": "isSet", "argv": [{"ref": "T"}]}, + {"fn": "isSet", "argv": [{"fn": "getAttr", "argv": [{"ref": "T"}, "[0]"]}]}] + """; + var results = """ + [{"conditions": [], "type": "error", "error": "{A}"}] + """; + var bdd = lower(trait("1.1", parameters, conditions, results, 100_000_001, -1, 1, -1)); + // C is optional and unread, so it is dropped. + assertEquals( + "((\"A\", \"string\", False, None, None, False), " + + "(\"T\", \"stringArray\", False, None, None, False))", + EndpointBddGenerator.literal(bdd.parameters())); + } + + @Test + public void sortsFunctionsByCodePoint() { + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, NODES)); + assertEquals( + "(\"booleanEquals\", \"coalesce\", \"isSet\", \"isValidHostLabel\", \"ite\", \"parseURL\", " + + "\"split\", \"stringEquals\", \"substring\")", + EndpointBddGenerator.literal(bdd.functions())); + } + + @Test + public void lowersConditions() { + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, NODES)); + var conditions = lines(bdd.conditions()); + assertEquals("((2, \"isSet\", (0, \"Endpoint\")), None)", conditions.get(0)); + // A string argument that is a single placeholder is still a template. + assertEquals("((2, \"parseURL\", \"{Endpoint}\"), \"url\")", conditions.get(1)); + assertEquals("((2, \"booleanEquals\", (0, \"UseFIPS\"), True), None)", conditions.get(2)); + assertEquals( + "((2, \"stringEquals\", (1, (0, \"url\"), \"scheme\"), \"http\"), None)", + conditions.get(3)); + assertEquals("((2, \"isValidHostLabel\", (0, \"Region\"), False), None)", conditions.get(5)); + // Literal strings keep their escaped braces; integers stay integers. + assertEquals( + "((2, \"stringEquals\", (2, \"coalesce\", " + + "(2, \"substring\", (0, \"Bucket\"), 0, 4, False), \"\"), \"ab{{c}}\"), None)", + conditions.get(6)); + assertEquals("((2, \"split\", (0, \"Bucket\"), \"--\", 0), \"parts\")", conditions.get(7)); + // Paths are written as in the trait; a negative index counts from the end. + assertEquals("((2, \"isSet\", (1, (0, \"parts\"), \"[-1]\")), None)", conditions.get(8)); + assertEquals( + "((2, \"ite\", (2, \"coalesce\", (0, \"ForcePathStyle\"), False), " + + "\"path\", \"vhost\"), \"style\")", + conditions.get(9)); + } + + @Test + public void lowersResults() { + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, NODES)); + var results = lines(bdd.results()); + assertEquals(5, results.size()); + assertEquals("None", results.get(0)); + // Templates are kept as written, escaped braces included. + assertEquals("(5, \"Invalid {Region}: {{escaped}} {Bucket}{Bucket}\")", results.get(1)); + // Objects in properties are maps even when they have ref or fn keys; header order is kept. + assertEquals( + "(6, \"{url#scheme}://{url#authority}{url#path}\", " + + "(4, (\"authSchemes\", \"literal\"), " + + "(3, (4, (\"name\", \"signingRegion\", \"disableDoubleEncoding\"), \"sigv4\", " + + "\"{Region}\", True)), " + + "(4, (\"ref\", \"fn\", \"n\", \"list\"), \"Region\", \"isSet\", 2, " + + "(3, \"{style}\", (3, \"x\")))), " + + "((\"x-b\", (\"{Region}\", \"b\")), (\"x-a\", ())))", + results.get(2)); + // Empty properties become None and empty headers (). + assertEquals("(6, (0, \"Endpoint\"), None, ())", results.get(3)); + assertEquals("(6, \"https://{Bucket}.example.com\", None, ())", results.get(4)); + } + + @Test + public void rendersTemplatesAsWritten() { + var parameters = """ + {"Endpoint": {"type": "string", "required": true}} + """; + var conditions = """ + [{"fn": "parseURL", "argv": [{"ref": "Endpoint"}], "assign": "url"}] + """; + var results = """ + [{"conditions": [], "type": "error", "error": "{{{url#authority} }} {Endpoint}{{}}"}] + """; + var bdd = lower(trait("1.1", parameters, conditions, results, 2, -1, 1, -1, 0, 100_000_001, 1)); + // Smithy's parts are rendered back to the trait's string, escapes and getAttr paths included. + assertEquals("(5, \"{{{url#authority} }} {Endpoint}{{}}\")", lines(bdd.results()).get(1)); + } + + @Test + public void lowersPathIndexes() { + var parameters = """ + {"Bucket": {"type": "string", "required": true}} + """; + var conditions = """ + [{"fn": "split", "argv": [{"ref": "Bucket"}, "-", 2], "assign": "parts"}, + {"fn": "isSet", "argv": [{"fn": "getAttr", "argv": [{"ref": "parts"}, "[1]"]}]}] + """; + var results = """ + [{"conditions": [], "type": "error", "error": "{Bucket}"}] + """; + var bdd = lower(trait("1.1", parameters, conditions, results, 2, -1, 1, -1, 0, 100_000_001, 1)); + assertEquals( + "((2, \"isSet\", (1, (0, \"parts\"), \"[1]\")), None)", + lines(bdd.conditions()).get(1)); + } + + @Test + public void copiesNodesVerbatim() { + var nodes = new int[] {-1, 1, -1, 0, -3, 1, 1, 100_000_001, -1}; + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, nodes)); + assertEquals("(-1, 1, -1, 0, -3, 1, 1, 100000001, -1)", EndpointBddGenerator.literal(bdd.nodes())); + } + + @Test + public void acceptsTerminalRoot() { + var bdd = lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, -1, -1, 1, -1)); + assertEquals(-1, bdd.root()); + assertEquals("(-1, 1, -1)", EndpointBddGenerator.literal(bdd.nodes())); + } + + @Test + public void rejectsComplementedResultReference() { + // Smithy's validator doesn't catch this one. + var e = assertThrows(CodegenException.class, + () -> lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, -1, 1, -1, 0, -100_000_001, 1))); + assertTrue(e.getMessage().contains("node 1 complements a result reference")); + } + + @Test + public void rejectsMalformedTerminalNode() { + var e = assertThrows(CodegenException.class, + () -> lower(trait("1.1", PARAMETERS, CONDITIONS, RESULTS, 2, 0, 1, -1, 0, 1, -1))); + assertTrue(e.getMessage().contains("node 0")); + } + + @Test + public void rendersLiterals() { + assertEquals("()", EndpointBddGenerator.literal(List.of())); + assertEquals("(\"a\",)", EndpointBddGenerator.literal(List.of("a"))); + assertEquals("(None, True, False, -5)", + EndpointBddGenerator.literal(java.util.Arrays.asList( + null, + true, + false, + -5))); + assertEquals( + "\"q\\\"b\\\\n\\n\\t\\x01\\u00e9\\U0001f600\"", + EndpointBddGenerator.literal("q\"b\\n\n\t\u0001é😀")); + } + + @Test + public void rendersModule() { + var parameters = """ + {"Endpoint": {"type": "string", "builtIn": "SDK::Endpoint"}} + """; + var conditions = """ + [{"fn": "isSet", "argv": [{"ref": "Endpoint"}]}] + """; + var results = """ + [{"conditions": [], "type": "endpoint", "endpoint": {"url": {"ref": "Endpoint"}}}, + {"conditions": [], "type": "error", "error": "no endpoint"}] + """; + var trait = trait("1.1", parameters, conditions, results, 2, -1, 1, -1, 0, 100_000_001, 100_000_002); + var source = EndpointBddGenerator.render("example#Service", trait, lower(trait)); + assertEquals(""" + # Code generated by smithy-python-codegen DO NOT EDIT. + + \"""Endpoint BDD for example#Service.\""" + + from smithy_core.endpoints import EndpointBdd + + # fmt: off + # Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. + BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("isSet",), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ), + results=( + None, # 0: no match + (6, (0, "Endpoint"), None, ()), # 1 + (5, "no endpoint"), # 2 + ), + root=2, + # (condition, high, low); 1 and -1 are terminals, a negative reference is a + # complement edge, and 100_000_000 + k is result k. + nodes=( + -1, 1, -1, + 0, 100_000_001, 100_000_002, + ), + ) + # fmt: on + """, source); + } + + private static EndpointBddGenerator.EndpointBdd lower(EndpointBddTrait trait) { + return lower(trait, Set.of()); + } + + private static EndpointBddGenerator.EndpointBdd lower(EndpointBddTrait trait, Set clientContext) { + return EndpointBddGenerator.lower(trait, clientContext); + } + + private static List lines(List section) { + var out = new ArrayList(); + for (Object item : section) { + out.add(EndpointBddGenerator.literal(item)); + } + return out; + } + + static EndpointBddTrait trait( + String version, + String parameters, + String conditions, + String results, + int root, + int... nodes + ) { + var buffer = ByteBuffer.allocate(nodes.length * 4); + for (int n : nodes) { + buffer.putInt(n); + } + var node = ObjectNode.builder() + .withMember("version", version) + .withMember("parameters", Node.parse(parameters)) + .withMember("conditions", Node.parse(conditions)) + .withMember("results", Node.parse(results)) + .withMember("root", root) + .withMember("nodeCount", nodes.length / 3) + .withMember("nodes", Base64.getEncoder().encodeToString(buffer.array())) + .build(); + return EndpointBddTrait.fromNode(node); + } +} diff --git a/designs/endpoints.md b/designs/endpoints.md new file mode 100644 index 000000000..ce1209edb --- /dev/null +++ b/designs/endpoints.md @@ -0,0 +1,476 @@ +# Endpoint Resolution + +## Summary + +This document describes how smithy-python clients resolve endpoints from each service's endpoint rules, which replace the fixed regional endpoint that AWS clients build today. Each model carries these rules in its `smithy.rules#endpointBdd` trait, and code generation lowers that trait into a `BDD` constant of plain data in the generated client package. One rules engine in `smithy-core` evaluates that data for every service, and `smithy-aws-core` adds the AWS rules functions, built-in parameters, and partition data. On the endpoint benchmark in the appendix, an uncached S3 resolution takes 10.3 µs, and boto3, which evaluates rule-set trees, takes 131 µs. + +--- + +## Motivation + +Today, generated AWS clients use `AsyncStandardRegionalEndpointsResolver`, which builds `..amazonaws.com` and ignores the service's rules. That host is wrong in partitions with other DNS suffixes, such as China and the ISO Regions. Clients also can't bind some parameters the rules need yet: the config has no FIPS or dual-stack fields, and the identity's account ID, which DynamoDB's account-based endpoints read, resolves after the endpoint. Generic clients default to `AsyncStaticEndpointResolver`, which only forwards `endpoint_uri`. Code generation already embeds the raw `endpointBdd` trait in every service schema, but nothing reads it yet. + +Endpoint rules are the only complete description of a service's endpoints, and every AWS model has them. Resolution runs on every attempt, and a workload that addresses many buckets misses the cache often, so misses have to be fast. The planned dynamic clients load models at runtime, so the engine can't depend on per-service generated code. + +--- + +## Goals + +- Each generated client gets its rules as data, one `BDD` constant with no generated logic. A dynamic client can build the same data at runtime, so both use the same engine. +- One shared resolver in `smithy-core` resolves the endpoint from the BDD data lowered from each service's `endpointBdd` trait. +- Endpoint parameters come from schema traits and config, with no per-operation generated code. + +This design doesn't evaluate `endpointRuleSet` decision trees. Specific implementation details are out of scope to keep the document concise. + +--- + +## Background + +### Endpoint Rules + +A service's endpoint rules, written in Smithy's rules language, compute an endpoint from a few named **parameters**, such as `Region`, `UseFIPS`, or S3's `Bucket`. The rules test **conditions**, each a call to a rules function such as `isSet(Region)` or `stringEquals(Region, "aws-global")`, and end at a **result**: an endpoint URL with optional properties and headers, or an error message. A condition can also assign its value to a name that later conditions and results read. For example, `PartitionResult = aws.partition(Region)` looks up the Region's partition, which gives its DNS suffix and whether it supports FIPS and dual-stack. + +Parameters get their values from the client and from the operation: + +- **Built-ins** are settings that every SDK supplies, such as `AWS::Region` from the `region` config field. +- **Client context parameters** are config settings that a service declares for its own client, such as S3's `ForcePathStyle`. +- **Context parameters** come from the operation: an input member marked `@contextParam`, such as S3's `Bucket`, a path into the input structure (`operationContextParams`), or a fixed value on the operation (`staticContextParams`). + +### Rule Sets and BDDs + +A model carries its rules in one of two traits. `endpointRuleSet` is a tree of rules. A resolution tries them in order and takes the first whose conditions all hold, so it can evaluate the same condition once for every rule that lists it. boto3 evaluates these trees. `endpointBdd` holds the same parameters, conditions, and results, arranged as a binary decision diagram (BDD). Smithy compiles a rule set into a BDD, so all AWS services already provide the BDD trait. + +A BDD is a graph of decisions. Each node tests one condition and has two branches: one to take when the condition holds, and one when it doesn't. Every path ends at a result. A resolution starts at the root and takes one branch per node, so it evaluates only the conditions on its path, each at most once. Paths that lead to the same decisions share nodes, so the graph stays small: S3's rules have 75 conditions, 115 results, and 553 nodes. Here is a simplified BDD for a regional service, without dual-stack: + +```mermaid +flowchart TD + custom_set{"isSet(Endpoint)"} + fips1{"booleanEquals(UseFIPS, true)"} + region{"isSet(Region)"} + partition{"PartitionResult =
aws.partition(Region)"} + fips2{"booleanEquals(UseFIPS, true)"} + + fipsError["Error:
FIPS and custom endpoint are not supported"] + custom["Endpoint:
Custom Endpoint"] + regionError["Error:
Missing Region"] + noMatch["No match"] + fipsEndpoint["Endpoint:
https://service-fips.{Region}.{PartitionResult#dnsSuffix}"] + regional["Endpoint:
https://service.{Region}.{PartitionResult#dnsSuffix}"] + + custom_set -- true --> fips1 + custom_set -- false --> region + fips1 -- true --> fipsError + fips1 -- false --> custom + region -- true --> partition + region -- false --> regionError + partition -- true --> fips2 + partition -- false --> noMatch + fips2 -- true --> fipsEndpoint + fips2 -- false --> regional +``` + +--- + +## Proposed State + +### Overview + +Code generation turns the trait into data in the client package, and the shared runtime packages do the rest. The `BDD` constant replaces the raw trait that generated schemas embed today, so `_private/schemas.py` no longer carries it. Green marks new components, and red marks removed ones: + +```mermaid +flowchart LR + subgraph codegen["Code generation, once per service"] + trait["endpointBdd trait"] --> generator["EndpointBddGenerator"] + end + + subgraph client["Generated client package"] + bdd["_private/endpoints.py
BDD constant"] + config["config.py
default endpoint_resolver,
endpoint config fields"] + raw["_private/schemas.py
raw endpointBdd trait"] + end + + subgraph core["smithy-core"] + resolver["AsyncBddEndpointResolver"] + sync_resolver["BddEndpointResolver"] + binding["Parameter binding"] + engine["Rules engine and
standard library"] + end + + subgraph aws["smithy-aws-core"] + extension["AWS_RULES_EXTENSION
aws.* functions, AWS built-ins,
partitions.json"] + end + + generator --> bdd & config + config -- "constructs" --> resolver + bdd -. "passed to" .-> resolver + extension -. "passed to" .-> resolver + resolver -- "delegates to" --> sync_resolver + sync_resolver --> binding & engine + + classDef new stroke:#34a853,stroke-width:3px + classDef removed stroke:#ea4335,stroke-width:3px,stroke-dasharray:5 5 + class generator,bdd,resolver,sync_resolver,binding,engine,extension new + class raw removed +``` + +Each part owns one concern: + +| Part | Owns | +| --- | --- | +| Code generation | Lowering the `endpointBdd` trait into the `BDD` constant, the endpoint config fields, and the default resolver | +| Generated client package | The `BDD` constant, the endpoint config fields, and the schema traits that the resolver reads. No endpoint logic | +| `smithy-core` | The resolver, the rules engine and its standard library, parameter binding, the extension API, the BDD format, and the request pipeline. Nothing AWS-specific | +| `smithy-aws-core` | `AWS_RULES_EXTENSION`, with the `aws.*` functions, the AWS built-in bindings, and the partition data | + +These are the new public names. Everything else is private: + +| Name | Module | Purpose | +| --- | --- | --- | +| `BddEndpointResolver` | `smithy_core.endpoints` | The sync resolver, which holds the resolution logic | +| `AsyncBddEndpointResolver` | `smithy_core.aio.endpoints` | The async resolver that generated config constructs, which delegates to `BddEndpointResolver` | +| `EndpointBdd` | `smithy_core.endpoints` | The BDD data that generated modules hold and the resolver takes | +| `RulesExtension`, `BuiltinBinding`, `BuiltinGetter` | `smithy_core.endpoints` | Add rules functions, built-in bindings, and auth scheme IDs | +| `Endpoint.auth_schemes`, `EndpointAuthScheme`, `ENDPOINT_HEADERS` | `smithy_core.endpoints`, `smithy_core.interfaces` | An endpoint's auth schemes, and the endpoint property for the headers the rules add | +| `RESOLVED_ENDPOINT` | `smithy_core.endpoints` | The endpoint resolved for the current attempt (context property) | +| `ENDPOINT_PARAMETERS` | `smithy_core.endpoints` | Endpoint parameter values that override the bound ones (context property) | +| `RESOLVED_IDENTITY` | `smithy_core.auth` | The identity resolved for the current attempt (context property) | +| `ContextParamTrait`, `OperationContextParamsTrait`, `StaticContextParamsTrait` | `smithy_core.traits` | The context parameter traits in generated schemas | +| `AWS_RULES_EXTENSION` | `smithy_aws_core.endpoints` | The AWS rules functions, built-ins, and partition data | + +Resolution never awaits, so the engine, `ParamPlan`, and the rules libraries are sync code under `smithy_core.endpoints` and `smithy_aws_core.endpoints`. This design defines the sync `EndpointResolver` protocol in `smithy_core.interfaces`, which is a placeholder today. `BddEndpointResolver` implements it and holds the resolution logic. `AsyncBddEndpointResolver` implements `AsyncEndpointResolver`, and its `resolve_endpoint` delegates to a `BddEndpointResolver`. + +### What a Generated Client Gets + +A generated client gets data and config, and no endpoint logic: + +- `_private/endpoints.py`, a new module with one constant, `BDD`. +- A new default `endpoint_resolver` in `config.py`. +- A config field for each client context parameter, and `use_fips_endpoint`, `use_dualstack_endpoint`, and `account_id_endpoint_mode` when the rules declare their built-ins (see [Config Fields](#config-fields)). +- Schemas that keep the three context parameter traits, which the resolver reads at runtime. They no longer embed the raw `endpointBdd` trait. + +AWS clients pass the AWS extension to the default resolver, and other clients construct `AsyncBddEndpointResolver(BDD)` with no extensions: + +```python +"endpoint_resolver": FieldSpec( + default_factory=lambda: AsyncBddEndpointResolver( + BDD, extensions=[AWS_RULES_EXTENSION] + ) +), +``` + +Here is the generated data, trimmed: + +```python +"""Endpoint BDD for aws.protocoltests.json10#JsonRpc10.""" + +from smithy_core.endpoints import EndpointBdd + +# Tags: ref=0, attr=1, call=2, list=3, map=4, error=5, endpoint=6. +BDD = EndpointBdd( + # (name, type, required, default, builtin, client_context) + parameters=( + ("Region", "string", False, None, "AWS::Region", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ), + functions=("aws.partition", "booleanEquals", "isSet", "stringEquals"), + conditions=( + ((2, "isSet", (0, "Endpoint")), None), # 0: isSet(Endpoint) + ((2, "isSet", (0, "Region")), None), # 1: isSet(Region) + ((2, "aws.partition", (0, "Region")), "PartitionResult"), # 2: PartitionResult = aws.partition(Region) + ... + ), + results=( + None, # 0: no match + (5, "Invalid Configuration: FIPS and custom endpoint are not supported"), # 1 + ... + (6, "https://jsonrpc10.{Region}.{PartitionResult#dnsSuffix}", None, ()), # 11 + (5, "Invalid Configuration: Missing Region"), # 12 + ), + root=2, + nodes=(...), +) +``` + +`EndpointBdd` is a public `NamedTuple` in `smithy_core.endpoints`. Its fields hold the trait's data: the parameters, the functions the rules call, the conditions, the results, the root, and the nodes, three integers per node for its condition and its two branches. Expressions and results are tuples that start with a small integer tag, listed in the module's first comment; integer tags load about 4% faster than string tags for S3 and keep the BDD one compile-time constant. Entries keep the trait's names and Smithy's template strings, so a reviewer can match each one to the trait. The BDD makes no decisions for the engine, so engine changes don't change generated output. + +Code generation validates the trait and accepts version 1.1 only. + +**Alternatives Considered:** + +- Generate a resolver for each service, as smithy-rs and smithy-go do. This was not chosen because the generated output would no longer be data, dynamic clients couldn't use it, and every engine change would change every generated client. + +### Dynamic Clients + +Dynamic clients are planned. A dynamic client loads a model at runtime instead of a generated package, and uses the same runtime pieces as a generated client: + +- A Python lowering turns the model's `endpointBdd` trait into the same `EndpointBdd` that code generation writes (see [Follow-Up Work](#follow-up-work)). +- The client builds `AsyncBddEndpointResolver(bdd, extensions=[AWS_RULES_EXTENSION])` and keeps it with the loaded model. +- Parameter binding reads the same context parameter traits from the runtime schemas, and reads input members by the same Python names that dynamic models use. +- Its config exposes client context parameters and built-ins under the same attribute names as generated config. + +### Package Layout + +These are the main new and changed files, with a generated protocol test client as the example client: + +```text +packages/smithy-core/src/smithy_core/ +├── __init__.py # URI host fast path +├── _jmespath.py # NEW: Compiles operationContextParams paths +├── auth.py # RESOLVED_IDENTITY +├── traits.py # The three context parameter traits +├── interfaces/__init__.py # Defines the sync EndpointResolver protocol +├── aio/ +│ ├── client.py # Resolves the identity before the endpoint +│ └── endpoints.py # AsyncBddEndpointResolver, which delegates to BddEndpointResolver +└── endpoints/ # NEW: replaces endpoints.py + ├── __init__.py # Re-exports BddEndpointResolver, EndpointBdd, RulesExtension + ├── _engine.py # RulesEngine + ├── _params.py # ParamPlan + ├── _resolver.py # BddEndpointResolver + ├── _standard_library.py # The standard functions the engine calls + └── _types.py # EndpointAuthScheme, EndpointBdd, RulesExtension, property keys + +packages/smithy-aws-core/src/smithy_aws_core/ +└── endpoints/ + ├── __init__.py + ├── _aws_library.py # NEW: Defines AWS_RULES_EXTENSION + └── partitions.json # NEW: The AWS SDK partition data + +codegen/ +├── core/.../generators/ +│ ├── EndpointBddGenerator.java # NEW: Lowers the trait into tuple format +│ └── ConfigGenerator.java # Set default resolver, add client context param fields +└── aws/core/.../ + ├── AwsEndpointRulesIntegration.java # NEW: Replaces AwsStandardRegionalEndpointsIntegration + └── AwsAsyncConfigIntegration.java # Set default resolver, AWS-specific endpoint fields + +codegen-output/aws-json-1-0/src/awsjson10/ +├── _private/endpoints.py # NEW: The EndpointBdd tuple +└── config.py # Default endpoint_resolver +``` + +### Resolving an Endpoint + +Each client config builds one resolver, and the resolver builds its own engine. On every attempt, the resolver binds the parameters and checks L1. On a miss, the engine checks L2 and walks the BDD: + +```mermaid +flowchart TD + subgraph resolver["BddEndpointResolver"] + bind["Bind the parameters
from the config, input, schema traits, and identity"] + l1{"L1: same values
as the last call?"} + end + + subgraph engine["RulesEngine"] + l2{"L2: values in
the LRU cache?"} + walk["Walk the BDD
from the root to a result"] + end + + resolved["Endpoint"] + failed["EndpointResolutionError"] + + bind -- "required value missing" --> failed + bind --> l1 + l1 -- yes --> resolved + l1 -- no --> l2 + l2 -- yes --> resolved + l2 -- no --> walk + walk -- "endpoint result" --> resolved + walk -- "error result or no match" --> failed +``` + +### Endpoint Parameters + +The resolver builds one `ParamPlan` from the BDD's parameters and the extensions' bindings, and the plan reads each operation's schema traits on that operation's first call. On every call, `bind` reads the config, the input, and the call context and returns the cache key, so per-call config overrides apply. Sources take precedence in this order, highest first; after the first, it is the order Smithy's rules engine specification gives: + +1. `ENDPOINT_PARAMETERS` in the call context, which overrides every other source +2. `staticContextParams` on the operation +3. `contextParam` on top-level input members +4. `operationContextParams` on the operation +5. `clientContextParams` +6. built-ins +7. the parameter's default + +Before the caches, the plan raises `EndpointResolutionError` when a required parameter has no value. + +A client context parameter reads the config field with its name in snake case, and so does a built-in that is also a client context parameter, such as S3's `ForcePathStyle`. Any other built-in reads what its extension binds, and a built-in that no extension binds takes its default. `operationContextParams` paths compile once with a private compiler for the JMESPath subset those paths allow, which reads structures, lists and maps without converting them. In the AWS models, only five DynamoDB operations use these paths. + +The AWS extension binds these built-ins: + +| Built-in | Binding | +| --- | --- | +| `AWS::Region` | `region` | +| `AWS::UseFIPS` | `use_fips_endpoint` | +| `AWS::UseDualStack` | `use_dualstack_endpoint` | +| `AWS::Auth::AccountIdEndpointMode` | `account_id_endpoint_mode` | +| `AWS::Auth::AccountId` | `account_id` of the identity under `RESOLVED_IDENTITY` | +| Other `AWS::S3::*` and `AWS::S3Control::*` | not bound: each is also a client context parameter, so it reads that field | +| `AWS::S3::UseGlobalEndpoint`, `AWS::STS::UseGlobalEndpoint` | not bound | + +The two unbound built-ins select legacy global endpoints. The AWS SDKs and Tools Reference Guide says new SDK major versions released after July 2022 default to regional STS endpoints, and Java 2.x, JavaScript 3.x, Go v2, Kotlin, Rust, and Swift don't support the setting. The S3 setting isn't a standardized SDK setting. Both keep their default, so the rules return regional endpoints. + +**Alternatives Considered:** + +- Use the `jmespath` package for `operationContextParams`. This was not chosen because it evaluates only dicts and lists, so the plan would convert each input first, at a cost that grows with the input. It would also add a dependency to `smithy-core`. The new `_jmespath.py` module can be reused for our future waiter support and is lazily loaded, so most evaluations don't load it. + +### Caching + +The resolver holds L1, and its engine holds L2. L1 is the last parameter values and their endpoint, stored as one tuple, so a reader sees a matching pair without taking a lock. L2 is a 128-entry `functools.lru_cache` around the engine's walk. Both are keyed by the tuple of parameter values, which holds only the parameters the rules read. A hit skips the walk but still binds the parameters. + +Calls share cached endpoints, so callers must treat them as read-only. `URI` is frozen and every value the rules produce is immutable, but `Endpoint` and its `properties` are not, so callers must not change them. + +### Evaluating the BDD + +`RulesEngine`, private to `smithy-core`, evaluates one BDD. At construction it raises one error that names every function the BDD lists that neither the engine nor an extension provides. A walk starts at the root, evaluates each node's condition to choose a branch, and stops at a result. + +The engine compiles each condition and result into a closure: a Python function that keeps the values it was built with. botocore uses the same technique for its clients. It builds each client class from the service model at runtime, and each operation method is a closure that captures its operation name. Here, a closure captures what the engine reads from a condition's tuple, so that reading happens once instead of on every call. For example, condition 7 of the JSON RPC 1.0 BDD: + +```python +# The condition, as the generated module stores it: +((2, "stringEquals", (1, (0, "PartitionResult"), "name"), "aws-us-gov"), None) + +# The closure it compiles to, simplified. `values` holds the bound parameter values in +# BDD order (Region, UseDualStack, UseFIPS, Endpoint), then one slot per name that a +# condition assigns, so slot 4 is PartitionResult. +def condition(values): + return values[4]["name"] == "aws-us-gov" +``` + +An interpreter would match the tags, look up `stringEquals`, and find `PartitionResult` on every call. The closure does that once, and each later call indexes a list and compares two strings. The engine compiles each entry the first time a walk reaches it and reuses the closure after that, so a process pays only for the rules its traffic reaches: each S3 case in the appendix reaches 15 to 31 of S3's 75 conditions. + +An endpoint result becomes an `Endpoint` with the rendered URI. `authSchemes` becomes the endpoint's `auth_schemes`, in the rules' order: each entry whose name an extension maps becomes an `EndpointAuthScheme(scheme_id, properties)`, and an endpoint whose entries no extension maps raises `EndpointResolutionError`. Rule headers go under `ENDPOINT_HEADERS`. Other properties keep their rules names, with lists and maps as tuples and read-only mappings. Error results and the no-match result raise `EndpointResolutionError`, and the engine wraps any other exception from a walk in one. + +**Alternatives Considered:** + +These numbers come from a study that ran each approach over the same S3 and SQS cases with caches off. + +- **Interpret the BDD's tuples on every call, as botocore does with its rule-set trees.** This was not chosen because an interpreter was about 60% slower than closures on an S3 miss. +- **Lower the BDD to bytecode for a stack VM, as smithy-php does.** This was not chosen because a Python VM was about 45% slower than closures. smithy-php's VM is fast because PHP's opcache compiles its opcode `switch` into a jump table and its tracing JIT compiles the hot loop. CPython has neither by default, so it compares opcodes one by one, and about half of the instructions load operands that a closure already holds. The generated module would also hold the bytecode as an opaque `bytes` constant. +- **Evaluate the BDD in a Rust extension.** This was not chosen because about 40% faster S3 misses don't justify a native extension and a pure-Python fallback to maintain. Most of the cost is building Python objects, which Rust can't avoid without depending on smithy-core's class layouts. The BDD doesn't depend on the engine, so a native engine can still come later behind the same resolver. + +### Extensions + +Rules functions, built-in bindings, and auth scheme IDs come from extensions. `RulesExtension`, `BuiltinBinding` and `BuiltinGetter` are public, in `smithy_core.endpoints`: + +```python +type BuiltinGetter = Callable[[Any, TypedProperties], Any] +type BuiltinBinding = str | BuiltinGetter + + +@dataclass(frozen=True, slots=True) +class RulesExtension: + functions: Mapping[str, Callable[..., Any]] + """Rules functions by rules-language name. Their result must depend only on their + arguments, with no side effects, because resolved endpoints are cached by + parameter values. Given None for an unset value, they return None.""" + + builtins: Mapping[str, BuiltinBinding] + """Built-in IDs mapped to a config attribute name, or to a getter that receives + the config and the call context.""" + + auth_schemes: Mapping[str, ShapeID] + """Auth scheme names, as the rules write them in authSchemes, mapped to the scheme + IDs they select.""" +``` + +The rules standard library is part of the engine, not an extension. The engine compiles `isSet`, `not`, `booleanEquals`, `stringEquals`, `coalesce`, and `ite` into closures and calls the other standard functions, and parameter binding provides the `SDK::Endpoint` built-in, which reads `endpoint_uri`. Their names are reserved: a resolver raises when an extension redefines a standard function or binds `SDK::Endpoint`. `smithy_aws_core.endpoints.AWS_RULES_EXTENSION` adds `aws.partition`, `aws.parseArn`, `aws.isVirtualHostableS3Bucket`, the AWS built-ins, and the IDs of the AWS auth schemes: `sigv4` is `aws.auth#sigv4`, `sigv4a` is `aws.auth#sigv4a`, and `sigv4-s3express` is `aws.auth#sigv4S3express`, as in smithy-java, because shape IDs can't contain `-`. Generated config passes extensions to the resolver explicitly, and the resolver gives their functions to the engine and their built-ins to parameter binding. + +The AWS partition data is `partitions.json`, the partitions file that every AWS SDK ships, kept exactly as published. The release pipeline is to replace the file when the published file changes, as the JavaScript and Go SDKs do, and the smithy-aws-core release that carries it bumps every client. `aws.partition` memoizes its result per Region. + +**Alternatives Considered:** + +- Keep the standard library as an extension that is always included first, so other extensions can override its functions and built-ins. This was not chosen because the engine compiles six of those functions itself, so overriding them would silently do nothing, and overriding the others would change standard semantics that every service's rules rely on. + +### Config Fields + +This design adds the fields the bindings read: + +- Code generation adds `use_fips_endpoint` and `use_dualstack_endpoint` only when the rules declare `AWS::UseFIPS` and `AWS::UseDualStack`. Of the 436 services, 433 declare the FIPS built-in and 385 the dual-stack one. +- Code generation adds `account_id_endpoint_mode` only when the rules declare `AWS::Auth::AccountIdEndpointMode`, as three services do. Its values are `preferred`, `disabled`, or `required`, and it defaults to `preferred`, as the AWS SDKs and Tools Reference Guide specifies. +- Code generation adds a field for each `clientContextParams` entry, named in snake case, typed and documented from the trait, and defaulting to `None`. + +None of these fields has a resolver or validator yet. They are set in code, and environment variables and profile keys (`AWS_USE_FIPS_ENDPOINT`, `AWS_ACCOUNT_ID_ENDPOINT_MODE`, S3's `AWS_S3_USE_ARN_REGION`, …) wait for the config redesign's generic resolver system for service-specific fields. + +Apart from `account_id_endpoint_mode`, an unset field reads `None`, so the rule default applies. The bindings are the only place resolution reads config, and the engine never reads it. If the config redesign moves a value, its binding changes from an attribute name to a getter, and the BDD, generated code, and engine stay the same. + +### Request Pipeline + +`AsyncRequestPipeline` now resolves the auth scheme and the identity before the endpoint. Green marks the steps that move. Today, they resolve after the endpoint, just before signing: + +```mermaid +flowchart LR + b1["read_before_attempt"] --> b2["Resolve endpoint"] --> b3["set_service_endpoint"] --> b4["Signing hooks"] --> b5["Resolve auth scheme"] --> b6["Resolve identity"] --> b7["Sign"] + + classDef moved stroke:#34a853,stroke-width:3px + class b5,b6 moved +``` + +With this design, they resolve first, so the endpoint rules can read the identity: + +```mermaid +flowchart LR + a1["read_before_attempt"] --> a2["Resolve auth scheme"] --> a3["Resolve identity
RESOLVED_IDENTITY"] --> a4["Resolve endpoint
RESOLVED_ENDPOINT"] --> a5["set_service_endpoint"] --> a6["Signing hooks"] --> a7["Sign"] + + classDef moved stroke:#34a853,stroke-width:3px + class a2,a3 moved +``` + +The `AWS::Auth::AccountId` getter reads the identity under `RESOLVED_IDENTITY`, so DynamoDB can resolve account-based endpoints. smithy-rs and smithy-java use the same order. Interceptors see the auth scheme and identity resolve after `read_before_attempt` instead of after `read_before_signing`, and identity errors surface before endpoint errors. + +`set_service_endpoint` merges the endpoint's URI into the request. It doesn't apply `ENDPOINT_HEADERS` yet, and in the AWS models only S3 Control's rules add headers. Setting `endpoint_uri` supplies `SDK::Endpoint`, so the rules apply their custom-endpoint behavior, such as rejecting FIPS with a custom endpoint. Setting `endpoint_resolver=AsyncStaticEndpointResolver()` bypasses the rules. + +### Endpoint Auth Schemes + +The rules attach `authSchemes` to many endpoints, including every S3 endpoint. Each entry names a scheme and can override `signingName`, `signingRegion` or `signingRegionSet`, and `disableDoubleEncoding`. As the Smithy client guidance for endpoints recommends, a resolved endpoint carries them as an optional list of auth schemes, each with its scheme ID and its properties: `Endpoint.auth_schemes` holds `EndpointAuthScheme(scheme_id, properties)` entries, in the rules' order, and an empty list means the endpoint has no special auth settings. The scheme ID is the one an auth scheme registers, so signing can match an entry to the resolved scheme. The properties keep their rules names. They reach auth through the endpoint that the pipeline stores before signing. + +As the Smithy rules engine specification describes, the service's auth trait chooses the scheme, and the endpoint only overrides its signing properties. When a request is signed: + +- With no entries, the resolved scheme signs with its own properties. +- With an entry for the resolved scheme, the entry's properties override the scheme's properties. For example, STS's `aws-global` Region and DynamoDB's `local` Region sign for `us-east-1` instead of `config.region`. +- With no entry for the resolved scheme, the request fails with an error that lists the endpoint's schemes and the client's supported schemes. + - Some services keep a legacy behavior: their rules list auth schemes that their auth traits don't, so the endpoint chooses the scheme. S3, SES v2, EventBridge, and CloudFront KeyValueStore do this, for example with `sigv4a` for S3 Multi-Region Access Points and `sigv4-s3express` for S3 Express. Supporting this legacy endpoint-driven auth resolution is out of scope because smithy-python doesn't support SigV4a or S3 Express yet so these requests will raise the error for now. + +### Faster URI Construction + +`URI.__post_init__` ran the full RFC 3986 host regular expression on every construction. `set_service_endpoint` builds a `URI` on every attempt, and the engine builds one on every cache miss. `__post_init__` now accepts a host made only of the unreserved characters `[A-Za-z0-9._~-]` without the full expression, and checks every other host as before. Every such host is a valid registered name, so the set of accepted hosts doesn't change. In a local measurement, construction takes about 60% less time on CPython 3.12 and 3.14. + +### Testing + +Testing has two layers: + +- **Component tests.** Each part has its own suite: the Java lowering and its validation, the rules engine and standard library, parameter binding, the AWS rules functions, and the resolver. +- **Generated client tests.** Code generation turns each model's `endpointTests` into tests in the generated client, so every client checks its own endpoints against the model's cases when its tests run. The tests pass the client's rules extensions to the checks, which map the auth scheme names a case expects to scheme IDs. + +--- + +## Open Items + +- Agree with the config redesign on how the service-specific and generic endpoint fields resolve from environment variables and profile keys, such as `AWS_USE_FIPS_ENDPOINT`, `AWS_ACCOUNT_ID_ENDPOINT_MODE`, and S3's `AWS_S3_USE_ARN_REGION`. + +--- + +## Follow-Up Work + +- Applying rule headers in `set_service_endpoint`, which S3 Control needs. +- The endpoint trait's `hostPrefix` and the `disable_host_prefix_injection` setting. Today `set_service_endpoint` replaces the host the REST serializers build, which drops the prefix. +- A Python lowering for dynamic clients and Python code generation. +- Legacy endpoint-driven auth resolution for S3, SES v2, EventBridge, and CloudFront KeyValueStore. This is required once smithy-python supports SigV4a or S3 Express. AWS SDKs keep this behavior for these four services only. + +--- + +## Appendix: Performance + +The benchmark times 1,000 resolutions after 100 warmup calls, with the same input on every call and the SDK's caches off. Here it times the resolver's `resolve_endpoint`, so each call binds the config and input and walks the BDD, with the operation's plan already built. These ran on CPython 3.14.10, in three rounds, and each value is the fastest round's mean, because interference slowed single rounds on the m7i host by up to 60%. boto3's numbers come from an earlier run of the same benchmark, with botocore 1.43.108 on CPython 3.14.8. Both ran on m7i.xlarge and m7g.xlarge in us-east-1. The hit columns time the same calls with the caches on, after a first warmup call that misses and fills them. For L1 hits, every timed call finds its values in L1. For L2 hits, L1 is cleared before each call, outside the timed region, so every timed call misses L1 and finds its endpoint in L2. The hit columns come from a later run of the same engine on new instances. The resolver in this benchmark was **partially optimized**; more improvements are still possible. + +Mean time per call, in µs: + +| Case | m7i, v4 miss | m7i, boto3 miss | m7g, v4 miss | m7g, boto3 miss | m7i, v4 L1 hit | m7i, v4 L2 hit | m7g, v4 L1 hit | m7g, v4 L2 hit | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| S3 virtual addressing | 10.3 | 131 | 12.5 | 181 | 1.09 | 1.21 | 1.84 | 1.99 | +| S3 path style | 10.9 | 108 | 13.2 | 144 | 1.06 | 1.21 | 1.85 | 2.01 | +| S3 Express, short zone name | 11.8 | 65.7 | 14.2 | 83.0 | 1.06 | 1.20 | 1.83 | 1.96 | +| S3 access point ARN | 18.1 | 130 | 22.1 | 169 | 1.06 | 1.14 | 1.83 | 1.95 | +| S3 Outposts | 22.6 | 121 | 26.4 | 154 | 1.08 | 1.21 | 1.86 | 1.97 | +| Lambda, us-east-1 | 4.6 | 18.4 | 5.6 | 25.1 | 0.88 | 0.94 | 1.42 | 1.56 | +| Lambda, us-gov-east-1 with FIPS and dual-stack | 5.0 | 27.1 | 6.2 | 37.7 | 0.82 | 0.92 | 1.43 | 1.56 | diff --git a/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-breaking-f922f4a4a4d24323ae5c5cd8e36df83a.json b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-breaking-f922f4a4a4d24323ae5c5cd8e36df83a.json new file mode 100644 index 000000000..1ea17651b --- /dev/null +++ b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-breaking-f922f4a4a4d24323ae5c5cd8e36df83a.json @@ -0,0 +1,4 @@ +{ + "type": "breaking", + "description": "Removed `AsyncStandardRegionalEndpointsResolver` and its `smithy_aws_core.aio.endpoints` package, along with `smithy_aws_core.endpoints.RegionalEndpointConfig` and `REGIONAL_ENDPOINT_CONFIG`. Generated AWS clients now resolve endpoints from the service's endpoint rules with `smithy_core.aio.endpoints.AsyncBddEndpointResolver` and `smithy_aws_core.endpoints.AWS_RULES_EXTENSION`." +} diff --git a/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-83b7bf9294df4bbcb37bb27f5bad88c2.json b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-83b7bf9294df4bbcb37bb27f5bad88c2.json new file mode 100644 index 000000000..bff4d9cdc --- /dev/null +++ b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-83b7bf9294df4bbcb37bb27f5bad88c2.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added `smithy_aws_core.endpoints.AWS_RULES_EXTENSION`, which adds AWS support to endpoint rules resolution. It provides the `aws.partition`, `aws.parseArn` and `aws.isVirtualHostableS3Bucket` functions, binds the AWS built-ins to client config, and maps the `sigv4`, `sigv4a` and `sigv4-s3express` auth schemes to their scheme IDs." +} diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/__init__.py deleted file mode 100644 index 33cbe867a..000000000 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/standard_regional.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/standard_regional.py deleted file mode 100644 index f575810f2..000000000 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/endpoints/standard_regional.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 -from typing import Any - -from smithy_core import URI -from smithy_core.aio.interfaces import AsyncEndpointResolver -from smithy_core.endpoints import Endpoint, EndpointResolverParams, resolve_static_uri -from smithy_core.exceptions import EndpointResolutionError - -from ...endpoints import REGIONAL_ENDPOINT_CONFIG - -__all__ = ["AsyncStandardRegionalEndpointsResolver"] - - -class AsyncStandardRegionalEndpointsResolver(AsyncEndpointResolver): - """Resolves endpoints for services with standard regional endpoints.""" - - def __init__(self, endpoint_prefix: str = "bedrock-runtime"): - self._endpoint_prefix = endpoint_prefix - - async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> Endpoint: - if (static_uri := resolve_static_uri(params)) is not None: - return Endpoint(uri=static_uri) - - region_config = params.context.get(REGIONAL_ENDPOINT_CONFIG) - if region_config is not None and region_config.region is not None: - # TODO: use dns suffix determined from partition metadata - dns_suffix = "amazonaws.com" - hostname = f"{self._endpoint_prefix}.{region_config.region}.{dns_suffix}" - - return Endpoint(uri=URI(host=hostname)) - - raise EndpointResolutionError( - "Unable to resolve endpoint - either endpoint_url or region are required." - ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/endpoints/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/__init__.py index bbc81a947..342d4f3b4 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/endpoints/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/__init__.py @@ -1,20 +1,5 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from smithy_core.endpoints import StaticEndpointConfig -from smithy_core.types import PropertyKey +from ._aws_library import AWS_RULES_EXTENSION -__all__ = [ - "REGIONAL_ENDPOINT_CONFIG", - "RegionalEndpointConfig", -] - - -class RegionalEndpointConfig(StaticEndpointConfig): - """Endpoint config for services with standard regional endpoints.""" - - region: str | None - """The AWS region to address the request to.""" - - -REGIONAL_ENDPOINT_CONFIG = PropertyKey(key="config", value_type=RegionalEndpointConfig) -"""Endpoint config for services with standard regional endpoints.""" +__all__ = ["AWS_RULES_EXTENSION"] diff --git a/packages/smithy-aws-core/src/smithy_aws_core/endpoints/_aws_library.py b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/_aws_library.py new file mode 100644 index 000000000..80b8770a2 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/_aws_library.py @@ -0,0 +1,168 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The AWS endpoint rules library: AWS functions and built-in parameter bindings.""" + +import json +import os +import re +from types import MappingProxyType +from typing import Any + +from smithy_core.auth import RESOLVED_IDENTITY +from smithy_core.endpoints import RulesExtension +from smithy_core.interfaces import TypedProperties +from smithy_core.shapes import ShapeID + +from ..traits import SigV4Trait + + +def _build_partitions( + partitions: list[dict[str, Any]], +) -> tuple[ + dict[str, dict[str, Any]], + tuple[tuple[re.Pattern[str], dict[str, Any]], ...], + dict[str, Any] | None, +]: + """Index the partitions: known regions, region patterns, and the aws partition. + + A known region's record is its partition's outputs merged with the region's own + properties. + """ + by_region: dict[str, dict[str, Any]] = {} + patterns: list[tuple[re.Pattern[str], dict[str, Any]]] = [] + default = None + for partition in partitions: + record = partition["outputs"] + for region, properties in partition["regions"].items(): + by_region.setdefault(region, {**record, **properties}) + patterns.append((re.compile(partition["regionRegex"], re.ASCII), record)) + if partition["id"] == "aws": + default = record + return by_region, tuple(patterns), default + + +def _load_partitions() -> list[dict[str, Any]]: + # partitions.json is the release pipeline's partitions artifact, byte for byte. + # The pipeline replaces it when the artifact changes, so don't edit it by hand. + with open(os.path.join(os.path.dirname(__file__), "partitions.json"), "rb") as file: + return json.load(file)["partitions"] + + +_BY_REGION, _PATTERNS, _DEFAULT_PARTITION = _build_partitions(_load_partitions()) +_PARTITION_CACHE: dict[str, dict[str, Any] | None] = {} +_PARTITION_CACHE_SIZE = 512 + + +def _partition(region: str | None) -> dict[str, Any] | None: + """``aws.partition``: the partition of a region, memoized per region. + + Known regions come first, then the first partition whose region pattern + matches, then the ``aws`` partition. The returned record is shared and must not + be mutated. + """ + if region is None: + return None + try: + return _PARTITION_CACHE[region] + except KeyError: + pass + record = _BY_REGION.get(region) + if record is None: + for pattern, candidate in _PATTERNS: + if pattern.fullmatch(region): + record = candidate + break + else: + record = _DEFAULT_PARTITION + if len(_PARTITION_CACHE) < _PARTITION_CACHE_SIZE: + _PARTITION_CACHE[region] = record + return record + + +_ARN_RESOURCE_SEPARATOR = re.compile(r"[:/]") + + +def _parse_arn(value: str | None) -> dict[str, Any] | None: + """``aws.parseArn``: the parts of an ARN, or None when it is not one.""" + if value is None or len(value) < 8 or not value.startswith("arn:"): + return None + parts = value.split(":", 5) + if len(parts) != 6: + return None + _, partition, service, region, account_id, resource = parts + if not partition or not service or not resource: + return None + return { + "partition": partition, + "service": service, + "region": region, + "accountId": account_id, + "resourceId": tuple(_ARN_RESOURCE_SEPARATOR.split(resource)), + } + + +_BUCKET = re.compile(r"[a-z0-9][a-z0-9-]*[a-z0-9]") +_DOTTED_BUCKET = re.compile( + r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*" +) +# The rules library's IPv4 heuristic: four dot-separated groups of one to three +# digits without leading zeros. +_IPV4 = re.compile(r"(?:[0-9]|[1-9][0-9]{1,2})(?:\.(?:[0-9]|[1-9][0-9]{1,2})){3}") + + +def _is_virtual_hostable_s3_bucket( + value: str | None, allow_sub_domains: bool +) -> bool | None: + """``aws.isVirtualHostableS3Bucket``: whether a bucket can be a host label.""" + if value is None: + return None + if not 3 <= len(value) <= 63: + return False + if not allow_sub_domains: + return _BUCKET.fullmatch(value) is not None + return ( + _DOTTED_BUCKET.fullmatch(value) is not None and _IPV4.fullmatch(value) is None + ) + + +def _account_id(config: Any, context: TypedProperties) -> str | None: + """``AWS::Auth::AccountId``: the account ID of the identity resolved for the call.""" + return getattr(context.get(RESOLVED_IDENTITY), "account_id", None) + + +AWS_RULES_EXTENSION = RulesExtension( + functions=MappingProxyType( + { + "aws.partition": _partition, + "aws.parseArn": _parse_arn, + "aws.isVirtualHostableS3Bucket": _is_virtual_hostable_s3_bucket, + } + ), + builtins=MappingProxyType( + { + "AWS::Region": "region", + "AWS::UseFIPS": "use_fips_endpoint", + "AWS::UseDualStack": "use_dualstack_endpoint", + "AWS::Auth::AccountIdEndpointMode": "account_id_endpoint_mode", + "AWS::Auth::AccountId": _account_id, + } + ), + auth_schemes=MappingProxyType( + { + "sigv4": SigV4Trait.id, + "sigv4a": ShapeID("aws.auth#sigv4a"), + # Shape IDs can't hold "-". + "sigv4-s3express": ShapeID("aws.auth#sigv4S3express"), + } + ), +) +"""AWS rules functions, built-in bindings and auth scheme IDs. + +``use_fips_endpoint``, ``use_dualstack_endpoint`` and ``account_id_endpoint_mode`` +are generated only on the configs of services whose rules declare their built-ins, and +``account_id_endpoint_mode`` defaults to ``preferred``. The ``AWS::S3::*`` and +``AWS::S3Control::*`` built-ins are also client context parameters, so they are read +from that parameter's generated config field. ``AWS::S3::UseGlobalEndpoint`` and +``AWS::STS::UseGlobalEndpoint`` are not bound: they select legacy global endpoints, +so they take their default of false. +""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/endpoints/partitions.json b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/partitions.json new file mode 100644 index 000000000..fb9ebb1fa --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/endpoints/partitions.json @@ -0,0 +1,267 @@ +{ + "partitions" : [ { + "id" : "aws", + "outputs" : { + "dnsSuffix" : "amazonaws.com", + "dualStackDnsSuffix" : "api.aws", + "implicitGlobalRegion" : "us-east-1", + "name" : "aws", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^(us|eu|ap|sa|ca|me|af|il|mx)\\-\\w+\\-\\d+$", + "regions" : { + "af-south-1" : { + "description" : "Africa (Cape Town)" + }, + "ap-east-1" : { + "description" : "Asia Pacific (Hong Kong)" + }, + "ap-east-2" : { + "description" : "Asia Pacific (Taipei)" + }, + "ap-northeast-1" : { + "description" : "Asia Pacific (Tokyo)" + }, + "ap-northeast-2" : { + "description" : "Asia Pacific (Seoul)" + }, + "ap-northeast-3" : { + "description" : "Asia Pacific (Osaka)" + }, + "ap-south-1" : { + "description" : "Asia Pacific (Mumbai)" + }, + "ap-south-2" : { + "description" : "Asia Pacific (Hyderabad)" + }, + "ap-southeast-1" : { + "description" : "Asia Pacific (Singapore)" + }, + "ap-southeast-2" : { + "description" : "Asia Pacific (Sydney)" + }, + "ap-southeast-3" : { + "description" : "Asia Pacific (Jakarta)" + }, + "ap-southeast-4" : { + "description" : "Asia Pacific (Melbourne)" + }, + "ap-southeast-5" : { + "description" : "Asia Pacific (Malaysia)" + }, + "ap-southeast-6" : { + "description" : "Asia Pacific (New Zealand)" + }, + "ap-southeast-7" : { + "description" : "Asia Pacific (Thailand)" + }, + "aws-global" : { + "description" : "aws global region" + }, + "ca-central-1" : { + "description" : "Canada (Central)" + }, + "ca-west-1" : { + "description" : "Canada West (Calgary)" + }, + "eu-central-1" : { + "description" : "Europe (Frankfurt)" + }, + "eu-central-2" : { + "description" : "Europe (Zurich)" + }, + "eu-north-1" : { + "description" : "Europe (Stockholm)" + }, + "eu-south-1" : { + "description" : "Europe (Milan)" + }, + "eu-south-2" : { + "description" : "Europe (Spain)" + }, + "eu-west-1" : { + "description" : "Europe (Ireland)" + }, + "eu-west-2" : { + "description" : "Europe (London)" + }, + "eu-west-3" : { + "description" : "Europe (Paris)" + }, + "il-central-1" : { + "description" : "Israel (Tel Aviv)" + }, + "me-central-1" : { + "description" : "Middle East (UAE)" + }, + "me-south-1" : { + "description" : "Middle East (Bahrain)" + }, + "mx-central-1" : { + "description" : "Mexico (Central)" + }, + "sa-east-1" : { + "description" : "South America (Sao Paulo)" + }, + "us-east-1" : { + "description" : "US East (N. Virginia)" + }, + "us-east-2" : { + "description" : "US East (Ohio)" + }, + "us-west-1" : { + "description" : "US West (N. California)" + }, + "us-west-2" : { + "description" : "US West (Oregon)" + } + } + }, { + "id" : "aws-cn", + "outputs" : { + "dnsSuffix" : "amazonaws.com.cn", + "dualStackDnsSuffix" : "api.amazonwebservices.com.cn", + "implicitGlobalRegion" : "cn-northwest-1", + "name" : "aws-cn", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^cn\\-\\w+\\-\\d+$", + "regions" : { + "aws-cn-global" : { + "description" : "aws-cn global region" + }, + "cn-north-1" : { + "description" : "China (Beijing)" + }, + "cn-northwest-1" : { + "description" : "China (Ningxia)" + } + } + }, { + "id" : "aws-eusc", + "outputs" : { + "dnsSuffix" : "amazonaws.eu", + "dualStackDnsSuffix" : "api.amazonwebservices.eu", + "implicitGlobalRegion" : "eusc-de-east-1", + "name" : "aws-eusc", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^eusc\\-(de)\\-\\w+\\-\\d+$", + "regions" : { + "eusc-de-east-1" : { + "description" : "AWS European Sovereign Cloud (Germany)" + } + } + }, { + "id" : "aws-iso", + "outputs" : { + "dnsSuffix" : "c2s.ic.gov", + "dualStackDnsSuffix" : "api.aws.ic.gov", + "implicitGlobalRegion" : "us-iso-east-1", + "name" : "aws-iso", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^us\\-iso\\-\\w+\\-\\d+$", + "regions" : { + "aws-iso-global" : { + "description" : "aws-iso global region" + }, + "us-iso-east-1" : { + "description" : "US ISO East" + }, + "us-iso-west-1" : { + "description" : "US ISO WEST" + } + } + }, { + "id" : "aws-iso-b", + "outputs" : { + "dnsSuffix" : "sc2s.sgov.gov", + "dualStackDnsSuffix" : "api.aws.scloud", + "implicitGlobalRegion" : "us-isob-east-1", + "name" : "aws-iso-b", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^us\\-isob\\-\\w+\\-\\d+$", + "regions" : { + "aws-iso-b-global" : { + "description" : "aws-iso-b global region" + }, + "us-isob-east-1" : { + "description" : "US ISOB East (Ohio)" + }, + "us-isob-west-1" : { + "description" : "US ISOB West" + } + } + }, { + "id" : "aws-iso-e", + "outputs" : { + "dnsSuffix" : "cloud.adc-e.uk", + "dualStackDnsSuffix" : "api.cloud-aws.adc-e.uk", + "implicitGlobalRegion" : "eu-isoe-west-1", + "name" : "aws-iso-e", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^eu\\-isoe\\-\\w+\\-\\d+$", + "regions" : { + "aws-iso-e-global" : { + "description" : "aws-iso-e global region" + }, + "eu-isoe-west-1" : { + "description" : "EU ISOE West" + } + } + }, { + "id" : "aws-iso-f", + "outputs" : { + "dnsSuffix" : "csp.hci.ic.gov", + "dualStackDnsSuffix" : "api.aws.hci.ic.gov", + "implicitGlobalRegion" : "us-isof-south-1", + "name" : "aws-iso-f", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^us\\-isof\\-\\w+\\-\\d+$", + "regions" : { + "aws-iso-f-global" : { + "description" : "aws-iso-f global region" + }, + "us-isof-east-1" : { + "description" : "US ISOF EAST" + }, + "us-isof-south-1" : { + "description" : "US ISOF SOUTH" + } + } + }, { + "id" : "aws-us-gov", + "outputs" : { + "dnsSuffix" : "amazonaws.com", + "dualStackDnsSuffix" : "api.aws", + "implicitGlobalRegion" : "us-gov-west-1", + "name" : "aws-us-gov", + "supportsDualStack" : true, + "supportsFIPS" : true + }, + "regionRegex" : "^us\\-gov\\-\\w+\\-\\d+$", + "regions" : { + "aws-us-gov-global" : { + "description" : "aws-us-gov global region" + }, + "us-gov-east-1" : { + "description" : "AWS GovCloud (US-East)" + }, + "us-gov-west-1" : { + "description" : "AWS GovCloud (US-West)" + } + } + } ], + "version" : "1.1" +} \ No newline at end of file diff --git a/packages/smithy-aws-core/tests/unit/endpoints/__init__.py b/packages/smithy-aws-core/tests/unit/endpoints/__init__.py new file mode 100644 index 000000000..04f8b7b76 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/endpoints/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/tests/unit/endpoints/test_aws_library.py b/packages/smithy-aws-core/tests/unit/endpoints/test_aws_library.py new file mode 100644 index 000000000..bcf8dcd65 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/endpoints/test_aws_library.py @@ -0,0 +1,209 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from types import SimpleNamespace +from typing import Any + +import pytest +from smithy_aws_core.endpoints import AWS_RULES_EXTENSION +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_core.auth import RESOLVED_IDENTITY +from smithy_core.endpoints import ( + BddEndpointResolver, + EndpointBdd, + EndpointResolverParams, +) +from smithy_core.endpoints._types import Tag +from smithy_core.schemas import APIOperation +from smithy_core.shapes import ShapeType +from smithy_core.types import TypedProperties + +FUNCTIONS = AWS_RULES_EXTENSION.functions + + +@pytest.mark.parametrize( + "region, name, dns", + [ + ("us-east-1", "aws", "amazonaws.com"), + ("us-gov-west-1", "aws-us-gov", "amazonaws.com"), + ("cn-north-1", "aws-cn", "amazonaws.com.cn"), + ("us-iso-east-1", "aws-iso", "c2s.ic.gov"), + ("eusc-de-east-1", "aws-eusc", "amazonaws.eu"), + # Known regions match before the region patterns. + ("aws-us-gov-global", "aws-us-gov", "amazonaws.com"), + # A region no pattern matches is in the aws partition. + ("mars-east-1", "aws", "amazonaws.com"), + # Unknown regions that match a partition's region pattern. + ("us-east-99", "aws", "amazonaws.com"), + ("us-gov-east-9", "aws-us-gov", "amazonaws.com"), + ("cn-north-9", "aws-cn", "amazonaws.com.cn"), + ], +) +def test_partition(region: str, name: str, dns: str) -> None: + partition = FUNCTIONS["aws.partition"](region) + assert partition["name"] == name + assert partition["dnsSuffix"] == dns + + +def test_known_regions_merge_their_properties() -> None: + partition = AWS_RULES_EXTENSION.functions["aws.partition"]("af-south-1") + assert partition["dnsSuffix"] == "amazonaws.com" + assert partition["description"] == "Africa (Cape Town)" + + +@pytest.mark.parametrize( + "value, expected", + [ + ( + "arn:aws:s3:us-west-2:123456789012:accesspoint/myendpoint", + { + "partition": "aws", + "service": "s3", + "region": "us-west-2", + "accountId": "123456789012", + "resourceId": ("accesspoint", "myendpoint"), + }, + ), + ( + "arn:aws:s3-outposts:us-west-2:123:outpost:op-01:accesspoint:ap", + { + "partition": "aws", + "service": "s3-outposts", + "region": "us-west-2", + "accountId": "123", + "resourceId": ("outpost", "op-01", "accesspoint", "ap"), + }, + ), + ( + "arn:aws:iam::123456789012:user/a/b", + { + "partition": "aws", + "service": "iam", + "region": "", + "accountId": "123456789012", + "resourceId": ("user", "a", "b"), + }, + ), + ("arn:aws:s3:us-west-2:123456789012:", None), + ("arn:aws:s3:us-west-2", None), + ("bucket", None), + ], +) +def test_parse_arn(value: str, expected: dict[str, Any] | None) -> None: + assert FUNCTIONS["aws.parseArn"](value) == expected + + +@pytest.mark.parametrize( + "value, allow_sub_domains, expected", + [ + ("my-bucket-1", False, True), + ("ab", False, False), + ("a" * 64, False, False), + ("Bucket", False, False), + ("-bucket", False, False), + ("my.bucket", False, False), + ("my.bucket", True, True), + ("my..bucket", True, False), + ("192.168.5.4", True, False), + ], +) +def test_is_virtual_hostable_s3_bucket( + value: str, allow_sub_domains: bool, expected: bool +) -> None: + assert FUNCTIONS["aws.isVirtualHostableS3Bucket"](value, allow_sub_domains) is ( + expected + ) + + +# --------------------------------------------------------------- built-ins + +# Each AWS built-in, and one that is also a client context parameter. +PARAMETERS: tuple[tuple[Any, ...], ...] = ( + ("Region", "string", False, None, "AWS::Region", False), + ("UseFIPS", "boolean", True, False, "AWS::UseFIPS", False), + ("UseDualStack", "boolean", True, False, "AWS::UseDualStack", False), + ("Mode", "string", False, None, "AWS::Auth::AccountIdEndpointMode", False), + ("AccountId", "string", False, None, "AWS::Auth::AccountId", False), + ("StsGlobal", "boolean", True, False, "AWS::STS::UseGlobalEndpoint", False), + ("S3Global", "boolean", True, False, "AWS::S3::UseGlobalEndpoint", False), + ("ForcePathStyle", "boolean", False, None, "AWS::S3::ForcePathStyle", True), +) +NAMES = tuple(name for name, *_ in PARAMETERS) +# One endpoint, with each parameter's value as a property of its name. +BDD = EndpointBdd( + parameters=PARAMETERS, + functions=(), + conditions=(), + results=( + None, + ( + Tag.ENDPOINT, + "https://example.com", + (Tag.MAP, NAMES, *((Tag.REF, name) for name in NAMES)), + (), + ), + ), + root=100_000_001, + nodes=(-1, 1, -1), +) +OPERATION: APIOperation[Any, Any] = APIOperation( + static_schema=( + ShapeType.OPERATION.value, + "com.example", + "Op", + 0, + (ShapeType.STRUCTURE.value, "com.example", "OpInput", 0, (), ()), + -1, + (), + ), +) + + +def _bound(config: Any, identity: Any = None) -> dict[str, Any]: + """The parameter values a call binds, by name.""" + context = TypedProperties({"config": config}) + if identity is not None: + context[RESOLVED_IDENTITY] = identity + resolver = BddEndpointResolver(BDD, extensions=[AWS_RULES_EXTENSION]) + endpoint = resolver.resolve_endpoint( + EndpointResolverParams(operation=OPERATION, input=None, context=context) + ) + return dict(endpoint.properties.items()) + + +def test_builtins_read_their_sources() -> None: + config = SimpleNamespace( + endpoint_uri=None, + region="us-west-2", + use_fips_endpoint=True, + use_dualstack_endpoint=True, + account_id_endpoint_mode="disabled", + force_path_style=True, + ) + identity = AWSCredentialsIdentity( + access_key_id="AKID", secret_access_key="SECRET", account_id="111122223333" + ) + assert _bound(config, identity) == { + "Region": "us-west-2", + "UseFIPS": True, + "UseDualStack": True, + "Mode": "disabled", + "AccountId": "111122223333", + # Not bound: they select legacy global endpoints. + "StsGlobal": False, + "S3Global": False, + "ForcePathStyle": True, + } + + +def test_unset_builtins_take_their_defaults() -> None: + identity = AWSCredentialsIdentity(access_key_id="AKID", secret_access_key="SECRET") + assert _bound(SimpleNamespace(endpoint_uri=None), identity) == { + "Region": None, + "UseFIPS": False, + "UseDualStack": False, + "Mode": None, + "AccountId": None, + "StsGlobal": False, + "S3Global": False, + "ForcePathStyle": None, + } diff --git a/packages/smithy-aws-core/tests/unit/endpoints/test_standard_regional.py b/packages/smithy-aws-core/tests/unit/endpoints/test_standard_regional.py deleted file mode 100644 index 1da0c2712..000000000 --- a/packages/smithy-aws-core/tests/unit/endpoints/test_standard_regional.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 -from dataclasses import dataclass -from typing import Any -from unittest.mock import Mock - -import pytest -from smithy_aws_core.aio.endpoints.standard_regional import ( - AsyncStandardRegionalEndpointsResolver, -) -from smithy_aws_core.endpoints import REGIONAL_ENDPOINT_CONFIG -from smithy_core import URI -from smithy_core.endpoints import EndpointResolverParams -from smithy_core.exceptions import EndpointResolutionError -from smithy_core.types import TypedProperties - - -@dataclass -class EndpointConfig: - endpoint_uri: str | URI | None = None - region: str | None = None - - @classmethod - def params( - cls, endpoint_uri: str | URI | None = None, region: str | None = None - ) -> EndpointResolverParams[Any]: - properties = TypedProperties( - { - REGIONAL_ENDPOINT_CONFIG.key: cls( - endpoint_uri=endpoint_uri, region=region - ) - } - ) - params = Mock(spec=EndpointResolverParams) - params.context = properties - return params - - -async def test_resolve_endpoint_with_valid_sdk_endpoint_string(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - params = EndpointConfig.params("https://example.com/path?query=123") - - endpoint = await resolver.resolve_endpoint(params) - - assert endpoint.uri.host == "example.com" - assert endpoint.uri.path == "/path" - assert endpoint.uri.scheme == "https" - assert endpoint.uri.query == "query=123" - - -async def test_resolve_endpoint_with_sdk_endpoint_uri(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - parsed_uri = URI( - host="example.com", path="/path", scheme="https", query="query=123", port=443 - ) - params = EndpointConfig.params(parsed_uri) - - endpoint = await resolver.resolve_endpoint(params) - - assert endpoint.uri == parsed_uri - - -async def test_resolve_endpoint_with_invalid_sdk_endpoint(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - params = EndpointConfig.params("invalid_uri") - - with pytest.raises(EndpointResolutionError): - await resolver.resolve_endpoint(params) - - -async def test_resolve_endpoint_with_region(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - params = EndpointConfig.params(region="us-west-2") - - endpoint = await resolver.resolve_endpoint(params) - - assert endpoint.uri.host == "service.us-west-2.amazonaws.com" - - -async def test_resolve_endpoint_with_no_sdk_endpoint_or_region(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - params = EndpointConfig.params() - - with pytest.raises(EndpointResolutionError): - await resolver.resolve_endpoint(params) - - -async def test_resolve_endpoint_with_sdk_endpoint_and_region(): - resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service") - params = EndpointConfig.params( - endpoint_uri="https://example.com", region="us-west-2" - ) - - endpoint = await resolver.resolve_endpoint(params) - - assert endpoint.uri.host == "example.com" diff --git a/packages/smithy-core/.changes/next-release/smithy-core-breaking-17d1b930e94f48f187aedf3016c4e907.json b/packages/smithy-core/.changes/next-release/smithy-core-breaking-17d1b930e94f48f187aedf3016c4e907.json new file mode 100644 index 000000000..11b563069 --- /dev/null +++ b/packages/smithy-core/.changes/next-release/smithy-core-breaking-17d1b930e94f48f187aedf3016c4e907.json @@ -0,0 +1,4 @@ +{ + "type": "breaking", + "description": "Added the required `auth_schemes` attribute to the `smithy_core.interfaces.Endpoint` protocol. Custom endpoint types that don't subclass `smithy_core.endpoints.Endpoint` must add it." +} diff --git a/packages/smithy-core/.changes/next-release/smithy-core-breaking-34ac73b42e58473ab63a15959a66880c.json b/packages/smithy-core/.changes/next-release/smithy-core-breaking-34ac73b42e58473ab63a15959a66880c.json new file mode 100644 index 000000000..001b74028 --- /dev/null +++ b/packages/smithy-core/.changes/next-release/smithy-core-breaking-34ac73b42e58473ab63a15959a66880c.json @@ -0,0 +1,4 @@ +{ + "type": "breaking", + "description": "The request pipeline now resolves the auth scheme and the identity after `read_before_attempt` and before endpoint resolution, then stores the identity in the call context under `smithy_core.auth.RESOLVED_IDENTITY`. This allows endpoint resolution to read account IDs." +} diff --git a/packages/smithy-core/.changes/next-release/smithy-core-feature-eb933edb101d4c5680d8a2c90642f1ef.json b/packages/smithy-core/.changes/next-release/smithy-core-feature-eb933edb101d4c5680d8a2c90642f1ef.json new file mode 100644 index 000000000..38429f7b9 --- /dev/null +++ b/packages/smithy-core/.changes/next-release/smithy-core-feature-eb933edb101d4c5680d8a2c90642f1ef.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added endpoint BDD resolution with `smithy_core.endpoints.BddEndpointResolver`, its async counterpart `smithy_core.aio.endpoints.AsyncBddEndpointResolver`, and `smithy_core.endpoints.EndpointBdd`, the endpoint BDD the resolvers evaluate." +} diff --git a/packages/smithy-core/src/smithy_core/__init__.py b/packages/smithy-core/src/smithy_core/__init__.py index 97c82ea09..bcd6869cb 100644 --- a/packages/smithy-core/src/smithy_core/__init__.py +++ b/packages/smithy-core/src/smithy_core/__init__.py @@ -59,10 +59,15 @@ class URI(interfaces.URI): def __post_init__(self) -> None: """Validate host component.""" - if not rfc3986.HOST_MATCHER.match(self.host) and not rfc3986.IPv6_MATCHER.match( - f"[{self.host}]" + host = self.host + # The full host matcher accepts every unreserved host; checking those first + # skips its cost. + if ( + rfc3986.UNRESERVED_HOST_MATCHER.fullmatch(host) is None + and not rfc3986.HOST_MATCHER.match(host) + and not rfc3986.IPv6_MATCHER.match(f"[{host}]") ): - raise SmithyError(f"Invalid host: {self.host}") + raise SmithyError(f"Invalid host: {host}") @property def netloc(self) -> str: diff --git a/packages/smithy-core/src/smithy_core/_jmespath.py b/packages/smithy-core/src/smithy_core/_jmespath.py new file mode 100644 index 000000000..d60b415c2 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/_jmespath.py @@ -0,0 +1,277 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Compile the JMESPath subset that operation context parameter paths use. + +The subset is identifiers, sub-expressions, list and object wildcards, flatten, +multi-select lists and the ``keys`` function. A compiled path reads structures by +attribute and maps by key, so its input isn't converted first. Smithy checks each +path against the input shape, so values are trusted to have their modeled types. +""" + +import json +import re +from collections.abc import Callable, Iterable +from dataclasses import fields, is_dataclass +from typing import Any, NamedTuple + +from ._model_naming import member_field_name + +type _Closure = Callable[[Any], Any] + + +def compile(expression: str) -> _Closure: + """Compile a path into a function of an operation input. + + :param expression: The JMESPath expression. + :raises ValueError: If the expression is outside the supported subset. + """ + return _Parser(expression).parse() + + +class _Token(NamedTuple): + kind: str + """``identifier``, ``quoted``, ``end``, or the symbol itself.""" + + text: str + position: int + + +_TOKEN = re.compile( + r""" + (?P[A-Za-z_][A-Za-z0-9_]*) + | (?P"(?:[^"\\]|\\.)*") + | (?P\[\]|[.*\[\],()]) + | (?P[ \t\n\r]+) + | (?P.) + """, + re.VERBOSE, +) + +# Binding powers from the JMESPath reference parser. Other tokens end an expression. +_BINDING_POWER = {"[]": 9, ".": 40, "[": 55} +_STAR_BINDING_POWER = 20 + + +def _tokenize(expression: str) -> list[_Token]: + tokens: list[_Token] = [] + for found in _TOKEN.finditer(expression): + text, position = found.group(), found.start() + match found.lastgroup: + case "identifier": + tokens.append(_Token("identifier", text, position)) + case "quoted": + tokens.append(_Token("quoted", json.loads(text), position)) + case "symbol": + tokens.append(_Token(text, text, position)) + case "space": + continue + case _: + raise _error(expression, position) + tokens.append(_Token("end", "", len(expression))) + return tokens + + +def _error(expression: str, position: int) -> ValueError: + return ValueError( + f"Unsupported JMESPath expression {expression!r} at position {position}" + ) + + +class _Parser: + """A Pratt parser that builds the closure for each expression it parses.""" + + def __init__(self, expression: str) -> None: + self._expression = expression + self._tokens = _tokenize(expression) + self._index = 0 + + def parse(self) -> _Closure: + closure = self._parse_expression(0) + token = self._token() + if token.kind != "end": + raise _error(self._expression, token.position) + return closure + + def _token(self, offset: int = 0) -> _Token: + return self._tokens[self._index + offset] + + def _advance(self) -> _Token: + token = self._tokens[self._index] + self._index += 1 + return token + + def _expect(self, kind: str) -> None: + token = self._advance() + if token.kind != kind: + raise _error(self._expression, token.position) + + def _parse_expression(self, binding_power: int) -> _Closure: + left = self._parse_prefix(self._advance()) + while binding_power < _BINDING_POWER.get(self._token().kind, 0): + left = self._parse_infix(self._advance(), left) + return left + + def _parse_prefix(self, token: _Token) -> _Closure: + match token.kind: + case "identifier" if self._token().kind == "(": + return self._parse_function(token) + case "identifier" | "quoted": + return _field(token.text) + case "*": + right = self._parse_projection_right(_STAR_BINDING_POWER) + return _projection(_identity, right, _object_values) + case "[" if self._token().kind == "*" and self._token(1).kind == "]": + self._expect("*") + self._expect("]") + right = self._parse_projection_right(_STAR_BINDING_POWER) + return _projection(_identity, right, _list_items) + case "[": + return self._parse_multi_select_list() + case "[]": + right = self._parse_projection_right(_BINDING_POWER["[]"]) + return _projection(_flatten(_identity), right, _list_items) + case _: + raise _error(self._expression, token.position) + + def _parse_infix(self, token: _Token, left: _Closure) -> _Closure: + match token.kind: + case "." if self._token().kind == "*": + self._advance() + right = self._parse_projection_right(_BINDING_POWER["."]) + return _projection(left, right, _object_values) + case ".": + right = self._parse_dot_right(_BINDING_POWER["."]) + return _subexpression(left, right) + case "[]": + right = self._parse_projection_right(_BINDING_POWER["[]"]) + return _projection(_flatten(left), right, _list_items) + case _: # "[" + self._expect("*") + self._expect("]") + right = self._parse_projection_right(_STAR_BINDING_POWER) + return _projection(left, right, _list_items) + + def _parse_projection_right(self, binding_power: int) -> _Closure: + match self._token().kind: + case ".": + self._advance() + return self._parse_dot_right(binding_power) + case "[": + return self._parse_expression(binding_power) + case _: + return _identity + + def _parse_dot_right(self, binding_power: int) -> _Closure: + token = self._token() + match token.kind: + case "identifier" | "quoted" | "*": + return self._parse_expression(binding_power) + case "[": + self._advance() + return self._parse_multi_select_list() + case _: + raise _error(self._expression, token.position) + + def _parse_multi_select_list(self) -> _Closure: + items = [self._parse_expression(0)] + while self._token().kind == ",": + self._advance() + items.append(self._parse_expression(0)) + self._expect("]") + return _multi_select_list(tuple(items)) + + def _parse_function(self, name: _Token) -> _Closure: + if name.text != "keys": + raise _error(self._expression, name.position) + self._expect("(") + argument = self._parse_expression(0) + self._expect(")") + return _keys(argument) + + +def _identity(value: Any) -> Any: + return value + + +def _field(name: str) -> _Closure: + attribute = member_field_name( + name, is_error=False, carries_response_metadata=False, is_enum=False + ) + + def field(value: Any) -> Any: + if isinstance(value, dict): + return value.get(name) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + return getattr(value, attribute, None) + + return field + + +def _subexpression(left: _Closure, right: _Closure) -> _Closure: + def subexpression(value: Any) -> Any: + return right(left(value)) + + return subexpression + + +def _list_items(value: Any) -> Iterable[Any] | None: + if isinstance(value, list | tuple): + return value # pyright: ignore[reportUnknownVariableType] + return None + + +def _object_values(value: Any) -> Iterable[Any] | None: + if is_dataclass(value): + return [getattr(value, field.name) for field in fields(value)] + if isinstance(value, dict): + return value.values() # pyright: ignore[reportUnknownVariableType] + return None + + +def _projection( + left: _Closure, right: _Closure, items_of: Callable[[Any], Iterable[Any] | None] +) -> _Closure: + def projection(value: Any) -> Any: + items = items_of(left(value)) + if items is None: + return None + return [result for item in items if (result := right(item)) is not None] + + return projection + + +def _flatten(left: _Closure) -> _Closure: + def flatten(value: Any) -> Any: + items = _list_items(left(value)) + if items is None: + return None + flat: list[Any] = [] + for item in items: + if isinstance(item, list | tuple): + flat.extend(item) # pyright: ignore[reportUnknownArgumentType] + else: + flat.append(item) + return flat + + return flatten + + +def _multi_select_list(items: tuple[_Closure, ...]) -> _Closure: + def multi_select_list(value: Any) -> Any: + if value is None: + return None + return [item(value) for item in items] + + return multi_select_list + + +def _keys(argument: _Closure) -> _Closure: + """``keys()``, which is None rather than an error for anything but a map, so an + unset map leaves its parameter unset.""" + + def keys(value: Any) -> Any: + target = argument(value) + if isinstance(target, dict): + return list(target) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] + return None + + return keys diff --git a/packages/smithy-core/src/smithy_core/aio/client.py b/packages/smithy-core/src/smithy_core/aio/client.py index 257c99ec7..08644684d 100644 --- a/packages/smithy-core/src/smithy_core/aio/client.py +++ b/packages/smithy-core/src/smithy_core/aio/client.py @@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Self from .. import URI -from ..auth import AuthParams +from ..auth import RESOLVED_IDENTITY, AuthParams from ..deserializers import DeserializeableShape, ShapeDeserializer -from ..endpoints import EndpointResolverParams +from ..endpoints import RESOLVED_ENDPOINT, EndpointResolverParams from ..exceptions import ( ClientTimeoutError, RetryError, @@ -575,6 +575,31 @@ async def _handle_attempt[I: SerializeableShape, O: DeserializeableShape]( interceptor = call.interceptor interceptor.read_before_attempt(request_context) + # The auth scheme and identity resolve before the endpoint, so endpoint + # rules can read the identity. + auth_params = AuthParams[I, O]( + protocol_id=self.protocol.id, + operation=call.operation, + context=request_context.properties, + ) + auth = self._resolve_auth(call, auth_params) + signing = None + if auth is not None: + option, scheme = auth + request_context.properties[AUTH_SCHEME] = scheme + identity_resolver = scheme.identity_resolver(context=call.context) + + identity_properties = scheme.identity_properties( + context=request_context.properties + ) + identity_properties.update(option.identity_properties) + + identity = await identity_resolver.get_identity( + properties=identity_properties + ) + request_context.properties[RESOLVED_IDENTITY] = identity + signing = option, scheme, identity + endpoint_params = EndpointResolverParams( operation=call.operation, input=request_context.request, @@ -585,6 +610,7 @@ async def _handle_attempt[I: SerializeableShape, O: DeserializeableShape]( endpoint_params ) _LOGGER.debug("Endpoint resolver result: %s", endpoint) + request_context.properties[RESOLVED_ENDPOINT] = endpoint request_context = replace( request_context, @@ -599,26 +625,8 @@ async def _handle_attempt[I: SerializeableShape, O: DeserializeableShape]( ) interceptor.read_before_signing(request_context) - auth_params = AuthParams[I, O]( - protocol_id=self.protocol.id, - operation=call.operation, - context=request_context.properties, - ) - auth = self._resolve_auth(call, auth_params) - if auth is not None: - option, scheme = auth - request_context.properties[AUTH_SCHEME] = scheme - identity_resolver = scheme.identity_resolver(context=call.context) - - identity_properties = scheme.identity_properties( - context=request_context.properties - ) - identity_properties.update(option.identity_properties) - - identity = await identity_resolver.get_identity( - properties=identity_properties - ) - + if signing is not None: + option, scheme, identity = signing signer_properties = scheme.signer_properties( context=request_context.properties ) diff --git a/packages/smithy-core/src/smithy_core/aio/endpoints.py b/packages/smithy-core/src/smithy_core/aio/endpoints.py index 80b93f704..b3fd9b550 100644 --- a/packages/smithy-core/src/smithy_core/aio/endpoints.py +++ b/packages/smithy-core/src/smithy_core/aio/endpoints.py @@ -1,16 +1,24 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from collections.abc import Sequence from typing import Any -from ..endpoints import Endpoint, EndpointResolverParams, resolve_static_uri +from ..endpoints import BddEndpointResolver as _BddEndpointResolver +from ..endpoints import ( + Endpoint, + EndpointBdd, + EndpointResolverParams, + RulesExtension, + resolve_static_uri, +) from ..exceptions import EndpointResolutionError from ..interfaces import Endpoint as _Endpoint -from .interfaces import AsyncEndpointResolver +from .interfaces import AsyncEndpointResolver as _AsyncEndpointResolver -__all__ = ["AsyncStaticEndpointResolver"] +__all__ = ["AsyncBddEndpointResolver", "AsyncStaticEndpointResolver"] -class AsyncStaticEndpointResolver(AsyncEndpointResolver): +class AsyncStaticEndpointResolver(_AsyncEndpointResolver): """A basic endpoint resolver that forwards a static URI.""" async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> _Endpoint: @@ -21,3 +29,27 @@ async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> _Endpoi ) return Endpoint(uri=static_uri) + + +class AsyncBddEndpointResolver(_AsyncEndpointResolver): + """Resolves endpoints by evaluating a service's endpoint binary decision diagram.""" + + def __init__( + self, + bdd: EndpointBdd, + *, + extensions: Sequence[RulesExtension] = (), + ) -> None: + """Create a resolver for an endpoint BDD. + + :param bdd: The service's endpoint binary decision diagram. + :param extensions: Functions and built-in bindings beyond the rules standard + library, which is always included. + """ + self._resolver = _BddEndpointResolver(bdd, extensions=extensions) + + def __deepcopy__(self, memo: Any) -> "AsyncBddEndpointResolver": + return self + + async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> _Endpoint: + return self._resolver.resolve_endpoint(params) diff --git a/packages/smithy-core/src/smithy_core/auth.py b/packages/smithy-core/src/smithy_core/auth.py index 634f4d95b..236dbcaad 100644 --- a/packages/smithy-core/src/smithy_core/auth.py +++ b/packages/smithy-core/src/smithy_core/auth.py @@ -6,12 +6,24 @@ from .deserializers import DeserializeableShape from .interfaces import TypedProperties as _TypedProperties +from .interfaces.identity import Identity as _Identity from .schemas import APIOperation from .serializers import SerializeableShape from .shapes import ShapeID -from .types import TypedProperties - -__all__ = ["AuthOption", "AuthParams", "DefaultAuthResolver", "NoAuthResolver"] +from .types import PropertyKey, TypedProperties + +__all__ = [ + "RESOLVED_IDENTITY", + "AuthOption", + "AuthParams", + "DefaultAuthResolver", + "NoAuthResolver", +] + +RESOLVED_IDENTITY: PropertyKey[_Identity] = PropertyKey( + key="resolved_identity", value_type=_Identity +) +"""A call context property holding the identity resolved for the current attempt.""" @dataclass(kw_only=True, frozen=True) diff --git a/packages/smithy-core/src/smithy_core/endpoints.py b/packages/smithy-core/src/smithy_core/endpoints.py deleted file mode 100644 index 11a1959e0..000000000 --- a/packages/smithy-core/src/smithy_core/endpoints.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 -from dataclasses import dataclass, field -from typing import Any, Protocol -from urllib.parse import urlparse - -from . import URI -from .exceptions import EndpointResolutionError -from .interfaces import Endpoint as _Endpoint -from .interfaces import TypedProperties as _TypedProperties -from .interfaces import URI as _URI -from .schemas import APIOperation -from .serializers import SerializeableShape -from .types import PropertyKey, TypedProperties - -__all__ = [ - "STATIC_ENDPOINT_CONFIG", - "Endpoint", - "EndpointResolverParams", - "StaticEndpointConfig", - "resolve_static_uri", -] - - -@dataclass(kw_only=True) -class Endpoint(_Endpoint): - """A resolved endpoint.""" - - uri: _URI - """The endpoint URI.""" - - properties: _TypedProperties = field(default_factory=TypedProperties) - """Properties required to interact with the endpoint. - - For example, in some AWS use cases this might contain HTTP headers to add to each - request. - """ - - -@dataclass(kw_only=True) -class EndpointResolverParams[I: SerializeableShape]: - """Parameters passed into an Endpoint Resolver's resolve_endpoint method.""" - - operation: APIOperation[I, Any] = field(repr=False) - """The operation to resolve an endpoint for.""" - - input: I - """The input to the operation.""" - - context: _TypedProperties - """The context of the operation invocation.""" - - -class StaticEndpointConfig(Protocol): - """A config that has a static endpoint.""" - - endpoint_uri: str | URI | None - """A static endpoint to use for the request.""" - - -STATIC_ENDPOINT_CONFIG = PropertyKey(key="config", value_type=StaticEndpointConfig) -"""Property containing a config that has a static endpoint.""" - - -def resolve_static_uri( - properties: _TypedProperties | EndpointResolverParams[Any], -) -> _URI | None: - """Attempt to resolve a static URI from the endpoint resolver params. - - :param properties: A TypedProperties bag or EndpointResolverParams to search. - """ - properties = ( - properties.context - if isinstance(properties, EndpointResolverParams) - else properties - ) - static_uri_config = properties.get(STATIC_ENDPOINT_CONFIG) - if static_uri_config is None or static_uri_config.endpoint_uri is None: - return None - - static_uri = static_uri_config.endpoint_uri - - # If it's not a string, it's already a parsed URI so just pass it along. - if not isinstance(static_uri, str): - return static_uri - - parsed = urlparse(static_uri) - if parsed.hostname is None: - raise EndpointResolutionError( - f"Unable to parse hostname from provided URI: {static_uri}" - ) - - return URI( - host=parsed.hostname, - path=parsed.path, - scheme=parsed.scheme, - query=parsed.query, - port=parsed.port, - ) diff --git a/packages/smithy-core/src/smithy_core/endpoints/__init__.py b/packages/smithy-core/src/smithy_core/endpoints/__init__.py new file mode 100644 index 000000000..0f6e027ae --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/__init__.py @@ -0,0 +1,34 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from ._resolver import BddEndpointResolver, resolve_static_uri +from ._types import ( + ENDPOINT_HEADERS, + ENDPOINT_PARAMETERS, + RESOLVED_ENDPOINT, + STATIC_ENDPOINT_CONFIG, + BuiltinBinding, + BuiltinGetter, + Endpoint, + EndpointAuthScheme, + EndpointBdd, + EndpointResolverParams, + RulesExtension, + StaticEndpointConfig, +) + +__all__ = [ + "ENDPOINT_HEADERS", + "ENDPOINT_PARAMETERS", + "RESOLVED_ENDPOINT", + "STATIC_ENDPOINT_CONFIG", + "BddEndpointResolver", + "BuiltinBinding", + "BuiltinGetter", + "Endpoint", + "EndpointAuthScheme", + "EndpointBdd", + "EndpointResolverParams", + "RulesExtension", + "StaticEndpointConfig", + "resolve_static_uri", +] diff --git a/packages/smithy-core/src/smithy_core/endpoints/_engine.py b/packages/smithy-core/src/smithy_core/endpoints/_engine.py new file mode 100644 index 000000000..58149bf9c --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/_engine.py @@ -0,0 +1,643 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The rules engine that evaluates an endpoint BDD. + +The engine compiles each condition and result of an :py:class:`EndpointBdd` into a +closure the first time a resolution reaches it, and keeps it for the life of the +engine. +""" + +import re +from collections.abc import Callable, Mapping, Sequence +from functools import lru_cache, partial +from operator import itemgetter +from types import MappingProxyType +from typing import Any, TypeGuard + +from .. import URI +from ..exceptions import EndpointResolutionError, ExpectationNotMetError +from ..shapes import ShapeID +from ..types import TypedProperties +from ..utils import parse_uri +from ._standard_library import STANDARD_FUNCTIONS +from ._types import ( + ENDPOINT_HEADERS, + Endpoint, + EndpointAuthScheme, + EndpointBdd, + RulesExtension, + Tag, +) + +type _Values = list[Any] +type _Closure = Callable[[_Values], Any] +"""A compiled condition, expression or result, called with the values.""" + +type _Steps = tuple[str | int, ...] +"""A parsed getAttr path: its keys and indexes, in order.""" + +_RESULT_OFFSET = 100_000_000 +"""BDD references at or above this value point at results.""" + +_CACHE_SIZE = 128 +"""The number of resolved endpoints each engine keeps.""" + +_COMPILED_FUNCTIONS = frozenset( + {"isSet", "not", "booleanEquals", "stringEquals", "coalesce", "ite"} +) +"""Standard functions the compiler builds into closures instead of calling.""" + +_NEVER_EMPTY = frozenset( + { + "isSet", + "not", + "booleanEquals", + "stringEquals", + "isValidHostLabel", + "substring", + "parseURL", + "split", + } +) +"""Standard functions whose values Python's truth test reads as the rules do. + +Each returns a bool, None, or a value that is never empty, so a condition that calls +one holds exactly when its value is truthy. +""" + + +class RulesEngine: + """Evaluates one endpoint BDD. + + It caches compiled closures and resolved endpoints. + """ + + def __init__( + self, bdd: EndpointBdd, extensions: Sequence[RulesExtension] = () + ) -> None: + """Create an engine. + + :param bdd: The endpoint BDD to evaluate. + :param extensions: Extensions whose functions the rules may call, beyond the + standard library, which is always included. + """ + redefined = [ + name + for extension in extensions + for name in extension.functions + if name in _COMPILED_FUNCTIONS or name in STANDARD_FUNCTIONS + ] + if redefined: + raise EndpointResolutionError( + f"Endpoint rules extensions redefine standard functions: {', '.join(redefined)}" + ) + functions = dict(STANDARD_FUNCTIONS) + scheme_ids: dict[str, ShapeID] = {} + for extension in extensions: + functions.update(extension.functions) + scheme_ids.update(extension.auth_schemes) + called = [name for name in bdd.functions if name not in _COMPILED_FUNCTIONS] + missing = [name for name in called if name not in functions] + if missing: + raise EndpointResolutionError( + f"Endpoint rules use functions no extension provides: {', '.join(missing)}" + ) + + self._conditions = bdd.conditions + self._results = bdd.results + self._compiler = _ClosureCompiler( + bdd, {name: functions[name] for name in called}, scheme_ids + ) + self._root, self._condition_indexes, self._high_refs, self._low_refs = ( + _node_table(bdd.nodes, bdd.root) + ) + + # One closure per condition and result, at the same index as its entry. Each + # starts as a stub that compiles its entry on first use and replaces itself. + condition_stub = self._condition_stub + result_stub = self._result_stub + self._condition_closures: list[_Closure] = [ + partial(condition_stub, index) for index in range(len(bdd.conditions)) + ] + self._result_closures: list[_Closure] = [_no_match] + self._result_closures += [ + partial(result_stub, index) for index in range(1, len(bdd.results)) + ] + + self.evaluate: Callable[[tuple[Any, ...]], Endpoint] = lru_cache( + maxsize=_CACHE_SIZE + )(self._evaluate) + """The endpoint for parameter values, through the engine's cache.""" + + def _evaluate(self, param_values: tuple[Any, ...]) -> Endpoint: + """Evaluate the decision diagram and build the endpoint, uncached. + + :param param_values: The parameter values, in BDD order. + """ + # The parameter values, then one unset slot per assign target. + values = [*param_values, *(None,) * self._compiler.target_count] + condition_closures = self._condition_closures + condition_indexes = self._condition_indexes + high_refs, low_refs = self._high_refs, self._low_refs + ref = self._root + try: + while ref < _RESULT_OFFSET: + ref = ( + high_refs[ref] + if condition_closures[condition_indexes[ref]](values) + else low_refs[ref] + ) + return self._result_closures[ref - _RESULT_OFFSET](values) + except EndpointResolutionError: + raise + except Exception as e: + raise EndpointResolutionError( + f"Endpoint rules failed with {type(e).__name__}: {e}" + ) from e + + def _condition_stub(self, index: int, values: _Values) -> Any: + """The stub for condition ``index``: compile it, replace the stub, and run it.""" + closure = self._compiler.compile_condition(self._conditions[index]) + self._condition_closures[index] = closure + return closure(values) + + def _result_stub(self, index: int, values: _Values) -> Any: + """The stub for result ``index``: compile it, replace the stub, and run it. + + Result 0, no match, has no stub. + """ + result = self._results[index] + if result is None: + raise EndpointResolutionError(f"Invalid endpoint BDD result {index}: None") + closure = self._compiler.compile_result(result) + self._result_closures[index] = closure + return closure(values) + + +class _ClosureCompiler: + """Compiles one BDD's conditions and results into closures. + + Closures read and write a list of values by slot: the parameter values in BDD + order, then one value per distinct assign target. + """ + + def __init__( + self, + bdd: EndpointBdd, + functions: Mapping[str, Callable[..., Any]], + scheme_ids: Mapping[str, ShapeID], + ) -> None: + slots = {name: slot for slot, (name, *_) in enumerate(bdd.parameters)} + for _, target in bdd.conditions: + if target is not None: + slots.setdefault(target, len(slots)) + self._slots = slots + self._functions = functions + self._scheme_ids = scheme_ids + self.target_count = len(slots) - len(bdd.parameters) + """The number of distinct assign targets; their slots follow the parameters'.""" + + def compile_condition(self, condition: tuple[Any, ...]) -> _Closure: + """A closure that tests a condition and stores its assigned value. + + A condition holds unless its value is None or False. + """ + expr, target = condition + # Most conditions call a compiled function, so they skip _compile_expr's + # dispatch, and the one match also names the function for _NEVER_EMPTY. + match expr: + case (Tag.CALL, function_name, *_) if function_name in _COMPILED_FUNCTIONS: + expr_closure = self._compile_function(expr) + case (Tag.CALL, function_name, *_): + expr_closure = self._compile_expr(expr) + case _: + function_name = None + expr_closure = self._compile_expr(expr) + if target is None: + if function_name in _NEVER_EMPTY: + return expr_closure + return lambda values: ( + (v := expr_closure(values)) is not None and v is not False + ) + slot = self._slots[target] + + def assign(values: _Values) -> bool: + v = expr_closure(values) + if v is None: + return False + values[slot] = v + return v is not False + + return assign + + def compile_result(self, result: tuple[Any, ...]) -> _Closure: + """A closure that builds a result's endpoint or raises its error.""" + match result: + case (Tag.ERROR, message): + message_closure = self._compile_expr(message) + + def error(values: _Values) -> Any: + raise EndpointResolutionError(message_closure(values)) + + return error + case (Tag.ENDPOINT, url, properties, headers): + return self._compile_endpoint(url, properties, headers) + case _: + raise EndpointResolutionError( + f"Invalid endpoint BDD result: {result!r}" + ) + + def _compile_endpoint( + self, url: Any, properties: Any, headers: tuple[Any, ...] + ) -> _Closure: + """A closure that builds an endpoint result's endpoint.""" + url_closure = self._compile_expr(url) + properties_closure = ( + None if properties is None else self._compile_expr(properties) + ) + header_closures = tuple( + (name, tuple(self._compile_expr(expr) for expr in exprs)) + for name, exprs in headers + ) + + scheme_ids = self._scheme_ids + + def build(values: _Values) -> Endpoint: + data: dict[str, Any] = {} + auth_schemes: tuple[EndpointAuthScheme, ...] = () + if properties_closure is not None: + for key, value in properties_closure(values).items(): + if key == "authSchemes": + auth_schemes = _auth_schemes(value, scheme_ids) + else: + data[key] = value + if header_closures: + data[ENDPOINT_HEADERS.key] = MappingProxyType( + { + name: tuple([closure(values) for closure in value_closures]) + for name, value_closures in header_closures + } + ) + endpoint_properties = TypedProperties() + # Hand the dict over: the constructor copies it item by item. + endpoint_properties.data = data + return Endpoint( + uri=_to_uri(url_closure(values)), + properties=endpoint_properties, + auth_schemes=auth_schemes, + ) + + return build + + def _compile_expr(self, expr: Any) -> _Closure: + """A closure that computes an expression's value. + + Lists become tuples and maps read-only mappings, so endpoints stay immutable. + """ + match expr: + case (Tag.REF, name): + return itemgetter(self._slots[name]) + case (Tag.ATTR, base, path): + return _attr(self._compile_expr(base), _parse_path(path)) + case (Tag.CALL, function_name, *_) if function_name in _COMPILED_FUNCTIONS: + return self._compile_function(expr) + case (Tag.CALL, function_name, (Tag.REF, name), *literals) if all( + map(_is_literal, literals) + ): + # A function of one reference and literals reads the reference + # directly and passes the literals as they are. + function, slot = self._functions[function_name], self._slots[name] + if not literals: + return lambda values: function(values[slot]) + return lambda values: function(values[slot], *literals) + case (Tag.CALL, function_name, *args): + function = self._functions[function_name] + arg_closures = tuple(self._compile_expr(arg) for arg in args) + return lambda values: function( + *[closure(values) for closure in arg_closures] + ) + case (Tag.LIST, *elements): + element_closures = tuple( + self._compile_expr(element) for element in elements + ) + return lambda values: tuple( + [closure(values) for closure in element_closures] + ) + case (Tag.MAP, keys, *elements): + item_closures = tuple( + zip(keys, map(self._compile_expr, elements), strict=True) + ) + return lambda values: MappingProxyType( + {key: closure(values) for key, closure in item_closures} + ) + case str(): + if not _is_plain(expr): + return self._compile_template(expr) + return _literal(expr) + case bool() | int(): + return _literal(expr) + case _: + raise EndpointResolutionError( + f"Invalid endpoint BDD expression: {expr!r}" + ) + + def _compile_function(self, call: Any) -> _Closure: + """A closure for a call of a function the compiler builds itself. + + Tests of a reference or against a literal, the most common conditions in + endpoint rules, read the slot or compare with the literal directly. + """ + match call: + case (Tag.CALL, "isSet", (Tag.REF, name)): + slot = self._slots[name] + return lambda values: values[slot] is not None + case (Tag.CALL, "isSet", arg): + arg_closure = self._compile_expr(arg) + return lambda values: arg_closure(values) is not None + case (Tag.CALL, "not", arg): + arg_closure = self._compile_expr(arg) + return lambda values: not arg_closure(values) + case (Tag.CALL, "booleanEquals", (Tag.REF, name), bool(literal)): + slot = self._slots[name] + return lambda values: values[slot] is literal + case (Tag.CALL, "booleanEquals", left, bool(literal)): + left_closure = self._compile_expr(left) + return lambda values: left_closure(values) is literal + case (Tag.CALL, "booleanEquals", left, right): + left_closure = self._compile_expr(left) + right_closure = self._compile_expr(right) + return lambda values: left_closure(values) is right_closure(values) + case (Tag.CALL, "stringEquals", (Tag.REF, name), str(literal)) if _is_plain( + literal + ): + slot = self._slots[name] + return lambda values: values[slot] == literal + case (Tag.CALL, "stringEquals", left, str(literal)) if _is_plain(literal): + left_closure = self._compile_expr(left) + return lambda values: left_closure(values) == literal + case (Tag.CALL, "stringEquals", left, right): + left_closure = self._compile_expr(left) + right_closure = self._compile_expr(right) + + def string_equals(values: _Values) -> bool: + left_value = left_closure(values) + right_value = right_closure(values) + return left_value is not None and left_value == right_value + + return string_equals + case (Tag.CALL, "coalesce", *args): + arg_closures = tuple(self._compile_expr(arg) for arg in args) + + def coalesce(values: _Values) -> Any: + for closure in arg_closures: + if (v := closure(values)) is not None: + return v + return None + + return coalesce + case (Tag.CALL, "ite", test, then, otherwise): + test_closure, then_closure, otherwise_closure = ( + self._compile_expr(test), + self._compile_expr(then), + self._compile_expr(otherwise), + ) + return lambda values: ( + then_closure(values) + if test_closure(values) + else otherwise_closure(values) + ) + case _: + raise EndpointResolutionError(f"Invalid endpoint BDD call: {call!r}") + + def _compile_template(self, template: str) -> _Closure: + """A closure that renders a template with at least one brace. + + The template is parsed here, once, into the closures of its parts. + """ + parts = _parse_template(template) + if len(parts) == 1 and isinstance(parts[0], str): + return _literal(parts[0]) + part_closures: list[_Closure] = [] + for part in parts: + if isinstance(part, str): + part_closures.append(_literal(part)) + continue + name, steps = part + value_closure = itemgetter(self._slots[name]) + part_closures.append( + value_closure if steps is None else _attr(value_closure, steps) + ) + return lambda values: "".join([closure(values) for closure in part_closures]) + + +def _is_plain(template: str) -> bool: + """Whether a template has no braces, so it is the string literal it reads as.""" + return "{" not in template and "}" not in template + + +def _is_literal(expr: Any) -> bool: + """Whether an expression is a literal: a boolean, an integer, or a plain string.""" + return isinstance(expr, bool | int) or (isinstance(expr, str) and _is_plain(expr)) + + +def _to_uri(url: str) -> URI: + """The URI of a rendered URL.""" + try: + return parse_uri(url) + except ExpectationNotMetError as e: + raise EndpointResolutionError(f"Invalid endpoint URI: {url}") from e + + +def _literal(value: Any) -> _Closure: + return lambda values: value + + +def _attr(base_closure: _Closure, steps: _Steps) -> _Closure: + match steps: + case (str(key),): + + def key_closure(values: _Values) -> Any: + value = base_closure(values) + if _is_dict(value): + return value.get(key) + return _get_attr(value, steps) + + return key_closure + case _: + return lambda values: _get_attr(base_closure(values), steps) + + +def _is_dict(value: Any) -> TypeGuard[dict[str, Any]]: + return isinstance(value, dict) + + +_ESCAPED_TEMPLATE_TOKEN = re.compile(r"\{\{|\}\}|\{([^{}]*)\}|[{}]") + + +def _parse_template(template: str) -> list[str | tuple[str, _Steps | None]]: + """Split a template into literal runs and ``(name, steps)`` placeholders. + + As in Smithy, ``{{`` and ``}}`` are literal braces and any other brace outside a + placeholder is an error. Adjacent literal text is merged into one run. + """ + if "{{" in template or "}}" in template: + return _parse_escaped_template(template) + # Without escapes, each { opens a placeholder that the next } closes. + head, *chunks = template.split("{") + if "}" in head: + raise _template_error(template) + parts: list[str | tuple[str, _Steps | None]] = [head] if head else [] + for chunk in chunks: + placeholder, closed, literal = chunk.partition("}") + if not closed or "}" in literal: + raise _template_error(template) + parts.append(_parse_placeholder(placeholder)) + if literal: + parts.append(literal) + return parts + + +def _parse_escaped_template(template: str) -> list[str | tuple[str, _Steps | None]]: + parts: list[str | tuple[str, _Steps | None]] = [] + literal = "" + end = 0 + for match in _ESCAPED_TEMPLATE_TOKEN.finditer(template): + literal += template[end : match.start()] + end = match.end() + placeholder = match.group(1) + if placeholder is not None: + if literal: + parts.append(literal) + literal = "" + parts.append(_parse_placeholder(placeholder)) + elif len(match.group()) == 2: + literal += match.group()[0] + else: + raise _template_error(template) + literal += template[end:] + if literal: + parts.append(literal) + return parts + + +def _parse_placeholder(placeholder: str) -> tuple[str, _Steps | None]: + name, _, path = placeholder.partition("#") + return name, _parse_path(path) if path else None + + +def _parse_path(path: str) -> _Steps: + steps: list[str | int] = [] + for segment in path.split("."): + key, bracket, index = segment.partition("[") + if key: + steps.append(key) + if bracket: + if not index.endswith("]"): + raise EndpointResolutionError(f"Invalid getAttr path: {path!r}") + steps.append(int(index[:-1])) + return tuple(steps) + + +def _template_error(template: str) -> EndpointResolutionError: + return EndpointResolutionError( + f"Unmatched brace in endpoint template: {template!r}" + ) + + +def _get_attr(value: Any, steps: _Steps) -> Any: + for step in steps: + if value is None: + return None + if isinstance(step, str): + # dict first: library functions return dicts, and the ABC check is slow. + if isinstance(value, dict | Mapping): + value = value.get(step) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + else: + return None + elif isinstance(value, tuple | list): + try: + value = value[step] # pyright: ignore[reportUnknownVariableType] + except IndexError: + return None + else: + return None + return value # pyright: ignore[reportUnknownVariableType] + + +def _no_match(values: _Values) -> Any: + raise EndpointResolutionError("No endpoint rule matched") + + +def _auth_schemes( + entries: Any, scheme_ids: Mapping[str, ShapeID] +) -> tuple[EndpointAuthScheme, ...]: + """The entries of an ``authSchemes`` property whose names an extension maps. + + They keep the rules' order. Rules that list only unmapped names give an endpoint no + auth scheme can sign for, so that raises. + """ + schemes: list[EndpointAuthScheme] = [] + for entry in entries: + scheme_id = scheme_ids.get(entry["name"]) + if scheme_id is not None: + properties = TypedProperties() + properties.data = { + key: value for key, value in entry.items() if key != "name" + } + schemes.append( + EndpointAuthScheme(scheme_id=scheme_id, properties=properties) + ) + if entries and not schemes: + names = ", ".join(entry["name"] for entry in entries) + raise EndpointResolutionError( + f"Endpoint rules use auth schemes no extension provides: {names}" + ) + return tuple(schemes) + + +def _node_table( + nodes: tuple[int, ...], root: int +) -> tuple[int, tuple[Any, ...], tuple[Any, ...], tuple[Any, ...]]: + """The root, and the condition indexes, high refs and low refs by reference. + + Index ``ref`` of each array describes trait node ``ref - 1``. When a terminal or + complement edge appears, terminals become the no-match result and each + complemented node gets a twin with its edges swapped. + """ + condition_indexes: Sequence[Any] = (None, *nodes[0::3]) + high_refs: Sequence[Any] = (None, *nodes[1::3]) + low_refs: Sequence[Any] = (None, *nodes[2::3]) + if root > 1 and min(nodes[4::3], default=2) > 1 and min(nodes[5::3], default=2) > 1: + return root, condition_indexes, high_refs, low_refs + if root < -1: + raise EndpointResolutionError(f"Invalid endpoint BDD root: {root}") + + # Twins are appended, so edit list copies. + condition_indexes = list(condition_indexes) + high_refs = list(high_refs) + low_refs = list(low_refs) + twins: dict[int, int] = {} + + def normalize(ref: int) -> int: + if ref == 1 or ref == -1: + return _RESULT_OFFSET + if ref > 0: + return ref + twin = twins.get(-ref) + if twin is None: + twin = twins[-ref] = len(condition_indexes) + condition_indexes.append(None) + high_refs.append(None) + low_refs.append(None) + return twin + + for ref in range(2, len(condition_indexes)): + high_refs[ref] = normalize(high_refs[ref]) + low_refs[ref] = normalize(low_refs[ref]) + for ref, twin in twins.items(): + condition_indexes[twin], high_refs[twin], low_refs[twin] = ( + condition_indexes[ref], + low_refs[ref], + high_refs[ref], + ) + return normalize(root), tuple(condition_indexes), tuple(high_refs), tuple(low_refs) diff --git a/packages/smithy-core/src/smithy_core/endpoints/_params.py b/packages/smithy-core/src/smithy_core/endpoints/_params.py new file mode 100644 index 000000000..96ceb1c91 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/_params.py @@ -0,0 +1,289 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Endpoint parameter plans: how a call's config, input and operation traits become +the parameter values an endpoint BDD is evaluated with.""" + +import logging +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any + +from .._model_naming import member_field_name, to_snake_case +from ..exceptions import EndpointResolutionError +from ..interfaces import TypedProperties as _TypedProperties +from ..schemas import APIOperation +from ..traits import ( + ContextParamTrait, + OperationContextParamsTrait, + RequiredTrait, + StaticContextParamsTrait, +) +from ._types import ( + ENDPOINT_PARAMETERS, + BuiltinBinding, + BuiltinGetter, + EndpointResolverParams, + RulesExtension, + StaticEndpointConfig, +) + +_LOGGER = logging.getLogger(__name__) + + +class ParamPlan: + """Plan to find each endpoint parameter's value, for any operation of a BDD.""" + + def __init__( + self, + parameters: Sequence[tuple[Any, ...]], + extensions: Sequence[RulesExtension] = (), + ) -> None: + """Build the sources every operation shares. + + A client context parameter is read from its own config field, even when it is + also a built-in. A built-in no extension binds takes its default. + + :param parameters: The rules parameters, in BDD order. + :param extensions: Extensions whose built-in bindings apply, beyond the standard + library, which always applies. + """ + redefined = [ + name + for extension in extensions + for name in extension.builtins + if name in _STANDARD_BUILTINS + ] + if redefined: + raise EndpointResolutionError( + f"Endpoint rules extensions redefine standard built-ins: {', '.join(redefined)}" + ) + builtins: dict[str, BuiltinBinding] = dict(_STANDARD_BUILTINS) + for extension in extensions: + builtins.update(extension.builtins) + slots: dict[str, int] = {} + names: list[str] = [] + defaults: list[Any] = [] + required: list[int] = [] + arrays: list[int] = [] + builtin_attributes: list[tuple[int, str]] = [] + builtin_getters: list[tuple[int, BuiltinGetter]] = [] + client_context_attributes: list[tuple[int, str]] = [] + for slot, parameter in enumerate(parameters): + name, type_, is_required, default, builtin, client_context = parameter + slots[name] = slot + names.append(name) + defaults.append(default) + if is_required and default is None: + required.append(slot) + if type_ == "stringArray": + arrays.append(slot) + if client_context: + client_context_attributes.append((slot, _config_attribute(name))) + elif builtin is not None: + binding = builtins.get(builtin) + if binding is None: + _LOGGER.debug( + "No extension binds the built-in %s; endpoint parameter %s" + " takes its default", + builtin, + name, + ) + elif isinstance(binding, str): + builtin_attributes.append((slot, binding)) + else: + builtin_getters.append((slot, binding)) + + self._slots = slots + # Sources by operation identity, holding the operation so its id stays valid. + self._operations: dict[int, tuple[Any, _OperationSources]] = {} + + self.defaults: tuple[Any, ...] = tuple(defaults) + """Each parameter's default, else None.""" + + self.builtin_attributes: tuple[tuple[int, str], ...] = tuple(builtin_attributes) + """(slot, config attribute) for built-ins bound to an attribute.""" + + self.builtin_getters: tuple[tuple[int, BuiltinGetter], ...] = tuple( + builtin_getters + ) + """(slot, getter) for built-ins bound to a getter.""" + + self.client_context_attributes: tuple[tuple[int, str], ...] = tuple( + client_context_attributes + ) + """(slot, config attribute) for client context parameters.""" + + self.required: tuple[int, ...] = tuple(required) + """Slots of required parameters with no default.""" + + self.arrays: tuple[int, ...] = tuple(arrays) + """Slots of stringArray parameters, normalized to tuples without None.""" + + self.names: tuple[str, ...] = tuple(names) + """Parameter names by slot, for errors.""" + + def bind(self, params: EndpointResolverParams[Any]) -> tuple[Any, ...]: + """The parameter values for one call, in BDD order. + + :param params: The call's operation, input, and context. The context holds + the client config under ``"config"``. + :raises EndpointResolutionError: If a required value is missing, or a required + context parameter member is unset or blank. + """ + operation = params.operation + input = params.input + context = params.context + config = context["config"] + entry = self._operations.get(id(operation)) + if entry is not None and entry[0] is operation: + sources = entry[1] + else: + sources = self._add_operation(operation) + values = list(self.defaults) + for slot, attribute in self.builtin_attributes: + value = getattr(config, attribute, None) + if value is not None: + values[slot] = value + for slot, getter in self.builtin_getters: + value = getter(config, context) + if value is not None: + values[slot] = value + for slot, attribute in self.client_context_attributes: + value = getattr(config, attribute, None) + if value is not None: + values[slot] = value + for slot, path in sources.operation_paths: + value = path(input) + if value is not None: + values[slot] = value + for attribute in sources.unread_required: + value = getattr(input, attribute, None) + if value is None or (type(value) is str and not value.strip()): + raise EndpointResolutionError( + f"Required input member {attribute} must not be unset or blank" + ) + for slot, attribute, required in sources.input_fields: + value = getattr(input, attribute, None) + if required and ( + value is None or (type(value) is str and not value.strip()) + ): + raise EndpointResolutionError( + f"Required input member {attribute} must not be unset or blank" + ) + if value is not None: + values[slot] = value + for slot, value in sources.static_values: + values[slot] = value + overrides = context.get(ENDPOINT_PARAMETERS) + if overrides: + slots = self._slots + defaults = self.defaults + for name, value in overrides.items(): + slot = slots.get(name) + if slot is not None: + values[slot] = defaults[slot] if value is None else value + for slot in self.arrays: + value = values[slot] + if value is not None: + if not isinstance(value, list | tuple): + raise EndpointResolutionError( + f"Endpoint parameter {self.names[slot]} must be a list" + f" of strings, not {type(value).__name__}" + ) + values[slot] = tuple([v for v in value if v is not None]) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] + for slot in self.required: + if values[slot] is None: + raise EndpointResolutionError( + f"Missing required endpoint parameter: {self.names[slot]}" + ) + return tuple(values) + + def _add_operation(self, operation: APIOperation[Any, Any]) -> "_OperationSources": + """Read the sources an operation's schema traits add, and keep them.""" + slots = self._slots + + operation_paths: list[tuple[int, Callable[[Any], Any]]] = [] + operation_params = operation.schema.get_trait(OperationContextParamsTrait) + if operation_params is not None: + from .. import _jmespath + + for name, path in operation_params.paths.items(): + slot = slots.get(name) + if slot is not None: + operation_paths.append((slot, _jmespath.compile(path))) + + input_fields: list[tuple[int, str, bool]] = [] + unread_required: list[str] = [] + for member in operation.input_schema.members.values(): + context_param = member.get_trait(ContextParamTrait) + if context_param is None: + continue + attribute = _input_attribute(member.expect_member_name()) + required = RequiredTrait in member + slot = slots.get(context_param.name) + if slot is not None: + input_fields.append((slot, attribute, required)) + elif required: + unread_required.append(attribute) + + static_values: list[tuple[int, Any]] = [] + static_params = operation.schema.get_trait(StaticContextParamsTrait) + if static_params is not None: + for name, value in static_params.values.items(): + slot = slots.get(name) + if slot is not None: + if isinstance(value, list): + value = tuple(value) + static_values.append((slot, value)) + + sources = _OperationSources( + operation_paths=tuple(operation_paths), + input_fields=tuple(input_fields), + unread_required=tuple(unread_required), + static_values=tuple(static_values), + ) + self._operations[id(operation)] = (operation, sources) + return sources + + +@dataclass(frozen=True, slots=True) +class _OperationSources: + """The parameter sources one operation's schema traits add.""" + + operation_paths: tuple[tuple[int, Callable[[Any], Any]], ...] + """(slot, compiled path) for operationContextParams.""" + + input_fields: tuple[tuple[int, str, bool], ...] + """(slot, input attribute, whether required) for contextParam members.""" + + unread_required: tuple[str, ...] + """Input attributes of required contextParam members the rules don't read.""" + + static_values: tuple[tuple[int, Any], ...] + """(slot, value) for staticContextParams.""" + + +def _config_attribute(parameter: str) -> str: + return to_snake_case(parameter) + + +def _input_attribute(member: str) -> str: + return member_field_name( + member, is_error=False, carries_response_metadata=False, is_enum=False + ) + + +def _endpoint_uri( + config: StaticEndpointConfig, context: _TypedProperties +) -> str | None: + value = config.endpoint_uri + if value is None or isinstance(value, str): + return value + return value.build() + + +_STANDARD_BUILTINS: Mapping[str, BuiltinGetter] = MappingProxyType( + {"SDK::Endpoint": _endpoint_uri} +) +"""The standard built-ins: ``SDK::Endpoint`` reads the config's ``endpoint_uri``.""" diff --git a/packages/smithy-core/src/smithy_core/endpoints/_resolver.py b/packages/smithy-core/src/smithy_core/endpoints/_resolver.py new file mode 100644 index 000000000..403cc5c27 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/_resolver.py @@ -0,0 +1,91 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The endpoint resolver that evaluates an endpoint BDD.""" + +from collections.abc import Sequence +from typing import Any + +from ..exceptions import EndpointResolutionError, ExpectationNotMetError +from ..interfaces import EndpointResolver as _EndpointResolver +from ..interfaces import TypedProperties as _TypedProperties +from ..interfaces import URI as _URI +from ..utils import parse_uri +from ._engine import RulesEngine +from ._params import ParamPlan +from ._types import ( + STATIC_ENDPOINT_CONFIG, + Endpoint, + EndpointBdd, + EndpointResolverParams, + RulesExtension, +) + + +def resolve_static_uri( + properties: _TypedProperties | EndpointResolverParams[Any], +) -> _URI | None: + """Attempt to resolve a static URI from the endpoint resolver params. + + :param properties: A TypedProperties bag or EndpointResolverParams to search. + :raises EndpointResolutionError: If the configured endpoint URI is invalid. + """ + properties = ( + properties.context + if isinstance(properties, EndpointResolverParams) + else properties + ) + static_uri_config = properties.get(STATIC_ENDPOINT_CONFIG) + if static_uri_config is None or static_uri_config.endpoint_uri is None: + return None + + static_uri = static_uri_config.endpoint_uri + + # If it's not a string, it's already a parsed URI so just pass it along. + if not isinstance(static_uri, str): + return static_uri + + try: + return parse_uri(static_uri) + except ExpectationNotMetError as e: + raise EndpointResolutionError(f"Invalid endpoint URI: {static_uri}") from e + + +class BddEndpointResolver(_EndpointResolver): + """Resolves endpoints by evaluating a service's endpoint binary decision diagram.""" + + def __init__( + self, + bdd: EndpointBdd, + *, + extensions: Sequence[RulesExtension] = (), + ) -> None: + """Create a resolver for an endpoint BDD. + + :param bdd: The service's endpoint binary decision diagram. + :param extensions: Functions and built-in bindings beyond the rules standard + library, which is always included. + """ + self._engine = RulesEngine(bdd, extensions) + self._plan = ParamPlan(bdd.parameters, extensions) + # Without an SDK::Endpoint parameter, a configured endpoint_uri is used as is. + self._uses_endpoint_builtin = any( + builtin == "SDK::Endpoint" for *_, builtin, _ in bdd.parameters + ) + # L1 cache: the last parameter values and their endpoint + self._last: tuple[tuple[Any, ...], Endpoint] | None = None + + def __deepcopy__(self, memo: Any) -> "BddEndpointResolver": + return self + + def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> Endpoint: + if not self._uses_endpoint_builtin: + uri = resolve_static_uri(params) + if uri is not None: + return Endpoint(uri=uri) + param_values = self._plan.bind(params) + last = self._last + if last is not None and last[0] == param_values: + return last[1] + endpoint = self._engine.evaluate(param_values) + self._last = (param_values, endpoint) + return endpoint diff --git a/packages/smithy-core/src/smithy_core/endpoints/_standard_library.py b/packages/smithy-core/src/smithy_core/endpoints/_standard_library.py new file mode 100644 index 000000000..091fe9189 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/_standard_library.py @@ -0,0 +1,125 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The endpoint rules standard library functions the engine calls. + +The engine compiles the other standard functions itself: ``isSet``, ``not``, +``booleanEquals``, ``stringEquals``, ``coalesce`` and ``ite``. +""" + +from collections.abc import Callable, Mapping +from re import compile as _re +from types import MappingProxyType +from typing import Any +from urllib.parse import quote, urlsplit + + +def _substring(value: str | None, start: int, stop: int, reverse: bool) -> str | None: + # A range that doesn't fit, including one with a negative start, gives None. + if ( + value is None + or start < 0 + or start >= stop + or len(value) < stop + or not value.isascii() + ): + return None + if reverse: + n = len(value) + return value[n - stop : n - start] + return value[start:stop] + + +_HOST_LABEL = _re(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}") +_HOST_LABELS = _re(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}(?:\.[A-Za-z0-9][A-Za-z0-9-]{0,62})*") + + +def _is_valid_host_label(value: str | None, allow_sub_domains: bool) -> bool | None: + if value is None: + return None + matcher = _HOST_LABELS if allow_sub_domains else _HOST_LABEL + return matcher.fullmatch(value) is not None + + +# The rules library's IPv4 heuristic: four dot-separated groups of one to three +# digits without leading zeros. +_IPV4 = _re(r"(?:[0-9]|[1-9][0-9]{1,2})(?:\.(?:[0-9]|[1-9][0-9]{1,2})){3}") + +# Characters a URI authority can't hold, which Smithy's reference rejects too. +_INVALID_AUTHORITY = _re(r'[\x00-\x20"<>\\^`{|}\x7f]') + + +def _is_ip_address(host: str) -> bool: + if host.startswith("[") and host.endswith("]") and len(host) > 1: + return True + return _IPV4.fullmatch(host) is not None + + +def _parse_url(value: str | None) -> dict[str, Any] | None: + if value is None: + return None + try: + parts = urlsplit(value) + # Accessing port validates it. + parts.port + except ValueError: + return None + # The authority is the host and port: the specification drops any userinfo. + authority = parts.netloc.rpartition("@")[2] + # Any query rejects the URL, even an empty one, which urlsplit doesn't report. + has_query = "?" in value.partition("#")[0] + if ( + parts.scheme not in ("http", "https") + or has_query + or not authority + or _INVALID_AUTHORITY.search(parts.netloc) + ): + return None + host = authority + if not host.startswith("["): + host = host.partition(":")[0] + elif "]" in host: + host = host[: host.index("]") + 1] + path = parts.path + if not path.strip(): + normalized = "/" + else: + normalized = path if path.startswith("/") else "/" + path + if not normalized.endswith("/"): + normalized += "/" + return { + "scheme": parts.scheme, + "authority": authority, + "path": path, + "normalizedPath": normalized, + "isIp": _is_ip_address(host), + } + + +def _uri_encode(value: str | None) -> str | None: + if value is None: + return None + return quote(value, safe="") + + +def _split(value: str | None, delimiter: str, limit: int) -> tuple[str, ...] | None: + if value is None: + return None + if not delimiter: + raise ValueError("split delimiter must not be empty") + if limit < 0: + raise ValueError(f"split limit must not be negative: {limit}") + if limit == 0: + return tuple(value.split(delimiter)) + return tuple(value.split(delimiter, limit - 1)) + + +STANDARD_FUNCTIONS: Mapping[str, Callable[..., Any]] = MappingProxyType( + { + "substring": _substring, + "isValidHostLabel": _is_valid_host_label, + "parseURL": _parse_url, + "uriEncode": _uri_encode, + "split": _split, + } +) +"""The standard functions the engine calls, by rules-language name.""" diff --git a/packages/smithy-core/src/smithy_core/endpoints/_types.py b/packages/smithy-core/src/smithy_core/endpoints/_types.py new file mode 100644 index 000000000..84a843b45 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/endpoints/_types.py @@ -0,0 +1,205 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The data types endpoint resolution shares: the BDD a resolver evaluates, the +extensions its rules call, what it is given and returns, and the properties that +carry them.""" + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Final, NamedTuple, Protocol + +from .. import URI +from ..interfaces import Endpoint as _Endpoint +from ..interfaces import TypedProperties as _TypedProperties +from ..interfaces import URI as _URI +from ..schemas import APIOperation +from ..serializers import SerializeableShape +from ..shapes import ShapeID +from ..types import PropertyKey, TypedProperties + + +@dataclass(frozen=True, slots=True) +class EndpointAuthScheme: + """An auth scheme an endpoint supports, with the properties it sets for it.""" + + scheme_id: ShapeID + """The ID of the auth scheme.""" + + properties: _TypedProperties = field(default_factory=TypedProperties) + """Properties for the auth scheme at this endpoint. + + Endpoint rules give them by their names in the rules. + """ + + +@dataclass(kw_only=True) +class Endpoint(_Endpoint): + """A resolved endpoint.""" + + uri: _URI + """The endpoint URI.""" + + properties: _TypedProperties = field(default_factory=TypedProperties) + """Properties required to interact with the endpoint. + + For example, in some AWS use cases this might contain HTTP headers to add to each + request. + """ + + auth_schemes: Sequence[EndpointAuthScheme] = () + """The auth schemes the endpoint supports, in order of preference. + + Empty when the endpoint has no special auth settings. + """ + + +ENDPOINT_HEADERS: PropertyKey[Mapping[str, tuple[str, ...]]] = PropertyKey( + key="endpoint_headers", + value_type=Mapping[str, tuple[str, ...]], +) +"""An endpoint property holding the headers its rules add, by header name.""" + +RESOLVED_ENDPOINT: PropertyKey[_Endpoint] = PropertyKey( + key="resolved_endpoint", + value_type=_Endpoint, +) +"""A call context property holding the endpoint resolved for the current attempt. + +Resolvers cache endpoints, so other calls may share this object: don't modify it. +""" + +ENDPOINT_PARAMETERS: PropertyKey[Mapping[str, Any]] = PropertyKey( + key="endpoint_parameters", + value_type=Mapping[str, Any], +) +"""A call context property holding endpoint parameter values by rules name. + +They override the values bound from the config, the input and the operation's traits. +None unsets a parameter, so it takes its default. Names the rules don't declare are +ignored. +""" + + +@dataclass(kw_only=True) +class EndpointResolverParams[I: SerializeableShape]: + """Parameters passed into an Endpoint Resolver's resolve_endpoint method.""" + + operation: APIOperation[I, Any] = field(repr=False) + """The operation to resolve an endpoint for.""" + + input: I + """The input to the operation.""" + + context: _TypedProperties + """The context of the operation invocation.""" + + +class StaticEndpointConfig(Protocol): + """A config that has a static endpoint.""" + + endpoint_uri: str | URI | None + """A static endpoint to use for the request.""" + + +STATIC_ENDPOINT_CONFIG = PropertyKey(key="config", value_type=StaticEndpointConfig) +"""Property containing a config that has a static endpoint.""" + + +class Tag: + """The integers that lead expression and result tuples in an endpoint BDD. + + Generated modules write them as literals, so each BDD stays one constant. + """ + + REF: Final = 0 + ATTR: Final = 1 + CALL: Final = 2 + LIST: Final = 3 + MAP: Final = 4 + ERROR: Final = 5 + ENDPOINT: Final = 6 + + +class EndpointBdd(NamedTuple): + """A service's endpoint rules, lowered from its ``endpointBdd`` trait. + + Generated ``_private/endpoints.py`` modules hold one as data. Its parts are + positional tuples, and expressions and results start with an integer tag: + ``REF`` 0, ``ATTR`` 1, ``CALL`` 2, ``LIST`` 3, ``MAP`` 4, ``ERROR`` 5 and + ``ENDPOINT`` 6. + + - Parameter: ``(name, type, required, default, builtin, client_context)``, where + ``type`` is ``"string"``, ``"boolean"`` or ``"stringArray"``. + - Condition: ``(expression, assign target or None)``. + - Result: ``(ERROR, message)`` or ``(ENDPOINT, url, properties, headers)``, + where ``properties`` is a map or None and ``headers`` is a tuple of + ``(name, (expression, ...))``. + - Expression: a bool or int literal, a template string, ``(REF, name)``, + ``(ATTR, expression, path)``, ``(CALL, function, *arguments)``, + ``(LIST, *items)`` or ``(MAP, keys, *values)``. + + Templates and getAttr paths are written as in the trait. In a template, + ``{name}`` and ``{name#path}`` are placeholders and ``{{`` and ``}}`` are literal + braces. A path is keys separated by ``.``, each optionally followed by an index + ``[i]`` that counts from the end when negative. + """ + + parameters: tuple[tuple[Any, ...], ...] + """The rules parameters, in trait order, minus optional ones no rule reads.""" + + functions: tuple[str, ...] + """Every function a condition or result calls, sorted.""" + + conditions: tuple[tuple[Any, ...], ...] + """The trait's conditions, in order.""" + + results: tuple[tuple[Any, ...] | None, ...] + """None, the no-match result, followed by the trait's results in order.""" + + root: int + """The trait's root reference.""" + + nodes: tuple[int, ...] + """The trait's nodes, three ints per node.""" + + +type BuiltinGetter = Callable[[Any, _TypedProperties], Any] +"""Computes a built-in parameter's value from the config and the call context.""" + +type BuiltinBinding = str | BuiltinGetter +"""How a built-in parameter gets its value: a config attribute name, or a getter.""" + + +@dataclass(frozen=True, slots=True) +class RulesExtension: + """Functions, built-in parameter bindings and auth scheme IDs contributed to the + rules engine.""" + + functions: Mapping[str, Callable[..., Any]] = field( + default_factory=dict[str, Callable[..., Any]] + ) + """Rules library functions by rules-language name. + + Their result must depend only on their arguments, with no side effects, because + resolved endpoints are cached by parameter values. Given None for an unset value, + they return None. The standard library's names are reserved: a resolver given an + extension that defines one raises. + """ + + builtins: Mapping[str, BuiltinBinding] = field( + default_factory=dict[str, BuiltinBinding] + ) + """Built-in parameter IDs mapped to a config attribute name, or to a getter + receiving (config, call context). + + ``SDK::Endpoint`` is reserved: a resolver given an extension that binds it raises. + """ + + auth_schemes: Mapping[str, ShapeID] = field(default_factory=dict[str, ShapeID]) + """Auth scheme names, as endpoint rules write them in ``authSchemes``, mapped to + the scheme IDs they select. + + An endpoint keeps the auth schemes its rules list whose names an extension maps, in + the rules' order. A resolver raises when an endpoint's rules list auth schemes but + no extension maps any of their names. + """ diff --git a/packages/smithy-core/src/smithy_core/interfaces/__init__.py b/packages/smithy-core/src/smithy_core/interfaces/__init__.py index 7846cf7ae..21ea0d1d1 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/__init__.py +++ b/packages/smithy-core/src/smithy_core/interfaces/__init__.py @@ -1,6 +1,13 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from collections.abc import Callable, ItemsView, Iterator, KeysView, ValuesView +from collections.abc import ( + Callable, + ItemsView, + Iterator, + KeysView, + Sequence, + ValuesView, +) from dataclasses import dataclass from inspect import iscoroutinefunction from typing import TYPE_CHECKING, Any, Protocol, TypeGuard, overload, runtime_checkable @@ -10,6 +17,7 @@ "BytesReader", "BytesWriter", "Endpoint", + "EndpointResolver", "PropertyKey", "SeekableBytesReader", "StreamingBlob", @@ -22,6 +30,7 @@ from typing_extensions import TypeForm from .._schema_compact import StaticSchema + from ..endpoints import EndpointAuthScheme, EndpointResolverParams from ..schemas import Schema @@ -127,6 +136,12 @@ class Endpoint(Protocol): request. """ + auth_schemes: Sequence["EndpointAuthScheme"] + """The auth schemes the endpoint supports, in order of preference. + + Empty when the endpoint has no special auth settings. + """ + @runtime_checkable class PropertyKey[T](Protocol): @@ -282,13 +297,14 @@ class ProtocolSettings: class EndpointResolver(Protocol): - """Resolves an operation's endpoint given parameters. + """Resolves an operation's endpoint given parameters.""" - Placeholder for the synchronous endpoint resolver; populated alongside the sync - client implementation. - """ + def resolve_endpoint(self, params: "EndpointResolverParams[Any]") -> Endpoint: + """Resolve an endpoint for the given operation. - # TODO(sync-api): implement alongside the sync client + :param params: The parameters available to resolve the endpoint. + """ + ... class ClientTransport(Protocol): diff --git a/packages/smithy-core/src/smithy_core/rfc3986.py b/packages/smithy-core/src/smithy_core/rfc3986.py index 2742b547d..222d56137 100644 --- a/packages/smithy-core/src/smithy_core/rfc3986.py +++ b/packages/smithy-core/src/smithy_core/rfc3986.py @@ -40,6 +40,7 @@ "REG_NAME", "REGULAR_NAME_RE", "SUB_DELIMITERS_RE", + "UNRESERVED_HOST_MATCHER", "UNRESERVED_RE", "ZONE_ID", ] @@ -143,3 +144,10 @@ # ######################### # End misc.py # ######################### + +UNRESERVED_HOST_MATCHER = re.compile(f"[{UNRESERVED_RE}]+") +"""Fully matches a host made only of unreserved characters. + +Such a host is a valid reg-name, with no port, userinfo or brackets. Use +``fullmatch``: the pattern isn't anchored. +""" diff --git a/packages/smithy-core/src/smithy_core/traits.py b/packages/smithy-core/src/smithy_core/traits.py index 670d3f6eb..6357cbf3e 100644 --- a/packages/smithy-core/src/smithy_core/traits.py +++ b/packages/smithy-core/src/smithy_core/traits.py @@ -22,6 +22,7 @@ __all__ = [ "ORIGINAL_SHAPE_ID", "APIKeyLocation", + "ContextParamTrait", "DefaultTrait", "DynamicTrait", "EndpointTrait", @@ -47,11 +48,13 @@ "JSONNameTrait", "LongPollTrait", "MediaTypeTrait", + "OperationContextParamsTrait", "RequiredTrait", "RequiresLengthTrait", "RpcV2CborTrait", "SensitiveTrait", "SparseTrait", + "StaticContextParamsTrait", "StreamingTrait", "TimestampFormatTrait", "Trait", @@ -508,3 +511,49 @@ def __post_init__(self): class XMLAttributeTrait(Trait, id=ShapeID("smithy.api#xmlAttribute")): def __post_init__(self): assert self.document_value is None + + +@dataclass(init=False, frozen=True) +class ContextParamTrait(Trait, id=ShapeID("smithy.rules#contextParam")): + name: str = field(repr=False, hash=False, compare=False) + + def __init__(self, value: "DocumentValue | DynamicTrait" = None): + super().__init__(value) + assert isinstance(self.document_value, Mapping) + name = self.document_value["name"] + assert isinstance(name, str) + object.__setattr__(self, "name", name) + + +@dataclass(init=False, frozen=True) +class StaticContextParamsTrait(Trait, id=ShapeID("smithy.rules#staticContextParams")): + values: Mapping[str, "DocumentValue"] = field(repr=False, hash=False, compare=False) + + def __init__(self, value: "DocumentValue | DynamicTrait" = None): + super().__init__(value) + assert isinstance(self.document_value, Mapping) + + values: dict[str, DocumentValue] = {} + for name, definition in self.document_value.items(): + assert isinstance(definition, Mapping) + values[name] = definition["value"] + object.__setattr__(self, "values", values) + + +@dataclass(init=False, frozen=True) +class OperationContextParamsTrait( + Trait, id=ShapeID("smithy.rules#operationContextParams") +): + paths: Mapping[str, str] = field(repr=False, hash=False, compare=False) + + def __init__(self, value: "DocumentValue | DynamicTrait" = None): + super().__init__(value) + assert isinstance(self.document_value, Mapping) + + paths: dict[str, str] = {} + for name, definition in self.document_value.items(): + assert isinstance(definition, Mapping) + path = definition["path"] + assert isinstance(path, str) + paths[name] = path + object.__setattr__(self, "paths", paths) diff --git a/packages/smithy-core/src/smithy_core/utils.py b/packages/smithy-core/src/smithy_core/utils.py index d8853c6af..b70d3fe97 100644 --- a/packages/smithy-core/src/smithy_core/utils.py +++ b/packages/smithy-core/src/smithy_core/utils.py @@ -6,8 +6,11 @@ from math import isinf, isnan from types import UnionType from typing import Any, overload +from urllib.parse import urlsplit +from . import URI as _URI from .exceptions import ExpectationNotMetError +from .rfc3986 import UNRESERVED_HOST_MATCHER as _UNRESERVED_HOST_MATCHER __all__ = [ "RFC3339", @@ -17,6 +20,7 @@ "expect_type", "limited_parse_float", "limited_serialize_float", + "parse_uri", "remove_dot_segments", "serialize_epoch_seconds", "serialize_float", @@ -269,3 +273,46 @@ def remove_dot_segments(path: str, remove_consecutive_slashes: bool = False) -> if remove_consecutive_slashes: result = result.replace("//", "/") return result + + +def parse_uri(url: str) -> _URI: + """Parse an absolute URI, keeping the host as written. + + :param url: The URI to parse. + :raises ExpectationNotMetError: If it has no scheme or host, or an invalid port. + """ + # An http or https URL with a host of unreserved characters and no port, query + # or fragment is split directly: urlsplit would return it unchanged. + scheme, _, rest = url.partition("://") + host, slash, path = rest.partition("/") + if ( + (scheme == "https" or scheme == "http") + and _UNRESERVED_HOST_MATCHER.fullmatch(host) + and "?" not in path + and "#" not in path + and path.isprintable() + ): + return _URI(scheme=scheme, host=host, path=slash + path or None) + try: + parts = urlsplit(url) + port = parts.port # Validates the port and its range. + except ValueError as e: + raise ExpectationNotMetError(f"Invalid URI: {url}") from e + # urlsplit lowercases hostname; take the host from the authority instead. + host = parts.netloc.rpartition("@")[2] + if host.startswith("["): + host = host[1 : host.index("]")] + else: + host = host.partition(":")[0] + if not parts.scheme or not host: + raise ExpectationNotMetError(f"Invalid URI: {url}") + return _URI( + scheme=parts.scheme, + username=parts.username, + password=parts.password, + host=host, + port=port, + path=parts.path or None, + query=parts.query or None, + fragment=parts.fragment or None, + ) diff --git a/packages/smithy-core/tests/unit/aio/_pipeline_harness.py b/packages/smithy-core/tests/unit/aio/_pipeline_harness.py index f38f4bdc9..27e882760 100644 --- a/packages/smithy-core/tests/unit/aio/_pipeline_harness.py +++ b/packages/smithy-core/tests/unit/aio/_pipeline_harness.py @@ -9,7 +9,7 @@ from smithy_core.aio.interfaces.retries import AsyncRetryStrategy from smithy_core.aio.retries import AsyncSimpleRetryStrategy from smithy_core.deserializers import ShapeDeserializer -from smithy_core.endpoints import EndpointResolverParams +from smithy_core.endpoints import EndpointAuthScheme, EndpointResolverParams from smithy_core.interceptors import InterceptorChain from smithy_core.prelude import BLOB from smithy_core.response import EMPTY_RESPONSE_METADATA, ResponseMetadata @@ -234,6 +234,7 @@ class StubEndpoint: def __init__(self) -> None: self.uri = URI(host="example.com") self.properties = TypedProperties() + self.auth_schemes: tuple[EndpointAuthScheme, ...] = () class StubEndpointResolver: diff --git a/packages/smithy-core/tests/unit/aio/test_client.py b/packages/smithy-core/tests/unit/aio/test_client.py index 42d99bdfc..a7c2783bd 100644 --- a/packages/smithy-core/tests/unit/aio/test_client.py +++ b/packages/smithy-core/tests/unit/aio/test_client.py @@ -13,13 +13,19 @@ from smithy_core.aio.interfaces import AsyncClientTransport from smithy_core.aio.retries import AsyncAdaptiveRetryStrategy, AsyncSimpleRetryStrategy from smithy_core.aio.types import AsyncBytesReader -from smithy_core.auth import AuthOption -from smithy_core.exceptions import CallError, SmithyError, UnsupportedTransportError +from smithy_core.auth import RESOLVED_IDENTITY, AuthOption +from smithy_core.endpoints import RESOLVED_ENDPOINT +from smithy_core.exceptions import ( + CallError, + SmithyError, + UnsupportedTransportError, +) from smithy_core.interceptors import ( InputContext, Interceptor, InterceptorChain, OutputContext, + RequestContext, ) from smithy_core.response import EMPTY_RESPONSE_METADATA from smithy_core.retries import ExponentialRetryBackoffStrategy @@ -257,6 +263,82 @@ async def test_modified_input_is_serialized_and_used_to_resolve_endpoint() -> No assert resolver.resolved_inputs == [interceptor.replacement] +class _RecordHooks(Interceptor[Any, Any, Any, Any]): + def __init__(self, events: list[str]) -> None: + self.events = events + + def read_before_attempt(self, context: RequestContext[Any, Any]) -> None: + self.events.append("read_before_attempt") + + def modify_before_signing(self, context: RequestContext[Any, Any]) -> Any: + self.events.append("modify_before_signing") + return context.transport_request + + def read_before_signing(self, context: RequestContext[Any, Any]) -> None: + self.events.append("read_before_signing") + + def read_after_signing(self, context: RequestContext[Any, Any]) -> None: + self.events.append("read_after_signing") + + +async def test_identity_resolves_before_the_endpoint_and_signing_after_hooks() -> None: + events: list[str] = [] + identity = Mock() + + async def get_identity(**kwargs: Any) -> Any: + events.append("get_identity") + return identity + + class _Resolver(StubEndpointResolver): + async def resolve_endpoint(self, params: Any) -> Any: + events.append("resolve_endpoint") + assert params.context[RESOLVED_IDENTITY] is identity + return await super().resolve_endpoint(params) + + def signer_properties(**kwargs: Any) -> dict[str, Any]: + events.append("signer_properties") + assert kwargs["context"][RESOLVED_ENDPOINT] is not None + return {} + + async def sign(**kwargs: Any) -> Any: + events.append("sign") + assert kwargs["identity"] is identity + return kwargs["request"] + + def resolve_auth_scheme(**kwargs: Any) -> list[AuthOption]: + events.append("resolve_auth_scheme") + return [option] + + harness = pipeline_harness(NonDuplexTransport()) + scheme = Mock() + scheme.identity_resolver.return_value.get_identity = get_identity + scheme.signer_properties = signer_properties + scheme.signer.return_value.sign = sign + option = AuthOption(scheme_id=ShapeID("com.example#auth")) + call = replace( + client_call(), + interceptor=InterceptorChain([_RecordHooks(events)]), + auth_scheme_resolver=Mock(resolve_auth_scheme=resolve_auth_scheme), + supported_auth_schemes={option.scheme_id: scheme}, + endpoint_resolver=_Resolver(), + ) + + await harness.pipeline(call) + + assert events == [ + "read_before_attempt", + "resolve_auth_scheme", + "get_identity", + "resolve_endpoint", + "modify_before_signing", + "read_before_signing", + "signer_properties", + "sign", + "read_after_signing", + ] + assert call.context[RESOLVED_IDENTITY] is identity + + async def test_modified_input_reaches_completion_when_serialization_fails() -> None: harness = pipeline_harness(NonDuplexTransport()) interceptor = _ReplaceInput(fail_after_modifying=True) diff --git a/packages/smithy-core/tests/unit/endpoints/__init__.py b/packages/smithy-core/tests/unit/endpoints/__init__.py new file mode 100644 index 000000000..04f8b7b76 --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-core/tests/unit/endpoints/_bdds.py b/packages/smithy-core/tests/unit/endpoints/_bdds.py new file mode 100644 index 000000000..45fd1f40e --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/_bdds.py @@ -0,0 +1,167 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Helpers for endpoint BDD tests.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +from smithy_core.endpoints import ( + ENDPOINT_PARAMETERS, + STATIC_ENDPOINT_CONFIG, + EndpointBdd, + EndpointResolverParams, +) +from smithy_core.endpoints._types import Tag +from smithy_core.interfaces import Endpoint, EndpointResolver +from smithy_core.schemas import APIOperation +from smithy_core.shapes import ShapeType +from smithy_core.types import TypedProperties + +RESULT_OFFSET = 100_000_000 +"""The endpointBdd trait's offset for result references.""" + +# ----------------------------------------------------------------- resolving + + +class Config: + """A client config with the given attributes.""" + + endpoint_uri: Any = None + + def __init__(self, **values: Any) -> None: + self.__dict__.update(values) + + +class Input: + """An operation input with the given attributes.""" + + def __init__(self, **values: Any) -> None: + self.__dict__.update(values) + + +def make_operation( + name: str = "Op", + members: Mapping[str, tuple[Any, Mapping[str, Any] | int]] | None = None, + traits: Mapping[str, Any] | None = None, +) -> APIOperation[Any, Any]: + """An operation with input members ``{name: (target schema, member traits)}``.""" + members = members or {} + input_schema = ( + ShapeType.STRUCTURE.value, + "com.example", + f"{name}Input", + 0, + tuple(members), + tuple( + (target, member_traits or 0) for target, member_traits in members.values() + ), + ) + return APIOperation( + static_schema=( + ShapeType.OPERATION.value, + "com.example", + name, + traits or 0, + input_schema, + -1, + (), + ), + ) + + +NO_BINDINGS = make_operation() +"""An operation without context parameters.""" + + +def resolve( + resolver: EndpointResolver, + *, + parameters: Mapping[str, Any] | None = None, + config: Any = None, + operation: APIOperation[Any, Any] = NO_BINDINGS, + input: Any = None, +) -> Endpoint: + """Resolve an endpoint for a call. + + :param parameters: Values by rules name, set under ``ENDPOINT_PARAMETERS``. + :param config: The client config; one with no fields by default. + """ + context = TypedProperties() + context[STATIC_ENDPOINT_CONFIG] = Config() if config is None else config + if parameters is not None: + context[ENDPOINT_PARAMETERS] = parameters + return resolver.resolve_endpoint( + EndpointResolverParams(operation=operation, input=input, context=context) + ) + + +# ----------------------------------------------------------------- building BDDs + + +def call_names(value: Any, out: set[str]) -> None: + if isinstance(value, tuple): + items: tuple[Any, ...] = value # pyright: ignore[reportUnknownVariableType] + if items and items[0] == Tag.CALL: + out.add(items[1]) + for v in items: + call_names(v, out) + + +def assemble( + parameters: Sequence[tuple[Any, ...]], + conditions: Sequence[tuple[Any, ...]], + results: Sequence[tuple[Any, ...]], + root: int, + nodes: Sequence[int], +) -> EndpointBdd: + """Assemble a BDD, deriving its functions.""" + names: set[str] = set() + call_names(tuple(conditions), names) + call_names(tuple(results), names) + return EndpointBdd( + parameters=tuple(parameters), + functions=tuple(sorted(names)), + conditions=tuple(conditions), + results=(None, *results), + root=root, + nodes=tuple(nodes), + ) + + +def chain( + parameters: Sequence[tuple[Any, ...]], + conditions: Sequence[tuple[Any, ...]], + results: Sequence[tuple[Any, ...]], +) -> EndpointBdd: + """A decision list: condition i true gives result i + 1; none gives the last.""" + assert len(results) == len(conditions) + 1 + nodes = [-1, 1, -1] + n = len(conditions) + for i in range(n): + low = i + 3 if i + 1 < n else RESULT_OFFSET + n + 1 + nodes += [i, RESULT_OFFSET + i + 1, low] + root = 2 if n else RESULT_OFFSET + 1 + return assemble(parameters, conditions, results, root, nodes) + + +def echo(parameters: Sequence[tuple[Any, ...]]) -> EndpointBdd: + """A BDD whose one endpoint has each parameter's value as a property of its name.""" + names = tuple(name for name, *_ in parameters) + properties = (Tag.MAP, names, *((Tag.REF, name) for name in names)) + return chain(parameters, [], [endpoint("https://example.com", properties)]) + + +def string(name: str, required: bool = False, default: Any = None) -> tuple[Any, ...]: + return (name, "string", required, default, None, False) + + +def boolean(name: str, required: bool = False, default: Any = None) -> tuple[Any, ...]: + return (name, "boolean", required, default, None, False) + + +def endpoint(url: Any, properties: Any = None, headers: Any = ()) -> tuple[Any, ...]: + return (Tag.ENDPOINT, url, properties, headers) + + +def error(message: Any) -> tuple[Any, ...]: + return (Tag.ERROR, message) diff --git a/packages/smithy-core/tests/unit/endpoints/test_engine.py b/packages/smithy-core/tests/unit/endpoints/test_engine.py new file mode 100644 index 000000000..72a319c70 --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/test_engine.py @@ -0,0 +1,332 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The rules engine, through the resolver: BDD walks, results and expressions.""" + +from typing import Any + +import pytest +from smithy_core import URI +from smithy_core.endpoints import ( + ENDPOINT_HEADERS, + BddEndpointResolver, + RulesExtension, +) +from smithy_core.endpoints._types import Tag +from smithy_core.exceptions import EndpointResolutionError +from smithy_core.shapes import ShapeID + +from ._bdds import ( + RESULT_OFFSET, + assemble, + boolean, + chain, + endpoint, + error, + resolve, + string, +) + +R = RESULT_OFFSET + + +def _parts(*values: Any) -> tuple[Any, ...]: + return values + + +EXTRA = RulesExtension(functions={"x.parts": _parts}) + + +def test_missing_functions_fail_at_construction() -> None: + bdd = chain( + [string("S")], + [((Tag.CALL, "x.b", (Tag.CALL, "x.a", (Tag.REF, "S"))), None)], + [endpoint("https://a"), error("no")], + ) + with pytest.raises(EndpointResolutionError) as e: + BddEndpointResolver(bdd) + assert str(e.value).endswith(": x.a, x.b") + + +# --------------------------------------------------------------- BDD walks + + +def _walk(nodes: list[int]) -> dict[tuple[bool, bool], str]: + """The result each pair of condition values reaches from node 2. + + Condition i tests boolean Pi, and result k is the error ``rk``. + """ + bdd = assemble( + [boolean(f"P{i}", required=True, default=False) for i in range(2)], + [ + ((Tag.CALL, "booleanEquals", (Tag.REF, f"P{i}"), True), None) + for i in range(2) + ], + [error("r1"), error("r2")], + 2, + nodes, + ) + resolver = BddEndpointResolver(bdd) + reached: dict[tuple[bool, bool], str] = {} + for bits in [(True, True), (True, False), (False, True), (False, False)]: + with pytest.raises(EndpointResolutionError) as e: + resolve(resolver, parameters={"P0": bits[0], "P1": bits[1]}) + reached[bits] = str(e.value) + return reached + + +def test_complement_edges_swap_branches() -> None: + # node 2: P0 ? node 3 : -node 3; node 3: P1 ? r1 : r2. + assert _walk([-1, 1, -1, 0, 3, -3, 1, R + 1, R + 2]) == { + (True, True): "r1", + (True, False): "r2", + (False, True): "r2", + (False, False): "r1", + } + + +def test_terminal_edges_mean_no_match() -> None: + # node 2: P0 ? terminal : node 3; node 3: P1 ? r1 : -terminal. + no_match = "No endpoint rule matched" + assert _walk([-1, 1, -1, 0, 1, 3, 1, R + 1, -1]) == { + (True, True): no_match, + (True, False): no_match, + (False, True): "r1", + (False, False): no_match, + } + + +# --------------------------------------------------------------- results + + +def _one( + result: Any, extensions: list[RulesExtension] | None = None +) -> BddEndpointResolver: + """A resolver whose rules always give one result.""" + return BddEndpointResolver( + chain([string("S"), string("T")], [], [result]), extensions=extensions or [] + ) + + +@pytest.mark.parametrize( + "url, values, expected", + [ + ("https://example.com", {}, URI(host="example.com")), + ( + "https://{S}.example.com/p/{T}", + {"S": "a", "T": "b"}, + URI(host="a.example.com", path="/p/b"), + ), + # A custom endpoint, with a port and a path. + ( + (Tag.REF, "S"), + {"S": "http://x.y:1/z"}, + URI(scheme="http", host="x.y", port=1, path="/z"), + ), + ], +) +def test_endpoint_urls(url: Any, values: dict[str, Any], expected: URI) -> None: + assert resolve(_one(endpoint(url)), parameters=values).uri == expected + + +def test_custom_endpoint_paths_keep_their_prefix() -> None: + url = "{url#scheme}://{url#authority}{url#normalizedPath}item" + resolver = BddEndpointResolver( + chain( + [string("Endpoint", required=True)], + [((Tag.CALL, "parseURL", "{Endpoint}"), "url")], + [endpoint(url), error("no")], + ) + ) + uri = resolve(resolver, parameters={"Endpoint": "http://localhost:9000/base"}).uri + assert uri == URI(scheme="http", host="localhost", port=9000, path="/base/item") + + +def test_error_results_render_their_message() -> None: + with pytest.raises(EndpointResolutionError, match=r"^Invalid region: a$"): + resolve(_one(error("Invalid region: {S}")), parameters={"S": "a"}) + + +def test_function_failures_are_resolution_errors() -> None: + def broken(value: Any) -> Any: + raise KeyError(value) + + resolver = BddEndpointResolver( + chain( + [string("S")], + [((Tag.CALL, "x.broken", (Tag.REF, "S")), None)], + [endpoint("https://a"), error("no")], + ), + extensions=[RulesExtension(functions={"x.broken": broken})], + ) + with pytest.raises(EndpointResolutionError, match="KeyError") as e: + resolve(resolver, parameters={"S": "s"}) + assert isinstance(e.value.__cause__, KeyError) + + +TOKEN = ShapeID("x.auth#token") +SIGNED = ShapeID("x.auth#signed") +AUTH = RulesExtension(auth_schemes={"token": TOKEN, "signed": SIGNED}) + + +def test_endpoint_properties() -> None: + properties = ( + Tag.MAP, + ("authSchemes", "backend", "nested"), + ( + Tag.LIST, + ( + Tag.MAP, + ("audience", "name", "scopes"), + "service", + "token", + (Tag.LIST, "*"), + ), + # No extension maps this name, so the endpoint leaves it out. + (Tag.MAP, ("name",), "x.unmapped"), + ( + Tag.MAP, + ("name", "region", "strict"), + "signed", + "{S}", + True, + ), + ), + "fast", + (Tag.MAP, ("a", "b"), (Tag.LIST, 1, True), (Tag.MAP, ("c",), "d")), + ) + headers = (("x-a", ("1", "{S}")),) + endpoint_ = resolve( + _one(endpoint("https://example.com", properties, headers), [AUTH]), + parameters={"S": "us-west-2"}, + ) + assert [(s.scheme_id, dict(s.properties)) for s in endpoint_.auth_schemes] == [ + (TOKEN, {"audience": "service", "scopes": ("*",)}), + (SIGNED, {"region": "us-west-2", "strict": True}), + ] + assert endpoint_.properties[ENDPOINT_HEADERS] == {"x-a": ("1", "us-west-2")} + assert endpoint_.properties["backend"] == "fast" + assert endpoint_.properties["nested"] == {"a": (1, True), "b": {"c": "d"}} + + +def test_rendered_urls_must_be_uris() -> None: + with pytest.raises(EndpointResolutionError, match="Invalid endpoint URI"): + resolve(_one(endpoint((Tag.REF, "S"))), parameters={"S": "example.com"}) + + +def test_auth_schemes_no_extension_maps_fail() -> None: + properties = (Tag.MAP, ("authSchemes",), (Tag.LIST, (Tag.MAP, ("name",), "token"))) + with pytest.raises(EndpointResolutionError, match="no extension provides: token"): + resolve(_one(endpoint("https://example.com", properties)), parameters={}) + + +# --------------------------------------------------------------- expressions + + +def _value_of(expression: Any, values: dict[str, Any]) -> Any: + """Evaluate an expression by assigning it in a condition and returning it as a + property; None if the condition doesn't hold.""" + resolver = BddEndpointResolver( + chain( + [ + string("S"), + string("T"), + boolean("B"), + ("A", "stringArray", False, None, None, False), + ], + [(expression, "v")], + [endpoint("https://h", (Tag.MAP, ("v",), (Tag.REF, "v"))), error("no")], + ), + extensions=[EXTRA], + ) + try: + return resolve(resolver, parameters={"S": "s", **values}).properties["v"] + except EndpointResolutionError as e: + if str(e) == "no": + return None + raise + + +@pytest.mark.parametrize( + "expression, values, expected", + [ + # Calls pass references, literals and nested calls in order. + ((Tag.CALL, "x.parts", (Tag.REF, "S"), 1, True, "z"), {}, ("s", 1, True, "z")), + ((Tag.CALL, "x.parts", (Tag.CALL, "x.parts", (Tag.REF, "S"))), {}, (("s",),)), + ((Tag.CALL, "x.parts", (Tag.LIST, "a", (Tag.REF, "S"))), {}, (("a", "s"),)), + ((Tag.CALL, "coalesce", (Tag.REF, "T"), (Tag.REF, "S")), {}, "s"), + ((Tag.CALL, "ite", (Tag.REF, "B"), "yes", "no"), {"B": True}, "yes"), + ((Tag.CALL, "ite", (Tag.REF, "B"), "yes", "no"), {"B": False}, "no"), + # getAttr: keys, indexes, negative indexes, and misses. + ( + (Tag.ATTR, (Tag.CALL, "parseURL", (Tag.REF, "S")), "authority"), + {"S": "https://h:1"}, + "h:1", + ), + ((Tag.ATTR, (Tag.REF, "A"), "[0]"), {"A": ["x", "y"]}, "x"), + ((Tag.ATTR, (Tag.REF, "A"), "[-1]"), {"A": ["x", "y"]}, "y"), + ((Tag.ATTR, (Tag.REF, "A"), "[5]"), {"A": ["x", "y"]}, None), + # Templates; escaped braces are literal. + ("{S}-{A#[0]}", {"A": ["q"]}, "s-q"), + ("a{{b}}c", {}, "a{b}c"), + # A value that is set but empty still satisfies its condition. + ((Tag.CALL, "uriEncode", (Tag.REF, "S")), {"S": ""}, ""), + ], +) +def test_expressions(expression: Any, values: dict[str, Any], expected: Any) -> None: + assert _value_of(expression, values) == expected + + +IS_IP = (Tag.ATTR, (Tag.CALL, "parseURL", (Tag.REF, "S")), "isIp") +SCHEME = (Tag.ATTR, (Tag.CALL, "parseURL", (Tag.REF, "S")), "scheme") + + +@pytest.mark.parametrize( + "condition, values, expected", + [ + # isSet considers presence, not truthiness. + ((Tag.CALL, "isSet", (Tag.REF, "S")), {"S": ""}, True), + ((Tag.CALL, "isSet", (Tag.REF, "T")), {}, False), + ((Tag.CALL, "not", (Tag.CALL, "isSet", (Tag.REF, "T"))), {}, True), + ((Tag.CALL, "booleanEquals", (Tag.REF, "B"), True), {"B": True}, True), + ((Tag.CALL, "booleanEquals", (Tag.REF, "B"), True), {"B": False}, False), + ((Tag.CALL, "stringEquals", (Tag.REF, "S"), "s"), {}, True), + ((Tag.CALL, "stringEquals", (Tag.REF, "T"), "s"), {}, False), + # A literal with braces is a template, so it's rendered, not compared as is. + ((Tag.CALL, "stringEquals", (Tag.REF, "S"), "{T}"), {"T": "s"}, True), + # An expression against a literal, as in getAttr(...) and coalesce(...). + ((Tag.CALL, "booleanEquals", IS_IP, True), {"S": "https://1.2.3.4"}, True), + ( + ( + Tag.CALL, + "booleanEquals", + (Tag.CALL, "coalesce", (Tag.REF, "B"), False), + True, + ), + {}, + False, + ), + ((Tag.CALL, "stringEquals", SCHEME, "http"), {"S": "http://h"}, True), + ((Tag.CALL, "stringEquals", SCHEME, "http"), {"S": "https://h"}, False), + # coalesce of only unset values is unset. + ( + (Tag.CALL, "isSet", (Tag.CALL, "coalesce", (Tag.REF, "T"), (Tag.REF, "T"))), + {}, + False, + ), + ], +) +def test_conditions(condition: Any, values: dict[str, Any], expected: bool) -> None: + resolver = BddEndpointResolver( + chain( + [string("S"), string("T"), boolean("B")], + [(condition, None)], + [endpoint("https://yes"), error("no")], + ) + ) + try: + resolve(resolver, parameters={"S": "s", **values}) + holds = True + except EndpointResolutionError as e: + assert str(e) == "no" + holds = False + assert holds is expected diff --git a/packages/smithy-core/tests/unit/endpoints/test_resolve_static_uri.py b/packages/smithy-core/tests/unit/endpoints/test_resolve_static_uri.py new file mode 100644 index 000000000..ccda5c73a --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/test_resolve_static_uri.py @@ -0,0 +1,19 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from types import SimpleNamespace + +import pytest +from smithy_core import URI +from smithy_core.endpoints import resolve_static_uri +from smithy_core.exceptions import EndpointResolutionError +from smithy_core.types import TypedProperties + + +def _resolve(endpoint_uri: str | URI | None) -> object: + config = SimpleNamespace(endpoint_uri=endpoint_uri) + return resolve_static_uri(TypedProperties({"config": config})) + + +def test_rejects_invalid_uris() -> None: + with pytest.raises(EndpointResolutionError, match="Invalid endpoint URI"): + _resolve("example.com") diff --git a/packages/smithy-core/tests/unit/endpoints/test_resolver.py b/packages/smithy-core/tests/unit/endpoints/test_resolver.py new file mode 100644 index 000000000..57f4d63b3 --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/test_resolver.py @@ -0,0 +1,350 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Endpoint resolvers: parameter binding, caching, and the async resolver.""" + +from dataclasses import dataclass +from typing import Any + +import pytest +from smithy_core import URI +from smithy_core.aio.endpoints import AsyncBddEndpointResolver +from smithy_core.endpoints import ( + ENDPOINT_PARAMETERS, + BddEndpointResolver, + EndpointBdd, + EndpointResolverParams, + RulesExtension, +) +from smithy_core.endpoints._types import Tag +from smithy_core.exceptions import EndpointResolutionError +from smithy_core.shapes import ShapeType +from smithy_core.types import TypedProperties + +from ._bdds import ( + Config, + Input, + chain, + echo, + endpoint, + error, + make_operation, + resolve, + string, +) + +STRING = ShapeType.STRING.value +CONTEXT = "smithy.rules#contextParam" +STATIC = "smithy.rules#staticContextParams" +OPERATION_CONTEXT = "smithy.rules#operationContextParams" +REQUIRED = "smithy.api#required" + +# One parameter per source, plus parameters several sources feed. +PARAMETERS: list[tuple[Any, ...]] = [ + ("Static", "string", False, None, "X::Static", True), + ("Context", "string", False, "default", "X::Context", True), + ("Path", "string", False, None, "X::Path", True), + ("Client", "string", False, None, "X::Client", True), + ("Builtin", "string", False, "default", "X::Builtin", False), + ("Getter", "string", False, None, "X::Getter", False), + ("Default", "string", True, "default", None, False), + ("Tables", "stringArray", False, None, None, False), +] + + +def _getter(config: Any, context: Any) -> Any: + return getattr(config, "getter_source", None) + + +EXTENSION = RulesExtension( + builtins={ + "X::Static": "static_builtin", + "X::Context": "context_builtin", + "X::Path": "path_builtin", + "X::Client": "client_builtin", + "X::Builtin": "builtin_attribute", + "X::Getter": _getter, + } +) + +OPERATION = make_operation( + "Everything", + { + "Static": (STRING, {CONTEXT: {"name": "Static"}}), + "ContextMember": (STRING, {CONTEXT: {"name": "Context"}}), + "Nested": ( + ( + ShapeType.STRUCTURE.value, + "com.example", + "Nested", + 0, + ("Value",), + ((STRING, 0),), + ), + 0, + ), + "Key": (STRING, {CONTEXT: {"name": "Key"}, REQUIRED: {}}), + "Names": ( + ( + ShapeType.LIST.value, + "com.example", + "Names", + 0, + ("member",), + ((STRING, 0),), + ), + 0, + ), + }, + { + STATIC: {"Static": {"value": "static"}, "Unknown": {"value": "x"}}, + OPERATION_CONTEXT: { + "Context": {"path": "Nested.Value"}, + "Path": {"path": "Nested.Value"}, + "Tables": {"path": "Names"}, + }, + }, +) + + +def _bound( + config: Any, + input: Any = None, + *, + parameters: dict[str, Any] | None = None, + bdd: EndpointBdd | None = None, + extensions: list[RulesExtension] | None = None, +) -> dict[str, Any]: + """The parameter values one call of OPERATION binds, by name.""" + resolver = BddEndpointResolver( + bdd or echo(PARAMETERS), + extensions=[EXTENSION] if extensions is None else extensions, + ) + endpoint = resolve( + resolver, + parameters=parameters, + config=config, + operation=OPERATION, + input=input or Input(key="k"), + ) + return dict(endpoint.properties.items()) + + +# --------------------------------------------------------------- binding + + +def test_sources_by_precedence() -> None: + everything = Config( + static=1, + context="client", + path="client", + client="client", + static_builtin="b", + context_builtin="b", + builtin_attribute="builtin", + getter_source="got", + ) + bound = _bound( + everything, + Input( + static="input", context_member="input", key="k", nested=Input(value="path") + ), + ) + assert bound == { + "Static": "static", + "Context": "input", + "Path": "path", + "Client": "client", + "Builtin": "builtin", + "Getter": "got", + "Default": "default", + "Tables": None, + } + + # None never overrides a lower-precedence source. + bound = _bound(everything, Input(key="k", nested=Input())) + assert bound["Context"] == "client" + assert bound["Path"] == "client" + # Client is both a built-in and a client context parameter, so it reads only + # its client context attribute. + bound = _bound(Config(client_builtin="builtin")) + assert bound["Context"] == "default" + assert bound["Builtin"] == "default" + assert bound["Client"] is None + assert bound["Getter"] is None + + +def test_endpoint_parameters_override_every_source() -> None: + bound = _bound( + Config(context="client", builtin_attribute="builtin"), + Input(context_member="input", key="k", nested=Input(value="path")), + parameters={ + "Static": "given", + "Context": None, + "Builtin": "given", + "Tables": ["a", None], + "Undeclared": "ignored", + }, + ) + assert bound == { + # Over staticContextParams, the highest-precedence trait source. + "Static": "given", + # None unsets the parameter, which then takes its default. + "Context": "default", + "Path": "path", + "Client": None, + "Builtin": "given", + "Getter": None, + "Default": "default", + "Tables": ("a",), + } + + +def test_string_arrays_become_tuples_without_none() -> None: + assert _bound(Config(), Input(key="k", names=["a", None, "b"]))["Tables"] == ( + "a", + "b", + ) + + +REGION: list[tuple[Any, ...]] = [("Region", "string", True, None, "X::Region", False)] +REGION_EXTENSION = RulesExtension(builtins={"X::Region": "region"}) + + +def test_required_parameters() -> None: + resolver = BddEndpointResolver(echo(REGION), extensions=[REGION_EXTENSION]) + with pytest.raises(EndpointResolutionError, match="parameter: Region"): + resolve(resolver) + assert resolve(resolver, config=Config(region="r")).properties["Region"] == "r" + # A static value satisfies a required parameter. + static = make_operation("Static", {}, {STATIC: {"Region": {"value": "s"}}}) + assert resolve(resolver, operation=static).properties["Region"] == "s" + + +def test_operations_bind_their_own_sources() -> None: + resolver = BddEndpointResolver(echo([("Name", "string", False, None, None, False)])) + with_name = make_operation( + "WithName", {"Name": (STRING, {CONTEXT: {"name": "Name"}})} + ) + without = make_operation("WithoutName", {"Name": (STRING, 0)}) + for _ in range(2): + bound = resolve(resolver, operation=with_name, input=Input(name="n")) + assert bound.properties["Name"] == "n" + bound = resolve(resolver, operation=without, input=Input(name="n")) + assert bound.properties["Name"] is None + + +@pytest.mark.parametrize( + "name, attribute", + [ + # The names codegen gives clientContextParams config fields (CaseUtils.toSnakeCase). + ("EnableTracing", "enable_tracing"), + ("UseV2Api", "use_v2_api"), + ], +) +def test_client_context_parameters_read_codegen_field_names( + name: str, attribute: str +) -> None: + bdd = echo([(name, "string", False, None, None, True)]) + assert _bound(Config(**{attribute: "v"}), bdd=bdd) == {name: "v"} + + +# --------------------------------------------------------------- resolving + +NAMED = chain( + [ + ("Name", "string", True, None, None, False), + ("Endpoint", "string", False, None, "SDK::Endpoint", False), + ], + [((Tag.CALL, "isSet", (Tag.REF, "Endpoint")), None)], + [ + endpoint("{Endpoint}/{Name}"), + endpoint("https://{Name}.example.com"), + ], +) + +THING_OPERATION = make_operation( + "GetThing", + { + "Name": (STRING, {CONTEXT: {"name": "Name"}, REQUIRED: {}}), + "Key": (STRING, {CONTEXT: {"name": "Key"}, REQUIRED: {}}), + }, +) + + +@dataclass +class ThingInput: + name: str | None + key: str = "k" + + +def _get_thing( + resolver: BddEndpointResolver, input: ThingInput, config: Any = None +) -> Any: + return resolve(resolver, config=config, operation=THING_OPERATION, input=input) + + +def test_resolver_resolves_endpoints() -> None: + resolver = BddEndpointResolver(NAMED) + input = ThingInput("n") + assert _get_thing(resolver, input).uri == URI(host="n.example.com") + endpoint_ = _get_thing(resolver, input, Config(endpoint_uri="http://h:1")) + assert endpoint_.uri == URI(scheme="http", host="h", port=1, path="/n") + uri = URI(scheme="http", host="u") + endpoint_ = _get_thing(resolver, input, Config(endpoint_uri=uri)) + assert endpoint_.uri == URI(scheme="http", host="u", path="/n") + + +@pytest.mark.parametrize("name", [None, "", " \t"]) +def test_blank_required_context_parameters_raise(name: str | None) -> None: + resolver = BddEndpointResolver(NAMED) + with pytest.raises(EndpointResolutionError, match="member name"): + _get_thing(resolver, ThingInput(name)) + + +@pytest.mark.parametrize("key", [None, "", " \t"]) +def test_blank_required_context_parameters_the_rules_dont_read_raise( + key: str | None, +) -> None: + # OPERATION's required Key member names a parameter its BDD doesn't declare. + with pytest.raises(EndpointResolutionError, match="member key"): + _bound(Config(), Input(key=key)) + + +def test_cached_endpoints_follow_the_parameters_rules_read() -> None: + resolver = BddEndpointResolver(NAMED) + first = _get_thing(resolver, ThingInput("n", "k1")) + # The rules don't read Key, so another Key gives the same endpoint. + assert _get_thing(resolver, ThingInput("n", "k2")) == first + other = _get_thing(resolver, ThingInput("c")) + assert other.uri.host == "c.example.com" + assert _get_thing(resolver, ThingInput("n", "k3")) == first + + +def test_bdds_without_sdk_endpoint_use_endpoint_uri_as_given() -> None: + no_endpoint = chain( + [("Region", "string", True, "r", None, False)], + [], + [endpoint("https://{Region}.example.com")], + ) + resolver = BddEndpointResolver(no_endpoint) + endpoint_ = resolve(resolver, config=Config(endpoint_uri="https://custom:8443/p")) + assert endpoint_.uri == URI(host="custom", port=8443, path="/p") + uri = URI(host="given") + assert resolve(resolver, config=Config(endpoint_uri=uri)).uri is uri + assert resolve(resolver).uri == URI(host="r.example.com") + + +async def test_async_resolver() -> None: + upper = chain( + [string("S")], + [((Tag.CALL, "x.upper", (Tag.REF, "S")), "u")], + [endpoint("https://{u}"), error("no")], + ) + extension = RulesExtension(functions={"x.upper": str.upper}) + resolver = AsyncBddEndpointResolver(upper, extensions=[extension]) + context = TypedProperties({"config": Config()}) + context[ENDPOINT_PARAMETERS] = {"S": "h"} + params = EndpointResolverParams( + operation=make_operation(), input=None, context=context + ) + assert (await resolver.resolve_endpoint(params)).uri == URI(host="H") diff --git a/packages/smithy-core/tests/unit/endpoints/test_standard_library.py b/packages/smithy-core/tests/unit/endpoints/test_standard_library.py new file mode 100644 index 000000000..2191e8a14 --- /dev/null +++ b/packages/smithy-core/tests/unit/endpoints/test_standard_library.py @@ -0,0 +1,123 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from typing import Any + +import pytest +from smithy_core.endpoints._standard_library import STANDARD_FUNCTIONS + +FUNCTIONS = STANDARD_FUNCTIONS + + +@pytest.mark.parametrize( + "args, expected", + [ + (("abcdefg", 0, 4, False), "abcd"), + (("abcdefg", 0, 4, True), "defg"), + (("abcdefg", 1, 3, True), "ef"), + (("abc", 0, 4, False), None), + (("abcdéfg", 0, 4, False), None), + ], +) +def test_substring(args: tuple[Any, ...], expected: str | None) -> None: + assert FUNCTIONS["substring"](*args) == expected + + +@pytest.mark.parametrize( + "value, allow_sub_domains, expected", + [ + ("us-east-1", False, True), + ("3abc4", False, True), + ("a" * 64, False, False), + ("-abc", False, False), + ("", False, False), + ("a.b", False, False), + ("a.b", True, True), + ("a..b", True, False), + ("a.", True, False), + ], +) +def test_is_valid_host_label( + value: str, allow_sub_domains: bool, expected: bool +) -> None: + assert FUNCTIONS["isValidHostLabel"](value, allow_sub_domains) is expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ( + "https://example.com", + { + "scheme": "https", + "authority": "example.com", + "path": "", + "normalizedPath": "/", + "isIp": False, + }, + ), + ( + "http://127.0.0.1:8080/foo/bar", + { + "scheme": "http", + "authority": "127.0.0.1:8080", + "path": "/foo/bar", + "normalizedPath": "/foo/bar/", + "isIp": True, + }, + ), + ( + "https://[fe80::1]:443/", + { + "scheme": "https", + "authority": "[fe80::1]:443", + "path": "/", + "normalizedPath": "/", + "isIp": True, + }, + ), + # The authority drops userinfo. + ( + "https://user:pass@example.com:8443", + { + "scheme": "https", + "authority": "example.com:8443", + "path": "", + "normalizedPath": "/", + "isIp": False, + }, + ), + ("https://user@", None), + ("https://example.com/?foo=bar", None), + ("https://example.com?", None), + ("https://a b.com", None), + ("ftp://example.com", None), + ("https://example.com:port", None), + ], +) +def test_parse_url(value: str, expected: dict[str, Any] | None) -> None: + assert FUNCTIONS["parseURL"](value) == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ("abc-_.~", "abc-_.~"), + ("a b/c:?", "a%20b%2Fc%3A%3F"), + ("é", "%C3%A9"), + ], +) +def test_uri_encode(value: str, expected: str) -> None: + assert FUNCTIONS["uriEncode"](value) == expected + + +@pytest.mark.parametrize( + "args, expected", + [ + (("a--b--c", "--", 0), ("a", "b", "c")), + (("a--b--c", "--", 1), ("a--b--c",)), + (("a--b--c", "--", 2), ("a", "b--c")), + (("", "--", 0), ("",)), + ], +) +def test_split(args: tuple[Any, ...], expected: tuple[str, ...]) -> None: + assert FUNCTIONS["split"](*args) == expected diff --git a/packages/smithy-core/tests/unit/test_jmespath.py b/packages/smithy-core/tests/unit/test_jmespath.py new file mode 100644 index 000000000..07707f127 --- /dev/null +++ b/packages/smithy-core/tests/unit/test_jmespath.py @@ -0,0 +1,115 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""The JMESPath subset that operationContextParams paths use.""" + +from dataclasses import dataclass +from types import ModuleType +from typing import Any + +import pytest +from smithy_core._dynamic_models import ModelRegistry +from smithy_core._jmespath import compile +from smithy_core.shapes import ShapeType + +INPUT: dict[str, Any] = { + "RequestItems": {"t1": {"Keys": []}, "t2": {}}, + "TableCreationParameters": {"TableName": "created"}, + "TransactItems": [ + {"Get": {"TableName": "g1"}}, + {"Put": {"TableName": "p1"}}, + {"Get": {"TableName": "g2"}, "Update": {"TableName": "u2"}}, + {"Delete": {}}, + ], +} + + +@pytest.mark.parametrize( + "expression, expected", + [ + ("keys(RequestItems)", ["t1", "t2"]), + ("TableCreationParameters.TableName", "created"), + ("TableCreationParameters.*", ["created"]), + ("TransactItems[*].Get.TableName", ["g1", "g2"]), + ( + "TransactItems[*].[ConditionCheck.TableName, Put.TableName, " + "Delete.TableName, Update.TableName][]", + ["p1", "u2"], + ), + ("Missing[*].Get", None), + ("Missing.TableName", None), + ("keys(Missing)", None), + ], +) +def test_paths(expression: str, expected: Any) -> None: + assert compile(expression)(INPUT) == expected + + +# Generated shapes are dataclasses whose attribute names come from member_field_name. + + +@dataclass(kw_only=True) +class _Get: + table_name: str | None = None + + +@dataclass(kw_only=True) +class _TransactItem: + get: _Get | None = None + put: _Get | None = None + + +@dataclass(kw_only=True) +class _Input: + transact_items: list[_TransactItem] | None = None + request_items: dict[str, Any] | None = None + + +def test_paths_over_shapes() -> None: + value = _Input( + transact_items=[ + _TransactItem(get=_Get(table_name="g1")), + _TransactItem(put=_Get(table_name="p1")), + ], + request_items={"a": 1, "b": 2}, + ) + assert compile("TransactItems[*].[Get.TableName, Put.TableName][]")(value) == [ + "g1", + "p1", + ] + assert compile("keys(RequestItems)")(value) == ["a", "b"] + assert compile("Unknown")(value) is None + + +def _dynamic_models() -> ModelRegistry: + ns = "ns.example" + get = (ShapeType.STRUCTURE.value, ns, "Get", 0, ("TableName",), ((3, 0),)) + item = (ShapeType.STRUCTURE.value, ns, "TransactItem", 0, ("Get",), ((get, 0),)) + items = (ShapeType.LIST.value, ns, "TransactItems", 0, ("member",), ((item, 0),)) + tables = (ShapeType.MAP.value, ns, "Tables", 0, ("key", "value"), ((3, 0), (3, 0))) + request = ( + ShapeType.STRUCTURE.value, + ns, + "Request", + 0, + ("TransactItems", "RequestItems"), + ((items, 0), (tables, 0)), + ) + module = ModuleType("ns.example._private.schemas") + for name, schema in [("GET", get), ("ITEM", item), ("REQUEST", request)]: + setattr(module, name, schema) + return ModelRegistry( + module, + index={"Get": "GET", "TransactItem": "ITEM", "Request": "REQUEST"}, + shape_names={"Get", "TransactItem", "Request"}, + ) + + +def test_paths_over_dynamic_models() -> None: + registry = _dynamic_models() + get, item, request = (registry.build(n) for n in ("Get", "TransactItem", "Request")) + value = request( + transact_items=[item(get=get(table_name="a")), item(), item(get=get())], + request_items={"x": "1"}, + ) + assert compile("TransactItems[*].Get.TableName")(value) == ["a"] + assert compile("keys(RequestItems)")(value) == ["x"] diff --git a/packages/smithy-core/tests/unit/test_traits.py b/packages/smithy-core/tests/unit/test_traits.py index 623387411..fbb8a5c04 100644 --- a/packages/smithy-core/tests/unit/test_traits.py +++ b/packages/smithy-core/tests/unit/test_traits.py @@ -6,11 +6,14 @@ from smithy_core.prelude import UNIT from smithy_core.shapes import ShapeID from smithy_core.traits import ( + ContextParamTrait, DefaultTrait, DynamicTrait, ErrorFault, ErrorTrait, JSONNameTrait, + OperationContextParamsTrait, + StaticContextParamsTrait, Trait, UnitTypeTrait, ) @@ -69,3 +72,27 @@ def test_cant_construct_trait_from_non_matching_dynamic_trait(): dynamic = DynamicTrait(id=JSONNameTrait.id, document_value="client") with pytest.raises(ValueError): ErrorTrait(dynamic) + + +def test_trait_factory_constructs_context_param_trait(): + trait = Trait.new(ContextParamTrait.id, {"name": "Bucket"}) + assert isinstance(trait, ContextParamTrait) + assert trait.name == "Bucket" + + +def test_trait_factory_constructs_static_context_params_trait(): + trait = Trait.new( + StaticContextParamsTrait.id, + {"UseS3ExpressControlEndpoint": {"value": True}, "Tags": {"value": ["a"]}}, + ) + assert isinstance(trait, StaticContextParamsTrait) + assert trait.values == {"UseS3ExpressControlEndpoint": True, "Tags": ["a"]} + + +def test_trait_factory_constructs_operation_context_params_trait(): + trait = Trait.new( + OperationContextParamsTrait.id, + {"DeleteObjectKeys": {"path": "Delete.Objects[*].Key"}}, + ) + assert isinstance(trait, OperationContextParamsTrait) + assert trait.paths == {"DeleteObjectKeys": "Delete.Objects[*].Key"} diff --git a/packages/smithy-core/tests/unit/test_utils.py b/packages/smithy-core/tests/unit/test_utils.py index 522f7c17a..4a15a4731 100644 --- a/packages/smithy-core/tests/unit/test_utils.py +++ b/packages/smithy-core/tests/unit/test_utils.py @@ -11,6 +11,7 @@ from unittest.mock import Mock import pytest +from smithy_core import URI from smithy_core.exceptions import ExpectationNotMetError from smithy_core.utils import ( ensure_utc, @@ -18,6 +19,7 @@ expect_type, limited_parse_float, limited_serialize_float, + parse_uri, remove_dot_segments, serialize_epoch_seconds, serialize_float, @@ -314,3 +316,44 @@ def test_remove_dot_segments( input_path: str, remove_consecutive_slashes: bool, expected_path: str ) -> None: assert remove_dot_segments(input_path, remove_consecutive_slashes) == expected_path + + +@pytest.mark.parametrize( + "url, expected", + [ + ("https://example.com", URI(host="example.com")), + ("http://example.com:8080", URI(scheme="http", host="example.com", port=8080)), + ( + "https://u:p@example.com/a/b?c=d#e", + URI( + host="example.com", + username="u", + password="p", + path="/a/b", + query="c=d", + fragment="e", + ), + ), + ("https://[::1]:8443/x", URI(host="::1", port=8443, path="/x")), + # The host keeps its case; endpoint tests compare URLs exactly. + ("https://Example.com?x", URI(host="Example.com", query="x")), + ("https://Example.com/a/b", URI(host="Example.com", path="/a/b")), + ("http://example.com/", URI(scheme="http", host="example.com", path="/")), + ], +) +def test_parse_uri(url: str, expected: URI) -> None: + assert parse_uri(url) == expected + + +@pytest.mark.parametrize( + "url", + [ + "example.com", + "https://", + "https://example.com:99999", + "https://[::1/x", + ], +) +def test_parse_uri_raises(url: str) -> None: + with pytest.raises(ExpectationNotMetError): + parse_uri(url) diff --git a/packages/smithy-test/.changes/next-release/smithy-test-feature-3b00eb4d722846cc9a89f3181a541464.json b/packages/smithy-test/.changes/next-release/smithy-test-feature-3b00eb4d722846cc9a89f3181a541464.json new file mode 100644 index 000000000..42d1cd9dc --- /dev/null +++ b/packages/smithy-test/.changes/next-release/smithy-test-feature-3b00eb4d722846cc9a89f3181a541464.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added async checks for client endpoint tests in `smithy_test.aio`: `check_rules_case_async` resolves a case's parameter values with a client's endpoint resolver and `check_client_case_async` calls a case's operation through a client." +} diff --git a/packages/smithy-test/src/smithy_test/__init__.py b/packages/smithy-test/src/smithy_test/__init__.py index ebbec3f1b..6de6ea4fc 100644 --- a/packages/smithy-test/src/smithy_test/__init__.py +++ b/packages/smithy-test/src/smithy_test/__init__.py @@ -6,4 +6,9 @@ from ._private.equality import xml_equal as xml_equal __version__ = "0.1.0" -__all__ = ("ConformanceError", "deep_equal", "verify_models", "xml_equal") +__all__ = ( + "ConformanceError", + "deep_equal", + "verify_models", + "xml_equal", +) diff --git a/packages/smithy-test/src/smithy_test/_private/endpoints.py b/packages/smithy-test/src/smithy_test/_private/endpoints.py new file mode 100644 index 000000000..201c3497b --- /dev/null +++ b/packages/smithy-test/src/smithy_test/_private/endpoints.py @@ -0,0 +1,145 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Helpers for the ``smithy.rules#endpointTests`` checks in :py:mod:`smithy_test.aio`.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +from smithy_core import URI +from smithy_core.endpoints import ENDPOINT_HEADERS, RESOLVED_ENDPOINT, RulesExtension +from smithy_core.exceptions import SmithyError +from smithy_core.interceptors import Interceptor, RequestContext +from smithy_core.interfaces import Endpoint +from smithy_core.schemas import APIOperation +from smithy_core.shapes import ShapeType + + +class UnboundConfig: + """A config with no fields, so no built-in or client context parameter is bound.""" + + endpoint_uri: str | URI | None = None + + +# An operation with no traits and no input members, so no parameter is bound. +UNBOUND_OPERATION: APIOperation[Any, Any] = APIOperation( + static_schema=( + ShapeType.OPERATION.value, + "smithy.test", + "EndpointRules", + 0, + (ShapeType.STRUCTURE.value, "smithy.test", "EndpointRulesInput", 0, (), ()), + -1, + (), + ), +) + + +class EndpointCaptured(SmithyError): + """Stops a call once its endpoint is applied to the request.""" + + def __init__(self, endpoint: Endpoint, request: Any) -> None: + super().__init__("Endpoint captured") + self.endpoint = endpoint + self.request = request + + +class CaptureEndpoint(Interceptor[Any, Any, Any, Any]): + """Stops a call at the first hook after its endpoint is applied to the request.""" + + def read_before_signing(self, context: RequestContext[Any, Any]) -> None: + raise EndpointCaptured( + context.properties[RESOLVED_ENDPOINT], context.transport_request + ) + + +def check_endpoint( + expect: Mapping[str, Any], + endpoint: Endpoint, + extensions: Sequence[RulesExtension], +) -> None: + url = endpoint.uri.build() + if "error" in expect: + raise AssertionError(f"Expected the error {expect['error']!r}, got {url}") + expected = expect["endpoint"] + _check("url", url, expected["url"]) + expected_properties = dict(expected.get("properties", {})) + _check( + "authSchemes", + [ + {"scheme_id": str(scheme.scheme_id), **scheme.properties} + for scheme in endpoint.auth_schemes + ], + _expected_auth_schemes(expected_properties.pop("authSchemes", ()), extensions), + ) + properties = dict(endpoint.properties.items()) + _check( + "headers", + properties.pop(ENDPOINT_HEADERS.key, {}), + expected.get("headers", {}), + ) + _check("properties", properties, expected_properties) + + +def _expected_auth_schemes( + entries: Sequence[Mapping[str, Any]], extensions: Sequence[RulesExtension] +) -> list[dict[str, Any]]: + """A case's expected ``authSchemes`` as resolution gives them: each name mapped to + its scheme ID, and names no extension maps left out.""" + scheme_ids = { + name: scheme_id + for extension in extensions + for name, scheme_id in extension.auth_schemes.items() + } + expected: list[dict[str, Any]] = [] + for entry in entries: + properties = dict(entry) + scheme_id = scheme_ids.get(properties.pop("name")) + if scheme_id is not None: + expected.append({"scheme_id": str(scheme_id), **properties}) + return expected + + +def check_error(expect: Mapping[str, Any], error: Exception) -> None: + if "error" not in expect: + raise AssertionError( + f"Expected {expect['endpoint']['url']}, got the error {error}" + ) from error + _check("error", str(error), expect["error"]) + + +def check_destination(endpoint: Endpoint, request: Any) -> None: + destination = getattr(request, "destination", None) + if destination is None: + return + uri = endpoint.uri + if ( + (destination.scheme, destination.host, destination.port) + != (uri.scheme, uri.host, uri.port) + or not _extends(destination.path, (uri.path or "").rstrip("/"), "/") + or not _extends(destination.query, uri.query, "&") + ): + raise AssertionError( + f"Expected request destination under {uri.build()}," + f" got {destination.build()}" + ) + + +def _extends(value: str | None, base: str | None, separator: str) -> bool: + """Whether a value is its base, or its base and separator followed by more.""" + if not base: + return True + return value == base or (value or "").startswith(base + separator) + + +def _check(name: str, actual: Any, expected: Any) -> None: + # Rules values are tuples and read-only mappings; cases are lists and dicts. + if _plain(actual) != _plain(expected): + raise AssertionError(f"Expected {name} {expected!r}, got {actual!r}") + + +def _plain(value: Any) -> Any: + if isinstance(value, tuple | list): + return [_plain(v) for v in value] # pyright: ignore[reportUnknownVariableType] + if isinstance(value, Mapping): + return {k: _plain(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType] + return value diff --git a/packages/smithy-test/src/smithy_test/aio/__init__.py b/packages/smithy-test/src/smithy_test/aio/__init__.py new file mode 100644 index 000000000..1b59468cd --- /dev/null +++ b/packages/smithy-test/src/smithy_test/aio/__init__.py @@ -0,0 +1,6 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from ._endpoints import check_client_case_async as check_client_case_async +from ._endpoints import check_rules_case_async as check_rules_case_async + +__all__ = ("check_client_case_async", "check_rules_case_async") diff --git a/packages/smithy-test/src/smithy_test/aio/_endpoints.py b/packages/smithy-test/src/smithy_test/aio/_endpoints.py new file mode 100644 index 000000000..b9ec27677 --- /dev/null +++ b/packages/smithy-test/src/smithy_test/aio/_endpoints.py @@ -0,0 +1,104 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Checks for generated clients' ``smithy.rules#endpointTests`` cases. + +A case's ``params`` and ``expect`` are passed as they appear in the trait: ``expect`` +is ``{"endpoint": {"url": ..., "headers": ..., "properties": ...}}`` or +``{"error": ...}``. +""" + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any + +from smithy_core.aio.interfaces import AsyncEndpointResolver +from smithy_core.endpoints import ( + ENDPOINT_PARAMETERS, + STATIC_ENDPOINT_CONFIG, + EndpointResolverParams, + RulesExtension, +) +from smithy_core.exceptions import EndpointResolutionError +from smithy_core.interceptors import Interceptor +from smithy_core.types import TypedProperties + +from .._private.endpoints import ( + UNBOUND_OPERATION, + CaptureEndpoint, + EndpointCaptured, + UnboundConfig, + check_destination, + check_endpoint, + check_error, +) + + +async def check_rules_case_async( + resolver: AsyncEndpointResolver, + params: Mapping[str, Any], + expect: Mapping[str, Any], + *, + extensions: Sequence[RulesExtension] = (), +) -> None: + """Resolve a case's parameter values with an endpoint resolver and check the result. + + The values are set under ``ENDPOINT_PARAMETERS`` for an operation and a config + that bind no parameter, so only they reach the rules. + + :param resolver: The client's endpoint resolver. + :param params: The case's parameter values, by rules name. + :param expect: The case's expected endpoint or error. + :param extensions: The rules extensions the resolver uses. They map the auth scheme + names in the expected endpoint to scheme IDs. + :raises AssertionError: If the result isn't the expected endpoint or error. + """ + context = TypedProperties() + context[STATIC_ENDPOINT_CONFIG] = UnboundConfig() + context[ENDPOINT_PARAMETERS] = params + try: + endpoint = await resolver.resolve_endpoint( + EndpointResolverParams( + operation=UNBOUND_OPERATION, input=None, context=context + ) + ) + except EndpointResolutionError as e: + check_error(expect, e) + else: + check_endpoint(expect, endpoint, extensions) + + +async def check_client_case_async[C, I]( + client: Callable[[Interceptor[Any, Any, Any, Any]], Awaitable[C]], + operation: Callable[[C, I], Awaitable[Any]], + input: I, + expect: Mapping[str, Any], + *, + extensions: Sequence[RulesExtension] = (), +) -> None: + """Call a case's operation through a client and check the endpoint it resolves. + + :param client: Builds the client with an interceptor added to its config, which + stops the call once its endpoint is applied to the request, so nothing is + signed or sent. + :param operation: The client's operation method. + :param input: The operation input. + :param expect: The case's expected endpoint or error. + :param extensions: The rules extensions the client's endpoint resolver uses. They map + the auth scheme names in the expected endpoint to scheme IDs. + :raises AssertionError: If the call doesn't resolve the expected endpoint or error, + or doesn't send its request to the endpoint's host, under its path and query. + """ + instance = await client(CaptureEndpoint()) + try: + await operation(instance, input) + except EndpointCaptured as captured: + check_endpoint(expect, captured.endpoint, extensions) + check_destination(captured.endpoint, captured.request) + except EndpointResolutionError as e: + check_error(expect, e) + else: + raise AssertionError("The call completed without resolving an endpoint") + finally: + # Clients hold their transport until they're closed. + close = getattr(instance, "close", None) + if close is not None: + await close() diff --git a/packages/smithy-test/tests/unit/test_endpoints.py b/packages/smithy-test/tests/unit/test_endpoints.py new file mode 100644 index 000000000..4532bea75 --- /dev/null +++ b/packages/smithy-test/tests/unit/test_endpoints.py @@ -0,0 +1,187 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from typing import Any + +import pytest +from smithy_core import URI +from smithy_core.aio.endpoints import AsyncBddEndpointResolver +from smithy_core.documents import Document +from smithy_core.endpoints import ( + RESOLVED_ENDPOINT, + Endpoint, + EndpointBdd, + RulesExtension, +) +from smithy_core.endpoints._types import Tag +from smithy_core.exceptions import EndpointResolutionError +from smithy_core.interceptors import Interceptor, RequestContext +from smithy_core.shapes import ShapeID +from smithy_core.types import TypedProperties +from smithy_test.aio import check_client_case_async, check_rules_case_async + +# isSet(Region) gives a regional endpoint with properties and headers, else an error. +BDD = EndpointBdd( + parameters=( + ("Region", "string", False, None, "X::Region", False), + ("Bucket", "string", False, None, None, False), + ), + functions=("isSet",), + conditions=(((Tag.CALL, "isSet", (Tag.REF, "Region")), None),), + results=( + None, + ( + Tag.ENDPOINT, + "https://{Region}.example.com", + ( + Tag.MAP, + ("authSchemes", "backend"), + (Tag.LIST, (Tag.MAP, ("name", "signingRegion"), "sigv4", "{Region}")), + (Tag.LIST, "a", "{Bucket}"), + ), + (("x-region", ("{Region}",)),), + ), + (Tag.ERROR, "Missing Region"), + ), + root=2, + nodes=(-1, 1, -1, 0, 100_000_001, 100_000_002), +) + +ENDPOINT = { + "endpoint": { + "url": "https://us-west-2.example.com", + "headers": {"x-region": ["us-west-2"]}, + "properties": { + "authSchemes": [{"name": "sigv4", "signingRegion": "us-west-2"}], + "backend": ["a", "b"], + }, + } +} + + +# The built-in binding is set so the check shows it binds nothing from config. +EXTENSION = RulesExtension( + builtins={"X::Region": "region"}, auth_schemes={"sigv4": ShapeID("x.auth#sigv4")} +) + + +def _resolver() -> AsyncBddEndpointResolver: + return AsyncBddEndpointResolver(BDD, extensions=[EXTENSION]) + + +async def test_rules_cases_resolve_only_the_given_values() -> None: + await check_rules_case_async( + _resolver(), + {"Region": "us-west-2", "Bucket": "b"}, + ENDPOINT, + extensions=[EXTENSION], + ) + await check_rules_case_async(_resolver(), {}, {"error": "Missing Region"}) + + +@pytest.mark.parametrize( + "params, expect, message", + [ + ( + {"Region": "us-east-1", "Bucket": "b"}, + ENDPOINT, + "Expected url 'https://us-west-2.example.com'", + ), + ({"Region": "us-west-2", "Bucket": "c"}, ENDPOINT, "Expected properties"), + ( + {"Region": "us-west-2", "Bucket": "b"}, + {"endpoint": {"url": "https://us-west-2.example.com"}}, + "Expected authSchemes \\[\\]", + ), + ( + {"Region": "us-west-2", "Bucket": "b"}, + {"error": "Missing Region"}, + "Expected the error 'Missing Region'", + ), + ({}, ENDPOINT, "got the error Missing Region"), + ({}, {"error": "Other"}, "Expected error 'Other'"), + ], +) +async def test_rules_cases_fail_on_mismatches( + params: dict[str, Any], expect: dict[str, Any], message: str +) -> None: + with pytest.raises(AssertionError, match=message): + await check_rules_case_async( + _resolver(), params, expect, extensions=[EXTENSION] + ) + + +class _Request: + def __init__(self, destination: URI) -> None: + self.destination = destination + + +class _Client: + """Runs the hook the capture interceptor uses, as the request pipeline does.""" + + ENDPOINT = Endpoint(uri=URI(host="h.example.com", path="/p", query="a=b")) + + def __init__(self, interceptor: Interceptor[Any, Any, Any, Any]) -> None: + self.interceptor = interceptor + + async def send(self, destination: URI) -> None: + properties = TypedProperties() + properties[RESOLVED_ENDPOINT] = self.ENDPOINT + context: RequestContext[Any, Any] = RequestContext( + request=Document(), + transport_request=_Request(destination), + properties=properties, + ) + self.interceptor.read_before_signing(context) + + async def fail(self, input: None) -> None: + raise EndpointResolutionError("Missing Region") + + async def complete(self, input: None) -> None: + pass + + +async def _client(interceptor: Interceptor[Any, Any, Any, Any]) -> _Client: + return _Client(interceptor) + + +CLIENT_ENDPOINT = {"endpoint": {"url": "https://h.example.com/p?a=b"}} + + +async def test_client_cases_check_the_captured_endpoint_and_request() -> None: + destination = URI(host="h.example.com", path="/p/op", query="a=b&x=y") + await check_client_case_async(_client, _Client.send, destination, CLIENT_ENDPOINT) + + +@pytest.mark.parametrize( + "destination", + [ + URI(host="prefix.h.example.com", path="/p", query="a=b"), + URI(host="h.example.com", path="/op", query="a=b"), + URI(host="h.example.com", path="/pop", query="a=b"), + URI(host="h.example.com", path="/p/op", query="x=y"), + ], +) +async def test_client_cases_fail_on_requests_outside_the_endpoint( + destination: URI, +) -> None: + with pytest.raises(AssertionError, match="Expected request destination"): + await check_client_case_async( + _client, _Client.send, destination, CLIENT_ENDPOINT + ) + + +async def test_client_cases_check_resolution_errors() -> None: + await check_client_case_async( + _client, _Client.fail, None, {"error": "Missing Region"} + ) + with pytest.raises(AssertionError, match="got the error Missing Region"): + await check_client_case_async( + _client, _Client.fail, None, {"endpoint": {"url": "https://h.example.com"}} + ) + + +async def test_client_cases_fail_when_no_endpoint_is_resolved() -> None: + with pytest.raises(AssertionError, match="without resolving an endpoint"): + await check_client_case_async( + _client, _Client.complete, None, {"error": "Missing Region"} + ) diff --git a/scripts/api-snapshot/api.json b/scripts/api-snapshot/api.json index 28f12f110..08f82a824 100644 --- a/scripts/api-snapshot/api.json +++ b/scripts/api-snapshot/api.json @@ -165,17 +165,6 @@ "resolve_retry_mode": "function[async]", "resolve_sdk_ua_app_id": "function[async]" }, - "smithy_aws_core.aio.endpoints": {}, - "smithy_aws_core.aio.endpoints.standard_regional": { - "AsyncEndpointResolver": "rex-protocol[async]", - "AsyncStandardRegionalEndpointsResolver": "class[async]", - "Endpoint": "rex-class", - "EndpointResolutionError": "rex-exception", - "EndpointResolverParams": "rex-class", - "REGIONAL_ENDPOINT_CONFIG": "rex-PropertyKey", - "URI": "rex-class", - "resolve_static_uri": "rex-function" - }, "smithy_aws_core.aio.identity": { "AWSCredentialsIdentity": "rex-class", "AWSIdentityProperties": "rex-class", @@ -474,10 +463,7 @@ "validate_sdk_ua_app_id": "function" }, "smithy_aws_core.endpoints": { - "PropertyKey": "rex-class", - "REGIONAL_ENDPOINT_CONFIG": "PropertyKey", - "RegionalEndpointConfig": "class", - "StaticEndpointConfig": "rex-protocol" + "AWS_RULES_EXTENSION": "rex-RulesExtension" }, "smithy_aws_core.identity": { "AWSCredentialsIdentity": "rex-class", @@ -696,6 +682,7 @@ "SmithyError": "rex-exception", "URI": "class", "aio": "rex-module", + "auth": "rex-module", "codecs": "rex-module", "deserializers": "rex-module", "documents": "rex-module", @@ -749,6 +736,8 @@ "InterceptorChain": "rex-class", "OutputContext": "rex-class", "PropertyKey": "rex-class", + "RESOLVED_ENDPOINT": "rex-PropertyKey", + "RESOLVED_IDENTITY": "rex-PropertyKey", "Request": "rex-protocol[async]", "RequestContext": "rex-class", "Response": "rex-protocol[async]", @@ -766,11 +755,13 @@ "seek": "rex-function[async]" }, "smithy_core.aio.endpoints": { - "AsyncEndpointResolver": "rex-protocol[async]", + "AsyncBddEndpointResolver": "class[async]", "AsyncStaticEndpointResolver": "class[async]", "Endpoint": "rex-class", + "EndpointBdd": "rex-class", "EndpointResolutionError": "rex-exception", "EndpointResolverParams": "rex-class", + "RulesExtension": "rex-class", "resolve_static_uri": "rex-function" }, "smithy_core.aio.eventstream": { @@ -892,6 +883,8 @@ "DefaultAuthResolver": "class", "DeserializeableShape": "rex-protocol", "NoAuthResolver": "class", + "PropertyKey": "rex-class", + "RESOLVED_IDENTITY": "PropertyKey", "SerializeableShape": "rex-protocol", "ShapeID": "rex-class", "TypedProperties": "rex-class" @@ -927,17 +920,20 @@ "expect_type": "rex-function" }, "smithy_core.endpoints": { - "APIOperation": "rex-class", - "Endpoint": "class", - "EndpointResolutionError": "rex-exception", - "EndpointResolverParams": "class", - "PropertyKey": "rex-class", - "STATIC_ENDPOINT_CONFIG": "PropertyKey", - "SerializeableShape": "rex-protocol", - "StaticEndpointConfig": "protocol", - "TypedProperties": "rex-class", - "URI": "rex-class", - "resolve_static_uri": "function" + "BddEndpointResolver": "rex-class", + "BuiltinBinding": "rex-type_alias", + "BuiltinGetter": "rex-type_alias", + "ENDPOINT_HEADERS": "rex-PropertyKey", + "ENDPOINT_PARAMETERS": "rex-PropertyKey", + "Endpoint": "rex-class", + "EndpointAuthScheme": "rex-class", + "EndpointBdd": "rex-class", + "EndpointResolverParams": "rex-class", + "RESOLVED_ENDPOINT": "rex-PropertyKey", + "RulesExtension": "rex-class", + "STATIC_ENDPOINT_CONFIG": "rex-PropertyKey", + "StaticEndpointConfig": "rex-protocol", + "resolve_static_uri": "rex-function" }, "smithy_core.exceptions": { "AsyncBodyError": "exception", @@ -1084,6 +1080,7 @@ "REGULAR_NAME_RE": "str", "REG_NAME": "str", "SUB_DELIMITERS_RE": "str", + "UNRESERVED_HOST_MATCHER": "Pattern", "UNRESERVED_RE": "str", "ZONE_ID": "str", "variations": "list" @@ -1121,6 +1118,7 @@ }, "smithy_core.traits": { "APIKeyLocation": "class", + "ContextParamTrait": "class", "DefaultTrait": "class", "DynamicTrait": "class", "EndpointTrait": "class", @@ -1147,6 +1145,7 @@ "LongPollTrait": "class", "MediaTypeTrait": "class", "ORIGINAL_SHAPE_ID": "ShapeID", + "OperationContextParamsTrait": "class", "PathPattern": "rex-class", "RequiredTrait": "class", "RequiresLengthTrait": "class", @@ -1154,6 +1153,7 @@ "SensitiveTrait": "class", "ShapeID": "rex-class", "SparseTrait": "class", + "StaticContextParamsTrait": "class", "StreamingTrait": "class", "TimestampFormat": "rex-class", "TimestampFormatTrait": "class", @@ -1189,6 +1189,7 @@ "expect_type": "function", "limited_parse_float": "function", "limited_serialize_float": "function", + "parse_uri": "function", "remove_dot_segments": "function", "serialize_epoch_seconds": "function", "serialize_float": "function", @@ -1728,6 +1729,10 @@ "verify_models": "rex-function", "xml_equal": "rex-function" }, + "smithy_test.aio": { + "check_client_case_async": "rex-function[async]", + "check_rules_case_async": "rex-function[async]" + }, "smithy_xml": { "BytesReader": "rex-protocol", "BytesWriter": "rex-protocol",