Unverified Commit 460ef539 authored by Russell Cohen's avatar Russell Cohen Committed by GitHub
Browse files

Add support for endpoints.json (#468)

* Add support for endpoints.json

* Backout Smithy 1.8 changes

* Delete DefaultAwsEndpoint resolver

* Add test for iam / fips

* IAM works now

* CR feedback

* Fix tests

* CR feedback
parent ae11859b
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -12,3 +12,4 @@ description = "AWS Endpoint Support"
smithy-http = { path = "../../../rust-runtime/smithy-http"}
aws-types = { path = "../aws-types" }
http = "0.2.3"
regex = { version = "1", default-features = false, features = ["std"]}
+82 −54
Original line number Diff line number Diff line
@@ -3,13 +3,20 @@
 * SPDX-License-Identifier: Apache-2.0.
 */

#[doc(hidden)]
pub mod partition;

#[doc(hidden)]
pub use partition::Partition;
#[doc(hidden)]
pub use partition::PartitionResolver;

use std::error::Error;
use std::fmt;
use std::fmt::{Debug, Display, Formatter};
use std::str::FromStr;
use std::sync::Arc;

use http::{HeaderValue, Uri};
use http::HeaderValue;

use aws_types::region::{Region, SigningRegion};
use aws_types::SigningService;
@@ -29,8 +36,7 @@ use std::convert::TryFrom;
#[derive(Clone)]
pub struct AwsEndpoint {
    endpoint: Endpoint,
    signing_service: Option<SigningService>,
    signing_region: Option<SigningRegion>,
    credential_scope: CredentialScope,
}

impl AwsEndpoint {
@@ -73,52 +79,73 @@ pub type BoxError = Box<dyn Error + Send + Sync + 'static>;
/// will be codegenerated from `endpoints.json`.
pub trait ResolveAwsEndpoint: Send + Sync {
    // TODO: consider if we want modeled error variants here
    fn endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError>;
    fn resolve_endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError>;
}

/// Default AWS Endpoint Implementation
///
/// This is used as a temporary stub. Prior to GA, this will be replaced with specifically generated endpoint
/// resolvers for each service that model the endpoints for each service correctly. Some services differ
/// from the standard endpoint pattern.
pub struct DefaultAwsEndpointResolver {
    service: &'static str,
#[derive(Clone, Default, Debug)]
pub struct CredentialScope {
    region: Option<SigningRegion>,
    service: Option<SigningService>,
}

impl DefaultAwsEndpointResolver {
    pub fn for_service(service: &'static str) -> Self {
        Self { service }
impl CredentialScope {
    pub fn builder() -> credential_scope::Builder {
        credential_scope::Builder::default()
    }
}

/// An `Endpoint` can be its own resolver to support static endpoints
impl ResolveAwsEndpoint for Endpoint {
    fn endpoint(&self, _region: &Region) -> Result<AwsEndpoint, BoxError> {
        Ok(AwsEndpoint {
            endpoint: self.clone(),
            signing_service: None,
            signing_region: None,
        })
pub mod credential_scope {
    use crate::CredentialScope;
    use aws_types::region::SigningRegion;
    use aws_types::SigningService;

    #[derive(Debug, Default)]
    pub struct Builder {
        region: Option<SigningRegion>,
        service: Option<SigningService>,
    }

    impl Builder {
        pub fn region(mut self, region: &'static str) -> Self {
            self.region = Some(SigningRegion::from_static(region));
            self
        }

        pub fn service(mut self, service: &'static str) -> Self {
            self.service = Some(SigningService::from_static(service));
            self
        }

        pub fn build(self) -> CredentialScope {
            CredentialScope {
                region: self.region,
                service: self.service,
            }
        }
    }
}

impl CredentialScope {
    pub fn merge(&self, other: &CredentialScope) -> CredentialScope {
        CredentialScope {
            region: self.region.clone().or_else(|| other.region.clone()),
            service: self.service.clone().or_else(|| other.service.clone()),
        }
    }
}

impl ResolveAwsEndpoint for DefaultAwsEndpointResolver {
    fn endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError> {
        let uri = Uri::from_str(&format!(
            "https://{}.{}.amazonaws.com",
            self.service,
            region.as_ref(),
        ))?;
/// An `Endpoint` can be its own resolver to support static endpoints
impl ResolveAwsEndpoint for Endpoint {
    fn resolve_endpoint(&self, _region: &Region) -> Result<AwsEndpoint, BoxError> {
        Ok(AwsEndpoint {
            endpoint: Endpoint::mutable(uri),
            signing_region: Some(region.clone().into()),
            signing_service: None,
            endpoint: self.clone(),
            credential_scope: Default::default(),
        })
    }
}

type AwsEndpointResolver = Arc<dyn ResolveAwsEndpoint>;
fn get_endpoint_resolver(config: &PropertyBag) -> Option<&AwsEndpointResolver> {
pub fn get_endpoint_resolver(config: &PropertyBag) -> Option<&AwsEndpointResolver> {
    config.get()
}

@@ -162,13 +189,14 @@ impl MapRequest for AwsEndpointStage {
                .get::<Region>()
                .ok_or(AwsEndpointStageError::NoRegion)?;
            let endpoint = provider
                .endpoint(region)
                .resolve_endpoint(region)
                .map_err(AwsEndpointStageError::EndpointResolutionError)?;
            let signing_region = endpoint
                .signing_region
                .credential_scope
                .region
                .unwrap_or_else(|| region.clone().into());
            config.insert::<SigningRegion>(signing_region);
            if let Some(signing_service) = endpoint.signing_service {
            if let Some(signing_service) = endpoint.credential_scope.service {
                config.insert::<SigningService>(signing_service);
            }
            endpoint
@@ -199,16 +227,18 @@ mod test {
    use smithy_http::middleware::MapRequest;
    use smithy_http::operation;

    use crate::{
        set_endpoint_resolver, AwsEndpoint, AwsEndpointStage, BoxError, DefaultAwsEndpointResolver,
        ResolveAwsEndpoint,
    };
    use crate::partition::endpoint::{Metadata, Protocol, SignatureVersion};
    use crate::{set_endpoint_resolver, AwsEndpointStage, CredentialScope};
    use http::header::HOST;
    use smithy_http::endpoint::Endpoint;

    #[test]
    fn default_endpoint_updates_request() {
        let provider = Arc::new(DefaultAwsEndpointResolver::for_service("kinesis"));
        let provider = Arc::new(Metadata {
            uri_template: "kinesis.{region}.amazonaws.com",
            protocol: Protocol::Https,
            credential_scope: Default::default(),
            signature_versions: SignatureVersion::V4,
        });
        let req = http::Request::new(SdkBody::from(""));
        let region = Region::new("us-east-1");
        let mut req = operation::Request::new(req);
@@ -241,17 +271,15 @@ mod test {

    #[test]
    fn sets_service_override_when_set() {
        struct ServiceOverrideResolver;
        impl ResolveAwsEndpoint for ServiceOverrideResolver {
            fn endpoint(&self, _region: &Region) -> Result<AwsEndpoint, BoxError> {
                Ok(AwsEndpoint {
                    endpoint: Endpoint::immutable(Uri::from_static("http://www.service.com")),
                    signing_service: Some(SigningService::from_static("qldb-override")),
                    signing_region: Some(SigningRegion::from(Region::new("us-east-override"))),
                })
            }
        }
        let provider = Arc::new(ServiceOverrideResolver);
        let provider = Arc::new(Metadata {
            uri_template: "www.service.com",
            protocol: Protocol::Http,
            credential_scope: CredentialScope::builder()
                .service("qldb-override")
                .region("us-east-override")
                .build(),
            signature_versions: SignatureVersion::V4,
        });
        let req = http::Request::new(SdkBody::from(""));
        let region = Region::new("us-east-1");
        let mut req = operation::Request::new(req);
+71 −0
Original line number Diff line number Diff line
/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0.
 */

use crate::{AwsEndpoint, BoxError, CredentialScope, ResolveAwsEndpoint};
use aws_types::region::Region;
use smithy_http::endpoint::Endpoint;

/// Endpoint metadata
///
/// Unlike other endpoint implementations, no merging occurs in here. All Endpoint merging occurs
/// during code generation allowing us to generate fully formed endpoints.
#[derive(Debug)]
pub struct Metadata {
    /// URI for the endpoint.
    ///
    /// May contain `{region}` which will replaced with the region during endpoint construction
    pub uri_template: &'static str,

    /// Protocol to use for this endpoint
    pub protocol: Protocol,

    /// Credential scope to set for requests to this endpoint
    pub credential_scope: CredentialScope,

    /// Signature versions supported by this endpoint.
    ///
    /// Currently unused since the SDK only supports SigV4
    pub signature_versions: SignatureVersion,
}

#[derive(Eq, PartialEq, Copy, Clone, Debug)]
pub enum Protocol {
    Http,
    Https,
}

impl Protocol {
    fn as_str(&self) -> &'static str {
        match self {
            Protocol::Http => "http",
            Protocol::Https => "https",
        }
    }
}

#[derive(Eq, PartialEq, Copy, Clone, Debug)]
pub enum SignatureVersion {
    V4,
}

impl ResolveAwsEndpoint for Metadata {
    fn resolve_endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError> {
        let uri = self.uri_template.replace("{region}", region.as_ref());
        let uri = format!("{}://{}", self.protocol.as_str(), uri);
        let endpoint = Endpoint::mutable(uri.parse()?);
        let ep = AwsEndpoint {
            endpoint,
            credential_scope: CredentialScope {
                service: self.credential_scope.service.clone(),
                region: self
                    .credential_scope
                    .region
                    .clone()
                    .or_else(|| Some(region.clone().into())),
            },
        };
        Ok(ep)
    }
}
+389 −0
Original line number Diff line number Diff line
/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0.
 */

pub mod endpoint;

use crate::{AwsEndpoint, BoxError, ResolveAwsEndpoint};
use aws_types::region::Region;
use regex::Regex;
use std::collections::HashMap;
use std::iter;

/// Root level resolver for an AWS Service
///
/// PartitionResolver resolves the endpoint for an AWS Service. Each partition will be checked
/// in turn, checking if the partition [can resolve](Partition::can_resolve) the given region. If
/// no regions match, `base` is used.
///
/// Once a partition has been identified, endpoint resolution is delegated to the underlying
/// partition.
pub struct PartitionResolver {
    /// Base partition used if no partitions match the region regex
    base: Partition,

    // base and rest are split so that we can validate that at least 1 partition is defined
    // at compile time.
    rest: Vec<Partition>,
}

impl PartitionResolver {
    /// Construct a new  `PartitionResolver` from a list of partitions
    pub fn new(base: Partition, rest: Vec<Partition>) -> Self {
        Self { base, rest }
    }

    fn partitions(&self) -> impl Iterator<Item = &Partition> {
        iter::once(&self.base).chain(self.rest.iter())
    }
}

impl ResolveAwsEndpoint for PartitionResolver {
    fn resolve_endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError> {
        let matching_partition = self
            .partitions()
            .find(|partition| partition.can_resolve(region))
            .unwrap_or(&self.base);
        matching_partition.resolve_endpoint(region)
    }
}

#[derive(Debug)]
pub struct Partition {
    id: &'static str,
    region_regex: Regex,
    partition_endpoint: Option<Region>,
    regionalized: Regionalized,
    default_endpoint: endpoint::Metadata,
    endpoints: HashMap<Region, endpoint::Metadata>,
}

#[derive(Default)]
pub struct Builder {
    id: Option<&'static str>,
    region_regex: Option<Regex>,
    partition_endpoint: Option<Region>,
    regionalized: Option<Regionalized>,
    default_endpoint: Option<endpoint::Metadata>,
    endpoints: HashMap<Region, endpoint::Metadata>,
}

impl Builder {
    pub fn id(mut self, id: &'static str) -> Self {
        self.id = Some(id);
        self
    }

    pub fn default_endpoint(mut self, default: endpoint::Metadata) -> Self {
        self.default_endpoint = Some(default);
        self
    }

    pub fn region_regex(mut self, regex: &'static str) -> Self {
        // We use a stripped down version of the regex crate without unicode support
        // To support `\d` and `\w`, we need to explicitly opt into the ascii-only version.
        let ascii_only = regex
            .replace("\\d", "(?-u:\\d)")
            .replace("\\w", "(?-u:\\w)");
        self.region_regex = Some(Regex::new(&ascii_only).expect("invalid regex"));
        self
    }

    pub fn partition_endpoint(mut self, partition_endpoint: &'static str) -> Self {
        self.partition_endpoint = Some(Region::new(partition_endpoint));
        self
    }

    pub fn regionalized(mut self, regionalized: Regionalized) -> Self {
        self.regionalized = Some(regionalized);
        self
    }

    pub fn endpoint(mut self, region: &'static str, endpoint: endpoint::Metadata) -> Self {
        self.endpoints.insert(Region::new(region), endpoint);
        self
    }

    /// Construct a Partition from the builder
    ///
    /// Returns `None` if:
    /// - DefaultEndpoint is not set
    /// - DefaultEndpoint has an empty list of supported signature versions
    pub fn build(self) -> Option<Partition> {
        let default_endpoint = self.default_endpoint?;
        let endpoints = self.endpoints.into_iter().collect();
        Some(Partition {
            id: self.id?,
            region_regex: self.region_regex?,
            partition_endpoint: self.partition_endpoint,
            regionalized: self.regionalized.unwrap_or_default(),
            default_endpoint,
            endpoints,
        })
    }
}

#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Regionalized {
    Regionalized,
    NotRegionalized,
}

impl Default for Regionalized {
    fn default() -> Self {
        Regionalized::Regionalized
    }
}

impl Partition {
    pub fn can_resolve(&self, region: &Region) -> bool {
        self.region_regex.is_match(region.as_ref())
    }

    pub fn builder() -> Builder {
        Builder::default()
    }
}

impl ResolveAwsEndpoint for Partition {
    fn resolve_endpoint(&self, region: &Region) -> Result<AwsEndpoint, BoxError> {
        if let Some(endpoint) = self.endpoints.get(region) {
            return endpoint.resolve_endpoint(region);
        }
        let resolved_region = match self.regionalized {
            Regionalized::NotRegionalized => self.partition_endpoint.as_ref(),
            Regionalized::Regionalized => Some(region),
        };
        let endpoint_for_region = resolved_region
            .and_then(|region| self.endpoints.get(&region))
            .unwrap_or(&self.default_endpoint);
        endpoint_for_region.resolve_endpoint(region)
    }
}

#[cfg(test)]
mod test {
    use crate::partition::endpoint::Metadata;
    use crate::partition::endpoint::Protocol::{Http, Https};
    use crate::partition::endpoint::SignatureVersion::{self, V4};
    use crate::partition::{endpoint, Partition};
    use crate::partition::{PartitionResolver, Regionalized};
    use crate::{CredentialScope, ResolveAwsEndpoint};
    use aws_types::region::{Region, SigningRegion};
    use aws_types::SigningService;
    use http::Uri;

    fn basic_partition() -> Partition {
        Partition::builder()
            .id("part-id-1")
            .region_regex(r#"^(us)-\w+-\d+$"#)
            .default_endpoint(endpoint::Metadata {
                uri_template: "service.{region}.amazonaws.com",
                protocol: Https,
                credential_scope: CredentialScope::default(),
                signature_versions: SignatureVersion::V4,
            })
            .partition_endpoint("")
            .regionalized(Regionalized::Regionalized)
            .endpoint(
                "us-west-1",
                endpoint::Metadata {
                    uri_template: "service.{region}.amazonaws.com",
                    protocol: Https,
                    credential_scope: CredentialScope::default(),
                    signature_versions: SignatureVersion::V4,
                },
            )
            .endpoint(
                "us-west-1-alt",
                Metadata {
                    uri_template: "service-alt.us-west-1.amazonaws.com",
                    protocol: Http,
                    credential_scope: CredentialScope {
                        region: Some(SigningRegion::from_static("us-west-1")),
                        service: Some(SigningService::from_static("foo")),
                    },
                    signature_versions: V4,
                },
            )
            .build()
            .expect("valid partition")
    }

    fn global_partition() -> Partition {
        Partition::builder()
            .id("part-id-1")
            .region_regex(r#"^(cn)-\w+-\d+$"#)
            .default_endpoint(Metadata {
                uri_template: "service.{region}.amazonaws.com",
                protocol: Https,
                credential_scope: CredentialScope {
                    service: Some(SigningService::from_static("foo")),
                    ..Default::default()
                },
                signature_versions: SignatureVersion::V4,
            })
            .partition_endpoint("partition")
            .regionalized(Regionalized::NotRegionalized)
            .endpoint(
                "partition",
                Metadata {
                    uri_template: "some-global-thing.amazonaws.cn",
                    protocol: Https,
                    credential_scope: CredentialScope {
                        region: Some(SigningRegion::from_static("cn-east-1")),
                        service: Some(SigningService::from_static("foo")),
                    },
                    signature_versions: SignatureVersion::V4,
                },
            )
            .endpoint(
                "cn-fips-1",
                Metadata {
                    uri_template: "fips.amazonaws.cn",
                    protocol: Https,
                    credential_scope: CredentialScope {
                        region: Some(SigningRegion::from_static("cn-fips")),
                        service: None,
                    },
                    signature_versions: SignatureVersion::V4,
                },
            )
            .build()
            .expect("valid partition")
    }

    fn partition_resolver() -> PartitionResolver {
        PartitionResolver::new(
            basic_partition(),
            vec![global_partition(), default_partition()],
        )
    }

    fn default_partition() -> Partition {
        Partition::builder()
            .id("part-id-3")
            .region_regex(r#"^(eu)-\w+-\d+$"#)
            .default_endpoint(Metadata {
                uri_template: "service.{region}.amazonaws.com",
                protocol: Https,
                signature_versions: V4,
                credential_scope: CredentialScope {
                    service: Some(SigningService::from_static("foo")),
                    ..Default::default()
                },
            })
            .build()
            .expect("valid partition")
    }

    struct TestCase {
        region: &'static str,
        uri: &'static str,
        signing_region: &'static str,
        signing_service: Option<&'static str>,
    }

    /// Modeled region with no endpoint overrides
    const MODELED_REGION: TestCase = TestCase {
        region: "us-west-1",
        uri: "https://service.us-west-1.amazonaws.com",
        signing_region: "us-west-1",
        signing_service: None,
    };

    /// Modeled region with endpoint overrides
    const MODELED_REGION_OVERRIDE: TestCase = TestCase {
        region: "us-west-1-alt",
        uri: "http://service-alt.us-west-1.amazonaws.com",
        signing_region: "us-west-1",
        signing_service: Some("foo"),
    };

    /// Validates falling back onto the default endpoint
    const FALLBACK_REGION: TestCase = TestCase {
        region: "us-east-1",
        uri: "https://service.us-east-1.amazonaws.com",
        signing_region: "us-east-1",
        signing_service: None,
    };

    /// Validates "PartitionName"
    const PARTITION_NAME: TestCase = TestCase {
        region: "cn-central-1",
        uri: "https://some-global-thing.amazonaws.cn",
        signing_region: "cn-east-1",
        signing_service: Some("foo"),
    };

    /// Validates non-regionalized endpoints still use endpoints
    const NON_REGIONALIZED_EXACT_MATCH: TestCase = TestCase {
        region: "cn-fips-1",
        uri: "https://fips.amazonaws.cn",
        signing_region: "cn-fips",
        signing_service: None,
    };

    const DEFAULT_ENDPOINT: TestCase = TestCase {
        region: "eu-west-1",
        uri: "https://service.eu-west-1.amazonaws.com",
        signing_region: "eu-west-1",
        signing_service: Some("foo"),
    };

    const TEST_CASES: &[TestCase] = &[
        MODELED_REGION,
        MODELED_REGION_OVERRIDE,
        FALLBACK_REGION,
        PARTITION_NAME,
        DEFAULT_ENDPOINT,
        NON_REGIONALIZED_EXACT_MATCH,
    ];

    #[test]
    fn validate_basic_partition() {
        let p10n = basic_partition();
        check_endpoint(&p10n, &MODELED_REGION);
        check_endpoint(&p10n, &MODELED_REGION_OVERRIDE);
        check_endpoint(&p10n, &FALLBACK_REGION);
    }

    #[test]
    fn validate_global_partition() {
        let partition = global_partition();
        check_endpoint(&partition, &PARTITION_NAME);
        check_endpoint(&partition, &NON_REGIONALIZED_EXACT_MATCH)
    }

    #[test]
    fn validate_default_endpoint() {
        check_endpoint(&default_partition(), &DEFAULT_ENDPOINT);
    }

    #[test]
    fn validate_partition_resolver() {
        let resolver = partition_resolver();
        for test_case in TEST_CASES {
            check_endpoint(&resolver, test_case);
        }
    }

    #[track_caller]
    fn check_endpoint(resolver: &impl ResolveAwsEndpoint, test_case: &TestCase) {
        let endpoint = resolver
            .resolve_endpoint(&Region::new(test_case.region))
            .expect("valid region");
        let mut test_uri = Uri::from_static("/");
        endpoint.set_endpoint(&mut test_uri, None);
        assert_eq!(test_uri, Uri::from_static(test_case.uri));
        assert_eq!(
            endpoint.credential_scope.region,
            Some(SigningRegion::from_static(test_case.signing_region))
        );
        assert_eq!(
            endpoint.credential_scope.service,
            test_case.signing_service.map(SigningService::from_static)
        )
    }
}
+8 −2
Original line number Diff line number Diff line
@@ -4,7 +4,8 @@
 */

use aws_auth::Credentials;
use aws_endpoint::{set_endpoint_resolver, DefaultAwsEndpointResolver};
use aws_endpoint::partition::endpoint::{Protocol, SignatureVersion};
use aws_endpoint::set_endpoint_resolver;
use aws_http::user_agent::AwsUserAgent;
use aws_http::AwsErrorRetryPolicy;
use aws_hyper::test_connection::TestConnection;
@@ -77,7 +78,12 @@ fn test_operation() -> Operation<TestOperationParser, AwsErrorRetryPolicy> {
        .augment(|req, mut conf| {
            set_endpoint_resolver(
                &mut conf,
                Arc::new(DefaultAwsEndpointResolver::for_service("test-service")),
                Arc::new(aws_endpoint::partition::endpoint::Metadata {
                    uri_template: "test-service.{region}.amazonaws.com",
                    protocol: Protocol::Https,
                    credential_scope: Default::default(),
                    signature_versions: SignatureVersion::V4,
                }),
            );
            aws_auth::set_provider(
                &mut conf,
Loading