Unverified Commit 7774d687 authored by Jon Gjengset's avatar Jon Gjengset Committed by GitHub
Browse files

Introduce generic fluent client generator to non-sdk codegen (#463)

* Implement Debug for more things

* Extract out generic hyper client to smithy-hyper

* Add generic fluent client generation

* Make the bounds nicer

* Make smithy-hyper hyper dep optional

* Rename smithy-hyper to smithy-client

* Enable rustls by default

* Also warn on rust 2018 idioms

* Add type-erased middleware

* Restore old DispatchLayer tracing

* Add connection type-erasure

* Fix rustdoc link

* Split up lib.rs

* Make Builder a little nicer to use

* Make aws_hyper simply wrap smithy_client

* Make it clear that bounds:: should never be implemented

* Finish adjusting aws fluent generator

* Make clippy happy

* Also re-expose test_connection in aws_hyper

* Make ktlint happy

* No Builder::native_tls with default features

Since the function "doesn't exist", we can't link to it. Arguably, the
docs should only be tested with all features enabled, but for now just
don't try to link to `native_tls`.

* Work around rustdoc bug

https://github.com/rust-lang/rust/issues/72081

* Better names for type-erase methods

* Add middleware_fn

* Better docs for client

* Fix remaining erase_connector

* Better name for service in docs

* Correct send+sync test name

* Use crate name with _ in Rust code

* Fix up relative links

The standard syntax doesn't work:
https://github.com/rust-lang/rust/issues/86120



* Fix the new integration test

* Hide temporary Operation type aliases

* Don't bound middleware_fn as it also bounds C

With the extra "helpful" bound, we also end up enforcing that C
implements Service, but since we're in a builder, C may not have been
set yet, and may be (), which in turn means that it isn't Service. So
users would end up with an error if they write:

    Builder::new().middleware_fn(|r| r).https().build()

but it would work with

    Builder::new().https().middleware_fn(|r| r).build()

which is silly.

Co-authored-by: default avatarRussell Cohen <rcoh@amazon.com>
parent c2c39a42
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -161,7 +161,7 @@ pub fn set_endpoint_resolver(config: &mut PropertyBag, provider: AwsEndpointReso
/// 3. Apply the endpoint to the URI in the request
/// 4. Set the `SigningRegion` and `SigningService` in the property bag to drive downstream
/// signing middleware.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct AwsEndpointStage;

#[derive(Debug)]
+1 −1
Original line number Diff line number Diff line
@@ -216,7 +216,7 @@ impl Display for ExecEnvMetadata {
}

#[non_exhaustive]
#[derive(Default, Clone)]
#[derive(Default, Clone, Debug)]
pub struct UserAgentStage;

impl UserAgentStage {
+3 −2
Original line number Diff line number Diff line
@@ -10,8 +10,8 @@ license = "Apache-2.0"
[features]
test-util = ["protocol-test-helpers"]
default = ["test-util"]
native-tls = ["hyper-tls"]
rustls = ["hyper-rustls"]
native-tls = ["hyper-tls", "smithy-client/native-tls"]
rustls = ["hyper-rustls", "smithy-client/rustls"]

[dependencies]
hyper = { version = "0.14.2", features = ["client", "http1", "http2", "tcp", "runtime"] }
@@ -28,6 +28,7 @@ http-body = "0.4.0"
smithy-http = { path = "../../../rust-runtime/smithy-http" }
smithy-types = { path = "../../../rust-runtime/smithy-types" }
smithy-http-tower = { path = "../../../rust-runtime/smithy-http-tower" }
smithy-client = { path = "../../../rust-runtime/smithy-client" }
fastrand = "1.4.0"
tokio = { version = "1", features = ["time"] }

+0 −203
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::BoxError;
use http::Request;
use hyper::client::ResponseFuture;
use hyper::Response;
use smithy_http::body::SdkBody;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tower::Service;

#[derive(Clone)]
pub struct Standard(Connector);

impl Standard {
    /// An https connection
    ///
    /// If the `rustls` feature is enabled, this will use `rustls`.
    /// If the ONLY the `native-tls` feature is enabled, this will use `native-tls`.
    /// If both features are enabled, this will use `rustls`
    #[cfg(any(feature = "native-tls", feature = "rustls"))]
    pub fn https() -> Self {
        #[cfg(feature = "rustls")]
        {
            Self::rustls()
        }

        // If we are compiling this function & rustls is not enabled, then native-tls MUST be enabled
        #[cfg(not(feature = "rustls"))]
        {
            Self::native_tls()
        }
    }

    #[cfg(feature = "rustls")]
    pub fn rustls() -> Self {
        let https = hyper_rustls::HttpsConnector::with_native_roots();
        let client = hyper::Client::builder().build::<_, SdkBody>(https);
        Self(Connector::RustlsHttps(client))
    }

    #[cfg(feature = "native-tls")]
    pub fn native_tls() -> Self {
        let https = hyper_tls::HttpsConnector::new();
        let client = hyper::Client::builder().build::<_, SdkBody>(https);
        Self(Connector::NativeHttps(client))
    }

    /// A connection based on the provided `impl HttpService`
    ///
    /// Generally, [`Standard::https()`](Standard::https) should be used. This constructor is intended to support
    /// using things like [`TestConnection`](crate::test_connection::TestConnection) or alternative
    /// http implementations.
    pub fn new(connector: impl HttpService + 'static) -> Self {
        Self(Connector::Dyn(Box::new(connector)))
    }
}

/// An Http connection type for most use cases
///
/// This supports three options:
/// 1. HTTPS
/// 2. A `TestConnection`
/// 3. Any implementation of the `HttpService` trait
///
/// This is designed to be used with [`aws_hyper::Client`](crate::Client) as a connector.
#[derive(Clone)]
enum Connector {
    /// An Https Connection
    ///
    /// This is the correct connection for use cases talking to real AWS services.
    #[cfg(feature = "native-tls")]
    NativeHttps(hyper::Client<hyper_tls::HttpsConnector<hyper::client::HttpConnector>, SdkBody>),

    /// An Https Connection
    ///
    /// This is the correct connection for use cases talking to real AWS services.
    #[cfg(feature = "rustls")]
    RustlsHttps(hyper::Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>, SdkBody>),

    /// A generic escape hatch
    ///
    /// This enables using any implementation of the HttpService trait. This allows using a totally
    /// separate HTTP stack or your own custom `TestConnection`.
    Dyn(Box<dyn HttpService>),
}

impl Clone for Box<dyn HttpService> {
    fn clone(&self) -> Self {
        self.clone_box()
    }
}

pub trait HttpService: Send + Sync {
    /// Return whether this service is ready to accept a request
    ///
    /// See [`Service::poll_ready`](tower::Service::poll_ready)
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>>;

    /// Call this service and return a response
    ///
    /// See [`Service::call`](tower::Service::call)
    fn call(
        &mut self,
        req: http::Request<SdkBody>,
    ) -> Pin<Box<dyn Future<Output = Result<http::Response<SdkBody>, BoxError>> + Send>>;

    /// Return a Boxed-clone of this service
    ///
    /// `aws_hyper::Client` will clone the inner service for each request so this should be a cheap
    /// clone operation.
    fn clone_box(&self) -> Box<dyn HttpService>;
}

/// Reverse implementation: If you have a correctly shaped tower service, it _is_ an `HttpService`
///
/// This is to facilitate ease of use for people using `Standard::Dyn`
impl<S> HttpService for S
where
    S: Service<http::Request<SdkBody>, Response = http::Response<SdkBody>>
        + Send
        + Sync
        + Clone
        + 'static,
    S::Error: Into<BoxError> + Send + Sync + 'static,
    S::Future: Send + 'static,
{
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
        Service::poll_ready(self, cx).map_err(|err| err.into())
    }

    fn call(
        &mut self,
        req: Request<SdkBody>,
    ) -> Pin<Box<dyn Future<Output = Result<Response<SdkBody>, BoxError>> + Send>> {
        let fut = Service::call(self, req);
        let fut = async move {
            fut.await
                .map(|res| res.map(SdkBody::from))
                .map_err(|err| err.into())
        };
        Box::pin(fut)
    }

    fn clone_box(&self) -> Box<dyn HttpService> {
        Box::new(self.clone())
    }
}

impl tower::Service<http::Request<SdkBody>> for Standard {
    type Response = http::Response<SdkBody>;
    type Error = BoxError;
    type Future = StandardFuture;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        match &mut self.0 {
            #[cfg(feature = "native-tls")]
            Connector::NativeHttps(https) => {
                Service::poll_ready(https, cx).map_err(|err| err.into())
            }
            #[cfg(feature = "rustls")]
            Connector::RustlsHttps(https) => {
                Service::poll_ready(https, cx).map_err(|err| err.into())
            }
            Connector::Dyn(conn) => conn.poll_ready(cx),
        }
    }

    fn call(&mut self, req: http::Request<SdkBody>) -> Self::Future {
        match &mut self.0 {
            #[cfg(feature = "native-tls")]
            Connector::NativeHttps(https) => StandardFuture::Https(Service::call(https, req)),
            #[cfg(feature = "rustls")]
            Connector::RustlsHttps(https) => StandardFuture::Https(Service::call(https, req)),
            Connector::Dyn(conn) => StandardFuture::Dyn(conn.call(req)),
        }
    }
}

/// Future returned by `Standard` when used as a tower::Service
#[pin_project::pin_project(project = FutProj)]
pub enum StandardFuture {
    Https(#[pin] ResponseFuture),
    Dyn(#[pin] Pin<Box<dyn Future<Output = Result<http::Response<SdkBody>, BoxError>> + Send>>),
}

impl Future for StandardFuture {
    type Output = Result<http::Response<SdkBody>, BoxError>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        match self.project() {
            FutProj::Https(fut) => fut
                .poll(cx)
                .map(|resp| resp.map(|res| res.map(SdkBody::from)))
                .map_err(|err| err.into()),
            FutProj::Dyn(dyn_fut) => dyn_fut.poll(cx),
        }
    }
}
+64 −113
Original line number Diff line number Diff line
@@ -3,41 +3,54 @@
 * SPDX-License-Identifier: Apache-2.0.
 */

pub mod conn;
mod retry;
#[cfg(feature = "test-util")]
pub mod test_connection;
#[doc(inline)]
pub use smithy_client::test_connection;

pub use retry::RetryConfig;
pub use smithy_client::retry::Config as RetryConfig;

use crate::conn::Standard;
use crate::retry::RetryHandlerFactory;
use aws_endpoint::AwsEndpointStage;
use aws_http::user_agent::UserAgentStage;
use aws_sig_auth::middleware::SigV4SigningStage;
use aws_sig_auth::signer::SigV4Signer;
use smithy_http::body::SdkBody;
use smithy_http::operation::Operation;
use smithy_http::response::ParseHttpResponse;
pub use smithy_http::result::{SdkError, SdkSuccess};
use smithy_http::retry::ClassifyResponse;
use smithy_http_tower::dispatch::DispatchLayer;
use smithy_http_tower::map_request::MapRequestLayer;
use smithy_http_tower::parse_response::ParseResponseLayer;
use smithy_types::retry::ProvideErrorKind;
use std::error::Error;
use std::fmt;
use std::fmt::{Debug, Formatter};
use tower::{Service, ServiceBuilder, ServiceExt};
use std::fmt::Debug;
use tower::layer::util::Stack;
use tower::ServiceBuilder;

type BoxError = Box<dyn Error + Send + Sync>;
pub type StandardClient = Client<conn::Standard>;
type AwsMiddlewareStack = Stack<
    MapRequestLayer<SigV4SigningStage>,
    Stack<MapRequestLayer<UserAgentStage>, MapRequestLayer<AwsEndpointStage>>,
>;

#[derive(Debug, Default)]
#[non_exhaustive]
pub struct AwsMiddleware;
impl<S> tower::Layer<S> for AwsMiddleware {
    type Service = <AwsMiddlewareStack as tower::Layer<S>>::Service;

    fn layer(&self, inner: S) -> Self::Service {
        let signer = MapRequestLayer::for_mapper(SigV4SigningStage::new(SigV4Signer::new()));
        let endpoint_resolver = MapRequestLayer::for_mapper(AwsEndpointStage);
        let user_agent = MapRequestLayer::for_mapper(UserAgentStage::new());
        // These layers can be considered as occuring in order, that is:
        // 1. Resolve an endpoint
        // 2. Add a user agent
        // 3. Sign
        // (4. Dispatch over the wire)
        ServiceBuilder::new()
            .layer(endpoint_resolver)
            .layer(user_agent)
            .layer(signer)
            .service(inner)
    }
}

/// AWS Service Client
///
/// Hyper-based AWS Service Client. Most customers will want to construct a client with
/// [`Client::https()`](Client::https). For testing & other more advanced use cases, a custom
/// connector may be used via [`Client::new(connector)`](Client::new).
/// [`Client::https`](smithy_client::Client::https). For testing & other more advanced use cases, a
/// custom connector may be used via [`Client::new(connector)`](smithy_client::Client::new).
///
/// The internal connector must implement the following trait bound to be used to dispatch requests:
/// ```rust,ignore
@@ -48,116 +61,54 @@ pub type StandardClient = Client<conn::Standard>;
///    S::Error: Into<BoxError> + Send + Sync + 'static,
///    S::Future: Send + 'static,
/// ```
pub struct Client<S> {
    inner: S,
    retry_handler: RetryHandlerFactory,
}
#[doc(inline)]
pub type Client<C> = smithy_client::Client<C, AwsMiddleware>;

impl<S> Debug for Client<S> {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let mut formatter = f.debug_struct("Client");
        formatter.field("retry_handler", &self.retry_handler);
        formatter.finish()
    }
}
#[doc(inline)]
pub use smithy_client::erase::DynConnector;
pub type StandardClient = Client<DynConnector>;

impl<S> Client<S> {
    /// Construct a new `Client` with a custom connector
    pub fn new(connector: S) -> Self {
        Client {
            inner: connector,
            retry_handler: RetryHandlerFactory::new(RetryConfig::default()),
        }
    }
#[doc(inline)]
pub use smithy_client::bounds::SmithyConnector;

    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
        self.retry_handler.with_config(retry_config);
        self
    }
}
#[doc(inline)]
pub type Builder<C> = smithy_client::Builder<C, AwsMiddleware>;

impl Client<Standard> {
/// Construct an `https` based client
///
/// If the `rustls` feature is enabled, this will use `rustls`.
/// If the ONLY the `native-tls` feature is enabled, this will use `native-tls`.
/// If both features are enabled, this will use `rustls`
#[cfg(any(feature = "native-tls", feature = "rustls"))]
pub fn https() -> StandardClient {
        Client {
            inner: Standard::https(),
            retry_handler: RetryHandlerFactory::new(RetryConfig::default()),
        }
    }
}
    #[cfg(feature = "rustls")]
    let with_https = |b: Builder<_>| b.rustls();
    // If we are compiling this function & rustls is not enabled, then native-tls MUST be enabled
    #[cfg(not(feature = "rustls"))]
    let with_https = |b: Builder<_>| b.native_tls();

impl<S> Client<S>
where
    S: Service<http::Request<SdkBody>, Response = http::Response<SdkBody>> + Send + Clone + 'static,
    S::Error: Into<BoxError> + Send + Sync + 'static,
    S::Future: Send + 'static,
{
    /// Dispatch this request to the network
    ///
    /// For ergonomics, this does not include the raw response for successful responses. To
    /// access the raw response use `call_raw`.
    pub async fn call<O, T, E, Retry>(&self, input: Operation<O, Retry>) -> Result<T, SdkError<E>>
    where
        O: ParseHttpResponse<SdkBody, Output = Result<T, E>> + Send + Sync + Clone + 'static,
        E: Error + ProvideErrorKind,
        Retry: ClassifyResponse<SdkSuccess<T>, SdkError<E>>,
    {
        self.call_raw(input).await.map(|res| res.parsed)
    with_https(smithy_client::Builder::new())
        .build()
        .into_dyn_connector()
}

    /// Dispatch this request to the network
    ///
    /// The returned result contains the raw HTTP response which can be useful for debugging or implementing
    /// unsupported features.
    pub async fn call_raw<O, R, E, Retry>(
        &self,
        input: Operation<O, Retry>,
    ) -> Result<SdkSuccess<R>, SdkError<E>>
    where
        O: ParseHttpResponse<SdkBody, Output = Result<R, E>> + Send + Sync + Clone + 'static,
        E: Error + ProvideErrorKind,
        Retry: ClassifyResponse<SdkSuccess<R>, SdkError<E>>,
    {
        let signer = MapRequestLayer::for_mapper(SigV4SigningStage::new(SigV4Signer::new()));
        let endpoint_resolver = MapRequestLayer::for_mapper(AwsEndpointStage);
        let user_agent = MapRequestLayer::for_mapper(UserAgentStage::new());
        let inner = self.inner.clone();
        let mut svc = ServiceBuilder::new()
            // Create a new request-scoped policy
            .retry(self.retry_handler.new_handler())
            .layer(ParseResponseLayer::<O, Retry>::new())
            // These layers can be considered as occuring in order, that is:
            // 1. Resolve an endpoint
            // 2. Add a user agent
            // 3. Sign
            // 4. Dispatch over the wire
            .layer(endpoint_resolver)
            .layer(user_agent)
            .layer(signer)
            .layer(DispatchLayer::new())
            .service(inner);
        svc.ready().await?.call(input).await
    }
}

#[cfg(test)]
mod tests {

mod static_tests {
    #[cfg(any(feature = "rustls", feature = "native-tls"))]
    #[test]
    #[allow(dead_code)]
    fn construct_default_client() {
        let c = crate::Client::https();
        fn is_send_sync<T: Send + Sync>(_c: T) {}
        is_send_sync(c);
    }
}

#[cfg(test)]
mod tests {
    #[cfg(any(feature = "rustls", feature = "native-tls"))]
    #[test]
    fn client_debug_includes_retry_info() {
        let client = crate::Client::https();
        let s = format!("{:?}", client);
        assert!(s.contains("RetryConfig"));
        assert!(s.contains("quota_available"));
    }
}
Loading