Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public List<RuntimeClientPlugin> getClientPlugins(GenerationContext context) {
"A unique and opaque application ID that is appended to the User-Agent header.")
.type(Symbol.builder().name("str").build())
.nullable(true)
.useDescriptor(true)
.validator(Symbol.builder()
.name("validate_and_sanitize_ua_string")
.namespace("smithy_aws_core.config.validators", ".")
.addDependency(AwsPythonDependency.SMITHY_AWS_CORE)
.build())
.build();

final String user_agent_plugin_file = "user_agent";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
# SPDX-License-Identifier: Apache-2.0

import re
from string import ascii_letters, digits
from typing import Any, get_args

from smithy_core.interfaces.retries import RetryStrategy
from smithy_core.retries import RetryStrategyOptions, RetryStrategyType

from smithy_aws_core.config.source_info import SourceInfo

_USERAGENT_ALLOWED_CHARACTERS = ascii_letters + digits + "!#$%&'*+-.^_`|~/"


class ConfigValidationError(ValueError):
"""Raised when a configuration value fails validation."""
Expand Down Expand Up @@ -144,3 +147,30 @@ def validate_retry_strategy(
f"retry_strategy must be RetryStrategy or RetryStrategyOptions, got {type(value).__name__}",
source,
)


def validate_and_sanitize_ua_string(
value: Any, source: SourceInfo | None = None
) -> str | None:
"""Validate and sanitize a User-Agent string component.
Replaces all disallowed characters with a hyphen. Allowed characters are
ASCII alphanumerics and "!#$%&'*+-.^_`|~/".
:param value: The UA string value to sanitize
:param source: The source that provided this value
:returns: The sanitized UA string or None if value is None
:raises ConfigValidationError: If the value is not a string
"""
if value is None:
return None
if not isinstance(value, str):
raise ConfigValidationError(
"sdk_ua_app_id",
value,
f"UA string must be a string, got {type(value).__name__}",
source,
)
return "".join(c if c in _USERAGENT_ALLOWED_CHARACTERS else "-" for c in value)
26 changes: 21 additions & 5 deletions packages/smithy-aws-core/tests/unit/config/test_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ def test_different_properties_resolve_independently(self) -> None:
assert region == "us-west-2"
assert retry_mode == "adaptive"

def test_unresolved_ua_app_id_defaults_to_none(self) -> None:
class ConfigWithNoDefault:
sdk_ua_app_id = ConfigProperty("sdk_ua_app_id")

def __init__(self, resolver: ConfigResolver) -> None:
self._resolver = resolver

source = StubSource("environment", {})
resolver = ConfigResolver(sources=[source])
config = ConfigWithNoDefault(resolver)

result = config.sdk_ua_app_id

assert result is None
assert getattr(config, "_cache_sdk_ua_app_id") == (
None,
SimpleSource("default"),
)


class TestConfigPropertyValidation:
"""Test suite for ConfigProperty validation behavior."""
Expand All @@ -107,7 +126,6 @@ def _create_config_with_validator(

class ConfigWithValidator:
region = ConfigProperty("region", validator=validator)
retry_strategy = ConfigProperty("retry_strategy", validator=validator)

def __init__(self, resolver: ConfigResolver) -> None:
self._resolver = resolver
Expand Down Expand Up @@ -153,9 +171,7 @@ class ConfigWithComplexResolver:
retry_strategy = ConfigProperty(
"retry_strategy",
resolver_func=mock_resolver,
default_value=RetryStrategyOptions(
retry_mode="standard", max_attempts=3
),
default_value=RetryStrategyOptions(retry_mode="standard"),
)

def __init__(self, resolver: ConfigResolver) -> None:
Expand All @@ -171,7 +187,7 @@ def __init__(self, resolver: ConfigResolver) -> None:

assert isinstance(result, RetryStrategyOptions)
assert result.retry_mode == "standard"
assert result.max_attempts == 3
assert result.max_attempts is None
assert source_info == SimpleSource("default")

def test_validator_not_called_on_cached_access(self) -> None:
Expand Down
25 changes: 25 additions & 0 deletions packages/smithy-aws-core/tests/unit/config/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from smithy_aws_core.config.validators import (
ConfigValidationError,
validate_and_sanitize_ua_string,
validate_max_attempts,
validate_region,
validate_retry_mode,
Expand Down Expand Up @@ -49,3 +50,27 @@ def test_invalid_retry_mode_error_message(self) -> None:
"Invalid value for 'retry_mode': 'random_mode'. retry_mode must be one "
"of ('standard',), got random_mode" in str(exc_info.value)
)


class TestValidateUaString:
def test_allows_alphanumeric(self) -> None:
assert validate_and_sanitize_ua_string("abc123") == "abc123"

def test_none_returns_none(self) -> None:
assert validate_and_sanitize_ua_string(None) is None

def test_allows_spec_special_chars(self) -> None:
assert validate_and_sanitize_ua_string("!#$%&'*+-.^_`|~/") == "!#$%&'*+-.^_`|~/"

def test_sanitizes_parentheses(self) -> None:
result = validate_and_sanitize_ua_string("Java_HotSpot_(TM)_64-Bit_Server_VM")
assert result == "Java_HotSpot_-TM-_64-Bit_Server_VM"

def test_empty_string_passthrough(self) -> None:
assert validate_and_sanitize_ua_string("") == ""

def test_rejects_non_string(self) -> None:
with pytest.raises(ConfigValidationError) as exc_info:
validate_and_sanitize_ua_string(123)

assert exc_info.value.key == "sdk_ua_app_id"
Loading