diff --git a/codegen-output/aws-json-1-0/src/awsjson10/aio/config.py b/codegen-output/aws-json-1-0/src/awsjson10/aio/config.py index b559cc049..23c61aeff 100644 --- a/codegen-output/aws-json-1-0/src/awsjson10/aio/config.py +++ b/codegen-output/aws-json-1-0/src/awsjson10/aio/config.py @@ -151,6 +151,7 @@ class _AsyncJSONRPC10ConfigOverrides(AsyncAwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -220,6 +221,12 @@ class AsyncJSONRPC10Config(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -234,6 +241,7 @@ class AsyncJSONRPC10Config(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("json_rpc_10").program 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 981c13a0c..27f631ad7 100644 --- a/codegen-output/aws-json-1-0/src/awsjson10/config.py +++ b/codegen-output/aws-json-1-0/src/awsjson10/config.py @@ -146,6 +146,7 @@ class _JSONRPC10ConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -215,6 +216,12 @@ class JSONRPC10Config(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -229,6 +236,7 @@ class JSONRPC10Config(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("json_rpc_10").program diff --git a/codegen-output/aws-json-1-0/tests/test_awsjson10_protocol.py b/codegen-output/aws-json-1-0/tests/test_awsjson10_protocol.py index b8250b06a..87b5e8c5f 100644 --- a/codegen-output/aws-json-1-0/tests/test_awsjson10_protocol.py +++ b/codegen-output/aws-json-1-0/tests/test_awsjson10_protocol.py @@ -176,7 +176,6 @@ async def test_aws_json10_empty_input_and_empty_output_send_json_object_response assert deep_equal(actual, expected) -@mark.xfail() async def test_aws_json10_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -250,7 +249,6 @@ async def test_aws_json10_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_aws_json10_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): diff --git a/codegen-output/aws-json-1-1/src/awsjson11/aio/config.py b/codegen-output/aws-json-1-1/src/awsjson11/aio/config.py index a81f1d599..56c1a5663 100644 --- a/codegen-output/aws-json-1-1/src/awsjson11/aio/config.py +++ b/codegen-output/aws-json-1-1/src/awsjson11/aio/config.py @@ -145,6 +145,7 @@ class _AsyncJsonProtocolConfigOverrides(AsyncAwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -214,6 +215,12 @@ class AsyncJsonProtocolConfig(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -228,6 +235,7 @@ class AsyncJsonProtocolConfig(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("json_protocol").program 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 a22340af6..72abb4c51 100644 --- a/codegen-output/aws-json-1-1/src/awsjson11/config.py +++ b/codegen-output/aws-json-1-1/src/awsjson11/config.py @@ -140,6 +140,7 @@ class _JsonProtocolConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -209,6 +210,12 @@ class JsonProtocolConfig(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -223,6 +230,7 @@ class JsonProtocolConfig(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("json_protocol").program diff --git a/codegen-output/aws-json-1-1/tests/test_awsjson11_protocol.py b/codegen-output/aws-json-1-1/tests/test_awsjson11_protocol.py index 34d858576..04c2929a6 100644 --- a/codegen-output/aws-json-1-1/tests/test_awsjson11_protocol.py +++ b/codegen-output/aws-json-1-1/tests/test_awsjson11_protocol.py @@ -464,7 +464,6 @@ async def test_json_1_1_service_responds_with_no_payload_response_empty_operatio assert deep_equal(actual, expected) -@mark.xfail() async def test_aws_json11_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -541,7 +540,6 @@ async def test_aws_json11_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_aws_json11_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): diff --git a/codegen-output/aws-query/src/awsquery/aio/config.py b/codegen-output/aws-query/src/awsquery/aio/config.py index 5ad73a8d7..88deb1360 100644 --- a/codegen-output/aws-query/src/awsquery/aio/config.py +++ b/codegen-output/aws-query/src/awsquery/aio/config.py @@ -200,6 +200,7 @@ class _AsyncQueryProtocolConfigOverrides(AsyncAwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -269,6 +270,12 @@ class AsyncQueryProtocolConfig(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -283,6 +290,7 @@ class AsyncQueryProtocolConfig(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("query_protocol").program diff --git a/codegen-output/aws-query/src/awsquery/config.py b/codegen-output/aws-query/src/awsquery/config.py index 7fd6e1f35..029f3c717 100644 --- a/codegen-output/aws-query/src/awsquery/config.py +++ b/codegen-output/aws-query/src/awsquery/config.py @@ -195,6 +195,7 @@ class _QueryProtocolConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -264,6 +265,12 @@ class QueryProtocolConfig(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -278,6 +285,7 @@ class QueryProtocolConfig(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("query_protocol").program diff --git a/codegen-output/aws-query/tests/test_awsquery_protocol.py b/codegen-output/aws-query/tests/test_awsquery_protocol.py index 3bf0996c2..c1872d70e 100644 --- a/codegen-output/aws-query/tests/test_awsquery_protocol.py +++ b/codegen-output/aws-query/tests/test_awsquery_protocol.py @@ -267,7 +267,6 @@ async def test_query_empty_input_and_empty_output_response_empty_input_and_empty assert deep_equal(actual, expected) -@mark.xfail() async def test_aws_query_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -349,7 +348,6 @@ async def test_aws_query_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_aws_query_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): diff --git a/codegen-output/ec2-query/src/ec2query/aio/config.py b/codegen-output/ec2-query/src/ec2query/aio/config.py index 47f6e483a..6ee04ba00 100644 --- a/codegen-output/ec2-query/src/ec2query/aio/config.py +++ b/codegen-output/ec2-query/src/ec2query/aio/config.py @@ -169,6 +169,7 @@ class _AsyncEC2ProtocolConfigOverrides(AsyncAwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -238,6 +239,12 @@ class AsyncEC2ProtocolConfig(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -252,6 +259,7 @@ class AsyncEC2ProtocolConfig(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("ec2_protocol").program diff --git a/codegen-output/ec2-query/src/ec2query/config.py b/codegen-output/ec2-query/src/ec2query/config.py index 7d6daa5c4..905caf8f8 100644 --- a/codegen-output/ec2-query/src/ec2query/config.py +++ b/codegen-output/ec2-query/src/ec2query/config.py @@ -164,6 +164,7 @@ class _EC2ProtocolConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -233,6 +234,12 @@ class EC2ProtocolConfig(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -247,6 +254,7 @@ class EC2ProtocolConfig(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("ec2_protocol").program diff --git a/codegen-output/ec2-query/tests/test_ec2query_protocol.py b/codegen-output/ec2-query/tests/test_ec2query_protocol.py index d6fc8dc31..45b150f6d 100644 --- a/codegen-output/ec2-query/tests/test_ec2query_protocol.py +++ b/codegen-output/ec2-query/tests/test_ec2query_protocol.py @@ -255,7 +255,6 @@ async def test_ec2_query_empty_input_and_empty_output_response_empty_input_and_e assert deep_equal(actual, expected) -@mark.xfail() async def test_ec2_query_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -337,7 +336,6 @@ async def test_ec2_query_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_ec2_query_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): diff --git a/codegen-output/rest-json-1/src/restjson/aio/config.py b/codegen-output/rest-json-1/src/restjson/aio/config.py index 603eaf268..3a3378360 100644 --- a/codegen-output/rest-json-1/src/restjson/aio/config.py +++ b/codegen-output/rest-json-1/src/restjson/aio/config.py @@ -630,6 +630,7 @@ class _AsyncRestJsonProtocolConfigOverrides(AsyncAwsConfigOverrides, total=False auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -699,6 +700,12 @@ class AsyncRestJsonProtocolConfig(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -713,6 +720,7 @@ class AsyncRestJsonProtocolConfig(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("rest_json_protocol").program diff --git a/codegen-output/rest-json-1/src/restjson/config.py b/codegen-output/rest-json-1/src/restjson/config.py index 98be99027..b2527a14a 100644 --- a/codegen-output/rest-json-1/src/restjson/config.py +++ b/codegen-output/rest-json-1/src/restjson/config.py @@ -625,6 +625,7 @@ class _RestJsonProtocolConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -694,6 +695,12 @@ class RestJsonProtocolConfig(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -708,6 +715,7 @@ class RestJsonProtocolConfig(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("rest_json_protocol").program diff --git a/codegen-output/rest-json-1/tests/test_restjson_protocol.py b/codegen-output/rest-json-1/tests/test_restjson_protocol.py index b972806b9..431293a69 100644 --- a/codegen-output/rest-json-1/tests/test_restjson_protocol.py +++ b/codegen-output/rest-json-1/tests/test_restjson_protocol.py @@ -2076,7 +2076,6 @@ async def test_rest_json_empty_input_and_empty_output_json_object_output_respons assert deep_equal(actual, expected) -@mark.xfail() async def test_rest_json_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -2150,7 +2149,6 @@ async def test_rest_json_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_rest_json_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): diff --git a/codegen-output/rest-xml/src/restxml/aio/config.py b/codegen-output/rest-xml/src/restxml/aio/config.py index 422929adf..98823110f 100644 --- a/codegen-output/rest-xml/src/restxml/aio/config.py +++ b/codegen-output/rest-xml/src/restxml/aio/config.py @@ -367,6 +367,7 @@ class _AsyncRestXmlProtocolConfigOverrides(AsyncAwsConfigOverrides, total=False) auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -436,6 +437,12 @@ class AsyncRestXmlProtocolConfig(AsyncAwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -450,6 +457,7 @@ class AsyncRestXmlProtocolConfig(AsyncAwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AsyncAwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("rest_xml_protocol").program diff --git a/codegen-output/rest-xml/src/restxml/config.py b/codegen-output/rest-xml/src/restxml/config.py index 5263dbbba..4b9c51f89 100644 --- a/codegen-output/rest-xml/src/restxml/config.py +++ b/codegen-output/rest-xml/src/restxml/config.py @@ -362,6 +362,7 @@ class _RestXmlProtocolConfigOverrides(AwsConfigOverrides, total=False): auth_scheme_resolver: AuthSchemeResolver | None use_dualstack_endpoint: bool | None use_fips_endpoint: bool | None + disable_host_prefix_injection: bool | None @dataclass(kw_only=True, repr=False, init=False) @@ -431,6 +432,12 @@ class RestXmlProtocolConfig(AwsConfig): header. """ + disable_host_prefix_injection: bool | None = None + """ + Whether to skip prepending operations' modeled host prefixes to the + endpoint host. + """ + _FIELDS: ClassVar[dict[str, FieldSpec]] = { "use_dualstack_endpoint": FieldSpec( default=None, resolver=resolve_use_dualstack_endpoint_program @@ -445,6 +452,7 @@ class RestXmlProtocolConfig(AwsConfig): "aws_session_token": FieldSpec(default=None), "user_agent_extra": FieldSpec(default=None), "sdk_ua_app_id": FieldSpec(default=None), + "disable_host_prefix_injection": FieldSpec(default=None), **AwsConfig._FIELDS, "endpoint_uri": FieldSpec( default=None, resolver=EndpointUriResolver("rest_xml_protocol").program diff --git a/codegen-output/rest-xml/tests/test_restxml_protocol.py b/codegen-output/rest-xml/tests/test_restxml_protocol.py index 287a5e3a0..896a1079d 100644 --- a/codegen-output/rest-xml/tests/test_restxml_protocol.py +++ b/codegen-output/rest-xml/tests/test_restxml_protocol.py @@ -1214,7 +1214,6 @@ async def test_empty_input_and_empty_output_response_empty_input_and_empty_outpu assert deep_equal(actual, expected) -@mark.xfail() async def test_rest_xml_endpoint_trait_request_endpoint_operation() -> None: """ Operations can prepend to the given host if they define the endpoint @@ -1288,7 +1287,6 @@ async def test_rest_xml_endpoint_trait_request_endpoint_operation() -> None: ) -@mark.xfail() async def test_rest_xml_endpoint_trait_with_host_label_and_http_binding_request_endpoint_with_host_label_header_operation() -> ( None ): @@ -1367,7 +1365,6 @@ async def test_rest_xml_endpoint_trait_with_host_label_and_http_binding_request_ ) -@mark.xfail() async def test_rest_xml_endpoint_trait_with_host_label_request_endpoint_with_host_label_operation() -> ( None ): 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 index 374f933a0..6a735c3f5 100644 --- 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 @@ -20,6 +20,7 @@ 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.EndpointTrait; import software.amazon.smithy.model.traits.HttpTrait; import software.amazon.smithy.model.transform.ModelTransformer; import software.amazon.smithy.python.codegen.CodegenUtils; @@ -43,7 +44,8 @@ * 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. + * strips the bucket from the operation URIs because the rules place it. For S3 Control, it + * removes the {@code {AccountId}.} host prefix for the same reason. */ @SmithyInternalApi public final class AwsEndpointRulesIntegration implements PythonIntegration { @@ -74,9 +76,21 @@ public final class AwsEndpointRulesIntegration implements PythonIntegration { 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 S3_CONTROL_SDK_ID = "S3 Control"; + private static final String ACCOUNT_ID_HOST_PREFIX = "{AccountId}."; private static final String BUCKET = "Bucket"; private static final String BUCKET_LABEL = "/{" + BUCKET + "}"; + @Override + public Model preprocessModel(Model model, PythonSettings settings) { + var service = settings.service(model); + return switch (service.getTrait(ServiceTrait.class).map(ServiceTrait::getSdkId).orElse("")) { + case S3_SDK_ID -> stripBucketLabels(model, service); + case S3_CONTROL_SDK_ID -> stripAccountIdHostPrefix(model, service); + default -> model; + }; + } + /** * 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 @@ -84,12 +98,7 @@ public final class AwsEndpointRulesIntegration implements PythonIntegration { * 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; - } + private static Model stripBucketLabels(Model model, ServiceShape service) { var replaced = new ArrayList(); for (OperationShape operation : TopDownIndex.of(model).getContainedOperations(service)) { var http = operation.getTrait(HttpTrait.class); @@ -106,6 +115,22 @@ public Model preprocessModel(Model model, PythonSettings settings) { return ModelTransformer.create().replaceShapes(model, replaced); } + /** + * Removes S3 Control's {@code {AccountId}.} host prefix, as the endpoint rules SEP + * requires. The rules already place the account ID in the endpoint host, so the request + * would carry it twice. + */ + private static Model stripAccountIdHostPrefix(Model model, ServiceShape service) { + var replaced = new ArrayList(); + for (OperationShape operation : TopDownIndex.of(model).getContainedOperations(service)) { + var endpoint = operation.getTrait(EndpointTrait.class); + if (endpoint.isPresent() && endpoint.get().getHostPrefix().toString().equals(ACCOUNT_ID_HOST_PREFIX)) { + replaced.add(operation.toBuilder().removeTrait(EndpointTrait.ID).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(); diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java index 99642d257..19029170a 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java @@ -28,11 +28,7 @@ public final class AwsJson10ProtocolGenerator implements ProtocolGenerator { "AwsJson10ClientPopulatesDefaultValuesInInput", "AwsJson10ClientSkipsTopLevelDefaultValuesInInput", "AwsJson10ClientUsesExplicitlyProvidedMemberValuesOverDefaults", - "AwsJson10ClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional", - - // TODO: support the endpoint trait. - "AwsJson10EndpointTrait", - "AwsJson10EndpointTraitWithHostLabel"); + "AwsJson10ClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional"); @Override public ShapeId getProtocol() { diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java index 713ab3ae0..74b808739 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java @@ -22,11 +22,7 @@ public final class AwsJson11ProtocolGenerator implements ProtocolGenerator { private static final Set TESTS_TO_SKIP = Set.of( // TODO: support the request compression trait. "SDKAppliedContentEncoding_awsJson1_1", - "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_1", - - // TODO: support the endpoint trait. - "AwsJson11EndpointTrait", - "AwsJson11EndpointTraitWithHostLabel"); + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_1"); @Override public ShapeId getProtocol() { diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsQueryProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsQueryProtocolGenerator.java index 8075f6431..c4e755e18 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsQueryProtocolGenerator.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsQueryProtocolGenerator.java @@ -21,11 +21,7 @@ public final class AwsQueryProtocolGenerator implements ProtocolGenerator { // TODO: support the request compression trait // https://smithy.io/2.0/spec/behavior-traits.html#smithy-api-requestcompression-trait "SDKAppliedContentEncoding_awsQuery", - "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsQuery", - - // TODO: support of the endpoint trait - "AwsQueryEndpointTraitWithHostLabel", - "AwsQueryEndpointTrait"); + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsQuery"); @Override public ShapeId getProtocol() { diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/Ec2QueryProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/Ec2QueryProtocolGenerator.java index 30dd0b54c..0a6131c07 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/Ec2QueryProtocolGenerator.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/Ec2QueryProtocolGenerator.java @@ -21,11 +21,7 @@ public final class Ec2QueryProtocolGenerator implements ProtocolGenerator { // TODO: support the request compression trait // https://smithy.io/2.0/spec/behavior-traits.html#smithy-api-requestcompression-trait "SDKAppliedContentEncoding_ec2Query", - "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_ec2Query", - - // TODO: support of the endpoint trait - "Ec2QueryEndpointTraitWithHostLabel", - "Ec2QueryEndpointTrait"); + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_ec2Query"); @Override public ShapeId getProtocol() { diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/RestXmlProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/RestXmlProtocolGenerator.java index 02340265e..25276150c 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/RestXmlProtocolGenerator.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/RestXmlProtocolGenerator.java @@ -23,12 +23,7 @@ public final class RestXmlProtocolGenerator implements ProtocolGenerator { // TODO: support the request compression trait // https://smithy.io/2.0/spec/behavior-traits.html#smithy-api-requestcompression-trait "SDKAppliedContentEncoding_restXml", - "SDKAppendedGzipAfterProvidedEncoding_restXml", - - // TODO: support of the endpoint trait - "RestXmlEndpointTraitWithHostLabelAndHttpBinding", - "RestXmlEndpointTraitWithHostLabel", - "RestXmlEndpointTrait"); + "SDKAppendedGzipAfterProvidedEncoding_restXml"); @Override public ShapeId getProtocol() { diff --git a/codegen/aws/core/src/test/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegrationTest.java b/codegen/aws/core/src/test/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegrationTest.java new file mode 100644 index 000000000..2af1bbe92 --- /dev/null +++ b/codegen/aws/core/src/test/java/software/amazon/smithy/python/aws/codegen/AwsEndpointRulesIntegrationTest.java @@ -0,0 +1,84 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.EndpointTrait; +import software.amazon.smithy.python.codegen.PythonSettings; + +public class AwsEndpointRulesIntegrationTest { + + private static final String NS = "smithy.example"; + + @Test + public void testRemovesS3ControlAccountIdHostPrefix() { + var model = preprocess("S3 Control"); + + assertFalse(hasEndpointTrait(model, "AccountIdOperation")); + assertEquals("data.", hostPrefix(model, "StaticOperation")); + } + + @Test + public void testKeepsAccountIdHostPrefixOfOtherServices() { + var model = preprocess("Weather"); + + assertEquals("{AccountId}.", hostPrefix(model, "AccountIdOperation")); + assertTrue(hasEndpointTrait(model, "StaticOperation")); + } + + private static Model preprocess(String sdkId) { + Model model = Model.assembler() + .discoverModels(AwsEndpointRulesIntegrationTest.class.getClassLoader()) + .addUnparsedModel("test.smithy", """ + $version: "2" + namespace smithy.example + + use aws.api#service + + @service(sdkId: "%s") + service TestService { + version: "2024-01-01" + operations: [AccountIdOperation, StaticOperation] + } + + @endpoint(hostPrefix: "{AccountId}.") + operation AccountIdOperation { + input := { + @required + @hostLabel + AccountId: String + } + } + + @endpoint(hostPrefix: "data.") + operation StaticOperation {} + """.formatted(sdkId)) + .assemble() + .unwrap(); + PythonSettings settings = PythonSettings.builder() + .service(ShapeId.from(NS + "#TestService")) + .moduleName("test_client") + .moduleVersion("0.0.1") + .build(); + return new AwsEndpointRulesIntegration().preprocessModel(model, settings); + } + + private static boolean hasEndpointTrait(Model model, String operation) { + return model.expectShape(ShapeId.from(NS + "#" + operation)).hasTrait(EndpointTrait.class); + } + + private static String hostPrefix(Model model, String operation) { + return model.expectShape(ShapeId.from(NS + "#" + operation)) + .expectTrait(EndpointTrait.class) + .getHostPrefix() + .toString(); + } +} 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 index 2cb4494f9..4e186a548 100644 --- 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 @@ -20,7 +20,6 @@ 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; @@ -287,21 +286,14 @@ private void writeClientCase( .accept(new ValueNodeVisitor(context, w, inputShape))), expectation(testCase.getExpect()), id, - marks(writer, operation, skips)); + marks(writer, 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) { + // A skip for a case the client can't express. + private static String marks(PythonWriter writer, 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 ""; } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HostPrefixIntegration.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HostPrefixIntegration.java new file mode 100644 index 000000000..a8f8e4753 --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HostPrefixIntegration.java @@ -0,0 +1,38 @@ +/* + * 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.List; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.traits.EndpointTrait; +import software.amazon.smithy.python.codegen.ConfigProperty; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Adds the {@code disable_host_prefix_injection} config field to clients of services with + * operations that have the {@code smithy.api#endpoint} trait. + */ +@SmithyInternalApi +public final class HostPrefixIntegration implements PythonIntegration { + + private static final ConfigProperty DISABLE_HOST_PREFIX_INJECTION = ConfigProperty.builder() + .name("disable_host_prefix_injection") + .type(Symbol.builder().name("bool").build()) + .documentation("Whether to skip prepending operations' modeled host prefixes to the endpoint host.") + .build(); + + @Override + public List getClientPlugins(GenerationContext context) { + return List.of(RuntimeClientPlugin.builder() + .servicePredicate((model, service) -> TopDownIndex.of(model) + .getContainedOperations(service) + .stream() + .anyMatch(operation -> operation.hasTrait(EndpointTrait.class))) + .addConfigProperty(DISABLE_HOST_PREFIX_INJECTION) + .build()); + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java index 449bb181a..afe163aa5 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java @@ -34,10 +34,6 @@ public class RestJsonProtocolGenerator implements ProtocolGenerator { // This requires support of the httpChecksumRequired trait "RestJsonHttpChecksumRequired", - // These require support of the endpoint trait - "RestJsonEndpointTraitWithHostLabel", - "RestJsonEndpointTrait", - // TODO: support the request compression trait // https://smithy.io/2.0/spec/behavior-traits.html#smithy-api-requestcompression-trait "SDKAppliedContentEncoding_restJson1", diff --git a/codegen/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration b/codegen/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration index ccdf63695..88b4ce0b6 100644 --- a/codegen/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration +++ b/codegen/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration @@ -8,3 +8,4 @@ software.amazon.smithy.python.codegen.integrations.Rpcv2CborIntegration software.amazon.smithy.python.codegen.integrations.HttpApiKeyAuth software.amazon.smithy.python.codegen.integrations.UserAgentIntegration software.amazon.smithy.python.codegen.integrations.EndpointBddIntegration +software.amazon.smithy.python.codegen.integrations.HostPrefixIntegration diff --git a/designs/endpoints.md b/designs/endpoints.md index 9e2a8ab41..2f4b9c49d 100644 --- a/designs/endpoints.md +++ b/designs/endpoints.md @@ -607,6 +607,20 @@ 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=StaticEndpointResolver()` bypasses the rules. +### Host Prefixes + +An operation's `@endpoint` trait can give a host prefix, such as `data.` or +`{Bucket}.`, whose labels name `@hostLabel` input members. The pipeline expands +the prefix once per call, after serialization, and stores it under the +`HOST_PREFIX` context property. Each attempt prepends it to the resolved +endpoint's host before `set_service_endpoint`, so it applies to every protocol +and to a configured `endpoint_uri`. `RESOLVED_ENDPOINT` stays the resolver's +result. A label value that isn't a valid host label raises +`EndpointResolutionError` before the request is sent. Host labels don't change +serialization, so a label member is still sent to its own binding. Setting +`disable_host_prefix_injection`, a config field of clients with `@endpoint` +operations, turns the prefix off. + ### Endpoint Auth Schemes Endpoint rules can attach `authSchemes` to an endpoint, as every S3 endpoint @@ -657,9 +671,6 @@ Testing has two layers: ## 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 @@ -668,19 +679,10 @@ Testing has two layers: ## 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. +input on every call. Here it times the resolver's `resolve_endpoint`. These ran +on CPython 3.14.10. boto3's ran on botocore 1.43.108 on CPython 3.14.8. Both ran +on m7i.xlarge and m7g.xlarge in us-east-1. The resolver in this benchmark was +**partially optimized**; more improvements are still possible. Mean time per call, in µs: diff --git a/designs/http-binding-serde.md b/designs/http-binding-serde.md index 2b6e4bc90..66f671fc5 100644 --- a/designs/http-binding-serde.md +++ b/designs/http-binding-serde.md @@ -93,9 +93,9 @@ visible construction side effects. * A response header lookup table and list-header classification. * The modeled default response status. -An operation schema extension separately caches the resolved `@http` and -`@endpoint` traits, HTTP method, parsed path, static query string and names, -response status, host-prefix pattern, and greedy label names. +An operation schema extension separately caches the resolved `@http` trait, +HTTP method, parsed path, static query string and names, response status, and +greedy label names. The metadata uses tuples, immutable collections, read-only mappings, and frozen dataclasses so a cached value cannot be changed by one protocol instance and diff --git a/packages/smithy-core/.changes/next-release/smithy-core-feature-7bec5a4454354d9fbf1d8fbacc20ffc4.json b/packages/smithy-core/.changes/next-release/smithy-core-feature-7bec5a4454354d9fbf1d8fbacc20ffc4.json new file mode 100644 index 000000000..8fd82b879 --- /dev/null +++ b/packages/smithy-core/.changes/next-release/smithy-core-feature-7bec5a4454354d9fbf1d8fbacc20ffc4.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Clients now prepend the `@endpoint` trait's host prefix, with its `@hostLabel` values, to the endpoint host. Set `disable_host_prefix_injection` to turn this off." +} \ No newline at end of file diff --git a/packages/smithy-core/src/smithy_core/_pipeline.py b/packages/smithy-core/src/smithy_core/_pipeline.py index ad1fda210..c60f0831e 100644 --- a/packages/smithy-core/src/smithy_core/_pipeline.py +++ b/packages/smithy-core/src/smithy_core/_pipeline.py @@ -18,9 +18,19 @@ from . import URI from .auth import RESOLVED_IDENTITY, AuthParams from .deserializers import DeserializeableShape -from .endpoints import RESOLVED_ENDPOINT, EndpointAuthScheme, EndpointResolverParams +from .endpoints import ( + HOST_PREFIX, + RESOLVED_ENDPOINT, + EndpointAuthScheme, + EndpointResolverParams, +) +from .endpoints import Endpoint as _Endpoint +from .endpoints._standard_library import ( + _is_valid_host_label, # pyright: ignore[reportPrivateUsage] +) from .exceptions import ( ClientTimeoutError, + EndpointResolutionError, RetryError, SmithyError, UnsupportedAuthSchemeError, @@ -203,6 +213,11 @@ def _handle_execution[I: SerializeableShape, O: DeserializeableShape]( endpoint=_UNRESOLVED, context=input_context.properties, ) + host_prefix = _expand_host_prefix( + input_context.request, call.operation, input_context.properties + ) + if host_prefix is not None: + input_context.properties[HOST_PREFIX] = host_prefix request_context = RequestContext( request=input_context.request, transport_request=transport_request, @@ -419,10 +434,13 @@ def _handle_attempt[I: SerializeableShape, O: DeserializeableShape]( self._logger.debug("Endpoint resolver result: %s", endpoint) request_context.properties[RESOLVED_ENDPOINT] = endpoint + service_endpoint = endpoint + if (host_prefix := request_context.properties.get(HOST_PREFIX)) is not None: + service_endpoint = _prefix_host(endpoint, host_prefix) request_context = replace( request_context, transport_request=self.protocol.set_service_endpoint( - request=request_context.transport_request, endpoint=endpoint + request=request_context.transport_request, endpoint=service_endpoint ), ) @@ -704,3 +722,51 @@ def _fill_idempotency_token[I: SerializeableShape]( filled = copy(input) object.__setattr__(filled, field_name, _default_idempotency_token()) return filled + + +def _expand_host_prefix( + input: SerializeableShape, + operation: APIOperation[Any, Any], + context: TypedProperties, +) -> str | None: + """Expand the operation's endpoint host prefix with the input's host labels. + + Returns None if the operation has no host prefix or the config disables it. + + :raises EndpointResolutionError: If a host label value isn't a valid host label. + """ + host_prefix = operation._host_prefix # pyright: ignore[reportPrivateUsage] + if host_prefix is None or getattr( + context.get("config"), "disable_host_prefix_injection", False + ): + return None + template, labels = host_prefix + values: dict[str, str] = {} + for label, field_name in labels: + value: str | None = getattr(input, field_name, None) + if value is None or not _is_valid_host_label(value, allow_sub_domains=False): + raise EndpointResolutionError( + f"{label} is part of the endpoint host, so it must be 1 to 63 " + f"letters, digits, or hyphens that start and end with a letter or " + f"digit, but was {value!r}" + ) + values[label] = value + return template.format_map(values) + + +def _prefix_host(endpoint: Endpoint, host_prefix: str) -> Endpoint: + uri = endpoint.uri + return _Endpoint( + uri=URI( + scheme=uri.scheme, + username=uri.username, + password=uri.password, + host=host_prefix + uri.host, + port=uri.port, + path=uri.path, + query=uri.query, + fragment=uri.fragment, + ), + properties=endpoint.properties, + auth_schemes=endpoint.auth_schemes, + ) diff --git a/packages/smithy-core/src/smithy_core/endpoints/__init__.py b/packages/smithy-core/src/smithy_core/endpoints/__init__.py index f6d44c717..f13c268c7 100644 --- a/packages/smithy-core/src/smithy_core/endpoints/__init__.py +++ b/packages/smithy-core/src/smithy_core/endpoints/__init__.py @@ -4,6 +4,7 @@ from ._types import ( ENDPOINT_HEADERS, ENDPOINT_PARAMETERS, + HOST_PREFIX, RESOLVED_ENDPOINT, STATIC_ENDPOINT_CONFIG, BuiltinBinding, @@ -20,6 +21,7 @@ __all__ = [ "ENDPOINT_HEADERS", "ENDPOINT_PARAMETERS", + "HOST_PREFIX", "RESOLVED_ENDPOINT", "STATIC_ENDPOINT_CONFIG", "BddEndpointResolver", diff --git a/packages/smithy-core/src/smithy_core/endpoints/_types.py b/packages/smithy-core/src/smithy_core/endpoints/_types.py index 59470b084..bc46eb67e 100644 --- a/packages/smithy-core/src/smithy_core/endpoints/_types.py +++ b/packages/smithy-core/src/smithy_core/endpoints/_types.py @@ -65,6 +65,13 @@ class Endpoint(_Endpoint): Resolvers cache endpoints, so other calls may share this object: don't modify it. """ +HOST_PREFIX: PropertyKey[str] = PropertyKey(key="host_prefix", value_type=str) +"""A call context property holding the operation's expanded endpoint host prefix. + +It's prepended to the host of every attempt's resolved endpoint. It's unset when the +operation has no host prefix or the config sets ``disable_host_prefix_injection``. +""" + ENDPOINT_PARAMETERS: PropertyKey[Mapping[str, Any]] = PropertyKey( key="endpoint_parameters", value_type=Mapping[str, Any], diff --git a/packages/smithy-core/src/smithy_core/schemas.py b/packages/smithy-core/src/smithy_core/schemas.py index 7a7f1fe35..2b1950c8f 100644 --- a/packages/smithy-core/src/smithy_core/schemas.py +++ b/packages/smithy-core/src/smithy_core/schemas.py @@ -18,6 +18,7 @@ from .shapes import ShapeID, ShapeType from .traits import ( DynamicTrait, + EndpointTrait, IdempotencyTokenTrait, LongPollTrait, StreamingTrait, @@ -517,3 +518,34 @@ def _idempotency_token_field(self) -> str | None: carries_response_metadata=False, is_enum=False, ) + + @cached_property + def _host_prefix(self) -> tuple[str, tuple[tuple[str, str], ...]] | None: + """The endpoint trait's host prefix, and the label and Python attribute name + of each input member it reads, or None. + + Cached because the client reads it on every call. + """ + trait = self.schema.get_trait(EndpointTrait) + if trait is None: + return None + + from string import Formatter + + from ._model_naming import member_field_name + + # Labels are member names, which are valid format fields. + labels = tuple( + ( + label, + member_field_name( + label, + is_error=False, + carries_response_metadata=False, + is_enum=False, + ), + ) + for _, label, _, _ in Formatter().parse(trait.host_prefix) + if label + ) + return trait.host_prefix, labels diff --git a/packages/smithy-core/tests/unit/aio/_pipeline_harness.py b/packages/smithy-core/tests/unit/aio/_pipeline_harness.py index 2b335dbb3..7591f4bb2 100644 --- a/packages/smithy-core/tests/unit/aio/_pipeline_harness.py +++ b/packages/smithy-core/tests/unit/aio/_pipeline_harness.py @@ -18,7 +18,7 @@ from smithy_core.schemas import APIOperation, Schema from smithy_core.serializers import ShapeSerializer from smithy_core.shapes import ShapeID, ShapeType -from smithy_core.traits import IdempotencyTokenTrait, StreamingTrait +from smithy_core.traits import HostLabelTrait, IdempotencyTokenTrait, StreamingTrait from smithy_core.types import TypedProperties _STRING = Schema(id=ShapeID("smithy.api#String"), shape_type=ShapeType.STRING) @@ -182,6 +182,7 @@ def __init__(self) -> None: self.serialize_request_calls = 0 self.serialized_inputs: list[Any] = [] self.set_service_endpoint_calls = 0 + self.service_endpoints: list[Any] = [] self.deserialize_response_calls = 0 self.create_event_publisher_calls = 0 self.create_event_receiver_calls = 0 @@ -205,6 +206,7 @@ def serialize_request(self, **kwargs: Any) -> StubRequest: def set_service_endpoint(self, *, request: Any, endpoint: Any) -> Any: self.set_service_endpoint_calls += 1 + self.service_endpoints.append(endpoint) return request def deserialize_response_program(self, **kwargs: Any) -> Program[StubOutput]: @@ -357,3 +359,36 @@ class TokenInput: def serialize(self, serializer: ShapeSerializer) -> None: pass + + +def host_prefix_operation(host_prefix: str) -> APIOperation[Any, StubOutput]: + """An operation with an endpoint trait whose input has two host label members.""" + return APIOperation( + static_schema=( + ShapeType.OPERATION.value, + "com.example", + "HostPrefixOperation", + {"smithy.api#endpoint": {"hostPrefix": host_prefix}}, + Schema.collection( + id=ShapeID("com.example#HostPrefixInput"), + members={ + "Bucket": {"target": _STRING, "traits": [HostLabelTrait()]}, + "AccountId": {"target": _STRING, "traits": [HostLabelTrait()]}, + }, + ), + _UNARY_OUTPUT_SCHEMA, + (), + ), + effective_auth_schemes=[], + ) + + +@dataclass(kw_only=True) +class HostPrefixInput: + """Shaped like a generated operation input with host label members.""" + + bucket: str | None = None + account_id: str | None = None + + def serialize(self, serializer: ShapeSerializer) -> None: + pass diff --git a/packages/smithy-core/tests/unit/aio/test_client.py b/packages/smithy-core/tests/unit/aio/test_client.py index f582a5b6d..5200e1b10 100644 --- a/packages/smithy-core/tests/unit/aio/test_client.py +++ b/packages/smithy-core/tests/unit/aio/test_client.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Callable, Iterator from dataclasses import dataclass, field, replace from io import BytesIO, UnsupportedOperation +from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch @@ -17,10 +18,11 @@ from smithy_core.auth import RESOLVED_IDENTITY, AuthOption from smithy_core.client import OPERATION as OPERATION_PROPERTY from smithy_core.client import ClientCall -from smithy_core.endpoints import RESOLVED_ENDPOINT, EndpointAuthScheme +from smithy_core.endpoints import HOST_PREFIX, RESOLVED_ENDPOINT, EndpointAuthScheme from smithy_core.exceptions import ( CallError, ClientTimeoutError, + EndpointResolutionError, ResourceLifecycleError, SmithyError, UnsupportedAuthSchemeError, @@ -50,7 +52,9 @@ DATA_STREAM_OPERATION, OPERATION, TOKEN_OPERATION, + UNARY_OPERATION, DuplexTransport, + HostPrefixInput, NonDuplexTransport, StubEndpoint, StubEndpointResolver, @@ -62,6 +66,7 @@ TokenInput, UndeclaredTransport, client_call, + host_prefix_operation, pipeline_harness, retryable_client_call, ) @@ -1110,3 +1115,104 @@ async def raise_error(**kwargs: Any) -> StubOutput: with pytest.raises(CallError, match="original"): await harness.pipeline(retryable_client_call()) + + +def _host_prefix_call( + host_prefix: str, + input: HostPrefixInput, + *, + disabled: bool = False, + retry_strategy: Any = None, +) -> Any: + config = SimpleNamespace(disable_host_prefix_injection=disabled) + return replace( + retryable_client_call(retry_strategy), + input=input, + operation=host_prefix_operation(host_prefix), + context=TypedProperties({"config": config}), + ) + + +# Includes the cases from the appendix of the AWS SDK host prefix SEP. +@pytest.mark.parametrize( + ("host_prefix", "input", "disabled", "host"), + [ + ("data.", HostPrefixInput(), False, "data.example.com"), + ( + "{Bucket}-{AccountId}.", + HostPrefixInput(bucket="SomeName", account_id="1234567890"), + False, + "SomeName-1234567890.example.com", + ), + ("data.", HostPrefixInput(), True, "example.com"), + ( + "{Bucket}.", + HostPrefixInput(bucket="attacker.example:1"), + True, + "example.com", + ), + ], +) +async def test_host_prefix_is_prepended_to_the_resolved_endpoint_host( + host_prefix: str, input: HostPrefixInput, disabled: bool, host: str +) -> None: + harness = pipeline_harness(NonDuplexTransport()) + call = _host_prefix_call(host_prefix, input, disabled=disabled) + + await harness.pipeline(call) + + [endpoint] = harness.protocol.service_endpoints + assert endpoint.uri.host == host + assert call.context[RESOLVED_ENDPOINT].uri.host == "example.com" + assert harness.protocol.serialized_inputs == [input] + + +@pytest.mark.parametrize( + "bucket", [None, "", "attacker.example:1", "a.b", "-a", "a" * 64] +) +async def test_invalid_host_label_fails_the_call_before_it_is_sent( + bucket: str | None, +) -> None: + harness = pipeline_harness(NonDuplexTransport()) + resolver = StubEndpointResolver() + call = replace( + _host_prefix_call("{Bucket}.", HostPrefixInput(bucket=bucket)), + endpoint_resolver=resolver, + ) + + with pytest.raises( + EndpointResolutionError, match="Bucket is part of the endpoint host" + ): + await harness.pipeline(call) + + assert resolver.resolved_inputs == [] + assert harness.transport.send_calls == 0 + + +async def test_host_prefix_is_prepended_once_on_every_attempt() -> None: + harness = pipeline_harness(_FailingTwiceTransport()) + strategy = SimpleRetryStrategy( + backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=0), + max_attempts=3, + ) + call = _host_prefix_call( + "{Bucket}.", HostPrefixInput(bucket="bucket"), retry_strategy=strategy + ) + + await harness.pipeline(call) + + hosts = [endpoint.uri.host for endpoint in harness.protocol.service_endpoints] + assert hosts == ["bucket.example.com"] * 3 + assert call.context[HOST_PREFIX] == "bucket." + + +def test_host_prefix_and_label_field_names_are_cached() -> None: + operation = host_prefix_operation("{Bucket}-{AccountId}.") + first = operation._host_prefix # pyright: ignore[reportPrivateUsage] + + assert first == ( + "{Bucket}-{AccountId}.", + (("Bucket", "bucket"), ("AccountId", "account_id")), + ) + assert operation.__dict__["_host_prefix"] is first + assert UNARY_OPERATION._host_prefix is None # pyright: ignore[reportPrivateUsage] diff --git a/packages/smithy-core/tests/unit/test_client.py b/packages/smithy-core/tests/unit/test_client.py index 9b4d7ab0a..0ec9b00d1 100644 --- a/packages/smithy-core/tests/unit/test_client.py +++ b/packages/smithy-core/tests/unit/test_client.py @@ -18,10 +18,12 @@ from .aio._pipeline_harness import ( TOKEN_OPERATION, + HostPrefixInput, StubOutput, StubProtocol, StubResponse, TokenInput, + host_prefix_operation, retryable_client_call, ) @@ -213,3 +215,20 @@ def test_sync_pipeline_fills_unset_idempotency_token() -> None: [serialized] = protocol.serialized_inputs assert serialized.client_token == "sync-token" assert input.client_token is None + + +def test_sync_pipeline_prepends_host_prefix() -> None: + protocol = _SyncProtocol() + pipeline: RequestPipeline[Any, Any] = RequestPipeline( + protocol=cast(Any, protocol), transport=cast(Any, _SyncTransport()) + ) + call = replace( + retryable_client_call(), + input=HostPrefixInput(bucket="bucket"), + operation=host_prefix_operation("{Bucket}."), + ) + + pipeline(call) + + [endpoint] = protocol.service_endpoints + assert endpoint.uri.host == "bucket.example.com" diff --git a/packages/smithy-http/.changes/next-release/smithy-http-breaking-de24dc2d086746e1b9a00555221f866b.json b/packages/smithy-http/.changes/next-release/smithy-http-breaking-de24dc2d086746e1b9a00555221f866b.json new file mode 100644 index 000000000..226eba915 --- /dev/null +++ b/packages/smithy-http/.changes/next-release/smithy-http-breaking-de24dc2d086746e1b9a00555221f866b.json @@ -0,0 +1,4 @@ +{ + "type": "breaking", + "description": "Removed `HostPrefixSerializer` and `Binding.HOST`. `@hostLabel` no longer changes where a member is serialized and smithy-core now applies host prefixes." +} \ No newline at end of file diff --git a/packages/smithy-http/src/smithy_http/bindings.py b/packages/smithy-http/src/smithy_http/bindings.py index 5393117f4..2bd304baa 100644 --- a/packages/smithy-http/src/smithy_http/bindings.py +++ b/packages/smithy-http/src/smithy_http/bindings.py @@ -8,7 +8,6 @@ from smithy_core.traits import ( ErrorFault, ErrorTrait, - HostLabelTrait, HTTPErrorTrait, HTTPHeaderTrait, HTTPLabelTrait, @@ -50,9 +49,6 @@ class Binding(Enum): QUERY_PARAMS = 7 """Indicates the member is bound to the query string as multiple key-value pairs.""" - HOST = 8 - """Indicates the member is bound to a prefix to the host AND to the body.""" - @dataclass(init=False) class _BindingMatcher: @@ -86,9 +82,7 @@ def __init__(self, struct: Schema, response_status: int) -> None: for member in members: binding = self._do_match(member) self.bindings[member.expect_member_index()] = binding - found_body = ( - found_body or binding is Binding.BODY or binding is Binding.HOST - ) + found_body = found_body or binding is Binding.BODY if binding is Binding.PAYLOAD: found_payload = True self.payload_member = member @@ -140,8 +134,6 @@ def _do_match(self, member: Schema) -> Binding: return Binding.PREFIX_HEADERS if HTTPPayloadTrait.id in member.traits: return Binding.PAYLOAD - if HostLabelTrait.id in member.traits: - return Binding.HOST return Binding.BODY diff --git a/packages/smithy-http/src/smithy_http/schema_extensions.py b/packages/smithy-http/src/smithy_http/schema_extensions.py index f97b6bd7a..497371f78 100644 --- a/packages/smithy-http/src/smithy_http/schema_extensions.py +++ b/packages/smithy-http/src/smithy_http/schema_extensions.py @@ -8,8 +8,6 @@ from smithy_core.schemas import Schema, SchemaExtension from smithy_core.shapes import ShapeType from smithy_core.traits import ( - EndpointTrait, - HostLabelTrait, HTTPHeaderTrait, HTTPLabelTrait, HTTPPrefixHeadersTrait, @@ -65,7 +63,7 @@ class HTTPMemberBindingMetadata: """The member's response-direction binding.""" request_wire_name: str | None - """Request header, query, prefix, label, or host-label name.""" + """Request header, query, prefix, or label name.""" response_wire_name: str | None """Response header or prefix name.""" @@ -258,19 +256,12 @@ class HTTPOperationSchemaMetadata: response_status: int """The operation's default HTTP response status.""" - host_prefix: str - """The operation's endpoint host-prefix pattern.""" - greedy_label_names: frozenset[str] """Names of greedy labels in the URI path.""" @classmethod - def from_traits( - cls, - http_trait: HTTPTrait, - endpoint_trait: EndpointTrait | None = None, - ) -> "HTTPOperationSchemaMetadata": - """Build operation metadata from already-resolved traits.""" + def from_traits(cls, http_trait: HTTPTrait) -> "HTTPOperationSchemaMetadata": + """Build operation metadata from an already-resolved ``@http`` trait.""" query = http_trait.query query_literal_names: frozenset[str] = ( frozenset(unquote(entry.partition("=")[0]) for entry in query.split("&")) @@ -284,9 +275,6 @@ def from_traits( query=query, query_literal_names=query_literal_names, response_status=http_trait.code, - host_prefix=endpoint_trait.host_prefix - if endpoint_trait is not None - else "", greedy_label_names=frozenset(http_trait.path.greedy_labels), ) @@ -301,8 +289,6 @@ def _wire_name(member: Schema, binding: Binding) -> str | None: return member.expect_trait(HTTPPrefixHeadersTrait).prefix case Binding.LABEL if HTTPLabelTrait in member: return member.expect_member_name() - case Binding.HOST if HostLabelTrait in member: - return member.expect_member_name() case _: return None @@ -457,9 +443,7 @@ def _build_http_binding_schema_metadata(schema: Schema) -> HTTPBindingSchemaMeta members=member_metadata, request=HTTPRequestBindingMetadata( bindings=request_bindings, - body_members=tuple( - binding in (Binding.BODY, Binding.HOST) for binding in request_bindings - ), + body_members=tuple(binding is Binding.BODY for binding in request_bindings), has_body=request_matcher.has_body, payload=request_payload, event_stream_member=request_matcher.event_stream_member, @@ -489,10 +473,7 @@ def _build_http_binding_schema_metadata(schema: Schema) -> HTTPBindingSchemaMeta def _build_http_operation_schema_metadata( schema: Schema, ) -> HTTPOperationSchemaMetadata: - return HTTPOperationSchemaMetadata.from_traits( - http_trait=schema.expect_trait(HTTPTrait), - endpoint_trait=schema.get_trait(EndpointTrait), - ) + return HTTPOperationSchemaMetadata.from_traits(schema.expect_trait(HTTPTrait)) HTTP_BINDING_SCHEMA_EXTENSION = SchemaExtension(_build_http_binding_schema_metadata) diff --git a/packages/smithy-http/src/smithy_http/serializers.py b/packages/smithy-http/src/smithy_http/serializers.py index 134ed06c5..1e98922ee 100644 --- a/packages/smithy-http/src/smithy_http/serializers.py +++ b/packages/smithy-http/src/smithy_http/serializers.py @@ -22,7 +22,6 @@ SpecificShapeSerializer, ) from smithy_core.traits import ( - EndpointTrait, HTTPHeaderTrait, HTTPPrefixHeadersTrait, HTTPQueryTrait, @@ -65,7 +64,6 @@ "HTTPResponseBindingSerializer", "HTTPResponseCodeSerializer", "HTTPResponseSerializer", - "HostPrefixSerializer", "RawPayloadSerializer", ] @@ -82,7 +80,6 @@ def __init__( self, payload_codec: Codec, http_trait: HTTPTrait, - endpoint_trait: EndpointTrait | None = None, omit_empty_payload: bool = True, operation_metadata: HTTPOperationSchemaMetadata | None = None, event_stream_body: "Callable[[], AsyncStreamingBlob] | None" = None, @@ -92,8 +89,6 @@ def __init__( :param payload_codec: The codec to use to serialize the HTTP payload, if one is present. :param http_trait: The HTTP trait of the operation being handled. - :param endpoint_trait: The optional endpoint trait of the operation being - handled. :param omit_empty_payload: Whether an empty payload should be omitted. :param operation_metadata: Optional cached operation binding metadata. :param event_stream_body: Creates the body that event stream events are @@ -101,7 +96,6 @@ def __init__( """ self._event_stream_body = event_stream_body self._http_trait = http_trait - self._endpoint_trait = endpoint_trait self._payload_codec = payload_codec self._omit_empty_payload = omit_empty_payload self._operation_metadata = operation_metadata @@ -113,7 +107,6 @@ def begin_struct(self, schema: Schema) -> Iterator[ShapeSerializer]: payload_codec=self._payload_codec, schema=schema, http_trait=self._http_trait, - endpoint_trait=self._endpoint_trait, omit_empty_payload=self._omit_empty_payload, operation_metadata=self._operation_metadata, event_stream_body=self._event_stream_body, @@ -141,7 +134,6 @@ def __init__( payload_codec: Codec, schema: Schema, http_trait: HTTPTrait, - endpoint_trait: EndpointTrait | None = None, omit_empty_payload: bool = True, operation_metadata: HTTPOperationSchemaMetadata | None = None, event_stream_body: "Callable[[], AsyncStreamingBlob] | None" = None, @@ -151,14 +143,13 @@ def __init__( :param payload_codec: The codec used to serialize document-bound members. :param schema: The structure schema whose members are being serialized. :param http_trait: The HTTP trait of the operation being handled. - :param endpoint_trait: The optional endpoint trait of the operation. :param omit_empty_payload: Whether an empty document payload should be omitted. :param operation_metadata: Optional cached operation binding metadata. :param event_stream_body: Creates the body that event stream events are written to. Event stream inputs are unsupported without it. """ - self._operation_metadata = operation_metadata or ( - HTTPOperationSchemaMetadata.from_traits(http_trait, endpoint_trait) + self._operation_metadata = ( + operation_metadata or HTTPOperationSchemaMetadata.from_traits(http_trait) ) self._binding_metadata: HTTPBindingSchemaMetadata = schema.get_extension( HTTP_BINDING_SCHEMA_EXTENSION @@ -218,10 +209,6 @@ def __init__( self._operation_metadata.path, greedy_label_names=self._operation_metadata.greedy_label_names, ) - self.host_prefix_serializer: HostPrefixSerializer = HostPrefixSerializer( - payload_serializer, - self._operation_metadata.host_prefix, - ) def before(self, schema: Schema) -> ShapeSerializer: """Return the serializer for the HTTP binding of ``schema``.""" @@ -236,9 +223,6 @@ def before(self, schema: Schema) -> ShapeSerializer: case Binding.LABEL: self.path_serializer.set_member_metadata(metadata) return self.path_serializer - case Binding.HOST: - self.host_prefix_serializer.set_member_metadata(metadata) - return self.host_prefix_serializer case _: return self._payload_serializer @@ -293,7 +277,7 @@ def build_request(self) -> HTTPRequest: self._result = _HTTPRequest( method=self._operation_metadata.method, destination=URI( - host=self.host_prefix_serializer.host_prefix, + host="", path=self.path_serializer.path, query=join_query_params( params=self.query_serializer.query_params, @@ -328,16 +312,12 @@ def __init__( self, payload_serializer: ShapeSerializer, path_pattern: PathPattern, - host_prefix_pattern: str, binding_matcher: RequestBindingMatcher, ) -> None: self._payload_serializer = payload_serializer self.header_serializer: HTTPHeaderSerializer = HTTPHeaderSerializer() self.query_serializer: HTTPQuerySerializer = HTTPQuerySerializer() self.path_serializer: HTTPPathSerializer = HTTPPathSerializer(path_pattern) - self.host_prefix_serializer: HostPrefixSerializer = HostPrefixSerializer( - payload_serializer, host_prefix_pattern - ) self._binding_matcher = binding_matcher def before(self, schema: Schema) -> ShapeSerializer: @@ -348,8 +328,6 @@ def before(self, schema: Schema) -> ShapeSerializer: return self.query_serializer case Binding.LABEL: return self.path_serializer - case Binding.HOST: - return self.host_prefix_serializer case _: return self._payload_serializer @@ -985,54 +963,6 @@ def begin_list(self, schema: Schema, size: int) -> Iterator[ShapeSerializer]: yield self -class HostPrefixSerializer(SpecificShapeSerializer): - """Binds properites of a serializable shape to the HTTP URI host. - - These properties are also bound to the payload. - """ - - def __init__( - self, - payload_serializer: ShapeSerializer, - host_prefix_pattern: str, - ) -> None: - """Initialize a HostPrefixSerializer. - - :param host_prefix_pattern: The pattern to bind properties to. - :param payload_serializer: The payload serializer to additionally write - properties to. - """ - self._prefix_params: dict[str, str] = {} - self._host_prefix_pattern = host_prefix_pattern - self._payload_serializer = payload_serializer - self._member_metadata: HTTPMemberBindingMetadata | None = None - - def set_member_metadata(self, metadata: HTTPMemberBindingMetadata) -> None: - """Set the cached plan for the next top-level member write.""" - self._member_metadata = metadata - - @property - def host_prefix(self) -> str: - """The formatted host prefix. - - This must not be accessed before serialization is complete, otherwise an - exception will be raised. - """ - return self._host_prefix_pattern.format(**self._prefix_params) - - def write_string(self, schema: Schema, value: str) -> None: - self._payload_serializer.write_string(schema, value) - self._prefix_params[self._name_for(schema)] = urlquote(value, safe=".") - - def _name_for(self, schema: Schema) -> str: - if ( - self._member_metadata is not None - and self._member_metadata.request_wire_name is not None - ): - return self._member_metadata.request_wire_name - return schema.expect_member_name() - - class HTTPResponseCodeSerializer(SpecificShapeSerializer): """Binds properties of a serializable shape to the HTTP response code.""" diff --git a/packages/smithy-http/tests/unit/test_bindings.py b/packages/smithy-http/tests/unit/test_bindings.py index 8bbd41738..213aae517 100644 --- a/packages/smithy-http/tests/unit/test_bindings.py +++ b/packages/smithy-http/tests/unit/test_bindings.py @@ -5,7 +5,6 @@ from smithy_core.schemas import Schema from smithy_core.shapes import ShapeID, ShapeType from smithy_core.traits import ( - EndpointTrait, ErrorTrait, HostLabelTrait, HTTPErrorTrait, @@ -145,7 +144,7 @@ def test_request_matching() -> None: prefix_member = GENERAL_BINDINGS.members["prefixHeaders"] assert matcher.match(prefix_member) == Binding.PREFIX_HEADERS - assert matcher.match(GENERAL_BINDINGS.members["hostLabel"]) == Binding.HOST + assert matcher.match(GENERAL_BINDINGS.members["hostLabel"]) == Binding.BODY assert matcher.match(GENERAL_BINDINGS.members["status"]) == Binding.BODY assert matcher.match(GENERAL_BINDINGS.members["body"]) == Binding.BODY @@ -346,7 +345,6 @@ def test_http_operation_schema_extension_is_cached() -> None: "uri": "/items/{id+}?fixed=value&flag", } ), - EndpointTrait({"hostPrefix": "{account}."}), ], ) @@ -358,5 +356,4 @@ def test_http_operation_schema_extension_is_cached() -> None: assert metadata.query == "fixed=value&flag" assert metadata.query_literal_names == frozenset({"fixed", "flag"}) assert metadata.response_status == 201 - assert metadata.host_prefix == "{account}." assert metadata.greedy_label_names == frozenset({"id"}) diff --git a/packages/smithy-http/tests/unit/test_serializers.py b/packages/smithy-http/tests/unit/test_serializers.py index b8e92c7c3..5b2c63c9f 100644 --- a/packages/smithy-http/tests/unit/test_serializers.py +++ b/packages/smithy-http/tests/unit/test_serializers.py @@ -32,7 +32,6 @@ ) from smithy_core.shapes import ShapeID, ShapeType from smithy_core.traits import ( - EndpointTrait, HostLabelTrait, HTTPHeaderTrait, HTTPLabelTrait, @@ -1188,7 +1187,6 @@ class HTTPMessageTestCase: http_trait: HTTPTrait = field( default_factory=lambda: HTTPTrait({"method": "POST", "code": 200, "uri": "/"}) ) - endpoint_trait: EndpointTrait | None = None # All of these test cases need to be created indirectly because they have mutable @@ -1767,20 +1765,11 @@ def host_cases() -> list[HTTPMessageTestCase]: HTTPMessageTestCase( HostLabel("foo"), HTTPMessage( - destination=URI(host="foo.", path="/"), body=AsyncBytesReader(BytesIO(b'{"label":"foo"}')), fields=tuples_to_fields( [("content-type", "application/json"), ("content-length", "15")] ), ), - endpoint_trait=EndpointTrait({"hostPrefix": "{label}."}), - ), - HTTPMessageTestCase( - HTTPHeaders(), - HTTPMessage( - destination=URI(host="foo.", path="/"), - ), - endpoint_trait=EndpointTrait({"hostPrefix": "foo."}), ), ] @@ -1928,7 +1917,6 @@ async def test_serialize_http_request(case: HTTPMessageTestCase) -> None: payload_codec=JSONCodec(), schema=case.shape.SCHEMA, http_trait=case.http_trait, - endpoint_trait=case.endpoint_trait, ) case.shape.serialize_members(serializer) actual = serializer.build_request() diff --git a/packages/smithy-test/.changes/next-release/smithy-test-bugfix-8788b66965b5435d828a832cab7bb4be.json b/packages/smithy-test/.changes/next-release/smithy-test-bugfix-8788b66965b5435d828a832cab7bb4be.json new file mode 100644 index 000000000..5ba08c57b --- /dev/null +++ b/packages/smithy-test/.changes/next-release/smithy-test-bugfix-8788b66965b5435d828a832cab7bb4be.json @@ -0,0 +1,4 @@ +{ + "type": "bugfix", + "description": "`xml_equal` now compares empty bodies instead of failing to parse them." +} \ No newline at end of file diff --git a/packages/smithy-test/.changes/next-release/smithy-test-enhancement-10df9a493f6a4d0c863630a8f826ca24.json b/packages/smithy-test/.changes/next-release/smithy-test-enhancement-10df9a493f6a4d0c863630a8f826ca24.json new file mode 100644 index 000000000..d4e31797b --- /dev/null +++ b/packages/smithy-test/.changes/next-release/smithy-test-enhancement-10df9a493f6a4d0c863630a8f826ca24.json @@ -0,0 +1,4 @@ +{ + "type": "enhancement", + "description": "`check_client_case_async` now expects the request host to start with the operation's expanded host prefix." +} \ No newline at end of file diff --git a/packages/smithy-test/src/smithy_test/_private/endpoints.py b/packages/smithy-test/src/smithy_test/_private/endpoints.py index 3228dbbd4..b423d6905 100644 --- a/packages/smithy-test/src/smithy_test/_private/endpoints.py +++ b/packages/smithy-test/src/smithy_test/_private/endpoints.py @@ -6,7 +6,12 @@ from typing import Any from smithy_core import URI -from smithy_core.endpoints import ENDPOINT_HEADERS, RESOLVED_ENDPOINT, RulesExtension +from smithy_core.endpoints import ( + ENDPOINT_HEADERS, + HOST_PREFIX, + RESOLVED_ENDPOINT, + RulesExtension, +) from smithy_core.exceptions import SmithyError from smithy_core.interceptors import Interceptor, RequestContext from smithy_core.interfaces import Endpoint @@ -37,10 +42,11 @@ class UnboundConfig: class EndpointCaptured(SmithyError): """Stops a call once its endpoint is applied to the request.""" - def __init__(self, endpoint: Endpoint, request: Any) -> None: + def __init__(self, endpoint: Endpoint, request: Any, host_prefix: str) -> None: super().__init__("Endpoint captured") self.endpoint = endpoint self.request = request + self.host_prefix = host_prefix class CaptureEndpoint(Interceptor[Any, Any, Any, Any]): @@ -48,7 +54,9 @@ class CaptureEndpoint(Interceptor[Any, Any, Any, Any]): def read_before_signing(self, context: RequestContext[Any, Any]) -> None: raise EndpointCaptured( - context.properties[RESOLVED_ENDPOINT], context.transport_request + context.properties[RESOLVED_ENDPOINT], + context.transport_request, + context.properties.get(HOST_PREFIX, ""), ) @@ -88,19 +96,20 @@ def check_error(expect: Mapping[str, Any], error: Exception) -> None: _check("error", str(error), expect["error"]) -def check_destination(endpoint: Endpoint, request: Any) -> None: +def check_destination(endpoint: Endpoint, request: Any, host_prefix: str) -> 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) + != (uri.scheme, host_prefix + uri.host, uri.port) or not _extends(destination.path, (uri.path or "").rstrip("/"), "/") or not _extends(destination.query, uri.query, "&") ): + prefixed = f" with host prefix {host_prefix!r}" if host_prefix else "" raise AssertionError( - f"Expected request destination under {uri.build()}," + f"Expected request destination under {uri.build()}{prefixed}," f" got {destination.build()}" ) diff --git a/packages/smithy-test/src/smithy_test/_private/equality.py b/packages/smithy-test/src/smithy_test/_private/equality.py index 15b6e76d3..d90ba2e86 100644 --- a/packages/smithy-test/src/smithy_test/_private/equality.py +++ b/packages/smithy-test/src/smithy_test/_private/equality.py @@ -43,8 +43,10 @@ def xml_equal(a: bytes, b: bytes) -> bool: Only the order of same-named sibling elements (such as list items) is significant; structure members, attributes, whitespace-only text, and whitespace around text may differ. Namespace declarations are compared as - attributes. + attributes. An empty body only equals another empty body. """ + if not a or not b: + return a == b return _canonical_xml(a) == _canonical_xml(b) diff --git a/packages/smithy-test/src/smithy_test/aio/_endpoints.py b/packages/smithy-test/src/smithy_test/aio/_endpoints.py index 6ca9759c2..801e1dfc7 100644 --- a/packages/smithy-test/src/smithy_test/aio/_endpoints.py +++ b/packages/smithy-test/src/smithy_test/aio/_endpoints.py @@ -42,14 +42,15 @@ async def check_client_case_async[C, I]( :param extensions: The rules extensions the client's endpoint resolver uses. Their factories build the auth schemes in the expected endpoint. :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. + or doesn't send its request to the endpoint's host with the operation's host + prefix, 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) + check_destination(captured.endpoint, captured.request, captured.host_prefix) except EndpointResolutionError as e: check_error(expect, e) else: diff --git a/packages/smithy-test/tests/unit/test_endpoints.py b/packages/smithy-test/tests/unit/test_endpoints.py index 315d82d09..9836939ff 100644 --- a/packages/smithy-test/tests/unit/test_endpoints.py +++ b/packages/smithy-test/tests/unit/test_endpoints.py @@ -7,6 +7,7 @@ from smithy_core import URI from smithy_core.documents import Document from smithy_core.endpoints import ( + HOST_PREFIX, RESOLVED_ENDPOINT, BddEndpointResolver, Endpoint, @@ -131,8 +132,10 @@ class _Client: def __init__(self, interceptor: Interceptor[Any, Any, Any, Any]) -> None: self.interceptor = interceptor - async def send(self, destination: URI) -> None: - properties = TypedProperties() + async def send( + self, destination: URI, properties: TypedProperties | None = None + ) -> None: + properties = properties or TypedProperties() properties[RESOLVED_ENDPOINT] = self.ENDPOINT context: RequestContext[Any, Any] = RequestContext( request=Document(), @@ -141,6 +144,9 @@ async def send(self, destination: URI) -> None: ) self.interceptor.read_before_signing(context) + async def send_with_host_prefix(self, destination: URI) -> None: + await self.send(destination, TypedProperties({HOST_PREFIX.key: "data."})) + async def fail(self, input: None) -> None: raise EndpointResolutionError("Missing Region") @@ -178,6 +184,21 @@ async def test_client_cases_fail_on_requests_outside_the_endpoint( ) +async def test_client_cases_expect_the_host_prefix_on_the_destination() -> None: + destination = URI(host="data.h.example.com", path="/p", query="a=b") + await check_client_case_async( + _client, _Client.send_with_host_prefix, destination, CLIENT_ENDPOINT + ) + + with pytest.raises(AssertionError, match=r"with host prefix 'data\.'"): + await check_client_case_async( + _client, + _Client.send_with_host_prefix, + URI(host="h.example.com", path="/p", query="a=b"), + CLIENT_ENDPOINT, + ) + + async def test_client_cases_check_resolution_errors() -> None: await check_client_case_async( _client, _Client.fail, None, {"error": "Missing Region"} diff --git a/packages/smithy-test/tests/unit/test_equality.py b/packages/smithy-test/tests/unit/test_equality.py index 260374306..59a91565a 100644 --- a/packages/smithy-test/tests/unit/test_equality.py +++ b/packages/smithy-test/tests/unit/test_equality.py @@ -65,3 +65,9 @@ def test_xml_equal_keeps_same_named_sibling_order() -> None: def test_xml_equal_compares_namespace_declarations() -> None: assert not xml_equal(b'', b"") assert not xml_equal(b"1", b"2") + + +def test_xml_equal_compares_empty_bodies() -> None: + assert xml_equal(b"", b"") + assert not xml_equal(b"", b"") + assert not xml_equal(b"", b"") diff --git a/scripts/api-snapshot/api-rex.json b/scripts/api-snapshot/api-rex.json index 5a1fc2995..00873d8ce 100644 --- a/scripts/api-snapshot/api-rex.json +++ b/scripts/api-snapshot/api-rex.json @@ -844,6 +844,7 @@ "EndpointAuthSchemeFactory": "rex-type_alias", "EndpointBdd": "rex-class", "EndpointResolverParams": "rex-class", + "HOST_PREFIX": "rex-PropertyKey", "RESOLVED_ENDPOINT": "rex-PropertyKey", "RulesExtension": "rex-class", "STATIC_ENDPOINT_CONFIG": "rex-PropertyKey", @@ -910,6 +911,7 @@ }, "smithy_core.schemas": { "DynamicTrait": "rex-class", + "EndpointTrait": "rex-class", "ExpectationNotMetError": "rex-exception", "IdempotencyTokenTrait": "rex-class", "LongPollTrait": "rex-class", @@ -1091,7 +1093,6 @@ "HTTPQueryParamsTrait": "rex-class", "HTTPQueryTrait": "rex-class", "HTTPResponseCodeTrait": "rex-class", - "HostLabelTrait": "rex-class", "Schema": "rex-class", "ShapeType": "rex-class", "StreamingTrait": "rex-class" @@ -1218,14 +1219,12 @@ }, "smithy_http.schema_extensions": { "Binding": "rex-class", - "EndpointTrait": "rex-class", "HTTPHeaderTrait": "rex-class", "HTTPLabelTrait": "rex-class", "HTTPPrefixHeadersTrait": "rex-class", "HTTPQueryTrait": "rex-class", "HTTPResponseBindingDispatch": "rex-type_alias", "HTTPTrait": "rex-class", - "HostLabelTrait": "rex-class", "MediaTypeTrait": "rex-class", "PathPattern": "rex-class", "RequestBindingMatcher": "rex-class", @@ -1241,7 +1240,6 @@ "smithy_http.serializers": { "Binding": "rex-class", "Codec": "rex-protocol", - "EndpointTrait": "rex-class", "Field": "rex-class", "Fields": "rex-class", "HTTPBindingSchemaMetadata": "rex-class", diff --git a/scripts/api-snapshot/api.json b/scripts/api-snapshot/api.json index 0d7b4b855..60adee9bb 100644 --- a/scripts/api-snapshot/api.json +++ b/scripts/api-snapshot/api.json @@ -802,7 +802,6 @@ "HTTPResponseBindingSerializer": "class", "HTTPResponseCodeSerializer": "class", "HTTPResponseSerializer": "class", - "HostPrefixSerializer": "class", "RawPayloadSerializer": "class" }, "smithy_http.testing": {},