RFC: Customizable Client Operations

Status: Implemented

For a summarized list of proposed changes, see the Changes Checklist section.

SDK customers occasionally need to add additional HTTP headers to requests, and currently, the SDK has no easy way to accomplish this. At time of writing, the lower level Smithy client has to be used to create an operation, and then the HTTP request augmented on that operation type. For example:

let input = SomeOperationInput::builder().some_value(5).build()?;

let operation = {
    let op = input.make_operation(&service_config).await?;
    let (request, response) = op.into_request_response();

    let request = request.augment(|req, _props| {
        req.headers_mut().insert(
            HeaderName::from_static("x-some-header"),
            HeaderValue::from_static("some-value")
        );
        Result::<_, Infallible>::Ok(req)
    })?;

    Operation::from_parts(request, response)
};

let response = smithy_client.call(operation).await?;

This approach is both difficult to discover and implement since it requires acquiring a Smithy client rather than the generated fluent client, and it's anything but ergonomic.

This RFC proposes an easier way to augment requests that is compatible with the fluent client.

Terminology

  • Smithy Client: A aws_smithy_client::Client<C, M, R> struct that is responsible for gluing together the connector, middleware, and retry policy.
  • Fluent Client: A code generated Client that has methods for each service operation on it. A fluent builder is generated alongside it to make construction easier.

Proposal

The code generated fluent builders returned by the fluent client should have a method added to them, similar to send, but that returns a customizable request. The customer experience should look as follows:

let response = client.some_operation()
    .some_value(5)
    .customize()
    .await?
    .mutate_request(|mut req| {
        req.headers_mut().insert(
            HeaderName::from_static("x-some-header"),
            HeaderValue::from_static("some-value")
        );
    })
    .send()
    .await?;

This new async customize method would return the following:

pub struct CustomizableOperation<O, R> {
    handle: Arc<Handle>,
    operation: Operation<O, R>,
}

impl<O, R> CustomizableOperation<O, R> {
    // Allows for customizing the operation's request
    fn map_request<E>(
        mut self,
        f: impl FnOnce(Request<SdkBody>) -> Result<Request<SdkBody>, E>,
    ) -> Result<Self, E> {
        let (request, response) = self.operation.into_request_response();
        let request = request.augment(|req, _props| f(req))?;
        self.operation = Operation::from_parts(request, response);
        Ok(self)
    }

    // Convenience for `map_request` where infallible direct mutation of request is acceptable
    fn mutate_request<E>(
        mut self,
        f: impl FnOnce(&mut Request<SdkBody>) -> (),
    ) -> Self {
        self.map_request(|mut req| {
            f(&mut req);
            Result::<_, Infallible>::Ok(req)
        }).expect("infallible");
        Ok(self)
    }

    // Allows for customizing the entire operation
    fn map_operation<E>(
        mut self,
        f: impl FnOnce(Operation<O, R>) -> Result<Operation<O, R>, E>,
    ) -> Result<Self, E> {
        self.operation = f(self.operation)?;
        Ok(self)
    }

    // Direct access to read the request
    fn request(&self) -> &Request<SdkBody> {
        self.operation.request()
    }

    // Direct access to mutate the request
    fn request_mut(&mut self) -> &mut Request<SdkBody> {
        self.operation.request_mut()
    }

    // Sends the operation's request
    async fn send<T, E>(self) -> Result<T, SdkError<E>>
    where
        O: ParseHttpResponse<Output = Result<T, E>> + Send + Sync + Clone + 'static,
        E: std::error::Error,
        R: ClassifyResponse<SdkSuccess<T>, SdkError<E>> + Send + Sync,
    {
        self.handle.client.call(self.operation).await
    }
}

Additionally, for those who want to avoid closures, the Operation type will have request and request_mut methods added to it to get direct access to its underlying HTTP request.

The CustomizableOperation type will then mirror these functions so that the experience can look as follows:

let mut operation = client.some_operation()
    .some_value(5)
    .customize()
    .await?;
operation.request_mut()
    .headers_mut()
    .insert(
        HeaderName::from_static("x-some-header"),
        HeaderValue::from_static("some-value")
    );
let response = operation.send().await?;

Why not remove async from customize to make this more ergonomic?

In the proposal above, customers must await the result of customize in order to get the CustomizableOperation. This is a result of the underlying map_operation function that customize needs to call being async, which was made async during the implementation of customizations for Glacier (see #797, #801, and #1474). It is possible to move these Glacier customizations into middleware to make map_operation sync, but keeping it async is much more future-proof since if a future customization or feature requires it to be async, it won't be a breaking change in the future.

Why the name customize?

Alternatively, the name build could be used, but this increases the odds that customers won't realize that they can call send directly, and then call a longer build/send chain when customization isn't needed:

client.some_operation()
    .some_value()
    .build() // Oops, didn't need to do this
    .send()
    .await?;

vs.

client.some_operation()
    .some_value()
    .send()
    .await?;

Additionally, no AWS services at time of writing have a member named customize that would conflict with the new function, so adding it would not be a breaking change.

Changes Checklist

  • Create CustomizableOperation as an inlinable, and code generate it into client so that it has access to Handle
  • Code generate the customize method on fluent builders
  • Update the RustReservedWords class to include customize
  • Add ability to mutate the HTTP request on Operation
  • Add examples for both approaches
  • Comment on older discussions asking about how to do this with this improved approach