diff --git a/rust-runtime/Cargo.toml b/rust-runtime/Cargo.toml index aed464b4bb6..9a26b87dbb8 100644 --- a/rust-runtime/Cargo.toml +++ b/rust-runtime/Cargo.toml @@ -11,6 +11,8 @@ members = [ "aws-smithy-http", "aws-smithy-http-client", "aws-smithy-http-server", + "aws-smithy-legacy-http", + "aws-smithy-legacy-http-server", "aws-smithy-http-server-python", "aws-smithy-json", "aws-smithy-protocol-test", diff --git a/rust-runtime/aws-smithy-legacy-http-server/Cargo.toml b/rust-runtime/aws-smithy-legacy-http-server/Cargo.toml new file mode 100644 index 00000000000..194349b13d7 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "aws-smithy-legacy-http-server" +version = "0.65.9" +authors = ["Smithy Rust Server "] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/smithy-lang/smithy-rs" +keywords = ["smithy", "framework", "web", "api", "aws"] +categories = ["asynchronous", "web-programming", "api-bindings"] +description = """ +Server runtime for Smithy Rust Server Framework. +""" +publish = true + +[features] +aws-lambda = ["dep:lambda_http"] +unredacted-logging = [] +request-id = ["dep:uuid"] + +[dependencies] +aws-smithy-http = { path = "../aws-smithy-legacy-http", features = ["rt-tokio"], package = "aws-smithy-legacy-http" } +aws-smithy-json = { path = "../aws-smithy-json" } +aws-smithy-runtime-api = { path = "../aws-smithy-runtime-api", features = ["http-02x"] } +aws-smithy-types = { path = "../aws-smithy-types", features = ["http-body-0-4-x", "hyper-0-14-x"] } +aws-smithy-xml = { path = "../aws-smithy-xml" } +aws-smithy-cbor = { path = "../aws-smithy-cbor" } +bytes = "1.10.0" +futures-util = { version = "0.3.29", default-features = false } +http = "0.2.9" +http-body = "0.4.5" +hyper = { version = "0.14.26", features = ["server", "http1", "http2", "tcp", "stream"] } +lambda_http = { version = "0.8.4", optional = true } +mime = "0.3.17" +nom = "7.1.3" +pin-project-lite = "0.2.14" +regex = "1.11.1" +serde_urlencoded = "0.7" +thiserror = "2" +tokio = { version = "1.40.0", features = ["full"] } +tower = { version = "0.4.13", features = ["util", "make"], default-features = false } +tower-http = { version = "0.3", features = ["add-extension", "map-response-body"] } +tracing = "0.1.40" +uuid = { version = "1.1.2", features = ["v4", "fast-rng"], optional = true } + +[dev-dependencies] +pretty_assertions = "1" + +[package.metadata.docs.rs] +all-features = true +targets = ["x86_64-unknown-linux-gnu"] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +rustdoc-args = ["--cfg", "docsrs"] +# End of docs.rs metadata diff --git a/rust-runtime/aws-smithy-legacy-http-server/LICENSE b/rust-runtime/aws-smithy-legacy-http-server/LICENSE new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/rust-runtime/aws-smithy-legacy-http-server/README.md b/rust-runtime/aws-smithy-legacy-http-server/README.md new file mode 100644 index 00000000000..701fcb7ddcd --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/README.md @@ -0,0 +1,13 @@ +# aws-smithy-legacy-http-server + +**This is a legacy crate that provides support for `http@0.x` and `hyper@0.x`.** + +Server libraries for smithy-rs generated servers. + +## Usage + +This crate is used when generating server SDKs without the `http-1x` codegen flag. For new projects, prefer using `aws-smithy-http-server` which supports `http@1.x` and `hyper@1.x`. + + +This crate is part of the [AWS SDK for Rust](https://awslabs.github.io/aws-sdk-rust/) and the [smithy-rs](https://github.com/smithy-lang/smithy-rs) code generator. In most cases, it should not be used directly. + diff --git a/rust-runtime/aws-smithy-legacy-http-server/additional-ci b/rust-runtime/aws-smithy-legacy-http-server/additional-ci new file mode 100755 index 00000000000..4db3b92f910 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/additional-ci @@ -0,0 +1,12 @@ +#!/bin/bash +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +# This script contains additional CI checks to run for this specific package + +set -e + +echo "### Testing unredacted-logging logging feature" +cargo test logging:: --features unredacted-logging diff --git a/rust-runtime/aws-smithy-legacy-http-server/rustfmt.toml b/rust-runtime/aws-smithy-legacy-http-server/rustfmt.toml new file mode 100644 index 00000000000..fb76357fd94 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/rustfmt.toml @@ -0,0 +1,4 @@ +edition = "2021" +max_width = 120 +# Prevent carriage returns +newline_style = "Unix" diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/body.rs b/rust-runtime/aws-smithy-legacy-http-server/src/body.rs new file mode 100644 index 00000000000..760e0e3a272 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/body.rs @@ -0,0 +1,57 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! HTTP body utilities. + +// Used in the codegen in trait bounds. +#[doc(hidden)] +pub use http_body::Body as HttpBody; + +pub use hyper::body::Body; + +use bytes::Bytes; + +use crate::error::{BoxError, Error}; + +/// The primary [`Body`] returned by the generated `smithy-rs` service. +pub type BoxBody = http_body::combinators::UnsyncBoxBody; + +// `boxed` is used in the codegen of the implementation of the operation `Handler` trait. +/// Convert a [`http_body::Body`] into a [`BoxBody`]. +pub fn boxed(body: B) -> BoxBody +where + B: http_body::Body + Send + 'static, + B::Error: Into, +{ + try_downcast(body).unwrap_or_else(|body| body.map_err(Error::new).boxed_unsync()) +} + +#[doc(hidden)] +pub(crate) fn try_downcast(k: K) -> Result +where + T: 'static, + K: Send + 'static, +{ + let mut k = Some(k); + if let Some(k) = ::downcast_mut::>(&mut k) { + Ok(k.take().unwrap()) + } else { + Err(k.unwrap()) + } +} + +pub(crate) fn empty() -> BoxBody { + boxed(http_body::Empty::new()) +} + +/// Convert anything that can be converted into a [`hyper::body::Body`] into a [`BoxBody`]. +/// This simplifies codegen a little bit. +#[doc(hidden)] +pub fn to_boxed(body: B) -> BoxBody +where + Body: From, +{ + boxed(Body::from(body)) +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/error.rs new file mode 100644 index 00000000000..fea99e54d29 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/error.rs @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +//! Error definition. + +use std::{error::Error as StdError, fmt}; + +/// Errors that can happen when using this crate. +#[derive(Debug)] +pub struct Error { + inner: BoxError, +} + +pub(crate) type BoxError = Box; + +impl Error { + /// Create a new `Error` from a boxable error. + pub(crate) fn new(error: impl Into) -> Self { + Self { inner: error.into() } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.fmt(f) + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.inner) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/extension.rs b/rust-runtime/aws-smithy-legacy-http-server/src/extension.rs new file mode 100644 index 00000000000..17ff01e9619 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/extension.rs @@ -0,0 +1,239 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Extension types. +//! +//! Extension types are types that are stored in and extracted from _both_ requests and +//! responses. +//! +//! There is only one _generic_ extension type _for requests_, [`Extension`]. +//! +//! On the other hand, the server SDK uses multiple concrete extension types for responses in order +//! to store a variety of information, like the operation that was executed, the operation error +//! that got returned, or the runtime error that happened, among others. The information stored in +//! these types may be useful to [`tower::Layer`]s that post-process the response: for instance, a +//! particular metrics layer implementation might want to emit metrics about the number of times an +//! an operation got executed. +//! +//! [extensions]: https://docs.rs/http/latest/http/struct.Extensions.html + +use std::hash::Hash; +use std::{fmt, fmt::Debug, future::Future, ops::Deref, pin::Pin, task::Context, task::Poll}; + +use futures_util::ready; +use futures_util::TryFuture; +use thiserror::Error; +use tower::Service; + +use crate::operation::OperationShape; +use crate::plugin::{HttpMarker, HttpPlugins, Plugin, PluginStack}; +use crate::shape_id::ShapeId; + +pub use crate::request::extension::{Extension, MissingExtension}; + +/// Extension type used to store information about Smithy operations in HTTP responses. +/// This extension type is inserted, via the [`OperationExtensionPlugin`], whenever it has been correctly determined +/// that the request should be routed to a particular operation. The operation handler might not even get invoked +/// because the request fails to deserialize into the modeled operation input. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OperationExtension(pub ShapeId); + +/// An error occurred when parsing an absolute operation shape ID. +#[derive(Debug, Clone, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ParseError { + #[error("# was not found - missing namespace")] + MissingNamespace, +} + +pin_project_lite::pin_project! { + /// The [`Service::Future`] of [`OperationExtensionService`] - inserts an [`OperationExtension`] into the + /// [`http::Response]`. + pub struct OperationExtensionFuture { + #[pin] + inner: Fut, + operation_extension: Option + } +} + +impl Future for OperationExtensionFuture +where + Fut: TryFuture>, +{ + type Output = Result, Fut::Error>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let resp = ready!(this.inner.try_poll(cx)); + let ext = this + .operation_extension + .take() + .expect("futures cannot be polled after completion"); + Poll::Ready(resp.map(|mut resp| { + resp.extensions_mut().insert(ext); + resp + })) + } +} + +/// Inserts a [`OperationExtension`] into the extensions of the [`http::Response`]. +#[derive(Debug, Clone)] +pub struct OperationExtensionService { + inner: S, + operation_extension: OperationExtension, +} + +impl Service> for OperationExtensionService +where + S: Service, Response = http::Response>, +{ + type Response = http::Response; + type Error = S::Error; + type Future = OperationExtensionFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + OperationExtensionFuture { + inner: self.inner.call(req), + operation_extension: Some(self.operation_extension.clone()), + } + } +} + +/// A [`Plugin`] which applies [`OperationExtensionService`] to every operation. +pub struct OperationExtensionPlugin; + +impl fmt::Debug for OperationExtensionPlugin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("OperationExtensionPlugin").field(&"...").finish() + } +} + +impl Plugin for OperationExtensionPlugin +where + Op: OperationShape, +{ + type Output = OperationExtensionService; + + fn apply(&self, inner: T) -> Self::Output { + OperationExtensionService { + inner, + operation_extension: OperationExtension(Op::ID), + } + } +} + +impl HttpMarker for OperationExtensionPlugin {} + +/// An extension trait on [`HttpPlugins`] allowing the application of [`OperationExtensionPlugin`]. +/// +/// See [`module`](crate::extension) documentation for more info. +pub trait OperationExtensionExt { + /// Apply the [`OperationExtensionPlugin`], which inserts the [`OperationExtension`] into every [`http::Response`]. + fn insert_operation_extension(self) -> HttpPlugins>; +} + +impl OperationExtensionExt for HttpPlugins { + fn insert_operation_extension(self) -> HttpPlugins> { + self.push(OperationExtensionPlugin) + } +} + +/// Extension type used to store the type of user-modeled error returned by an operation handler. +/// These are modeled errors, defined in the Smithy model. +#[derive(Debug, Clone)] +pub struct ModeledErrorExtension(&'static str); + +impl ModeledErrorExtension { + /// Creates a new `ModeledErrorExtension`. + pub fn new(value: &'static str) -> ModeledErrorExtension { + ModeledErrorExtension(value) + } +} + +impl Deref for ModeledErrorExtension { + type Target = &'static str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Extension type used to store the _name_ of the possible runtime errors. +/// These are _unmodeled_ errors; the operation handler was not invoked. +#[derive(Debug, Clone)] +pub struct RuntimeErrorExtension(String); + +impl RuntimeErrorExtension { + /// Creates a new `RuntimeErrorExtension`. + pub fn new(value: String) -> RuntimeErrorExtension { + RuntimeErrorExtension(value) + } +} + +impl Deref for RuntimeErrorExtension { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use tower::{service_fn, Layer, ServiceExt}; + + use crate::{plugin::PluginLayer, protocol::rest_json_1::RestJson1}; + + use super::*; + + #[test] + fn ext_accept() { + let value = "com.amazonaws.ebs#CompleteSnapshot"; + let ext = ShapeId::new( + "com.amazonaws.ebs#CompleteSnapshot", + "com.amazonaws.ebs", + "CompleteSnapshot", + ); + + assert_eq!(ext.absolute(), value); + assert_eq!(ext.namespace(), "com.amazonaws.ebs"); + assert_eq!(ext.name(), "CompleteSnapshot"); + } + + #[tokio::test] + async fn plugin() { + struct DummyOp; + + impl OperationShape for DummyOp { + const ID: ShapeId = ShapeId::new( + "com.amazonaws.ebs#CompleteSnapshot", + "com.amazonaws.ebs", + "CompleteSnapshot", + ); + + type Input = (); + type Output = (); + type Error = (); + } + + // Apply `Plugin`. + let plugins = HttpPlugins::new().insert_operation_extension(); + + // Apply `Plugin`s `Layer`. + let layer = PluginLayer::new::(plugins); + let svc = service_fn(|_: http::Request<()>| async { Ok::<_, ()>(http::Response::new(())) }); + let svc = layer.layer(svc); + + // Check for `OperationExtension`. + let response = svc.oneshot(http::Request::new(())).await.unwrap(); + let expected = DummyOp::ID; + let actual = response.extensions().get::().unwrap(); + assert_eq!(actual.0, expected); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/mod.rs new file mode 100644 index 00000000000..9aa57022e2a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/mod.rs @@ -0,0 +1,144 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#![deny(missing_docs, missing_debug_implementations)] + +//! Provides [`InstrumentOperation`] and a variety of helpers structures for dealing with sensitive data. Together they +//! allow compliance with the [sensitive trait]. +//! +//! # Example +//! +//! ``` +//! # use std::convert::Infallible; +//! # use aws_smithy_legacy_http_server::instrumentation::{*, sensitivity::{*, headers::*, uri::*}}; +//! # use aws_smithy_legacy_http_server::shape_id::ShapeId; +//! # use http::{Request, Response}; +//! # use tower::{util::service_fn, Service}; +//! # async fn service(request: Request<()>) -> Result, Infallible> { +//! # Ok(Response::new(())) +//! # } +//! # async fn example() { +//! # let service = service_fn(service); +//! # const ID: ShapeId = ShapeId::new("namespace#foo-operation", "namespace", "foo-operation"); +//! let request = Request::get("http://localhost/a/b/c/d?bar=hidden") +//! .header("header-name-a", "hidden") +//! .body(()) +//! .unwrap(); +//! +//! let request_fmt = RequestFmt::new() +//! .header(|name| HeaderMarker { +//! value: name == "header-name-a", +//! key_suffix: None, +//! }) +//! .query(|name| QueryMarker { key: false, value: name == "bar" }) +//! .label(|index| index % 2 == 0, None); +//! let response_fmt = ResponseFmt::new() +//! .header(|name| { +//! if name.as_str().starts_with("prefix-") { +//! HeaderMarker { +//! value: true, +//! key_suffix: Some("prefix-".len()), +//! } +//! } else { +//! HeaderMarker { +//! value: name == "header-name-b", +//! key_suffix: None, +//! } +//! } +//! }) +//! .status_code(); +//! let mut service = InstrumentOperation::new(service, ID) +//! .request_fmt(request_fmt) +//! .response_fmt(response_fmt); +//! +//! let _ = service.call(request).await.unwrap(); +//! # } +//! ``` +//! +//! [sensitive trait]: https://smithy.io/2.0/spec/documentation-traits.html#sensitive-trait + +mod plugin; +pub mod sensitivity; +mod service; + +use std::fmt::{Debug, Display}; + +pub use plugin::*; +pub use service::*; + +/// A standard interface for taking some component of the HTTP request/response and transforming it into new struct +/// which enjoys [`Debug`] or [`Display`]. This allows for polymorphism over formatting approaches. +pub trait MakeFmt { + /// Target of the `fmt` transformation. + type Target; + + /// Transforms a source into a target, altering it's [`Display`] or [`Debug`] implementation. + fn make(&self, source: T) -> Self::Target; +} + +impl MakeFmt for &U +where + U: MakeFmt, +{ + type Target = U::Target; + + fn make(&self, source: T) -> Self::Target { + U::make(self, source) + } +} + +/// Identical to [`MakeFmt`] but with a [`Display`] bound on the associated type. +pub trait MakeDisplay { + /// Mirrors [`MakeFmt::Target`]. + type Target: Display; + + /// Mirrors [`MakeFmt::make`]. + fn make_display(&self, source: T) -> Self::Target; +} + +impl MakeDisplay for U +where + U: MakeFmt, + U::Target: Display, +{ + type Target = U::Target; + + fn make_display(&self, source: T) -> Self::Target { + U::make(self, source) + } +} + +/// Identical to [`MakeFmt`] but with a [`Debug`] bound on the associated type. +pub trait MakeDebug { + /// Mirrors [`MakeFmt::Target`]. + type Target: Debug; + + /// Mirrors [`MakeFmt::make`]. + fn make_debug(&self, source: T) -> Self::Target; +} + +impl MakeDebug for U +where + U: MakeFmt, + U::Target: Debug, +{ + type Target = U::Target; + + fn make_debug(&self, source: T) -> Self::Target { + U::make(self, source) + } +} + +/// A blanket, identity, [`MakeFmt`] implementation. Applies no changes to the [`Display`]/[`Debug`] implementation. +#[derive(Debug, Clone, Default)] +pub struct MakeIdentity; + +impl MakeFmt for MakeIdentity { + type Target = T; + + fn make(&self, source: T) -> Self::Target { + source + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/plugin.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/plugin.rs new file mode 100644 index 00000000000..7aa9b1d85da --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/plugin.rs @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::plugin::{HttpMarker, HttpPlugins, PluginStack}; +use crate::{operation::OperationShape, plugin::Plugin}; + +use super::sensitivity::Sensitivity; +use super::InstrumentOperation; + +/// A [`Plugin`] which applies [`InstrumentOperation`] to every operation. +#[derive(Debug)] +pub struct InstrumentPlugin; + +impl Plugin for InstrumentPlugin +where + Op: OperationShape, + Op: Sensitivity, +{ + type Output = InstrumentOperation; + + fn apply(&self, input: T) -> Self::Output { + InstrumentOperation::new(input, Op::ID) + .request_fmt(Op::request_fmt()) + .response_fmt(Op::response_fmt()) + } +} + +impl HttpMarker for InstrumentPlugin {} + +/// An extension trait for applying [`InstrumentPlugin`]. +pub trait InstrumentExt { + /// Applies an [`InstrumentOperation`] to every operation, respecting the [@sensitive] trait given on the input and + /// output models. See [`InstrumentOperation`] for more information. + /// + /// [@sensitive]: https://smithy.io/2.0/spec/documentation-traits.html#sensitive-trait + fn instrument(self) -> HttpPlugins>; +} + +impl InstrumentExt for HttpPlugins { + fn instrument(self) -> HttpPlugins> { + self.push(InstrumentPlugin) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/headers.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/headers.rs new file mode 100644 index 00000000000..75423efcddd --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/headers.rs @@ -0,0 +1,266 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A wrapper around [`HeaderMap`] to allow for sensitivity. + +use std::fmt::{Debug, Display, Error, Formatter}; + +use http::{header::HeaderName, HeaderMap}; + +use crate::instrumentation::MakeFmt; + +use super::Sensitive; + +/// Marks the sensitive data of a header pair. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct HeaderMarker { + /// Set to `true` to mark the value as sensitive. + pub value: bool, + /// Set to `Some(x)` to mark `key[x..]` as sensitive. + pub key_suffix: Option, +} + +/// A wrapper around [`&HeaderMap`](HeaderMap) which modifies the behavior of [`Debug`]. Specific parts of the +/// [`HeaderMap`] are marked as sensitive using a closure. This accommodates the [httpPrefixHeaders trait] and +/// [httpHeader trait]. +/// +/// The [`Debug`] implementation will respect the `unredacted-logging` flag. +/// +/// # Example +/// +/// ``` +/// # use aws_smithy_legacy_http_server::instrumentation::sensitivity::headers::{SensitiveHeaders, HeaderMarker}; +/// # use http::header::HeaderMap; +/// # let headers = HeaderMap::new(); +/// // Headers with keys equal to "header-name" are sensitive +/// let marker = |key| +/// HeaderMarker { +/// value: key == "header-name", +/// key_suffix: None +/// }; +/// let headers = SensitiveHeaders::new(&headers, marker); +/// println!("{headers:?}"); +/// ``` +/// +/// [httpPrefixHeaders trait]: https://smithy.io/2.0/spec/http-bindings.html#httpprefixheaders-trait +/// [httpHeader trait]: https://smithy.io/2.0/spec/http-bindings.html#httpheader-trait +pub struct SensitiveHeaders<'a, F> { + headers: &'a HeaderMap, + marker: F, +} + +impl<'a, F> SensitiveHeaders<'a, F> { + /// Constructs a new [`SensitiveHeaders`]. + pub fn new(headers: &'a HeaderMap, marker: F) -> Self { + Self { headers, marker } + } +} + +/// Concatenates the [`Debug`] of [`&str`](str) and ['Sensitive<&str>`](Sensitive). +struct ThenDebug<'a>(&'a str, Sensitive<&'a str>); + +impl Debug for ThenDebug<'_> { + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + write!(f, "\"{}{}\"", self.0, self.1) + } +} + +/// Allows for formatting of `Left` or `Right` variants. +enum OrFmt { + Left(Left), + Right(Right), +} + +impl Debug for OrFmt +where + Left: Debug, + Right: Debug, +{ + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Left(left) => left.fmt(f), + Self::Right(right) => right.fmt(f), + } + } +} + +impl Display for OrFmt +where + Left: Display, + Right: Display, +{ + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + match self { + Self::Left(left) => left.fmt(f), + Self::Right(right) => right.fmt(f), + } + } +} + +impl<'a, F> Debug for SensitiveHeaders<'a, F> +where + F: Fn(&'a HeaderName) -> HeaderMarker, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + let iter = self.headers.iter().map(|(key, value)| { + let HeaderMarker { + value: value_sensitive, + key_suffix, + } = (self.marker)(key); + + let key = if let Some(key_suffix) = key_suffix { + let key_str = key.as_str(); + OrFmt::Left(ThenDebug(&key_str[..key_suffix], Sensitive(&key_str[key_suffix..]))) + } else { + OrFmt::Right(key) + }; + + let value = if value_sensitive { + OrFmt::Left(Sensitive(value)) + } else { + OrFmt::Right(value) + }; + + (key, value) + }); + + f.debug_map().entries(iter).finish() + } +} + +/// A [`MakeFmt`] producing [`SensitiveHeaders`]. +#[derive(Clone)] +pub struct MakeHeaders(pub(crate) F); + +impl Debug for MakeHeaders { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("MakeHeaders").field(&"...").finish() + } +} + +impl<'a, F> MakeFmt<&'a HeaderMap> for MakeHeaders +where + F: Clone, +{ + type Target = SensitiveHeaders<'a, F>; + + fn make(&self, source: &'a HeaderMap) -> Self::Target { + SensitiveHeaders::new(source, self.0.clone()) + } +} +#[cfg(test)] +mod tests { + use http::{header::HeaderName, HeaderMap, HeaderValue}; + + use super::*; + + // This is needed because we header maps with "{redacted}" are disallowed. + struct TestDebugMap([(&'static str, &'static str); 4]); + + impl Debug for TestDebugMap { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_map().entries(self.0).finish() + } + } + + const HEADER_MAP: [(&str, &str); 4] = [ + ("name-a", "value-a"), + ("name-b", "value-b"), + ("prefix-a-x", "value-c"), + ("prefix-b-y", "value-d"), + ]; + + fn to_header_map(values: I) -> HeaderMap + where + I: IntoIterator, + { + values + .into_iter() + .map(|(key, value)| (HeaderName::from_static(key), HeaderValue::from_static(value))) + .collect() + } + + #[test] + fn mark_none() { + let original: HeaderMap = to_header_map(HEADER_MAP); + + let output = SensitiveHeaders::new(&original, |_| HeaderMarker::default()); + assert_eq!(format!("{output:?}"), format!("{:?}", original)); + } + + #[cfg(not(feature = "unredacted-logging"))] + const ALL_VALUES_HEADER_MAP: [(&str, &str); 4] = [ + ("name-a", "{redacted}"), + ("name-b", "{redacted}"), + ("prefix-a-x", "{redacted}"), + ("prefix-b-y", "{redacted}"), + ]; + #[cfg(feature = "unredacted-logging")] + const ALL_VALUES_HEADER_MAP: [(&str, &str); 4] = HEADER_MAP; + + #[test] + fn mark_all_values() { + let original: HeaderMap = to_header_map(HEADER_MAP); + let expected = TestDebugMap(ALL_VALUES_HEADER_MAP); + + let output = SensitiveHeaders::new(&original, |_| HeaderMarker { + value: true, + key_suffix: None, + }); + assert_eq!(format!("{output:?}"), format!("{:?}", expected)); + } + + #[cfg(not(feature = "unredacted-logging"))] + const NAME_A_HEADER_MAP: [(&str, &str); 4] = [ + ("name-a", "{redacted}"), + ("name-b", "value-b"), + ("prefix-a-x", "value-c"), + ("prefix-b-y", "value-d"), + ]; + #[cfg(feature = "unredacted-logging")] + const NAME_A_HEADER_MAP: [(&str, &str); 4] = HEADER_MAP; + + #[test] + fn mark_name_a_values() { + let original: HeaderMap = to_header_map(HEADER_MAP); + let expected = TestDebugMap(NAME_A_HEADER_MAP); + + let output = SensitiveHeaders::new(&original, |name| HeaderMarker { + value: name == "name-a", + key_suffix: None, + }); + assert_eq!(format!("{output:?}"), format!("{:?}", expected)); + } + + #[cfg(not(feature = "unredacted-logging"))] + const PREFIX_A_HEADER_MAP: [(&str, &str); 4] = [ + ("name-a", "value-a"), + ("name-b", "value-b"), + ("prefix-a{redacted}", "value-c"), + ("prefix-b-y", "value-d"), + ]; + #[cfg(feature = "unredacted-logging")] + const PREFIX_A_HEADER_MAP: [(&str, &str); 4] = HEADER_MAP; + + #[test] + fn mark_prefix_a_values() { + let original: HeaderMap = to_header_map(HEADER_MAP); + let expected = TestDebugMap(PREFIX_A_HEADER_MAP); + + let prefix = "prefix-a"; + let output = SensitiveHeaders::new(&original, |name: &HeaderName| HeaderMarker { + value: false, + key_suffix: if name.as_str().starts_with(prefix) { + Some(prefix.len()) + } else { + None + }, + }); + assert_eq!(format!("{output:?}"), format!("{:?}", expected)); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/mod.rs new file mode 100644 index 00000000000..85d0093df24 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/mod.rs @@ -0,0 +1,41 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The combination of [HTTP binding traits] and the [sensitive trait] require us to redact +//! portions of the HTTP requests/responses during logging. +//! +//! [HTTP binding traits]: https://smithy.io/2.0/spec/http-bindings.html +//! [sensitive trait]: https://smithy.io/2.0/spec/documentation-traits.html#sensitive-trait + +pub mod headers; +mod request; +mod response; +mod sensitive; +pub mod uri; + +use http::{HeaderMap, StatusCode, Uri}; +pub use request::*; +pub use response::*; +pub use sensitive::*; + +use super::{MakeDebug, MakeDisplay}; + +/// The string placeholder for redacted data. +pub const REDACTED: &str = "{redacted}"; + +/// An interface for providing [`MakeDebug`] and [`MakeDisplay`] for [`Request`](http::Request) and +/// [`Response`](http::Response). +pub trait Sensitivity { + /// The [`MakeDebug`] and [`MakeDisplay`] for the request [`HeaderMap`] and [`Uri`]. + type RequestFmt: for<'a> MakeDebug<&'a HeaderMap> + for<'a> MakeDisplay<&'a Uri>; + /// The [`MakeDebug`] and [`MakeDisplay`] for the response [`HeaderMap`] and [`StatusCode`]. + type ResponseFmt: for<'a> MakeDebug<&'a HeaderMap> + MakeDisplay; + + /// Returns the [`RequestFmt`](Sensitivity::RequestFmt). + fn request_fmt() -> Self::RequestFmt; + + /// Returns the [`ResponseFmt`](Sensitivity::ResponseFmt). + fn response_fmt() -> Self::ResponseFmt; +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/request.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/request.rs new file mode 100644 index 00000000000..710eedb161d --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/request.rs @@ -0,0 +1,130 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A builder whose methods allow for configuration of [`MakeFmt`] implementations over parts of [`http::Request`]. + +use std::fmt::{Debug, Error, Formatter}; + +use http::{header::HeaderName, HeaderMap}; + +use crate::instrumentation::{MakeFmt, MakeIdentity}; + +use super::{ + headers::{HeaderMarker, MakeHeaders}, + uri::{GreedyLabel, MakeLabel, MakeQuery, MakeUri, QueryMarker}, +}; + +/// Allows the modification the requests URIs [`Display`](std::fmt::Display) and headers +/// [`Debug`] to accommodate sensitivity. +/// +/// This enjoys [`MakeFmt`] for [`&HeaderMap`](HeaderMap) and [`&Uri`](http::Uri). +#[derive(Clone)] +pub struct RequestFmt { + headers: Headers, + uri: Uri, +} + +impl Debug for RequestFmt { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_struct("RequestFmt").finish_non_exhaustive() + } +} + +/// Default [`RequestFmt`]. +pub type DefaultRequestFmt = RequestFmt>; + +impl Default for DefaultRequestFmt { + fn default() -> Self { + Self { + headers: MakeIdentity, + uri: MakeUri::default(), + } + } +} + +impl DefaultRequestFmt { + /// Constructs a new [`RequestFmt`] with no redactions. + pub fn new() -> Self { + Self::default() + } +} + +impl RequestFmt { + /// Marks parts of headers as sensitive using a closure. + /// + /// See [`SensitiveHeaders`](super::headers::SensitiveHeaders) for more info. + pub fn header(self, headers: F) -> RequestFmt, Uri> + where + F: Fn(&HeaderName) -> HeaderMarker, + { + RequestFmt { + headers: MakeHeaders(headers), + uri: self.uri, + } + } +} + +impl RequestFmt> { + /// Marks parts of the URI as sensitive. + /// + /// See [`Label`](super::uri::Label) for more info. + pub fn label( + self, + label_marker: F, + greedy_label: Option, + ) -> RequestFmt, Q>> + where + F: Fn(usize) -> bool, + { + RequestFmt { + headers: self.headers, + uri: MakeUri { + make_path: MakeLabel { + label_marker, + greedy_label, + }, + make_query: self.uri.make_query, + }, + } + } + + /// Marks parts of the query as sensitive. + /// + /// See [`Query`](super::uri::Query) for more info. + pub fn query(self, query: F) -> RequestFmt>> + where + F: Fn(&str) -> QueryMarker, + { + RequestFmt { + headers: self.headers, + uri: MakeUri { + make_path: self.uri.make_path, + make_query: MakeQuery(query), + }, + } + } +} + +impl<'a, Headers, Uri> MakeFmt<&'a HeaderMap> for RequestFmt +where + Headers: MakeFmt<&'a HeaderMap>, +{ + type Target = Headers::Target; + + fn make(&self, source: &'a HeaderMap) -> Self::Target { + self.headers.make(source) + } +} + +impl<'a, Headers, Uri> MakeFmt<&'a http::Uri> for RequestFmt +where + Uri: MakeFmt<&'a http::Uri>, +{ + type Target = Uri::Target; + + fn make(&self, source: &'a http::Uri) -> Self::Target { + self.uri.make(source) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/response.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/response.rs new file mode 100644 index 00000000000..77d3652678c --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/response.rs @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A builder whose methods allow for configuration of [`MakeFmt`] implementations over parts of [`http::Response`]. + +use std::fmt::{Debug, Error, Formatter}; + +use http::{header::HeaderName, HeaderMap}; + +use crate::instrumentation::{MakeFmt, MakeIdentity}; + +use super::{ + headers::{HeaderMarker, MakeHeaders}, + MakeSensitive, +}; + +/// Allows the modification the responses status code [`Display`](std::fmt::Display) and headers +/// [`Debug`] to accommodate sensitivity. +/// +/// This enjoys [`MakeFmt`] for [`&HeaderMap`](HeaderMap) and [`StatusCode`](http::StatusCode). +#[derive(Clone)] +pub struct ResponseFmt { + headers: Headers, + status_code: StatusCode, +} + +impl Debug for ResponseFmt { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_struct("ResponseFmt").finish_non_exhaustive() + } +} + +/// Default [`ResponseFmt`]. +pub type DefaultResponseFmt = ResponseFmt; + +impl Default for DefaultResponseFmt { + fn default() -> Self { + Self { + headers: MakeIdentity, + status_code: MakeIdentity, + } + } +} + +impl DefaultResponseFmt { + /// Constructs a new [`ResponseFmt`] with no redactions. + pub fn new() -> Self { + Self::default() + } +} + +impl ResponseFmt { + /// Marks headers as sensitive using a closure. + /// + /// See [`SensitiveHeaders`](super::headers::SensitiveHeaders) for more info. + pub fn header(self, header: F) -> ResponseFmt, StatusCode> + where + F: Fn(&HeaderName) -> HeaderMarker, + { + ResponseFmt { + headers: MakeHeaders(header), + status_code: self.status_code, + } + } + + /// Marks request status code as sensitive. + pub fn status_code(self) -> ResponseFmt { + ResponseFmt { + headers: self.headers, + status_code: MakeSensitive, + } + } +} + +impl<'a, Headers, StatusCode> MakeFmt<&'a HeaderMap> for ResponseFmt +where + Headers: MakeFmt<&'a HeaderMap>, +{ + type Target = Headers::Target; + + fn make(&self, source: &'a HeaderMap) -> Self::Target { + self.headers.make(source) + } +} + +impl MakeFmt for ResponseFmt +where + StatusCode: MakeFmt, +{ + type Target = StatusCode::Target; + + fn make(&self, source: http::StatusCode) -> Self::Target { + self.status_code.make(source) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/sensitive.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/sensitive.rs new file mode 100644 index 00000000000..480e9ff7c10 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/sensitive.rs @@ -0,0 +1,102 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A general wrapper to allow for feature flagged redactions. + +use std::fmt::{Debug, Display, Error, Formatter}; + +use crate::instrumentation::MakeFmt; + +use super::REDACTED; + +/// A wrapper used to modify the [`Display`] and [`Debug`] implementation of the inner structure +/// based on the feature flag `unredacted-logging`. When the `unredacted-logging` feature is enabled, the +/// implementations will defer to those on `T`, when disabled they will defer to [`REDACTED`]. +/// +/// Note that there are [`Display`] and [`Debug`] implementations for `&T` where `T: Display` +/// and `T: Debug` respectively - wrapping references is allowed for the cases when consuming the +/// inner struct is not desired. +/// +/// # Example +/// +/// ``` +/// # use aws_smithy_legacy_http_server::instrumentation::sensitivity::Sensitive; +/// # let address = ""; +/// tracing::debug!( +/// name = %Sensitive("Alice"), +/// friends = ?Sensitive(["Bob"]), +/// address = ?Sensitive(&address) +/// ); +/// ``` +pub struct Sensitive(pub T); + +impl Debug for Sensitive +where + T: Debug, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + if cfg!(feature = "unredacted-logging") { + self.0.fmt(f) + } else { + Debug::fmt(&REDACTED, f) + } + } +} + +impl Display for Sensitive +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + if cfg!(feature = "unredacted-logging") { + self.0.fmt(f) + } else { + Display::fmt(&REDACTED, f) + } + } +} + +/// A [`MakeFmt`] producing [`Sensitive`]. +#[derive(Debug, Clone)] +pub struct MakeSensitive; + +impl MakeFmt for MakeSensitive { + type Target = Sensitive; + + fn make(&self, source: T) -> Self::Target { + Sensitive(source) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug() { + let inner = "hello world"; + let sensitive = Sensitive(inner); + let actual = format!("{sensitive:?}"); + let expected = if cfg!(feature = "unredacted-logging") { + format!("{inner:?}") + } else { + format!("{REDACTED:?}") + }; + assert_eq!(actual, expected) + } + + #[test] + fn display() { + let inner = "hello world"; + let sensitive = Sensitive(inner); + let actual = format!("{sensitive}"); + let expected = if cfg!(feature = "unredacted-logging") { + inner.to_string() + } else { + REDACTED.to_string() + }; + assert_eq!(actual, expected) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/label.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/label.rs new file mode 100644 index 00000000000..bef03454a5f --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/label.rs @@ -0,0 +1,330 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A wrapper around a path [`&str`](str) to allow for sensitivity. + +use std::fmt::{Debug, Display, Error, Formatter}; + +use crate::instrumentation::{sensitivity::Sensitive, MakeFmt}; + +/// A wrapper around a path [`&str`](str) which modifies the behavior of [`Display`]. Specific path segments are marked +/// as sensitive by providing predicate over the segment index. This accommodates the [httpLabel trait] with +/// non-greedy labels. +/// +/// The [`Display`] implementation will respect the `unredacted-logging` flag. +/// +/// # Example +/// +/// ``` +/// # use aws_smithy_legacy_http_server::instrumentation::sensitivity::uri::Label; +/// # use http::Uri; +/// # let path = ""; +/// // Path segment 2 is redacted and a trailing greedy label +/// let uri = Label::new(&path, |x| x == 2, None); +/// println!("{uri}"); +/// ``` +/// +/// [httpLabel trait]: https://smithy.io/2.0/spec/http-bindings.html#httplabel-trait +#[allow(missing_debug_implementations)] +#[derive(Clone)] +pub struct Label<'a, F> { + path: &'a str, + label_marker: F, + greedy_label: Option, +} + +/// Marks a segment as a greedy label up until a char offset from the end. +/// +/// # Example +/// +/// The pattern, `/alpha/beta/{greedy+}/trail`, has segment index 2 and offset from the end of 6. +/// +/// ```rust +/// # use aws_smithy_legacy_http_server::instrumentation::sensitivity::uri::GreedyLabel; +/// let greedy_label = GreedyLabel::new(2, 6); +/// ``` +#[derive(Clone, Debug)] +pub struct GreedyLabel { + segment_index: usize, + end_offset: usize, +} + +impl GreedyLabel { + /// Constructs a new [`GreedyLabel`] from a segment index and an offset from the end of the URI. + pub fn new(segment_index: usize, end_offset: usize) -> Self { + Self { + segment_index, + end_offset, + } + } +} + +impl<'a, F> Label<'a, F> { + /// Constructs a new [`Label`]. + pub fn new(path: &'a str, label_marker: F, greedy_label: Option) -> Self { + Self { + path, + label_marker, + greedy_label, + } + } +} + +impl Display for Label<'_, F> +where + F: Fn(usize) -> bool, +{ + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + if let Some(greedy_label) = &self.greedy_label { + // Calculate the byte index of the start of the greedy label and whether it was reached while writing the + // normal labels. + // TODO(clippy): Switch from fold to try_fold + #[allow(clippy::manual_try_fold)] + let (greedy_start, greedy_hit) = self + .path + .split('/') + // Skip the first segment which will always be empty. + .skip(1) + // Iterate up to the segment index given in the `GreedyLabel`. + .take(greedy_label.segment_index + 1) + .enumerate() + .fold(Ok((0, false)), |acc, (index, segment)| { + acc.and_then(|(greedy_start, _)| { + if index == greedy_label.segment_index { + // We've hit the greedy label, set `hit_greedy` to `true`. + Ok((greedy_start, true)) + } else { + // Prior to greedy segment, use `label_marker` to redact segments. + if (self.label_marker)(index) { + write!(f, "/{}", Sensitive(segment))?; + } else { + write!(f, "/{segment}")?; + } + // Add the segment length and the separator to the `greedy_start`. + let greedy_start = greedy_start + segment.len() + 1; + Ok((greedy_start, false)) + } + }) + })?; + + // If we reached the greedy label segment then use the `end_offset` to redact the interval + // and print the remainder. + if greedy_hit { + if let Some(end_index) = self.path.len().checked_sub(greedy_label.end_offset) { + if greedy_start < end_index { + // [greedy_start + 1 .. end_index] is a non-empty slice - redact it. + let greedy_redaction = Sensitive(&self.path[greedy_start + 1..end_index]); + let remainder = &self.path[end_index..]; + write!(f, "/{greedy_redaction}{remainder}")?; + } else { + // [greedy_start + 1 .. end_index] is an empty slice - don't redact it. + // NOTE: This is unreachable if the greedy label is valid. + write!(f, "{}", &self.path[greedy_start..])?; + } + } + } else { + // NOTE: This is unreachable if the greedy label is valid. + } + } else { + // Use `label_marker` to redact segments. + for (index, segment) in self + .path + .split('/') + // Skip the first segment which will always be empty. + .skip(1) + .enumerate() + { + if (self.label_marker)(index) { + write!(f, "/{}", Sensitive(segment))?; + } else { + write!(f, "/{segment}")?; + } + } + } + + Ok(()) + } +} + +/// A [`MakeFmt`] producing [`Label`]. +#[derive(Clone)] +pub struct MakeLabel { + pub(crate) label_marker: F, + pub(crate) greedy_label: Option, +} + +impl Debug for MakeLabel { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_struct("MakeLabel") + .field("greedy_label", &self.greedy_label) + .finish_non_exhaustive() + } +} + +impl<'a, F> MakeFmt<&'a str> for MakeLabel +where + F: Clone, +{ + type Target = Label<'a, F>; + + fn make(&self, path: &'a str) -> Self::Target { + Label::new(path, self.label_marker.clone(), self.greedy_label.clone()) + } +} + +#[cfg(test)] +mod tests { + use http::Uri; + + use crate::instrumentation::sensitivity::uri::{tests::EXAMPLES, GreedyLabel}; + + use super::Label; + + #[test] + fn mark_none() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + for original in originals { + let expected = original.path().to_string(); + let output = Label::new(original.path(), |_| false, None).to_string(); + assert_eq!(output, expected, "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + const ALL_EXAMPLES: [&str; 19] = [ + "g:h", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}", + "http://a/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}", + "http://a/{redacted}/{redacted}", + "http://a/{redacted}", + ]; + + #[cfg(feature = "unredacted-logging")] + pub const ALL_EXAMPLES: [&str; 19] = EXAMPLES; + + #[test] + fn mark_all() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Label::new(original.path(), |_| true, None).to_string(); + assert_eq!(output, expected.path(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const GREEDY_EXAMPLES: [&str; 19] = [ + "g:h", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/g", + "http://g", + "http://a/b/{redacted}?y", + "http://a/b/{redacted}?y", + "http://a/b/{redacted}?q#s", + "http://a/b/{redacted}", + "http://a/b/{redacted}?y#s", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/b/{redacted}?y#s", + "http://a/b/{redacted}?q", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/", + ]; + + #[cfg(feature = "unredacted-logging")] + pub const GREEDY_EXAMPLES: [&str; 19] = EXAMPLES; + + #[test] + fn greedy() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = GREEDY_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(1, 0))).to_string(); + assert_eq!(output, expected.path(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const GREEDY_EXAMPLES_OFFSET: [&str; 19] = [ + "g:h", + "http://a/b/{redacted}g", + "http://a/b/{redacted}/", + "http://a/g", + "http://g", + "http://a/b/{redacted}p?y", + "http://a/b/{redacted}g?y", + "http://a/b/{redacted}p?q#s", + "http://a/b/{redacted}g", + "http://a/b/{redacted}g?y#s", + "http://a/b/{redacted}x", + "http://a/b/{redacted}x", + "http://a/b/{redacted}x?y#s", + "http://a/b/{redacted}p?q", + "http://a/b/{redacted}/", + "http://a/b/{redacted}/", + "http://a/b/", + "http://a/b/{redacted}g", + "http://a/", + ]; + + #[cfg(feature = "unredacted-logging")] + pub const GREEDY_EXAMPLES_OFFSET: [&str; 19] = EXAMPLES; + + #[test] + fn greedy_offset_a() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = GREEDY_EXAMPLES_OFFSET.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(1, 1))).to_string(); + assert_eq!(output, expected.path(), "original = {original}"); + } + } + + const EXTRA_EXAMPLES_UNREDACTED: [&str; 4] = [ + "http://base/a/b/hello_world", + "http://base/a/b/c/hello_world", + "http://base/a", + "http://base/a/b/c", + ]; + + #[cfg(feature = "unredacted-logging")] + const EXTRA_EXAMPLES_REDACTED: [&str; 4] = EXTRA_EXAMPLES_UNREDACTED; + #[cfg(not(feature = "unredacted-logging"))] + const EXTRA_EXAMPLES_REDACTED: [&str; 4] = [ + "http://base/a/b/{redacted}world", + "http://base/a/b/{redacted}world", + "http://base/a", + "http://base/a/b/c", + ]; + + #[test] + fn greedy_offset_b() { + let originals = EXTRA_EXAMPLES_UNREDACTED.into_iter().map(Uri::from_static); + let expecteds = EXTRA_EXAMPLES_REDACTED.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Label::new(original.path(), |_| false, Some(GreedyLabel::new(2, 5))).to_string(); + assert_eq!(output, expected.path(), "original = {original}"); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/mod.rs new file mode 100644 index 00000000000..5b63d5303a2 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/mod.rs @@ -0,0 +1,390 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Wrappers around [`Uri`] and it's constituents to allow for sensitivity. + +mod label; +mod query; + +use std::fmt::{Debug, Display, Error, Formatter}; + +use http::Uri; + +pub use label::*; +pub use query::*; + +use crate::instrumentation::{MakeDisplay, MakeFmt, MakeIdentity}; + +/// A wrapper around [`&Uri`](Uri) which modifies the behavior of [`Display`]. Specific parts of the [`Uri`] as are +/// marked as sensitive using the methods provided. +/// +/// The [`Display`] implementation will respect the `unredacted-logging` flag. +#[allow(missing_debug_implementations)] +pub struct SensitiveUri<'a, P, Q> { + uri: &'a Uri, + make_path: P, + make_query: Q, +} + +impl<'a> SensitiveUri<'a, MakeIdentity, MakeIdentity> { + /// Constructs a new [`SensitiveUri`] with nothing marked as sensitive. + pub fn new(uri: &'a Uri) -> Self { + Self { + uri, + make_path: MakeIdentity, + make_query: MakeIdentity, + } + } +} + +impl<'a, P, Q> SensitiveUri<'a, P, Q> { + pub(crate) fn make_path(self, make_path: M) -> SensitiveUri<'a, M, Q> { + SensitiveUri { + uri: self.uri, + make_path, + make_query: self.make_query, + } + } + + pub(crate) fn make_query(self, make_query: M) -> SensitiveUri<'a, P, M> { + SensitiveUri { + uri: self.uri, + make_path: self.make_path, + make_query, + } + } + + /// Marks path segments as sensitive by providing predicate over the segment index. + /// + /// See [`Label`] for more info. + pub fn label(self, label_marker: F, greedy_label: Option) -> SensitiveUri<'a, MakeLabel, Q> { + self.make_path(MakeLabel { + label_marker, + greedy_label, + }) + } + + /// Marks specific query string values as sensitive by supplying a predicate over the query string keys. + /// + /// See [`Query`] for more info. + pub fn query(self, marker: F) -> SensitiveUri<'a, P, MakeQuery> { + self.make_query(MakeQuery(marker)) + } +} + +impl<'a, P, Q> Display for SensitiveUri<'a, P, Q> +where + P: MakeDisplay<&'a str>, + Q: MakeDisplay<&'a str>, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + if let Some(scheme) = self.uri.scheme() { + write!(f, "{scheme}://")?; + } + + if let Some(authority) = self.uri.authority() { + write!(f, "{authority}")?; + } + + let path = self.uri.path(); + let path = self.make_path.make_display(path); + write!(f, "{path}")?; + + if let Some(query) = self.uri.query() { + let query = self.make_query.make_display(query); + write!(f, "?{query}")?; + } + + Ok(()) + } +} + +/// A [`MakeFmt`] producing [`SensitiveUri`]. +#[derive(Clone)] +pub struct MakeUri { + pub(crate) make_path: P, + pub(crate) make_query: Q, +} + +impl Debug for MakeUri { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_struct("MakeUri").finish_non_exhaustive() + } +} + +impl<'a, P, Q> MakeFmt<&'a http::Uri> for MakeUri +where + Q: Clone, + P: Clone, +{ + type Target = SensitiveUri<'a, P, Q>; + + fn make(&self, source: &'a http::Uri) -> Self::Target { + SensitiveUri::new(source) + .make_query(self.make_query.clone()) + .make_path(self.make_path.clone()) + } +} + +impl Default for MakeUri { + fn default() -> Self { + Self { + make_path: MakeIdentity, + make_query: MakeIdentity, + } + } +} + +#[cfg(test)] +mod tests { + use http::Uri; + + use super::{QueryMarker, SensitiveUri}; + + // https://www.w3.org/2004/04/uri-rel-test.html + // NOTE: http::Uri's `Display` implementation trims the fragment, we mirror this behavior + pub const EXAMPLES: [&str; 19] = [ + "g:h", + "http://a/b/c/g", + "http://a/b/c/g/", + "http://a/g", + "http://g", + "http://a/b/c/d;p?y", + "http://a/b/c/g?y", + "http://a/b/c/d;p?q#s", + "http://a/b/c/g#s", + "http://a/b/c/g?y#s", + "http://a/b/c/;x", + "http://a/b/c/g;x", + "http://a/b/c/g;x?y#s", + "http://a/b/c/d;p?q", + "http://a/b/c/", + "http://a/b/c/", + "http://a/b/", + "http://a/b/g", + "http://a/", + ]; + + pub const QUERY_STRING_EXAMPLES: [&str; 11] = [ + "http://a/b/c/g?&", + "http://a/b/c/g?x", + "http://a/b/c/g?x&y", + "http://a/b/c/g?x&y&", + "http://a/b/c/g?x&y&z", + "http://a/b/c/g?x=y&x=z", + "http://a/b/c/g?x=y&z", + "http://a/b/c/g?x=y&", + "http://a/b/c/g?x=y&y=z", + "http://a/b/c/g?&x=z", + "http://a/b/c/g?x&x=y", + ]; + + #[test] + fn path_mark_none() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + for original in originals { + let output = SensitiveUri::new(&original).to_string(); + assert_eq!(output, original.to_string()); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + const FIRST_PATH_EXAMPLES: [&str; 19] = [ + "g:h", + "http://a/{redacted}/c/g", + "http://a/{redacted}/c/g/", + "http://a/{redacted}", + "http://g/{redacted}", + "http://a/{redacted}/c/d;p?y", + "http://a/{redacted}/c/g?y", + "http://a/{redacted}/c/d;p?q#s", + "http://a/{redacted}/c/g#s", + "http://a/{redacted}/c/g?y#s", + "http://a/{redacted}/c/;x", + "http://a/{redacted}/c/g;x", + "http://a/{redacted}/c/g;x?y#s", + "http://a/{redacted}/c/d;p?q", + "http://a/{redacted}/c/", + "http://a/{redacted}/c/", + "http://a/{redacted}/", + "http://a/{redacted}/g", + "http://a/{redacted}", + ]; + #[cfg(feature = "unredacted-logging")] + const FIRST_PATH_EXAMPLES: [&str; 19] = EXAMPLES; + + #[test] + fn path_mark_first_segment() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = FIRST_PATH_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = SensitiveUri::new(&original).label(|x| x == 0, None).to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + const LAST_PATH_EXAMPLES: [&str; 19] = [ + "g:h", + "http://a/b/c/{redacted}", + "http://a/b/c/g/{redacted}", + "http://a/{redacted}", + "http://g/{redacted}", + "http://a/b/c/{redacted}?y", + "http://a/b/c/{redacted}?y", + "http://a/b/c/{redacted}?q#s", + "http://a/b/c/{redacted}#s", + "http://a/b/c/{redacted}?y#s", + "http://a/b/c/{redacted}", + "http://a/b/c/{redacted}", + "http://a/b/c/{redacted}?y#s", + "http://a/b/c/{redacted}?q", + "http://a/b/c/{redacted}", + "http://a/b/c/{redacted}", + "http://a/b/{redacted}", + "http://a/b/{redacted}", + "http://a/{redacted}", + ]; + #[cfg(feature = "unredacted-logging")] + const LAST_PATH_EXAMPLES: [&str; 19] = EXAMPLES; + + #[test] + fn path_mark_last_segment() { + let originals = EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = LAST_PATH_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let path_len = original.path().split('/').skip(1).count(); + let output = SensitiveUri::new(&original) + .label(|x| x + 1 == path_len, None) + .to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const ALL_KEYS_QUERY_STRING_EXAMPLES: [&str; 11] = [ + "http://a/b/c/g?&", + "http://a/b/c/g?x", + "http://a/b/c/g?x&y", + "http://a/b/c/g?x&y&", + "http://a/b/c/g?x&y&z", + "http://a/b/c/g?{redacted}=y&{redacted}=z", + "http://a/b/c/g?{redacted}=y&z", + "http://a/b/c/g?{redacted}=y&", + "http://a/b/c/g?{redacted}=y&{redacted}=z", + "http://a/b/c/g?&{redacted}=z", + "http://a/b/c/g?x&{redacted}=y", + ]; + #[cfg(feature = "unredacted-logging")] + pub const ALL_KEYS_QUERY_STRING_EXAMPLES: [&str; 11] = QUERY_STRING_EXAMPLES; + + #[test] + fn query_mark_all_keys() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_KEYS_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = SensitiveUri::new(&original) + .query(|_| QueryMarker { + key: true, + value: false, + }) + .to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const ALL_VALUES_QUERY_STRING_EXAMPLES: [&str; 11] = [ + "http://a/b/c/g?&", + "http://a/b/c/g?x", + "http://a/b/c/g?x&y", + "http://a/b/c/g?x&y&", + "http://a/b/c/g?x&y&z", + "http://a/b/c/g?x={redacted}&x={redacted}", + "http://a/b/c/g?x={redacted}&z", + "http://a/b/c/g?x={redacted}&", + "http://a/b/c/g?x={redacted}&y={redacted}", + "http://a/b/c/g?&x={redacted}", + "http://a/b/c/g?x&x={redacted}", + ]; + #[cfg(feature = "unredacted-logging")] + pub const ALL_VALUES_QUERY_STRING_EXAMPLES: [&str; 11] = QUERY_STRING_EXAMPLES; + + #[test] + fn query_mark_all_values() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_VALUES_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = SensitiveUri::new(&original) + .query(|_| QueryMarker { + key: false, + value: true, + }) + .to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const ALL_PAIRS_QUERY_STRING_EXAMPLES: [&str; 11] = [ + "http://a/b/c/g?&", + "http://a/b/c/g?x", + "http://a/b/c/g?x&y", + "http://a/b/c/g?x&y&", + "http://a/b/c/g?x&y&z", + "http://a/b/c/g?{redacted}={redacted}&{redacted}={redacted}", + "http://a/b/c/g?{redacted}={redacted}&z", + "http://a/b/c/g?{redacted}={redacted}&", + "http://a/b/c/g?{redacted}={redacted}&{redacted}={redacted}", + "http://a/b/c/g?&{redacted}={redacted}", + "http://a/b/c/g?x&{redacted}={redacted}", + ]; + #[cfg(feature = "unredacted-logging")] + pub const ALL_PAIRS_QUERY_STRING_EXAMPLES: [&str; 11] = QUERY_STRING_EXAMPLES; + + #[test] + fn query_mark_all_pairs() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_PAIRS_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = SensitiveUri::new(&original) + .query(|_| QueryMarker { key: true, value: true }) + .to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } + + #[cfg(not(feature = "unredacted-logging"))] + pub const X_QUERY_STRING_EXAMPLES: [&str; 11] = [ + "http://a/b/c/g?&", + "http://a/b/c/g?x", + "http://a/b/c/g?x&y", + "http://a/b/c/g?x&y&", + "http://a/b/c/g?x&y&z", + "http://a/b/c/g?x={redacted}&x={redacted}", + "http://a/b/c/g?x={redacted}&z", + "http://a/b/c/g?x={redacted}&", + "http://a/b/c/g?x={redacted}&y=z", + "http://a/b/c/g?&x={redacted}", + "http://a/b/c/g?x&x={redacted}", + ]; + #[cfg(feature = "unredacted-logging")] + pub const X_QUERY_STRING_EXAMPLES: [&str; 11] = QUERY_STRING_EXAMPLES; + + #[test] + fn query_mark_x() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = X_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = SensitiveUri::new(&original) + .query(|key| QueryMarker { + key: false, + value: key == "x", + }) + .to_string(); + assert_eq!(output, expected.to_string(), "original = {original}"); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/query.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/query.rs new file mode 100644 index 00000000000..4f373ba01a0 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/sensitivity/uri/query.rs @@ -0,0 +1,191 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A wrapper around a query string [`&str`](str) to allow for sensitivity. + +use std::fmt::{Debug, Display, Error, Formatter}; + +use crate::instrumentation::{sensitivity::Sensitive, MakeFmt}; + +/// Marks the sensitive data of a query string pair. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct QueryMarker { + /// Set to `true` to mark the key as sensitive. + pub key: bool, + /// Set to `true` to mark the value as sensitive. + pub value: bool, +} + +/// A wrapper around a query string [`&str`](str) which modifies the behavior of [`Display`]. Specific query string +/// values are marked as sensitive by providing predicate over the keys. This accommodates the [httpQuery trait] and +/// the [httpQueryParams trait]. +/// +/// The [`Display`] implementation will respect the `unredacted-logging` flag. +/// +/// # Example +/// +/// ``` +/// # use aws_smithy_legacy_http_server::instrumentation::sensitivity::uri::{Query, QueryMarker}; +/// # let uri = ""; +/// // Query string value with key "name" is redacted +/// let uri = Query::new(&uri, |x| QueryMarker { key: false, value: x == "name" } ); +/// println!("{uri}"); +/// ``` +/// +/// [httpQuery trait]: https://smithy.io/2.0/spec/http-bindings.html#httpquery-trait +/// [httpQueryParams trait]: https://smithy.io/2.0/spec/http-bindings.html#httpqueryparams-trait +#[allow(missing_debug_implementations)] +pub struct Query<'a, F> { + query: &'a str, + marker: F, +} + +impl<'a, F> Query<'a, F> { + /// Constructs a new [`Query`]. + pub fn new(query: &'a str, marker: F) -> Self { + Self { query, marker } + } +} + +#[inline] +fn write_pair<'a, F>(section: &'a str, marker: F, f: &mut Formatter<'_>) -> Result<(), Error> +where + F: Fn(&'a str) -> QueryMarker, +{ + if let Some((key, value)) = section.split_once('=') { + match (marker)(key) { + QueryMarker { key: true, value: true } => write!(f, "{}={}", Sensitive(key), Sensitive(value)), + QueryMarker { + key: true, + value: false, + } => write!(f, "{}={value}", Sensitive(key)), + QueryMarker { + key: false, + value: true, + } => write!(f, "{key}={}", Sensitive(value)), + QueryMarker { + key: false, + value: false, + } => write!(f, "{key}={value}"), + } + } else { + write!(f, "{section}") + } +} + +impl<'a, F> Display for Query<'a, F> +where + F: Fn(&'a str) -> QueryMarker, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + let mut it = self.query.split('&'); + + if let Some(section) = it.next() { + write_pair(section, &self.marker, f)?; + } + + for section in it { + write!(f, "&")?; + write_pair(section, &self.marker, f)?; + } + + Ok(()) + } +} + +/// A [`MakeFmt`] producing [`Query`]. +#[derive(Clone)] +pub struct MakeQuery(pub(crate) F); + +impl Debug for MakeQuery { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.debug_tuple("MakeQuery").field(&"...").finish() + } +} +impl<'a, F> MakeFmt<&'a str> for MakeQuery +where + F: Clone, +{ + type Target = Query<'a, F>; + + fn make(&self, path: &'a str) -> Self::Target { + Query::new(path, self.0.clone()) + } +} + +#[cfg(test)] +mod tests { + use http::Uri; + + use crate::instrumentation::sensitivity::uri::tests::{ + ALL_KEYS_QUERY_STRING_EXAMPLES, ALL_PAIRS_QUERY_STRING_EXAMPLES, ALL_VALUES_QUERY_STRING_EXAMPLES, EXAMPLES, + QUERY_STRING_EXAMPLES, X_QUERY_STRING_EXAMPLES, + }; + + use super::*; + + #[test] + fn mark_none() { + let originals = EXAMPLES.into_iter().chain(QUERY_STRING_EXAMPLES).map(Uri::from_static); + for original in originals { + if let Some(query) = original.query() { + let output = Query::new(query, |_| QueryMarker::default()).to_string(); + assert_eq!(output, query, "original = {original}"); + } + } + } + + #[test] + fn mark_all_keys() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_KEYS_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Query::new(original.query().unwrap(), |_| QueryMarker { + key: true, + value: false, + }) + .to_string(); + assert_eq!(output, expected.query().unwrap(), "original = {original}"); + } + } + + #[test] + fn mark_all_values() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_VALUES_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Query::new(original.query().unwrap(), |_| QueryMarker { + key: false, + value: true, + }) + .to_string(); + assert_eq!(output, expected.query().unwrap(), "original = {original}"); + } + } + + #[test] + fn mark_all_pairs() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = ALL_PAIRS_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Query::new(original.query().unwrap(), |_| QueryMarker { key: true, value: true }).to_string(); + assert_eq!(output, expected.query().unwrap(), "original = {original}"); + } + } + + #[test] + fn mark_x() { + let originals = QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + let expecteds = X_QUERY_STRING_EXAMPLES.into_iter().map(Uri::from_static); + for (original, expected) in originals.zip(expecteds) { + let output = Query::new(original.query().unwrap(), |key| QueryMarker { + key: false, + value: key == "x", + }) + .to_string(); + assert_eq!(output, expected.query().unwrap(), "original = {original}"); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/service.rs b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/service.rs new file mode 100644 index 00000000000..fb2c4eb6e03 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/instrumentation/service.rs @@ -0,0 +1,188 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A [`Service`] and it's associated [`Future`] providing sensitivity aware logging. + +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use futures_util::{ready, TryFuture}; +use http::{HeaderMap, Request, Response, StatusCode, Uri}; +use tower::Service; +use tracing::{debug, debug_span, instrument::Instrumented, Instrument}; + +use crate::shape_id::ShapeId; + +use super::{MakeDebug, MakeDisplay, MakeIdentity}; + +pin_project_lite::pin_project! { + /// A [`Future`] responsible for logging the response status code and headers. + struct InnerFuture { + #[pin] + inner: Fut, + make: ResponseMakeFmt + } +} + +impl Future for InnerFuture +where + Fut: TryFuture>, + Fut: Future>, + + for<'a> ResponseMakeFmt: MakeDebug<&'a HeaderMap>, + for<'a> ResponseMakeFmt: MakeDisplay, +{ + type Output = Fut::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let response = ready!(this.inner.poll(cx))?; + + { + let headers = this.make.make_debug(response.headers()); + let status_code = this.make.make_display(response.status()); + debug!(?headers, %status_code, "response"); + } + + Poll::Ready(Ok(response)) + } +} + +// This is to provide type erasure. +pin_project_lite::pin_project! { + /// An instrumented [`Future`] responsible for logging the response status code and headers. + pub struct InstrumentedFuture { + #[pin] + inner: Instrumented> + } +} + +impl Future for InstrumentedFuture +where + Fut: TryFuture>, + Fut: Future>, + + for<'a> ResponseMakeFmt: MakeDebug<&'a HeaderMap>, + for<'a> ResponseMakeFmt: MakeDisplay, +{ + type Output = Fut::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.project().inner.poll(cx) + } +} + +/// A middleware [`Service`] responsible for: +/// - Opening a [`tracing::debug_span`] for the lifetime of the request, which includes the operation name, the +/// [`Uri`], and the request headers. +/// - A [`tracing::debug`] during response, which includes the response status code and headers. +/// +/// The [`Display`](std::fmt::Display) and [`Debug`] of the request and response components can be modified using +/// [`request_fmt`](InstrumentOperation::request_fmt) and [`response_fmt`](InstrumentOperation::response_fmt). +/// +/// # Example +/// +/// ``` +/// # use aws_smithy_legacy_http_server::instrumentation::{sensitivity::{*, uri::*, headers::*}, *}; +/// # use aws_smithy_legacy_http_server::shape_id::ShapeId; +/// # use tower::{Service, service_fn}; +/// # use http::{Request, Response}; +/// # async fn f(request: Request<()>) -> Result, ()> { Ok(Response::new(())) } +/// # let mut svc = service_fn(f); +/// # const ID: ShapeId = ShapeId::new("namespace#foo-operation", "namespace", "foo-operation"); +/// let request_fmt = RequestFmt::new() +/// .label(|index| index == 1, None) +/// .query(|_| QueryMarker { key: false, value: true }); +/// let response_fmt = ResponseFmt::new().status_code(); +/// let mut svc = InstrumentOperation::new(svc, ID) +/// .request_fmt(request_fmt) +/// .response_fmt(response_fmt); +/// # svc.call(Request::new(())); +/// ``` +#[derive(Debug, Clone)] +pub struct InstrumentOperation { + inner: S, + operation_id: ShapeId, + make_request: RequestMakeFmt, + make_response: ResponseMakeFmt, +} + +impl InstrumentOperation { + /// Constructs a new [`InstrumentOperation`] with no data redacted. + pub fn new(inner: S, operation_id: ShapeId) -> Self { + Self { + inner, + operation_id, + make_request: MakeIdentity, + make_response: MakeIdentity, + } + } +} + +impl InstrumentOperation { + /// Configures the request format. + /// + /// The argument is typically [`RequestFmt`](super::sensitivity::RequestFmt). + pub fn request_fmt(self, make_request: R) -> InstrumentOperation { + InstrumentOperation { + inner: self.inner, + operation_id: self.operation_id, + make_request, + make_response: self.make_response, + } + } + + /// Configures the response format. + /// + /// The argument is typically [`ResponseFmt`](super::sensitivity::ResponseFmt). + pub fn response_fmt(self, make_response: R) -> InstrumentOperation { + InstrumentOperation { + inner: self.inner, + operation_id: self.operation_id, + make_request: self.make_request, + make_response, + } + } +} + +impl Service> + for InstrumentOperation +where + S: Service, Response = Response>, + + for<'a> RequestMakeFmt: MakeDebug<&'a HeaderMap>, + for<'a> RequestMakeFmt: MakeDisplay<&'a Uri>, + + ResponseMakeFmt: Clone, + for<'a> ResponseMakeFmt: MakeDebug<&'a HeaderMap>, + for<'a> ResponseMakeFmt: MakeDisplay, +{ + type Response = S::Response; + type Error = S::Error; + type Future = InstrumentedFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let span = { + let headers = self.make_request.make_debug(request.headers()); + let uri = self.make_request.make_display(request.uri()); + debug_span!("request", operation = %self.operation_id.absolute(), method = %request.method(), %uri, ?headers) + }; + + InstrumentedFuture { + inner: InnerFuture { + inner: self.inner.call(request), + make: self.make_response.clone(), + } + .instrument(span), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/layer/alb_health_check.rs b/rust-runtime/aws-smithy-legacy-http-server/src/layer/alb_health_check.rs new file mode 100644 index 00000000000..737a0eeac37 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/layer/alb_health_check.rs @@ -0,0 +1,181 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Middleware for handling [ALB health +//! checks](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html). +//! +//! # Example +//! +//! ```no_run +//! use aws_smithy_legacy_http_server::layer::alb_health_check::AlbHealthCheckLayer; +//! use hyper::StatusCode; +//! use tower::Layer; +//! +//! // Handle all `/ping` health check requests by returning a `200 OK`. +//! let ping_layer = AlbHealthCheckLayer::from_handler("/ping", |_req| async { +//! StatusCode::OK +//! }); +//! # async fn handle() { } +//! let app = tower::service_fn(handle); +//! let app = ping_layer.layer(app); +//! ``` + +use std::borrow::Cow; +use std::convert::Infallible; +use std::task::{Context, Poll}; + +use futures_util::{Future, FutureExt}; +use http::StatusCode; +use hyper::{Body, Request, Response}; +use pin_project_lite::pin_project; +use tower::{service_fn, util::Oneshot, Layer, Service, ServiceExt}; + +use crate::body::BoxBody; + +use crate::plugin::either::Either; +use crate::plugin::either::EitherProj; + +/// A [`tower::Layer`] used to apply [`AlbHealthCheckService`]. +#[derive(Clone, Debug)] +pub struct AlbHealthCheckLayer { + health_check_uri: Cow<'static, str>, + health_check_handler: HealthCheckHandler, +} + +impl AlbHealthCheckLayer<()> { + /// Handle health check requests at `health_check_uri` with the specified handler. + pub fn from_handler, H: Fn(Request) -> HandlerFuture + Clone>( + health_check_uri: impl Into>, + health_check_handler: H, + ) -> AlbHealthCheckLayer< + impl Service< + Request, + Response = StatusCode, + Error = Infallible, + Future = impl Future>, + > + Clone, + > { + let service = service_fn(move |req| health_check_handler(req).map(Ok)); + + AlbHealthCheckLayer::new(health_check_uri, service) + } + + /// Handle health check requests at `health_check_uri` with the specified service. + pub fn new, Response = StatusCode>>( + health_check_uri: impl Into>, + health_check_handler: H, + ) -> AlbHealthCheckLayer { + AlbHealthCheckLayer { + health_check_uri: health_check_uri.into(), + health_check_handler, + } + } +} + +impl Layer for AlbHealthCheckLayer { + type Service = AlbHealthCheckService; + + fn layer(&self, inner: S) -> Self::Service { + AlbHealthCheckService { + inner, + layer: self.clone(), + } + } +} + +/// A middleware [`Service`] responsible for handling health check requests. +#[derive(Clone, Debug)] +pub struct AlbHealthCheckService { + inner: S, + layer: AlbHealthCheckLayer, +} + +impl Service> for AlbHealthCheckService +where + S: Service, Response = Response> + Clone, + S::Future: Send + 'static, + H: Service, Response = StatusCode, Error = Infallible> + Clone, +{ + type Response = S::Response; + type Error = S::Error; + type Future = AlbHealthCheckFuture; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + // The check that the service is ready is done by `Oneshot` below. + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + if req.uri() == self.layer.health_check_uri.as_ref() { + let clone = self.layer.health_check_handler.clone(); + let service = std::mem::replace(&mut self.layer.health_check_handler, clone); + let handler_future = service.oneshot(req); + + AlbHealthCheckFuture::handler_future(handler_future) + } else { + let clone = self.inner.clone(); + let service = std::mem::replace(&mut self.inner, clone); + let service_future = service.oneshot(req); + + AlbHealthCheckFuture::service_future(service_future) + } + } +} + +type HealthCheckFutureInner = Either>, Oneshot>>; + +pin_project! { + /// Future for [`AlbHealthCheckService`]. + pub struct AlbHealthCheckFuture, Response = StatusCode>, S: Service>> { + #[pin] + inner: HealthCheckFutureInner + } +} + +impl AlbHealthCheckFuture +where + H: Service, Response = StatusCode>, + S: Service>, +{ + fn handler_future(handler_future: Oneshot>) -> Self { + Self { + inner: Either::Left { value: handler_future }, + } + } + + fn service_future(service_future: Oneshot>) -> Self { + Self { + inner: Either::Right { value: service_future }, + } + } +} + +impl Future for AlbHealthCheckFuture +where + H: Service, Response = StatusCode, Error = Infallible>, + S: Service, Response = Response>, +{ + type Output = Result; + + fn poll(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let either_proj = self.project().inner.project(); + + match either_proj { + EitherProj::Left { value } => { + let polled: Poll = value.poll(cx).map(|res| { + res.map(|status_code| { + Response::builder() + .status(status_code) + .body(crate::body::empty()) + .unwrap() + }) + .map_err(|never| match never {}) + }); + polled + } + EitherProj::Right { value } => value.poll(cx), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/layer/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/layer/mod.rs new file mode 100644 index 00000000000..fcbce76f44c --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/layer/mod.rs @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! This module hosts [`Layer`](tower::Layer)s that are generally meant to be applied _around_ the +//! [`Router`](crate::routing::Router), so they are enacted before a request is routed. + +pub mod alb_health_check; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/lib.rs b/rust-runtime/aws-smithy-legacy-http-server/src/lib.rs new file mode 100644 index 00000000000..9893eeee20f --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/lib.rs @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Automatically managed default lints */ +#![cfg_attr(docsrs, feature(doc_cfg))] +/* End of automatically managed default lints */ +#![allow(clippy::derive_partial_eq_without_eq)] + +//! HTTP server runtime and utilities, loosely based on [axum]. +//! +//! [axum]: https://docs.rs/axum/latest/axum/ +#[macro_use] +pub(crate) mod macros; + +pub mod body; +pub(crate) mod error; +pub mod extension; +pub mod instrumentation; +pub mod layer; +pub mod operation; +pub mod plugin; +#[doc(hidden)] +pub mod protocol; +#[doc(hidden)] +pub mod rejection; +pub mod request; +#[doc(hidden)] +pub mod response; +pub mod routing; +#[doc(hidden)] +pub mod runtime_error; +pub mod service; +pub mod shape_id; + +#[doc(inline)] +pub(crate) use self::error::Error; +#[doc(inline)] +pub use self::request::extension::Extension; +#[doc(inline)] +pub use tower_http::add_extension::{AddExtension, AddExtensionLayer}; + +#[cfg(test)] +mod test_helpers; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/macros.rs b/rust-runtime/aws-smithy-legacy-http-server/src/macros.rs new file mode 100644 index 00000000000..0bb2f4e3520 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/macros.rs @@ -0,0 +1,94 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +//! Macros implementation. + +/// Define a type that implements [`std::future::Future`]. +#[doc(hidden)] +#[macro_export] +macro_rules! opaque_future { + ($(#[$m:meta])* pub type $name:ident = $actual:ty;) => { + opaque_future! { + $(#[$m])* + #[allow(clippy::type_complexity)] + pub type $name<> = $actual; + } + }; + + ($(#[$m:meta])* pub type $name:ident<$($param:ident),*> = $actual:ty;) => { + pin_project_lite::pin_project! { + $(#[$m])* + pub struct $name<$($param),*> { + #[pin] future: $actual, + } + } + + impl<$($param),*> $name<$($param),*> { + pub(crate) fn new(future: $actual) -> Self { + Self { future } + } + } + + impl<$($param),*> std::fmt::Debug for $name<$($param),*> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple(stringify!($name)).field(&format_args!("...")).finish() + } + } + + impl<$($param),*> std::future::Future for $name<$($param),*> + where + $actual: std::future::Future, + { + type Output = <$actual as std::future::Future>::Output; + + #[inline] + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + self.project().future.poll(cx) + } + } + }; +} + +macro_rules! convert_to_request_rejection { + ($from:ty, $to:ident) => { + impl From<$from> for RequestRejection { + fn from(err: $from) -> Self { + Self::$to(crate::Error::new(err)) + } + } + }; +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/operation/handler.rs b/rust-runtime/aws-smithy-legacy-http-server/src/operation/handler.rs new file mode 100644 index 00000000000..28ad3fd7437 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/operation/handler.rs @@ -0,0 +1,153 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::{ + convert::Infallible, + future::Future, + marker::PhantomData, + task::{Context, Poll}, +}; + +use futures_util::{future::Map, FutureExt}; +use tower::Service; + +use super::OperationShape; + +/// A utility trait used to provide an even interface for all operation handlers. +/// +/// See [`operation`](crate::operation) documentation for more info. +pub trait Handler +where + Op: OperationShape, +{ + type Future: Future>; + + fn call(&mut self, input: Op::Input, exts: Exts) -> Self::Future; +} + +/// A utility trait used to provide an even interface over return types `Result`/`Ok`. +trait IntoResult { + fn into_result(self) -> Result; +} + +// We can convert from `Result` to `Result`. +impl IntoResult for Result { + fn into_result(self) -> Result { + self + } +} + +// We can convert from `T` to `Result`. +impl IntoResult for Ok { + fn into_result(self) -> Result { + Ok(self) + } +} + +// fn(Input) -> Output +impl Handler for F +where + Op: OperationShape, + F: Fn(Op::Input) -> Fut, + Fut: Future, + Fut::Output: IntoResult, +{ + type Future = Map Result>; + + fn call(&mut self, input: Op::Input, _exts: ()) -> Self::Future { + (self)(input).map(IntoResult::into_result) + } +} + +// fn(Input, Ext_i) -> Output +macro_rules! impl_handler { + ($($var:ident),+) => ( + impl Handler for F + where + Op: OperationShape, + F: Fn(Op::Input, $($var,)*) -> Fut, + Fut: Future, + Fut::Output: IntoResult, + { + type Future = Map Result>; + + fn call(&mut self, input: Op::Input, exts: ($($var,)*)) -> Self::Future { + #[allow(non_snake_case)] + let ($($var,)*) = exts; + (self)(input, $($var,)*).map(IntoResult::into_result) + } + } + ) +} + +impl_handler!(Exts0); +impl_handler!(Exts0, Exts1); +impl_handler!(Exts0, Exts1, Exts2); +impl_handler!(Exts0, Exts1, Exts2, Exts3); +impl_handler!(Exts0, Exts1, Exts2, Exts3, Exts4); +impl_handler!(Exts0, Exts1, Exts2, Exts3, Exts4, Exts5); +impl_handler!(Exts0, Exts1, Exts2, Exts3, Exts4, Exts5, Exts6); +impl_handler!(Exts0, Exts1, Exts2, Exts3, Exts4, Exts5, Exts6, Exts7); +impl_handler!(Exts0, Exts1, Exts2, Exts3, Exts4, Exts5, Exts6, Exts7, Exts8); + +/// An extension trait for [`Handler`]. +pub trait HandlerExt: Handler +where + Op: OperationShape, +{ + /// Convert the [`Handler`] into a [`Service`]. + fn into_service(self) -> IntoService + where + Self: Sized, + { + IntoService { + handler: self, + _operation: PhantomData, + } + } +} + +impl HandlerExt for H +where + Op: OperationShape, + H: Handler, +{ +} + +/// A [`Service`] provided for every [`Handler`]. +pub struct IntoService { + pub(crate) handler: H, + pub(crate) _operation: PhantomData, +} + +impl Clone for IntoService +where + H: Clone, +{ + fn clone(&self) -> Self { + Self { + handler: self.handler.clone(), + _operation: PhantomData, + } + } +} + +impl Service<(Op::Input, Exts)> for IntoService +where + Op: OperationShape, + H: Handler, +{ + type Response = Op::Output; + type Error = Op::Error; + type Future = H::Future; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, (input, exts): (Op::Input, Exts)) -> Self::Future { + self.handler.call(input, exts) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/operation/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/operation/mod.rs new file mode 100644 index 00000000000..8a1a9c09bcc --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/operation/mod.rs @@ -0,0 +1,171 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The shape of a [Smithy operation] is modelled by the [`OperationShape`] trait. Its associated types +//! [`OperationShape::Input`], [`OperationShape::Output`], and [`OperationShape::Error`] map to the structures +//! representing the Smithy inputs, outputs, and errors respectively. When an operation error is not specified +//! [`OperationShape::Error`] is [`Infallible`](std::convert::Infallible). +//! +//! We generate a marker struct for each Smithy operation and implement [`OperationShape`] on them. This +//! is used as a helper - providing static methods and parameterizing other traits. +//! +//! The model +//! +//! ```smithy +//! operation GetShopping { +//! input: CartIdentifier, +//! output: ShoppingCart, +//! errors: [...] +//! } +//! ``` +//! +//! is identified with the implementation +//! +//! ```rust,no_run +//! # use aws_smithy_legacy_http_server::shape_id::ShapeId; +//! # use aws_smithy_legacy_http_server::operation::OperationShape; +//! # pub struct CartIdentifier; +//! # pub struct ShoppingCart; +//! # pub enum GetShoppingError {} +//! pub struct GetShopping; +//! +//! impl OperationShape for GetShopping { +//! const ID: ShapeId = ShapeId::new("namespace#GetShopping", "namespace", "GetShopping"); +//! +//! type Input = CartIdentifier; +//! type Output = ShoppingCart; +//! type Error = GetShoppingError; +//! } +//! ``` +//! +//! The behavior of a Smithy operation is encoded by a [`Service`](tower::Service). The [`OperationShape`] types can +//! be used to construct specific operations using [`OperationShapeExt::from_handler`] and +//! [`OperationShapeExt::from_service`] methods. The [from_handler](OperationShapeExt::from_handler) constructor takes +//! a [`Handler`] whereas the [from_service](OperationShapeExt::from_service) takes a [`OperationService`]. Both traits +//! serve a similar purpose - they provide a common interface over a class of structures. +//! +//! ## [`Handler`] +//! +//! The [`Handler`] trait is implemented by all async functions which accept [`OperationShape::Input`] as their first +//! argument, the remaining arguments implement [`FromParts`](crate::request::FromParts), and return either +//! [`OperationShape::Output`] when [`OperationShape::Error`] is [`Infallible`](std::convert::Infallible) or +//! [`Result`]<[`OperationShape::Output`],[`OperationShape::Error`]>. The following are examples of async functions which +//! implement [`Handler`]: +//! +//! ```rust,no_run +//! # use aws_smithy_legacy_http_server::Extension; +//! # pub struct CartIdentifier; +//! # pub struct ShoppingCart; +//! # pub enum GetShoppingError {} +//! # pub struct Context; +//! # pub struct ExtraContext; +//! // Simple handler where no error is modelled. +//! async fn handler_a(input: CartIdentifier) -> ShoppingCart { +//! todo!() +//! } +//! +//! // Handler with an extension where no error is modelled. +//! async fn handler_b(input: CartIdentifier, ext: Extension) -> ShoppingCart { +//! todo!() +//! } +//! +//! // More than one extension can be provided. +//! async fn handler_c(input: CartIdentifier, ext_1: Extension, ext_2: Extension) -> ShoppingCart { +//! todo!() +//! } +//! +//! // When an error is modelled we must return a `Result`. +//! async fn handler_d(input: CartIdentifier, ext: Extension) -> Result { +//! todo!() +//! } +//! ``` +//! +//! ## [`OperationService`] +//! +//! Similarly, the [`OperationService`] trait is implemented by all `Service<(Op::Input, ...)>` with +//! `Response = Op::Output`, and `Error = Op::Error`. +//! +//! The following are examples of [`Service`](tower::Service)s which implement [`OperationService`]: +//! +//! - `Service`. +//! - `Service<(CartIdentifier, Extension), Response = ShoppingCart, Error = GetShoppingCartError>`. +//! - `Service<(CartIdentifier, Extension, Extension), Response = ShoppingCart, Error = GetShoppingCartError)`. +//! +//! Notice the parallels between [`OperationService`] and [`Handler`]. +//! +//! ## Constructing an Operation +//! +//! The following is an example of using both construction approaches: +//! +//! ```rust,no_run +//! # use std::task::{Poll, Context}; +//! # use aws_smithy_legacy_http_server::operation::*; +//! # use tower::Service; +//! # use aws_smithy_legacy_http_server::shape_id::ShapeId; +//! # pub struct CartIdentifier; +//! # pub struct ShoppingCart; +//! # pub enum GetShoppingError {} +//! # pub struct GetShopping; +//! # impl OperationShape for GetShopping { +//! # const ID: ShapeId = ShapeId::new("namespace#GetShopping", "namespace", "GetShopping"); +//! # +//! # type Input = CartIdentifier; +//! # type Output = ShoppingCart; +//! # type Error = GetShoppingError; +//! # } +//! # type OpFuture = std::future::Ready>; +//! // Construction of an `Operation` from a `Handler`. +//! +//! async fn op_handler(input: CartIdentifier) -> Result { +//! todo!() +//! } +//! +//! let operation = GetShopping::from_handler(op_handler); +//! +//! // Construction of an `Operation` from a `Service`. +//! +//! pub struct OpService; +//! +//! impl Service for OpService { +//! type Response = ShoppingCart; +//! type Error = GetShoppingError; +//! type Future = OpFuture; +//! +//! fn poll_ready(&mut self, cx: &mut Context) -> Poll> { +//! todo!() +//! } +//! +//! fn call(&mut self, request: CartIdentifier) -> Self::Future { +//! todo!() +//! } +//! } +//! +//! let operation = GetShopping::from_service(OpService); +//! +//! ``` +//! +//! ## Upgrading Smithy services to HTTP services +//! +//! Both [`Handler`] and [`OperationService`] accept and return Smithy model structures. They are converted to a +//! canonical form `Service<(Op::Input, Exts), Response = Op::Output, Error = Op::Error>`. The +//! [`UpgradePlugin`] acts upon such services by converting them to +//! `Service` by applying serialization/deserialization +//! and validation specified by the Smithy contract. +//! +//! +//! The [`UpgradePlugin`], being a [`Plugin`](crate::plugin::Plugin), is parameterized by a protocol. This allows for +//! upgrading to `Service` to be protocol dependent. +//! +//! [Smithy operation]: https://smithy.io/2.0/spec/service-types.html#operation + +mod handler; +mod operation_service; +mod shape; +mod upgrade; + +pub use handler::*; +pub use operation_service::*; +pub use shape::*; +pub use upgrade::*; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/operation/operation_service.rs b/rust-runtime/aws-smithy-legacy-http-server/src/operation/operation_service.rs new file mode 100644 index 00000000000..b9a87aa6e9e --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/operation/operation_service.rs @@ -0,0 +1,131 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::{ + marker::PhantomData, + task::{Context, Poll}, +}; + +use tower::Service; + +use super::OperationShape; + +/// A utility trait used to provide an even interface for all operation services. +/// +/// This serves to take [`Service`]s of the form `Service<(Op::Input, Ext0, Ext1, ...)>` to the canonical +/// representation of `Service<(Input, (Ext0, Ext1, ...))>` inline with +/// [`IntoService`](super::IntoService). +/// +/// See [`operation`](crate::operation) documentation for more info. +pub trait OperationService: Service +where + Op: OperationShape, +{ + type Normalized; + + // Normalize the request type. + fn normalize(input: Op::Input, exts: Exts) -> Self::Normalized; +} + +// `Service` +impl OperationService for S +where + Op: OperationShape, + S: Service, +{ + type Normalized = Op::Input; + + fn normalize(input: Op::Input, _exts: ()) -> Self::Normalized { + input + } +} + +// `Service<(Op::Input, Ext0)>` +impl OperationService for S +where + Op: OperationShape, + S: Service<(Op::Input, Ext0), Response = Op::Output, Error = Op::Error>, +{ + type Normalized = (Op::Input, Ext0); + + fn normalize(input: Op::Input, exts: (Ext0,)) -> Self::Normalized { + (input, exts.0) + } +} + +// `Service<(Op::Input, Ext0, Ext1)>` +impl OperationService for S +where + Op: OperationShape, + S: Service<(Op::Input, Ext0, Ext1), Response = Op::Output, Error = Op::Error>, +{ + type Normalized = (Op::Input, Ext0, Ext1); + + fn normalize(input: Op::Input, exts: (Ext0, Ext1)) -> Self::Normalized { + (input, exts.0, exts.1) + } +} + +/// An extension trait of [`OperationService`]. +pub trait OperationServiceExt: OperationService +where + Op: OperationShape, +{ + /// Convert the [`OperationService`] into a canonicalized [`Service`]. + fn normalize(self) -> Normalize + where + Self: Sized, + { + Normalize { + inner: self, + _operation: PhantomData, + } + } +} + +impl OperationServiceExt for F +where + Op: OperationShape, + F: OperationService, +{ +} + +/// A [`Service`] normalizing the request type of a [`OperationService`]. +#[derive(Debug)] +pub struct Normalize { + pub(crate) inner: S, + pub(crate) _operation: PhantomData, +} + +impl Clone for Normalize +where + S: Clone, +{ + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + _operation: PhantomData, + } + } +} + +impl Service<(Op::Input, Exts)> for Normalize +where + Op: OperationShape, + S: OperationService, +{ + type Response = S::Response; + type Error = S::Error; + type Future = >::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, (input, exts): (Op::Input, Exts)) -> Self::Future { + let req = S::normalize(input, exts); + self.inner.call(req) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/operation/shape.rs b/rust-runtime/aws-smithy-legacy-http-server/src/operation/shape.rs new file mode 100644 index 00000000000..aec46938074 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/operation/shape.rs @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::marker::PhantomData; + +use super::{Handler, IntoService, Normalize, OperationService}; +use crate::shape_id::ShapeId; + +/// Models the [Smithy Operation shape]. +/// +/// [Smithy Operation shape]: https://smithy.io/2.0/spec/service-types.html#operation +pub trait OperationShape { + /// The ID of the operation. + const ID: ShapeId; + + /// The operation input. + type Input; + /// The operation output. + type Output; + /// The operation error. [`Infallible`](std::convert::Infallible) in the case where no error + /// exists. + type Error; +} + +/// An extension trait over [`OperationShape`]. +pub trait OperationShapeExt: OperationShape { + /// Creates a new [`Service`](tower::Service), [`IntoService`], for well-formed [`Handler`]s. + fn from_handler(handler: H) -> IntoService + where + H: Handler, + Self: Sized, + { + IntoService { + handler, + _operation: PhantomData, + } + } + + /// Creates a new normalized [`Service`](tower::Service), [`Normalize`], for well-formed + /// [`Service`](tower::Service)s. + fn from_service(svc: S) -> Normalize + where + S: OperationService, + Self: Sized, + { + Normalize { + inner: svc, + _operation: PhantomData, + } + } +} + +impl OperationShapeExt for S where S: OperationShape {} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/operation/upgrade.rs b/rust-runtime/aws-smithy-legacy-http-server/src/operation/upgrade.rs new file mode 100644 index 00000000000..cd9e333bb2a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/operation/upgrade.rs @@ -0,0 +1,229 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::{ + convert::Infallible, + future::{Future, Ready}, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +use futures_util::ready; +use pin_project_lite::pin_project; +use tower::{util::Oneshot, Service, ServiceExt}; +use tracing::error; + +use crate::{ + body::BoxBody, plugin::Plugin, request::FromRequest, response::IntoResponse, + runtime_error::InternalFailureException, service::ServiceShape, +}; + +use super::OperationShape; + +/// A [`Plugin`] responsible for taking an operation [`Service`], accepting and returning Smithy +/// types and converting it into a [`Service`] taking and returning [`http`] types. +/// +/// See [`Upgrade`]. +#[derive(Debug, Clone)] +pub struct UpgradePlugin { + _extractors: PhantomData, +} + +impl Default for UpgradePlugin { + fn default() -> Self { + Self { + _extractors: PhantomData, + } + } +} + +impl UpgradePlugin { + /// Creates a new [`UpgradePlugin`]. + pub fn new() -> Self { + Self::default() + } +} + +impl Plugin for UpgradePlugin +where + Ser: ServiceShape, + Op: OperationShape, +{ + type Output = Upgrade; + + fn apply(&self, inner: T) -> Self::Output { + Upgrade { + _protocol: PhantomData, + _input: PhantomData, + inner, + } + } +} + +/// A [`Service`] responsible for wrapping an operation [`Service`] accepting and returning Smithy +/// types, and converting it into a [`Service`] accepting and returning [`http`] types. +pub struct Upgrade { + _protocol: PhantomData, + _input: PhantomData, + inner: S, +} + +impl Clone for Upgrade +where + S: Clone, +{ + fn clone(&self) -> Self { + Self { + _protocol: PhantomData, + _input: PhantomData, + inner: self.inner.clone(), + } + } +} + +pin_project! { + #[project = InnerProj] + #[project_replace = InnerProjReplace] + enum Inner { + FromRequest { + #[pin] + inner: FromFut + }, + Inner { + #[pin] + call: HandlerFut + } + } +} + +type InnerAlias = Inner<>::Future, Oneshot>; + +pin_project! { + /// The [`Service::Future`] of [`Upgrade`]. + pub struct UpgradeFuture + where + Input: FromRequest, + S: Service, + { + service: Option, + #[pin] + inner: InnerAlias + } +} + +impl Future for UpgradeFuture +where + Input: FromRequest, + >::Rejection: std::fmt::Display, + S: Service, + S::Response: IntoResponse

, + S::Error: IntoResponse

, +{ + type Output = Result, Infallible>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + loop { + let mut this = self.as_mut().project(); + let this2 = this.inner.as_mut().project(); + + let call = match this2 { + InnerProj::FromRequest { inner } => { + let result = ready!(inner.poll(cx)); + match result { + Ok(ok) => this + .service + .take() + .expect("futures cannot be polled after completion") + .oneshot(ok), + Err(err) => { + // The error may arise either from a `FromRequest` failure for any user-defined + // handler's additional input parameters, or from a de-serialization failure + // of an input parameter specific to the operation. + tracing::trace!(error = %err, "parameter for the handler cannot be constructed"); + return Poll::Ready(Ok(err.into_response())); + } + } + } + InnerProj::Inner { call } => { + let result = ready!(call.poll(cx)); + let output = match result { + Ok(ok) => ok.into_response(), + Err(err) => err.into_response(), + }; + return Poll::Ready(Ok(output)); + } + }; + + this.inner.as_mut().project_replace(Inner::Inner { call }); + } + } +} + +impl Service> for Upgrade +where + Input: FromRequest, + >::Rejection: std::fmt::Display, + S: Service + Clone, + S::Response: IntoResponse

, + S::Error: IntoResponse

, +{ + type Response = http::Response; + type Error = Infallible; + type Future = UpgradeFuture; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + // The check that the inner service is ready is done by `Oneshot` in `UpgradeFuture`'s + // implementation. + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + let clone = self.inner.clone(); + let service = std::mem::replace(&mut self.inner, clone); + UpgradeFuture { + service: Some(service), + inner: Inner::FromRequest { + inner: >::from_request(req), + }, + } + } +} + +/// A [`Service`] which always returns an internal failure message and logs an error. +#[derive(Copy)] +pub struct MissingFailure

{ + _protocol: PhantomData, +} + +impl

Default for MissingFailure

{ + fn default() -> Self { + Self { _protocol: PhantomData } + } +} + +impl

Clone for MissingFailure

{ + fn clone(&self) -> Self { + MissingFailure { _protocol: PhantomData } + } +} + +impl Service for MissingFailure

+where + InternalFailureException: IntoResponse

, +{ + type Response = http::Response; + type Error = Infallible; + type Future = Ready>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _request: R) -> Self::Future { + error!("the operation has not been set"); + std::future::ready(Ok(InternalFailureException.into_response())) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/closure.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/closure.rs new file mode 100644 index 00000000000..13812e67283 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/closure.rs @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::service::ContainsOperation; + +use super::Plugin; + +/// An adapter to convert a `Fn(ShapeId, T) -> Service` closure into a [`Plugin`]. See [`plugin_from_operation_fn`] for more details. +pub struct OperationFn { + f: F, +} + +impl Plugin for OperationFn +where + Ser: ContainsOperation, + F: Fn(Ser::Operations, T) -> NewService, +{ + type Output = NewService; + + fn apply(&self, input: T) -> Self::Output { + (self.f)(Ser::VALUE, input) + } +} + +/// Constructs a [`Plugin`] using a closure over the [`ServiceShape::] `F: Fn(ShapeId, T) -> Service`. +/// +/// # Example +/// +/// ```rust +/// # use aws_smithy_legacy_http_server::{service::*, operation::OperationShape, plugin::Plugin, shape_id::ShapeId}; +/// # pub enum Operation { CheckHealth, GetPokemonSpecies } +/// # impl Operation { fn shape_id(&self) -> ShapeId { ShapeId::new("", "", "") }} +/// # pub struct CheckHealth; +/// # pub struct GetPokemonSpecies; +/// # pub struct PokemonService; +/// # impl ServiceShape for PokemonService { +/// # const ID: ShapeId = ShapeId::new("", "", ""); +/// # const VERSION: Option<&'static str> = None; +/// # type Protocol = (); +/// # type Operations = Operation; +/// # } +/// # impl OperationShape for CheckHealth { const ID: ShapeId = ShapeId::new("", "", ""); type Input = (); type Output = (); type Error = (); } +/// # impl OperationShape for GetPokemonSpecies { const ID: ShapeId = ShapeId::new("", "", ""); type Input = (); type Output = (); type Error = (); } +/// # impl ContainsOperation for PokemonService { const VALUE: Operation = Operation::CheckHealth; } +/// # impl ContainsOperation for PokemonService { const VALUE: Operation = Operation::GetPokemonSpecies; } +/// use aws_smithy_legacy_http_server::plugin::plugin_from_operation_fn; +/// use tower::layer::layer_fn; +/// +/// struct FooService { +/// info: String, +/// inner: S +/// } +/// +/// fn map(op: Operation, inner: S) -> FooService { +/// match op { +/// Operation::CheckHealth => FooService { info: op.shape_id().name().to_string(), inner }, +/// Operation::GetPokemonSpecies => FooService { info: "bar".to_string(), inner }, +/// _ => todo!() +/// } +/// } +/// +/// // This plugin applies the `FooService` middleware around every operation. +/// let plugin = plugin_from_operation_fn(map); +/// # let _ = Plugin::::apply(&plugin, ()); +/// # let _ = Plugin::::apply(&plugin, ()); +/// ``` +pub fn plugin_from_operation_fn(f: F) -> OperationFn { + OperationFn { f } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/either.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/either.rs new file mode 100644 index 00000000000..d0c2da70953 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/either.rs @@ -0,0 +1,124 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Contains the [`Either`] enum. + +use pin_project_lite::pin_project; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use tower::{Layer, Service}; + +use super::Plugin; + +// TODO(https://github.com/smithy-lang/smithy-rs/pull/2441#pullrequestreview-1331345692): Seems like +// this type should land in `tower-0.5`. +pin_project! { + /// Combine two different [`Futures`](std::future::Future)/[`Services`](tower::Service)/ + /// [`Layers`](tower::Layer)/[`Plugins`](super::Plugin) into a single type. + /// + /// # Notes on [`Future`](std::future::Future) + /// + /// The [`Future::Output`] must be identical. + /// + /// # Notes on [`Service`](tower::Service) + /// + /// The [`Service::Response`] and [`Service::Error`] must be identical. + #[derive(Clone, Debug)] + #[project = EitherProj] + pub enum Either { + /// One type of backing [`Service`]. + Left { #[pin] value: L }, + /// The other type of backing [`Service`]. + Right { #[pin] value: R }, + } +} + +impl Future for Either +where + L: Future, + R: Future, +{ + type Output = L::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.project() { + EitherProj::Left { value } => value.poll(cx), + EitherProj::Right { value } => value.poll(cx), + } + } +} + +impl Service for Either +where + L: Service, + R: Service, +{ + type Response = L::Response; + type Error = L::Error; + type Future = Either; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + use self::Either::*; + + match self { + Left { value } => value.poll_ready(cx), + Right { value } => value.poll_ready(cx), + } + } + + fn call(&mut self, request: Request) -> Self::Future { + use self::Either::*; + + match self { + Left { value } => Either::Left { + value: value.call(request), + }, + Right { value } => Either::Right { + value: value.call(request), + }, + } + } +} + +impl Layer for Either +where + L: Layer, + R: Layer, +{ + type Service = Either; + + fn layer(&self, inner: S) -> Self::Service { + match self { + Either::Left { value } => Either::Left { + value: value.layer(inner), + }, + Either::Right { value } => Either::Right { + value: value.layer(inner), + }, + } + } +} + +impl Plugin for Either +where + Le: Plugin, + Ri: Plugin, +{ + type Output = Either; + + fn apply(&self, input: T) -> Self::Output { + match self { + Either::Left { value } => Either::Left { + value: value.apply(input), + }, + Either::Right { value } => Either::Right { + value: value.apply(input), + }, + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/filter.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/filter.rs new file mode 100644 index 00000000000..131b936bf6f --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/filter.rs @@ -0,0 +1,78 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use super::{either::Either, IdentityPlugin, ModelMarker}; + +use crate::operation::OperationShape; +use crate::service::ContainsOperation; + +use super::{HttpMarker, Plugin}; + +/// Filters the application of an inner [`Plugin`] using a predicate over the +/// [`ServiceShape::Operations`](crate::service::ServiceShape::Operations). +/// +/// This contrasts with [`Scoped`](crate::plugin::Scoped) which can be used to selectively apply a [`Plugin`] to a +/// subset of operations at _compile time_. +/// +/// See [`filter_by_operation`] for more details. +pub struct FilterByOperation { + inner: Inner, + predicate: F, +} + +impl Plugin for FilterByOperation +where + Ser: ContainsOperation, + F: Fn(Ser::Operations) -> bool, + Inner: Plugin, + Op: OperationShape, +{ + type Output = Either; + + fn apply(&self, input: T) -> Self::Output { + let either_plugin = if (self.predicate)(>::VALUE) { + Either::Left { value: &self.inner } + } else { + Either::Right { value: IdentityPlugin } + }; + either_plugin.apply(input) + } +} + +impl HttpMarker for FilterByOperation where Inner: HttpMarker {} +impl ModelMarker for FilterByOperation where Inner: ModelMarker {} + +/// Filters the application of an inner [`Plugin`] using a predicate over the +/// [`ServiceShape::Operations`](crate::service::ServiceShape::Operations). +/// +/// Users should prefer [`Scoped`](crate::plugin::Scoped) and fallback to [`filter_by_operation`] +/// in cases where [`Plugin`] application must be decided at runtime. +/// +/// # Example +/// +/// ```rust +/// use aws_smithy_legacy_http_server::plugin::filter_by_operation; +/// # use aws_smithy_legacy_http_server::{plugin::Plugin, operation::OperationShape, shape_id::ShapeId, service::{ServiceShape, ContainsOperation}}; +/// # struct Pl; +/// # struct PokemonService; +/// # #[derive(PartialEq, Eq)] +/// # enum Operation { CheckHealth } +/// # impl ServiceShape for PokemonService { const VERSION: Option<&'static str> = None; const ID: ShapeId = ShapeId::new("", "", ""); type Operations = Operation; type Protocol = (); } +/// # impl ContainsOperation for PokemonService { const VALUE: Operation = Operation::CheckHealth; } +/// # struct CheckHealth; +/// # impl OperationShape for CheckHealth { const ID: ShapeId = ShapeId::new("", "", ""); type Input = (); type Output = (); type Error = (); } +/// # impl Plugin for Pl { type Output = (); fn apply(&self, input: ()) -> Self::Output { input }} +/// # let plugin = Pl; +/// # let svc = (); +/// // Prevents `plugin` from being applied to the `CheckHealth` operation. +/// let filtered_plugin = filter_by_operation(plugin, |name| name != Operation::CheckHealth); +/// let new_operation = filtered_plugin.apply(svc); +/// ``` +pub fn filter_by_operation(plugins: Inner, predicate: F) -> FilterByOperation { + FilterByOperation { + inner: plugins, + predicate, + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/http_plugins.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/http_plugins.rs new file mode 100644 index 00000000000..0209c11505c --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/http_plugins.rs @@ -0,0 +1,201 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// If you make any updates to this file (including Rust docs), make sure you make them to +// `model_plugins.rs` too! + +use crate::plugin::{IdentityPlugin, Plugin, PluginStack}; + +use super::{HttpMarker, LayerPlugin}; + +/// A wrapper struct for composing HTTP plugins. +/// +/// ## Applying plugins in a sequence +/// +/// You can use the [`push`](HttpPlugins::push) method to apply a new HTTP plugin after the ones that +/// have already been registered. +/// +/// ```rust +/// use aws_smithy_legacy_http_server::plugin::HttpPlugins; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as MetricsPlugin; +/// +/// let http_plugins = HttpPlugins::new().push(LoggingPlugin).push(MetricsPlugin); +/// ``` +/// +/// The plugins' runtime logic is executed in registration order. +/// In our example above, `LoggingPlugin` would run first, while `MetricsPlugin` is executed last. +/// +/// ## Wrapping the current plugin pipeline +/// +/// From time to time, you might have a need to transform the entire pipeline that has been built +/// so far - e.g. you only want to apply those plugins for a specific operation. +/// +/// `HttpPlugins` is itself a [`Plugin`]: you can apply any transformation that expects a +/// [`Plugin`] to an entire pipeline. In this case, we could use a [scoped +/// plugin](crate::plugin::Scoped) to limit the scope of the logging and metrics plugins to the +/// `CheckHealth` operation: +/// +/// ```rust +/// use aws_smithy_legacy_http_server::scope; +/// use aws_smithy_legacy_http_server::plugin::{HttpPlugins, Scoped}; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as MetricsPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as AuthPlugin; +/// use aws_smithy_legacy_http_server::shape_id::ShapeId; +/// # #[derive(PartialEq)] +/// # enum Operation { CheckHealth } +/// # struct CheckHealth; +/// # impl CheckHealth { const ID: ShapeId = ShapeId::new("namespace#MyName", "namespace", "MyName"); } +/// +/// // The logging and metrics plugins will only be applied to the `CheckHealth` operation. +/// let plugin = HttpPlugins::new() +/// .push(LoggingPlugin) +/// .push(MetricsPlugin); +/// +/// scope! { +/// struct OnlyCheckHealth { +/// includes: [CheckHealth], +/// excludes: [/* The rest of the operations go here */] +/// } +/// } +/// +/// let filtered_plugin = Scoped::new::(&plugin); +/// let http_plugins = HttpPlugins::new() +/// .push(filtered_plugin) +/// // The auth plugin will be applied to all operations. +/// .push(AuthPlugin); +/// ``` +/// +/// ## Concatenating two collections of HTTP plugins +/// +/// `HttpPlugins` is a good way to bundle together multiple plugins, ensuring they are all +/// registered in the correct order. +/// +/// Since `HttpPlugins` is itself a HTTP plugin (it implements the `HttpMarker` trait), you can use +/// the [`push`](HttpPlugins::push) to append, at once, all the HTTP plugins in another +/// `HttpPlugins` to the current `HttpPlugins`: +/// +/// ```rust +/// use aws_smithy_legacy_http_server::plugin::{IdentityPlugin, HttpPlugins, PluginStack}; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as MetricsPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as AuthPlugin; +/// +/// pub fn get_bundled_http_plugins() -> HttpPlugins>> { +/// HttpPlugins::new().push(LoggingPlugin).push(MetricsPlugin) +/// } +/// +/// let http_plugins = HttpPlugins::new() +/// .push(AuthPlugin) +/// .push(get_bundled_http_plugins()); +/// ``` +/// +/// ## Providing custom methods on `HttpPlugins` +/// +/// You use an **extension trait** to add custom methods on `HttpPlugins`. +/// +/// This is a simple example using `AuthPlugin`: +/// +/// ```rust +/// use aws_smithy_legacy_http_server::plugin::{HttpPlugins, PluginStack}; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; +/// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as AuthPlugin; +/// +/// pub trait AuthPluginExt { +/// fn with_auth(self) -> HttpPlugins>; +/// } +/// +/// impl AuthPluginExt for HttpPlugins { +/// fn with_auth(self) -> HttpPlugins> { +/// self.push(AuthPlugin) +/// } +/// } +/// +/// let http_plugins = HttpPlugins::new() +/// .push(LoggingPlugin) +/// // Our custom method! +/// .with_auth(); +/// ``` +#[derive(Debug)] +pub struct HttpPlugins

(pub(crate) P); + +impl Default for HttpPlugins { + fn default() -> Self { + Self(IdentityPlugin) + } +} + +impl HttpPlugins { + /// Create an empty [`HttpPlugins`]. + /// + /// You can use [`HttpPlugins::push`] to add plugins to it. + pub fn new() -> Self { + Self::default() + } +} + +impl

HttpPlugins

{ + /// Apply a new HTTP plugin after the ones that have already been registered. + /// + /// ```rust + /// use aws_smithy_legacy_http_server::plugin::HttpPlugins; + /// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; + /// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as MetricsPlugin; + /// + /// let http_plugins = HttpPlugins::new().push(LoggingPlugin).push(MetricsPlugin); + /// ``` + /// + /// The plugins' runtime logic is executed in registration order. + /// In our example above, `LoggingPlugin` would run first, while `MetricsPlugin` is executed last. + /// + /// ## Implementation notes + /// + /// Plugins are applied to the underlying [`Service`](tower::Service) in opposite order compared + /// to their registration order. + /// + /// As an example: + /// + /// ```rust,compile_fail + /// #[derive(Debug)] + /// pub struct PrintPlugin; + /// + /// impl Plugin for PrintPlugin + /// // [...] + /// { + /// // [...] + /// fn apply(&self, inner: T) -> Self::Service { + /// PrintService { + /// inner, + /// service_id: Ser::ID, + /// operation_id: Op::ID + /// } + /// } + /// } + /// ``` + // We eagerly require `NewPlugin: HttpMarker`, despite not really needing it, because compiler + // errors get _substantially_ better if the user makes a mistake. + pub fn push(self, new_plugin: NewPlugin) -> HttpPlugins> { + HttpPlugins(PluginStack::new(new_plugin, self.0)) + } + + /// Applies a single [`tower::Layer`] to all operations _before_ they are deserialized. + pub fn layer(self, layer: L) -> HttpPlugins, P>> { + HttpPlugins(PluginStack::new(LayerPlugin(layer), self.0)) + } +} + +impl Plugin for HttpPlugins +where + InnerPlugin: Plugin, +{ + type Output = InnerPlugin::Output; + + fn apply(&self, input: T) -> Self::Output { + self.0.apply(input) + } +} + +impl HttpMarker for HttpPlugins where InnerPlugin: HttpMarker {} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/identity.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/identity.rs new file mode 100644 index 00000000000..6ec684a5326 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/identity.rs @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use super::{HttpMarker, ModelMarker, Plugin}; + +/// A [`Plugin`] that maps a service to itself. +#[derive(Debug)] +pub struct IdentityPlugin; + +impl Plugin for IdentityPlugin { + type Output = S; + + fn apply(&self, svc: S) -> S { + svc + } +} + +impl ModelMarker for IdentityPlugin {} +impl HttpMarker for IdentityPlugin {} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/layer.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/layer.rs new file mode 100644 index 00000000000..0a2f31bc485 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/layer.rs @@ -0,0 +1,58 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::marker::PhantomData; + +use tower::Layer; + +use super::{HttpMarker, ModelMarker, Plugin}; + +/// A [`Plugin`] which acts as a [`Layer`] `L`. +pub struct LayerPlugin(pub L); + +impl Plugin for LayerPlugin +where + L: Layer, +{ + type Output = L::Service; + + fn apply(&self, svc: S) -> Self::Output { + self.0.layer(svc) + } +} + +// Without more information about what the layer `L` does, we can't know whether it's appropriate +// to run this plugin as a HTTP plugin or a model plugin, so we implement both marker traits. + +impl HttpMarker for LayerPlugin {} +impl ModelMarker for LayerPlugin {} + +/// A [`Layer`] which acts as a [`Plugin`] `Pl` for specific protocol `P` and operation `Op`. +pub struct PluginLayer { + plugin: Pl, + _ser: PhantomData, + _op: PhantomData, +} + +impl Layer for PluginLayer +where + Pl: Plugin, +{ + type Service = Pl::Output; + + fn layer(&self, inner: S) -> Self::Service { + self.plugin.apply(inner) + } +} + +impl PluginLayer<(), (), Pl> { + pub fn new(plugin: Pl) -> PluginLayer { + PluginLayer { + plugin, + _ser: PhantomData, + _op: PhantomData, + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/mod.rs new file mode 100644 index 00000000000..92557473fd5 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/mod.rs @@ -0,0 +1,464 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The plugin system allows you to build middleware with an awareness of the operation it is applied to. +//! +//! The system centers around the [`Plugin`], [`HttpMarker`], and [`ModelMarker`] traits. In +//! addition, this module provides helpers for composing and combining [`Plugin`]s. +//! +//! # HTTP plugins vs model plugins +//! +//! Plugins come in two flavors: _HTTP_ plugins and _model_ plugins. The key difference between +//! them is _when_ they run: +//! +//! - A HTTP plugin acts on the HTTP request before it is deserialized, and acts on the HTTP response +//! after it is serialized. +//! - A model plugin acts on the modeled operation input after it is deserialized, and acts on the +//! modeled operation output or the modeled operation error before it is serialized. +//! +//! See the relevant section in [the book], which contains an illustrative diagram. +//! +//! Both kinds of plugins implement the [`Plugin`] trait, but only HTTP plugins implement the +//! [`HttpMarker`] trait and only model plugins implement the [`ModelMarker`] trait. There is no +//! difference in how an HTTP plugin or a model plugin is applied, so both the [`HttpMarker`] trait +//! and the [`ModelMarker`] trait are _marker traits_, they carry no behavior. Their only purpose +//! is to mark a plugin, at the type system leve, as allowed to run at a certain time. A plugin can be +//! _both_ a HTTP plugin and a model plugin by implementing both traits; in this case, when the +//! plugin runs is decided by you when you register it in your application. [`IdentityPlugin`], +//! [`Scoped`], and [`LayerPlugin`] are examples of plugins that implement both traits. +//! +//! In practice, most plugins are HTTP plugins. Since HTTP plugins run before a request has been +//! correctly deserialized, HTTP plugins should be fast and lightweight. Only use model plugins if +//! you absolutely require your middleware to run after deserialization, or to act on particular +//! fields of your deserialized operation's input/output/errors. +//! +//! [the book]: https://smithy-lang.github.io/smithy-rs/design/server/anatomy.html +//! +//! # Filtered application of a HTTP [`Layer`](tower::Layer) +//! +//! ``` +//! # use aws_smithy_legacy_http_server::plugin::*; +//! # use aws_smithy_legacy_http_server::scope; +//! # use aws_smithy_legacy_http_server::shape_id::ShapeId; +//! # let layer = (); +//! # #[derive(PartialEq)] +//! # enum Operation { GetPokemonSpecies } +//! # struct GetPokemonSpecies; +//! # impl GetPokemonSpecies { const ID: ShapeId = ShapeId::new("namespace#name", "namespace", "name"); }; +//! // Create a `Plugin` from a HTTP `Layer` +//! let plugin = LayerPlugin(layer); +//! +//! scope! { +//! struct OnlyGetPokemonSpecies { +//! includes: [GetPokemonSpecies], +//! excludes: [/* The rest of the operations go here */] +//! } +//! } +//! +//! // Only apply the layer to operations with name "GetPokemonSpecies". +//! let filtered_plugin = Scoped::new::(&plugin); +//! +//! // The same effect can be achieved at runtime. +//! let filtered_plugin = filter_by_operation(&plugin, |operation: Operation| operation == Operation::GetPokemonSpecies); +//! ``` +//! +//! # Construct a [`Plugin`] from a closure that takes as input the operation name +//! +//! ```rust +//! # use aws_smithy_legacy_http_server::{service::*, operation::OperationShape, plugin::Plugin, shape_id::ShapeId}; +//! # pub enum Operation { CheckHealth, GetPokemonSpecies } +//! # impl Operation { fn shape_id(&self) -> ShapeId { ShapeId::new("", "", "") }} +//! # pub struct CheckHealth; +//! # pub struct GetPokemonSpecies; +//! # pub struct PokemonService; +//! # impl ServiceShape for PokemonService { +//! # const ID: ShapeId = ShapeId::new("", "", ""); +//! # const VERSION: Option<&'static str> = None; +//! # type Protocol = (); +//! # type Operations = Operation; +//! # } +//! # impl OperationShape for CheckHealth { const ID: ShapeId = ShapeId::new("", "", ""); type Input = (); type Output = (); type Error = (); } +//! # impl OperationShape for GetPokemonSpecies { const ID: ShapeId = ShapeId::new("", "", ""); type Input = (); type Output = (); type Error = (); } +//! # impl ContainsOperation for PokemonService { const VALUE: Operation = Operation::CheckHealth; } +//! # impl ContainsOperation for PokemonService { const VALUE: Operation = Operation::GetPokemonSpecies; } +//! use aws_smithy_legacy_http_server::plugin::plugin_from_operation_fn; +//! use tower::layer::layer_fn; +//! +//! struct FooService { +//! info: String, +//! inner: S +//! } +//! +//! fn map(op: Operation, inner: S) -> FooService { +//! match op { +//! Operation::CheckHealth => FooService { info: op.shape_id().name().to_string(), inner }, +//! Operation::GetPokemonSpecies => FooService { info: "bar".to_string(), inner }, +//! _ => todo!() +//! } +//! } +//! +//! // This plugin applies the `FooService` middleware around every operation. +//! let plugin = plugin_from_operation_fn(map); +//! # let _ = Plugin::::apply(&plugin, ()); +//! # let _ = Plugin::::apply(&plugin, ()); +//! ``` +//! +//! # Combine [`Plugin`]s +//! +//! ```no_run +//! # use aws_smithy_legacy_http_server::plugin::*; +//! # struct Foo; +//! # impl HttpMarker for Foo { } +//! # let a = Foo; let b = Foo; +//! // Combine `Plugin`s `a` and `b`. Both need to implement `HttpMarker`. +//! let plugin = HttpPlugins::new() +//! .push(a) +//! .push(b); +//! ``` +//! +//! As noted in the [`HttpPlugins`] documentation, the plugins' runtime logic is executed in registration order, +//! meaning that `a` is run _before_ `b` in the example above. +//! +//! Similarly, you can use [`ModelPlugins`] to combine model plugins. +//! +//! # Example implementation of a [`Plugin`] +//! +//! The following is an example implementation of a [`Plugin`] that prints out the service's name +//! and the name of the operation that was hit every time it runs. Since it doesn't act on the HTTP +//! request nor the modeled operation input/output/errors, this plugin can be both an HTTP plugin +//! and a model plugin. In practice, however, you'd only want to register it once, as either an +//! HTTP plugin or a model plugin. +//! +//! ```no_run +//! use aws_smithy_legacy_http_server::{ +//! operation::OperationShape, +//! service::ServiceShape, +//! plugin::{Plugin, HttpMarker, HttpPlugins, ModelMarker}, +//! shape_id::ShapeId, +//! }; +//! # use tower::{layer::util::Stack, Layer, Service}; +//! # use std::task::{Context, Poll}; +//! +//! /// A [`Service`] that adds a print log. +//! #[derive(Clone, Debug)] +//! pub struct PrintService { +//! inner: S, +//! service_id: ShapeId, +//! operation_id: ShapeId +//! } +//! +//! impl Service for PrintService +//! where +//! S: Service, +//! { +//! type Response = S::Response; +//! type Error = S::Error; +//! type Future = S::Future; +//! +//! fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { +//! self.inner.poll_ready(cx) +//! } +//! +//! fn call(&mut self, req: R) -> Self::Future { +//! println!("Hi {} in {}", self.operation_id.absolute(), self.service_id.absolute()); +//! self.inner.call(req) +//! } +//! } +//! +//! /// A [`Plugin`] for a service builder to add a [`PrintLayer`] over operations. +//! #[derive(Debug)] +//! pub struct PrintPlugin; +//! +//! impl Plugin for PrintPlugin +//! where +//! Ser: ServiceShape, +//! Op: OperationShape, +//! { +//! type Output = PrintService; +//! +//! fn apply(&self, inner: T) -> Self::Output { +//! PrintService { +//! inner, +//! service_id: Op::ID, +//! operation_id: Ser::ID, +//! } +//! } +//! } +//! +//! // This plugin could be registered as an HTTP plugin and a model plugin, so we implement both +//! // marker traits. +//! +//! impl HttpMarker for PrintPlugin { } +//! impl ModelMarker for PrintPlugin { } +//! ``` + +mod closure; +pub(crate) mod either; +mod filter; +mod http_plugins; +mod identity; +mod layer; +mod model_plugins; +#[doc(hidden)] +pub mod scoped; +mod stack; + +pub use closure::{plugin_from_operation_fn, OperationFn}; +pub use either::Either; +pub use filter::{filter_by_operation, FilterByOperation}; +pub use http_plugins::HttpPlugins; +pub use identity::IdentityPlugin; +pub use layer::{LayerPlugin, PluginLayer}; +pub use model_plugins::ModelPlugins; +pub use scoped::Scoped; +pub use stack::PluginStack; + +/// A mapping from one [`Service`](tower::Service) to another. This should be viewed as a +/// [`Layer`](tower::Layer) parameterized by the protocol and operation. +/// +/// The generics `Ser` and `Op` allow the behavior to be parameterized by the [Smithy service] and +/// [operation] it's applied to. +/// +/// See [module](crate::plugin) documentation for more information. +/// +/// [Smithy service]: https://smithy.io/2.0/spec/service-types.html#service +/// [operation]: https://smithy.io/2.0/spec/service-types.html#operation +pub trait Plugin { + /// The type of the new [`Service`](tower::Service). + type Output; + + /// Maps a [`Service`](tower::Service) to another. + fn apply(&self, input: T) -> Self::Output; +} + +impl Plugin for &Pl +where + Pl: Plugin, +{ + type Output = Pl::Output; + + fn apply(&self, inner: T) -> Self::Output { + >::apply(self, inner) + } +} + +/// A HTTP plugin is a plugin that acts on the HTTP request before it is deserialized, and acts on +/// the HTTP response after it is serialized. +/// +/// This trait is a _marker_ trait to indicate that a plugin can be registered as an HTTP plugin. +/// +/// Compare with [`ModelMarker`] in the [module](crate::plugin) documentation, which contains an +/// example implementation too. +pub trait HttpMarker {} +impl HttpMarker for &Pl where Pl: HttpMarker {} + +/// A model plugin is a plugin that acts on the modeled operation input after it is deserialized, +/// and acts on the modeled operation output or the modeled operation error before it is +/// serialized. +/// +/// This trait is a _marker_ trait to indicate that a plugin can be registered as a model plugin. +/// +/// Compare with [`HttpMarker`] in the [module](crate::plugin) documentation. +/// +/// # Example implementation of a model plugin +/// +/// Model plugins are most useful when you really need to rely on the actual shape of your modeled +/// operation input, operation output, and/or operation errors. For this reason, most (but not all) +/// model plugins are _operation-specific_: somewhere in the type signature of their definition, +/// they'll rely on a particular operation shape's types. It is therefore important that you scope +/// application of model plugins to the operations they are meant to work on, via +/// [`Scoped`] or [`filter_by_operation`]. +/// +/// Below is an example implementation of a model plugin that can only be applied to the +/// `CheckHealth` operation: note how in the `Service` trait implementation, we require access to +/// the operation's input, where we log the `health_info` field. +/// +/// ```no_run +/// use std::marker::PhantomData; +/// +/// use aws_smithy_legacy_http_server::{operation::OperationShape, plugin::{ModelMarker, Plugin}}; +/// use tower::Service; +/// # pub struct SimpleService; +/// # pub struct CheckHealth; +/// # pub struct CheckHealthInput { +/// # health_info: (), +/// # } +/// # pub struct CheckHealthOutput; +/// # impl aws_smithy_legacy_http_server::operation::OperationShape for CheckHealth { +/// # const ID: aws_smithy_legacy_http_server::shape_id::ShapeId = aws_smithy_legacy_http_server::shape_id::ShapeId::new( +/// # "com.amazonaws.simple#CheckHealth", +/// # "com.amazonaws.simple", +/// # "CheckHealth", +/// # ); +/// # type Input = CheckHealthInput; +/// # type Output = CheckHealthOutput; +/// # type Error = std::convert::Infallible; +/// # } +/// +/// /// A model plugin that can only be applied to the `CheckHealth` operation. +/// pub struct CheckHealthPlugin { +/// pub _exts: PhantomData, +/// } +/// +/// impl CheckHealthPlugin { +/// pub fn new() -> Self { +/// Self { _exts: PhantomData } +/// } +/// } +/// +/// impl Plugin for CheckHealthPlugin { +/// type Output = CheckHealthService; +/// +/// fn apply(&self, input: T) -> Self::Output { +/// CheckHealthService { +/// inner: input, +/// _exts: PhantomData, +/// } +/// } +/// } +/// +/// impl ModelMarker for CheckHealthPlugin { } +/// +/// #[derive(Clone)] +/// pub struct CheckHealthService { +/// inner: S, +/// _exts: PhantomData, +/// } +/// +/// impl Service<(::Input, Exts)> for CheckHealthService +/// where +/// S: Service<(::Input, Exts)>, +/// { +/// type Response = S::Response; +/// type Error = S::Error; +/// type Future = S::Future; +/// +/// fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll> { +/// self.inner.poll_ready(cx) +/// } +/// +/// fn call(&mut self, req: (::Input, Exts)) -> Self::Future { +/// let (input, _exts) = &req; +/// +/// // We have access to `CheckHealth`'s modeled operation input! +/// dbg!(&input.health_info); +/// +/// self.inner.call(req) +/// } +/// } +/// +/// // In `main.rs` or wherever we register plugins, we have to make sure we only apply this plugin +/// // to the the only operation it can be applied to, the `CheckHealth` operation. If we apply the +/// // plugin to other operations, we will get a compilation error. +/// +/// use aws_smithy_legacy_http_server::plugin::Scoped; +/// use aws_smithy_legacy_http_server::scope; +/// +/// pub fn main() { +/// scope! { +/// struct OnlyCheckHealth { +/// includes: [CheckHealth], +/// excludes: [/* The rest of the operations go here */] +/// } +/// } +/// +/// let model_plugin = CheckHealthPlugin::new(); +/// # _foo(&model_plugin); +/// +/// // Scope the plugin to the `CheckHealth` operation. +/// let scoped_plugin = Scoped::new::(model_plugin); +/// # fn _foo(model_plugin: &CheckHealthPlugin<()>) {} +/// } +/// ``` +/// +/// If you are a service owner and don't care about giving a name to the model plugin, you can +/// simplify this down to: +/// +/// ```no_run +/// use std::marker::PhantomData; +/// +/// use aws_smithy_legacy_http_server::operation::OperationShape; +/// use tower::Service; +/// # pub struct SimpleService; +/// # pub struct CheckHealth; +/// # pub struct CheckHealthInput { +/// # health_info: (), +/// # } +/// # pub struct CheckHealthOutput; +/// # impl aws_smithy_legacy_http_server::operation::OperationShape for CheckHealth { +/// # const ID: aws_smithy_legacy_http_server::shape_id::ShapeId = aws_smithy_legacy_http_server::shape_id::ShapeId::new( +/// # "com.amazonaws.simple#CheckHealth", +/// # "com.amazonaws.simple", +/// # "CheckHealth", +/// # ); +/// # type Input = CheckHealthInput; +/// # type Output = CheckHealthOutput; +/// # type Error = std::convert::Infallible; +/// # } +/// +/// #[derive(Clone)] +/// pub struct CheckHealthService { +/// inner: S, +/// _exts: PhantomData, +/// } +/// +/// impl Service<(::Input, Exts)> for CheckHealthService +/// where +/// S: Service<(::Input, Exts)>, +/// { +/// type Response = S::Response; +/// type Error = S::Error; +/// type Future = S::Future; +/// +/// fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll> { +/// self.inner.poll_ready(cx) +/// } +/// +/// fn call(&mut self, req: (::Input, Exts)) -> Self::Future { +/// let (input, _exts) = &req; +/// +/// // We have access to `CheckHealth`'s modeled operation input! +/// dbg!(&input.health_info); +/// +/// self.inner.call(req) +/// } +/// } +/// +/// // In `main.rs`: +/// +/// use aws_smithy_legacy_http_server::plugin::LayerPlugin; +/// use aws_smithy_legacy_http_server::plugin::Scoped; +/// use aws_smithy_legacy_http_server::scope; +/// +/// fn new_check_health_service(inner: S) -> CheckHealthService { +/// CheckHealthService { +/// inner, +/// _exts: PhantomData, +/// } +/// } +/// +/// pub fn main() { +/// scope! { +/// struct OnlyCheckHealth { +/// includes: [CheckHealth], +/// excludes: [/* The rest of the operations go here */] +/// } +/// } +/// +/// # fn new_check_health_service(inner: ()) -> CheckHealthService<(), ()> { +/// # CheckHealthService { +/// # inner, +/// # _exts: PhantomData, +/// # } +/// # } +/// let layer = tower::layer::layer_fn(new_check_health_service); +/// let model_plugin = LayerPlugin(layer); +/// +/// // Scope the plugin to the `CheckHealth` operation. +/// let scoped_plugin = Scoped::new::(model_plugin); +/// } +/// ``` +pub trait ModelMarker {} +impl ModelMarker for &Pl where Pl: ModelMarker {} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/model_plugins.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/model_plugins.rs new file mode 100644 index 00000000000..12fda189bb6 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/model_plugins.rs @@ -0,0 +1,94 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// If you make any updates to this file (including Rust docs), make sure you make them to +// `http_plugins.rs` too! + +use crate::plugin::{IdentityPlugin, Plugin, PluginStack}; + +use super::{LayerPlugin, ModelMarker}; + +/// A wrapper struct for composing model plugins. +/// It operates identically to [`HttpPlugins`](crate::plugin::HttpPlugins); see its documentation. +#[derive(Debug)] +pub struct ModelPlugins

(pub(crate) P); + +impl Default for ModelPlugins { + fn default() -> Self { + Self(IdentityPlugin) + } +} + +impl ModelPlugins { + /// Create an empty [`ModelPlugins`]. + /// + /// You can use [`ModelPlugins::push`] to add plugins to it. + pub fn new() -> Self { + Self::default() + } +} + +impl

ModelPlugins

{ + /// Apply a new model plugin after the ones that have already been registered. + /// + /// ```rust + /// use aws_smithy_legacy_http_server::plugin::ModelPlugins; + /// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as LoggingPlugin; + /// # use aws_smithy_legacy_http_server::plugin::IdentityPlugin as MetricsPlugin; + /// + /// let model_plugins = ModelPlugins::new().push(LoggingPlugin).push(MetricsPlugin); + /// ``` + /// + /// The plugins' runtime logic is executed in registration order. + /// In our example above, `LoggingPlugin` would run first, while `MetricsPlugin` is executed last. + /// + /// ## Implementation notes + /// + /// Plugins are applied to the underlying [`Service`](tower::Service) in opposite order compared + /// to their registration order. + /// + /// As an example: + /// + /// ```rust,compile_fail + /// #[derive(Debug)] + /// pub struct PrintPlugin; + /// + /// impl Plugin for PrintPlugin + /// // [...] + /// { + /// // [...] + /// fn apply(&self, inner: T) -> Self::Service { + /// PrintService { + /// inner, + /// service_id: Ser::ID, + /// operation_id: Op::ID + /// } + /// } + /// } + /// ``` + // We eagerly require `NewPlugin: ModelMarker`, despite not really needing it, because compiler + // errors get _substantially_ better if the user makes a mistake. + pub fn push(self, new_plugin: NewPlugin) -> ModelPlugins> { + ModelPlugins(PluginStack::new(new_plugin, self.0)) + } + + /// Applies a single [`tower::Layer`] to all operations _before_ they are deserialized. + pub fn layer(self, layer: L) -> ModelPlugins, P>> { + ModelPlugins(PluginStack::new(LayerPlugin(layer), self.0)) + } +} + +impl Plugin for ModelPlugins +where + InnerPlugin: Plugin, +{ + type Output = InnerPlugin::Output; + + fn apply(&self, input: T) -> Self::Output { + self.0.apply(input) + } +} + +impl ModelMarker for ModelPlugins where InnerPlugin: ModelMarker {} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/scoped.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/scoped.rs new file mode 100644 index 00000000000..17a93a28bed --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/scoped.rs @@ -0,0 +1,192 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::marker::PhantomData; + +use super::{HttpMarker, ModelMarker, Plugin}; + +/// Marker struct for `true`. +/// +/// Implements [`ConditionalApply`] which applies the [`Plugin`]. +pub struct True; + +/// Marker struct for `false`. +/// +/// Implements [`ConditionalApply`] which does nothing. +pub struct False; + +/// Conditionally applies a [`Plugin`] `Pl` to some service `S`. +/// +/// See [`True`] and [`False`]. +pub trait ConditionalApply { + type Service; + + fn apply(plugin: &Pl, svc: T) -> Self::Service; +} + +impl ConditionalApply for True +where + Pl: Plugin, +{ + type Service = Pl::Output; + + fn apply(plugin: &Pl, input: T) -> Self::Service { + plugin.apply(input) + } +} + +impl ConditionalApply for False { + type Service = T; + + fn apply(_plugin: &Pl, input: T) -> Self::Service { + input + } +} + +/// A [`Plugin`] which scopes the application of an inner [`Plugin`]. +/// +/// In cases where operation selection must be performed at runtime [`filter_by_operation`](crate::plugin::filter_by_operation) +/// can be used. +/// +/// Operations within the scope will have the inner [`Plugin`] applied. +/// +/// # Example +/// +/// ```rust +/// # use aws_smithy_legacy_http_server::{scope, plugin::Scoped}; +/// # struct OperationA; struct OperationB; struct OperationC; +/// # let plugin = (); +/// +/// // Define a scope over a service with 3 operations +/// scope! { +/// struct OnlyAB { +/// includes: [OperationA, OperationB], +/// excludes: [OperationC] +/// } +/// } +/// +/// // Create a scoped plugin +/// let scoped_plugin = Scoped::new::(plugin); +/// ``` +pub struct Scoped { + scope: PhantomData, + plugin: Pl, +} + +impl Scoped<(), Pl> { + /// Creates a new [`Scoped`] from a `Scope` and [`Plugin`]. + pub fn new(plugin: Pl) -> Scoped { + Scoped { + scope: PhantomData, + plugin, + } + } +} + +/// A trait marking which operations are in scope via the associated type [`Membership::Contains`]. +pub trait Membership { + type Contains; +} + +impl Plugin for Scoped +where + Scope: Membership, + Scope::Contains: ConditionalApply, +{ + type Output = >::Service; + + fn apply(&self, input: T) -> Self::Output { + >::apply(&self.plugin, input) + } +} + +impl HttpMarker for Scoped where Pl: HttpMarker {} +impl ModelMarker for Scoped where Pl: ModelMarker {} + +/// A macro to help with scoping [plugins](crate::plugin) to a subset of all operations. +/// +/// The scope must partition _all_ operations, that is, each and every operation must be included or excluded, but not +/// both. +/// +/// The generated server SDK exports a similar `scope` macro which is aware of a service's operations and can complete +/// underspecified scopes automatically. +/// +/// # Example +/// +/// For a service with three operations: `OperationA`, `OperationB`, `OperationC`. +/// +/// ```rust +/// # use aws_smithy_legacy_http_server::scope; +/// # struct OperationA; struct OperationB; struct OperationC; +/// scope! { +/// struct OnlyAB { +/// includes: [OperationA, OperationB], +/// excludes: [OperationC] +/// } +/// } +/// ``` +#[macro_export] +macro_rules! scope { + ( + $(#[$attrs:meta])* + $vis:vis struct $name:ident { + includes: [$($include:ty),*], + excludes: [$($exclude:ty),*] + } + ) => { + $(#[$attrs])* + $vis struct $name; + + $( + impl $crate::plugin::scoped::Membership<$include> for $name { + type Contains = $crate::plugin::scoped::True; + } + )* + $( + impl $crate::plugin::scoped::Membership<$exclude> for $name { + type Contains = $crate::plugin::scoped::False; + } + )* + }; +} + +#[cfg(test)] +mod tests { + use crate::plugin::Plugin; + + use super::Scoped; + + struct OperationA; + struct OperationB; + + scope! { + /// Includes A, not B. + pub struct AuthScope { + includes: [OperationA], + excludes: [OperationB] + } + } + + struct MockPlugin; + + impl Plugin for MockPlugin { + type Output = String; + + fn apply(&self, svc: u32) -> Self::Output { + svc.to_string() + } + } + + #[test] + fn scope() { + let plugin = MockPlugin; + let scoped_plugin = Scoped::new::(plugin); + + let out: String = Plugin::<(), OperationA, _>::apply(&scoped_plugin, 3_u32); + assert_eq!(out, "3".to_string()); + let out: u32 = Plugin::<(), OperationB, _>::apply(&scoped_plugin, 3_u32); + assert_eq!(out, 3); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/plugin/stack.rs b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/stack.rs new file mode 100644 index 00000000000..c42462ec52f --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/plugin/stack.rs @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use super::{HttpMarker, ModelMarker, Plugin}; +use std::fmt::Debug; + +/// A wrapper struct which composes an `Inner` and an `Outer` [`Plugin`]. +/// +/// The `Inner::map` is run _then_ the `Outer::map`. +/// +/// Note that the primary tool for composing HTTP plugins is +/// [`HttpPlugins`](crate::plugin::HttpPlugins), and the primary tool for composing HTTP plugins is +/// [`ModelPlugins`](crate::plugin::ModelPlugins); if you are an application writer, you should +/// prefer composing plugins using these. +#[derive(Debug)] +pub struct PluginStack { + inner: Inner, + outer: Outer, +} + +impl PluginStack { + /// Creates a new [`PluginStack`]. + pub fn new(inner: Inner, outer: Outer) -> Self { + PluginStack { inner, outer } + } +} + +impl Plugin for PluginStack +where + Inner: Plugin, + Outer: Plugin, +{ + type Output = Outer::Output; + + fn apply(&self, input: T) -> Self::Output { + let svc = self.inner.apply(input); + self.outer.apply(svc) + } +} + +impl HttpMarker for PluginStack +where + Inner: HttpMarker, + Outer: HttpMarker, +{ +} + +impl ModelMarker for PluginStack +where + Inner: ModelMarker, + Outer: ModelMarker, +{ +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/mod.rs new file mode 100644 index 00000000000..af7b0a809ba --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/mod.rs @@ -0,0 +1,8 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod rejection; +pub mod router; +pub mod runtime_error; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/rejection.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/rejection.rs new file mode 100644 index 00000000000..7a199a536f5 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/rejection.rs @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::rejection::MissingContentTypeReason; +use aws_smithy_runtime_api::http::HttpError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ResponseRejection { + #[error("error building HTTP response: {0}")] + Build(#[from] aws_smithy_types::error::operation::BuildError), + #[error("error serializing JSON-encoded body: {0}")] + Serialization(#[from] aws_smithy_types::error::operation::SerializationError), + #[error("error building HTTP response: {0}")] + HttpBuild(#[from] http::Error), +} + +#[derive(Debug, Error)] +pub enum RequestRejection { + #[error("error converting non-streaming body to bytes: {0}")] + BufferHttpBodyBytes(crate::Error), + #[error("request contains invalid value for `Accept` header")] + NotAcceptable, + #[error("expected `Content-Type` header not found: {0}")] + MissingContentType(#[from] MissingContentTypeReason), + #[error("error deserializing request HTTP body as JSON: {0}")] + JsonDeserialize(#[from] aws_smithy_json::deserialize::error::DeserializeError), + #[error("request does not adhere to modeled constraints: {0}")] + ConstraintViolation(String), + + /// Typically happens when the request has headers that are not valid UTF-8. + #[error("failed to convert request: {0}")] + HttpConversion(#[from] HttpError), +} + +impl From for RequestRejection { + fn from(_err: std::convert::Infallible) -> Self { + match _err {} + } +} + +convert_to_request_rejection!(hyper::Error, BufferHttpBodyBytes); +convert_to_request_rejection!(Box, BufferHttpBodyBytes); diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/router.rs new file mode 100644 index 00000000000..38538fe1e9a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/router.rs @@ -0,0 +1,151 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::convert::Infallible; + +use tower::Layer; +use tower::Service; + +use crate::body::BoxBody; +use crate::routing::tiny_map::TinyMap; +use crate::routing::Route; +use crate::routing::Router; + +use http::header::ToStrError; +use thiserror::Error; + +/// An AWS JSON routing error. +#[derive(Debug, Error)] +pub enum Error { + /// Relative URI was not "/". + #[error("relative URI is not \"/\"")] + NotRootUrl, + /// Method was not `POST`. + #[error("method not POST")] + MethodNotAllowed, + /// Missing the `x-amz-target` header. + #[error("missing the \"x-amz-target\" header")] + MissingHeader, + /// Unable to parse header into UTF-8. + #[error("failed to parse header: {0}")] + InvalidHeader(ToStrError), + /// Operation not found. + #[error("operation not found")] + NotFound, +} + +// This constant determines when the `TinyMap` implementation switches from being a `Vec` to a +// `HashMap`. This is chosen to be 15 as a result of the discussion around +// https://github.com/smithy-lang/smithy-rs/pull/1429#issuecomment-1147516546 +pub(crate) const ROUTE_CUTOFF: usize = 15; + +/// A [`Router`] supporting [AWS JSON 1.0] and [AWS JSON 1.1] protocols. +/// +/// [AWS JSON 1.0]: https://smithy.io/2.0/aws/protocols/aws-json-1_0-protocol.html +/// [AWS JSON 1.1]: https://smithy.io/2.0/aws/protocols/aws-json-1_1-protocol.html +#[derive(Debug, Clone)] +pub struct AwsJsonRouter { + routes: TinyMap<&'static str, S, ROUTE_CUTOFF>, +} + +impl AwsJsonRouter { + /// Applies a [`Layer`] uniformly to all routes. + pub fn layer(self, layer: L) -> AwsJsonRouter + where + L: Layer, + { + AwsJsonRouter { + routes: self + .routes + .into_iter() + .map(|(key, route)| (key, layer.layer(route))) + .collect(), + } + } + + /// Applies type erasure to the inner route using [`Route::new`]. + pub fn boxed(self) -> AwsJsonRouter> + where + S: Service, Response = http::Response, Error = Infallible>, + S: Send + Clone + 'static, + S::Future: Send + 'static, + { + AwsJsonRouter { + routes: self.routes.into_iter().map(|(key, s)| (key, Route::new(s))).collect(), + } + } +} + +impl Router for AwsJsonRouter +where + S: Clone, +{ + type Service = S; + type Error = Error; + + fn match_route(&self, request: &http::Request) -> Result { + // The URI must be root, + if request.uri() != "/" { + return Err(Error::NotRootUrl); + } + + // Only `Method::POST` is allowed. + if request.method() != http::Method::POST { + return Err(Error::MethodNotAllowed); + } + + // Find the `x-amz-target` header. + let target = request.headers().get("x-amz-target").ok_or(Error::MissingHeader)?; + let target = target.to_str().map_err(Error::InvalidHeader)?; + + // Lookup in the `TinyMap` for a route for the target. + let route = self.routes.get(target).ok_or(Error::NotFound)?; + Ok(route.clone()) + } +} + +impl FromIterator<(&'static str, S)> for AwsJsonRouter { + #[inline] + fn from_iter>(iter: T) -> Self { + Self { + routes: iter.into_iter().collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{protocol::test_helpers::req, routing::Router}; + + use http::{HeaderMap, HeaderValue, Method}; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn simple_routing() { + let routes = vec![("Service.Operation")]; + let router: AwsJsonRouter<_> = routes.clone().into_iter().map(|operation| (operation, ())).collect(); + + let mut headers = HeaderMap::new(); + headers.insert("x-amz-target", HeaderValue::from_static("Service.Operation")); + + // Valid request, should match. + router + .match_route(&req(&Method::POST, "/", Some(headers.clone()))) + .unwrap(); + + // No headers, should return `MissingHeader`. + let res = router.match_route(&req(&Method::POST, "/", None)); + assert_eq!(res.unwrap_err().to_string(), Error::MissingHeader.to_string()); + + // Wrong HTTP method, should return `MethodNotAllowed`. + let res = router.match_route(&req(&Method::GET, "/", Some(headers.clone()))); + assert_eq!(res.unwrap_err().to_string(), Error::MethodNotAllowed.to_string()); + + // Wrong URI, should return `NotRootUrl`. + let res = router.match_route(&req(&Method::POST, "/something", Some(headers))); + assert_eq!(res.unwrap_err().to_string(), Error::NotRootUrl.to_string()); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/runtime_error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/runtime_error.rs new file mode 100644 index 00000000000..d1c42ac5602 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json/runtime_error.rs @@ -0,0 +1,115 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::protocol::aws_json_11::AwsJson1_1; +use crate::response::IntoResponse; +use crate::runtime_error::{InternalFailureException, INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE}; +use crate::{extension::RuntimeErrorExtension, protocol::aws_json_10::AwsJson1_0}; +use http::StatusCode; + +use super::rejection::{RequestRejection, ResponseRejection}; + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Serialization`] + #[error("request failed to deserialize or response failed to serialize: {0}")] + Serialization(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::InternalFailure`] + #[error("internal failure: {0}")] + InternalFailure(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::NotAcceptable`] + #[error("not acceptable request: request contains an `Accept` header with a MIME type, and the server cannot return a response body adhering to that MIME type")] + NotAcceptable, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::UnsupportedMediaType`] + #[error("unsupported media type: request does not contain the expected `Content-Type` header value")] + UnsupportedMediaType, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Validation`] + #[error("validation failure: operation input contains data that does not adhere to the modeled constraints: {0}")] + Validation(String), +} + +impl RuntimeError { + pub fn name(&self) -> &'static str { + match self { + Self::Serialization(_) => "SerializationException", + Self::InternalFailure(_) => "InternalFailureException", + Self::NotAcceptable => "NotAcceptableException", + Self::UnsupportedMediaType => "UnsupportedMediaTypeException", + Self::Validation(_) => "ValidationException", + } + } + + pub fn status_code(&self) -> StatusCode { + match self { + Self::Serialization(_) => StatusCode::BAD_REQUEST, + Self::InternalFailure(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE, + Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Validation(_) => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for InternalFailureException { + fn into_response(self) -> http::Response { + IntoResponse::::into_response(RuntimeError::InternalFailure(crate::Error::new(String::new()))) + } +} + +impl IntoResponse for InternalFailureException { + fn into_response(self) -> http::Response { + IntoResponse::::into_response(RuntimeError::InternalFailure(crate::Error::new(String::new()))) + } +} + +impl IntoResponse for RuntimeError { + fn into_response(self) -> http::Response { + let res = http::Response::builder() + .status(self.status_code()) + .header("Content-Type", "application/x-amz-json-1.0") + .extension(RuntimeErrorExtension::new(self.name().to_string())); + + let body = match self { + RuntimeError::Validation(reason) => crate::body::to_boxed(reason), + // See https://awslabs.github.io/smithy/2.0/aws/protocols/aws-json-1_0-protocol.html#empty-body-serialization + _ => crate::body::to_boxed("{}"), + }; + + res.body(body) + .expect(INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE) + } +} + +impl IntoResponse for RuntimeError { + fn into_response(self) -> http::Response { + let res = http::Response::builder() + .status(self.status_code()) + .header("Content-Type", "application/x-amz-json-1.1") + .extension(RuntimeErrorExtension::new(self.name().to_string())); + + let body = match self { + RuntimeError::Validation(reason) => crate::body::to_boxed(reason), + _ => crate::body::to_boxed(""), + }; + + res.body(body) + .expect(INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE) + } +} + +impl From for RuntimeError { + fn from(err: ResponseRejection) -> Self { + Self::Serialization(crate::Error::new(err)) + } +} + +impl From for RuntimeError { + fn from(err: RequestRejection) -> Self { + match err { + RequestRejection::ConstraintViolation(reason) => Self::Validation(reason), + _ => Self::Serialization(crate::Error::new(err)), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/mod.rs new file mode 100644 index 00000000000..a3e8f2c9192 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/mod.rs @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod router; + +/// [AWS JSON 1.0](https://smithy.io/2.0/aws/protocols/aws-json-1_0-protocol.html) protocol. +pub struct AwsJson1_0; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/router.rs new file mode 100644 index 00000000000..ac963ffe512 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_10/router.rs @@ -0,0 +1,31 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::body::{empty, BoxBody}; +use crate::extension::RuntimeErrorExtension; +use crate::response::IntoResponse; +use crate::routing::{method_disallowed, UNKNOWN_OPERATION_EXCEPTION}; + +use super::AwsJson1_0; + +pub use crate::protocol::aws_json::router::*; + +// TODO(https://github.com/smithy-lang/smithy/issues/2348): We're probably non-compliant here, but +// we have no tests to pin our implemenation against! +impl IntoResponse for Error { + fn into_response(self) -> http::Response { + match self { + Error::MethodNotAllowed => method_disallowed(), + _ => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/x-amz-json-1.0") + .extension(RuntimeErrorExtension::new( + UNKNOWN_OPERATION_EXCEPTION.to_string(), + )) + .body(empty()) + .expect("invalid HTTP response for AWS JSON 1.0 routing error; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/mod.rs new file mode 100644 index 00000000000..697aae52d3e --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/mod.rs @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod router; + +/// [AWS JSON 1.1](https://smithy.io/2.0/aws/protocols/aws-json-1_1-protocol.html) protocol. +pub struct AwsJson1_1; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/router.rs new file mode 100644 index 00000000000..2e3e16d8ad4 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/aws_json_11/router.rs @@ -0,0 +1,31 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::body::{empty, BoxBody}; +use crate::extension::RuntimeErrorExtension; +use crate::response::IntoResponse; +use crate::routing::{method_disallowed, UNKNOWN_OPERATION_EXCEPTION}; + +use super::AwsJson1_1; + +pub use crate::protocol::aws_json::router::*; + +// TODO(https://github.com/smithy-lang/smithy/issues/2348): We're probably non-compliant here, but +// we have no tests to pin our implemenation against! +impl IntoResponse for Error { + fn into_response(self) -> http::Response { + match self { + Error::MethodNotAllowed => method_disallowed(), + _ => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/x-amz-json-1.1") + .extension(RuntimeErrorExtension::new( + UNKNOWN_OPERATION_EXCEPTION.to_string(), + )) + .body(empty()) + .expect("invalid HTTP response for AWS JSON 1.1 routing error; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/mod.rs new file mode 100644 index 00000000000..55800980a69 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/mod.rs @@ -0,0 +1,313 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod aws_json; +pub mod aws_json_10; +pub mod aws_json_11; +pub mod rest; +pub mod rest_json_1; +pub mod rest_xml; +pub mod rpc_v2_cbor; + +use crate::rejection::MissingContentTypeReason; +use aws_smithy_runtime_api::http::Headers as SmithyHeaders; +use http::header::CONTENT_TYPE; +use http::HeaderMap; + +#[cfg(test)] +pub mod test_helpers { + use http::{HeaderMap, Method, Request}; + + /// Helper function to build a `Request`. Used in other test modules. + pub fn req(method: &Method, uri: &str, headers: Option) -> Request<()> { + let mut r = Request::builder().method(method).uri(uri).body(()).unwrap(); + if let Some(headers) = headers { + *r.headers_mut() = headers + } + r + } + + // Returns a `Response`'s body as a `String`, without consuming the response. + pub async fn get_body_as_string(body: B) -> String + where + B: http_body::Body + std::marker::Unpin, + B::Error: std::fmt::Debug, + { + let body_bytes = hyper::body::to_bytes(body).await.unwrap(); + String::from(std::str::from_utf8(&body_bytes).unwrap()) + } +} + +#[allow(clippy::result_large_err)] +fn parse_mime(content_type: &str) -> Result { + content_type + .parse::() + .map_err(MissingContentTypeReason::MimeParseError) +} + +/// Checks that the `content-type` header from a `SmithyHeaders` matches what we expect. +#[allow(clippy::result_large_err)] +pub fn content_type_header_classifier_smithy( + headers: &SmithyHeaders, + expected_content_type: Option<&'static str>, +) -> Result<(), MissingContentTypeReason> { + let actual_content_type = headers.get(CONTENT_TYPE); + content_type_header_classifier(actual_content_type, expected_content_type) +} + +/// Checks that the `content-type` header matches what we expect. +#[allow(clippy::result_large_err)] +fn content_type_header_classifier( + actual_content_type: Option<&str>, + expected_content_type: Option<&'static str>, +) -> Result<(), MissingContentTypeReason> { + fn parse_expected_mime(expected_content_type: &str) -> mime::Mime { + let mime = expected_content_type + .parse::() + // `expected_content_type` comes from the codegen. + .expect("BUG: MIME parsing failed, `expected_content_type` is not valid; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"); + debug_assert_eq!( + mime, expected_content_type, + "BUG: expected `content-type` header value we own from codegen should coincide with its mime type; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues", + ); + mime + } + + match (actual_content_type, expected_content_type) { + (None, None) => Ok(()), + (None, Some(expected_content_type)) => { + let expected_mime = parse_expected_mime(expected_content_type); + Err(MissingContentTypeReason::UnexpectedMimeType { + expected_mime: Some(expected_mime), + found_mime: None, + }) + } + (Some(actual_content_type), None) => { + let found_mime = parse_mime(actual_content_type)?; + Err(MissingContentTypeReason::UnexpectedMimeType { + expected_mime: None, + found_mime: Some(found_mime), + }) + } + (Some(actual_content_type), Some(expected_content_type)) => { + let expected_mime = parse_expected_mime(expected_content_type); + let found_mime = parse_mime(actual_content_type)?; + if expected_mime != found_mime.essence_str() { + Err(MissingContentTypeReason::UnexpectedMimeType { + expected_mime: Some(expected_mime), + found_mime: Some(found_mime), + }) + } else { + Ok(()) + } + } + } +} + +pub fn accept_header_classifier(headers: &HeaderMap, content_type: &mime::Mime) -> bool { + if !headers.contains_key(http::header::ACCEPT) { + return true; + } + headers + .get_all(http::header::ACCEPT) + .into_iter() + .flat_map(|header| { + header + .to_str() + .ok() + .into_iter() + /* + * Turn a header value of: "type0/subtype0, type1/subtype1, ..." + * into: ["type0/subtype0", "type1/subtype1", ...] + * and remove the optional "; q=x" parameters + * NOTE: the `unwrap`() is safe, because it takes the first element (if there's nothing to split, returns the string) + */ + .flat_map(|s| s.split(',').map(|typ| typ.split(';').next().unwrap().trim())) + }) + .filter_map(|h| h.parse::().ok()) + .any(|mim| { + let typ = content_type.type_(); + let subtype = content_type.subtype(); + // Accept: */*, type/*, type/subtype + match (mim.type_(), mim.subtype()) { + (t, s) if t == typ && s == subtype => true, + (t, mime::STAR) if t == typ => true, + (mime::STAR, mime::STAR) => true, + _ => false, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::header::{HeaderValue, ACCEPT, CONTENT_TYPE}; + + fn req_content_type_smithy(content_type: &'static str) -> SmithyHeaders { + let mut headers = SmithyHeaders::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(content_type).unwrap()); + headers + } + + fn req_accept(accept: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static(accept)); + headers + } + + const APPLICATION_JSON: Option<&'static str> = Some("application/json"); + + // Validates the rejection type since we cannot implement `PartialEq` + // for `MissingContentTypeReason`. + fn assert_unexpected_mime_type( + result: Result<(), MissingContentTypeReason>, + actually_expected_mime: Option, + actually_found_mime: Option, + ) { + match result { + Ok(()) => panic!("content-type validation is expected to fail"), + Err(e) => match e { + MissingContentTypeReason::UnexpectedMimeType { + expected_mime, + found_mime, + } => { + assert_eq!(actually_expected_mime, expected_mime); + assert_eq!(actually_found_mime, found_mime); + } + _ => panic!("unexpected `MissingContentTypeReason`: {e}"), + }, + } + } + + #[test] + fn check_valid_content_type() { + let headers = req_content_type_smithy("application/json"); + assert!(content_type_header_classifier_smithy(&headers, APPLICATION_JSON,).is_ok()); + } + + #[test] + fn check_invalid_content_type() { + let invalid = vec!["application/jason", "text/xml"]; + for invalid_mime in invalid { + let headers = req_content_type_smithy(invalid_mime); + let results = vec![content_type_header_classifier_smithy(&headers, APPLICATION_JSON)]; + + let actually_expected_mime = Some(parse_mime(APPLICATION_JSON.unwrap()).unwrap()); + for result in results { + let actually_found_mime = invalid_mime.parse::().ok(); + assert_unexpected_mime_type(result, actually_expected_mime.clone(), actually_found_mime); + } + } + } + + #[test] + fn check_missing_content_type_is_not_allowed() { + let actually_expected_mime = Some(parse_mime(APPLICATION_JSON.unwrap()).unwrap()); + let result = content_type_header_classifier_smithy(&SmithyHeaders::new(), APPLICATION_JSON); + assert_unexpected_mime_type(result, actually_expected_mime, None); + } + + #[test] + fn check_missing_content_type_is_expected() { + let headers = req_content_type_smithy(APPLICATION_JSON.unwrap()); + let actually_found_mime = Some(parse_mime(APPLICATION_JSON.unwrap()).unwrap()); + let actually_expected_mime = None; + + let result = content_type_header_classifier_smithy(&headers, None); + assert_unexpected_mime_type(result, actually_expected_mime, actually_found_mime); + } + + #[test] + fn check_not_parsable_content_type() { + let request = req_content_type_smithy("123"); + let result = content_type_header_classifier_smithy(&request, APPLICATION_JSON); + assert!(matches!( + result.unwrap_err(), + MissingContentTypeReason::MimeParseError(_) + )); + } + + #[test] + fn check_non_ascii_visible_characters_content_type() { + // Note that for Smithy headers, validation fails when attempting to parse the mime type, + // unlike with `http`'s `HeaderMap`, that would fail when checking the header value is + // valid (~ASCII string). + let request = req_content_type_smithy("application/💩"); + let result = content_type_header_classifier_smithy(&request, APPLICATION_JSON); + assert!(matches!( + result.unwrap_err(), + MissingContentTypeReason::MimeParseError(_) + )); + } + + #[test] + fn valid_content_type_header_classifier_http_params() { + let request = req_content_type_smithy("application/json; charset=utf-8"); + let result = content_type_header_classifier_smithy(&request, APPLICATION_JSON); + assert!(result.is_ok()); + } + + #[test] + fn valid_accept_header_classifier_multiple_values() { + let valid_request = req_accept("text/strings, application/json, invalid"); + assert!(accept_header_classifier( + &valid_request, + &"application/json".parse().unwrap() + )); + } + + #[test] + fn invalid_accept_header_classifier() { + let invalid_request = req_accept("text/invalid, invalid, invalid/invalid"); + assert!(!accept_header_classifier( + &invalid_request, + &"application/json".parse().unwrap() + )); + } + + #[test] + fn valid_accept_header_classifier_star() { + let valid_request = req_accept("application/*"); + assert!(accept_header_classifier( + &valid_request, + &"application/json".parse().unwrap() + )); + } + + #[test] + fn valid_accept_header_classifier_star_star() { + let valid_request = req_accept("*/*"); + assert!(accept_header_classifier( + &valid_request, + &"application/json".parse().unwrap() + )); + } + + #[test] + fn valid_empty_accept_header_classifier() { + assert!(accept_header_classifier( + &HeaderMap::new(), + &"application/json".parse().unwrap() + )); + } + + #[test] + fn valid_accept_header_classifier_with_params() { + let valid_request = req_accept("application/json; q=30, */*"); + assert!(accept_header_classifier( + &valid_request, + &"application/json".parse().unwrap() + )); + } + + #[test] + fn valid_accept_header_classifier() { + let valid_request = req_accept("application/json"); + assert!(accept_header_classifier( + &valid_request, + &"application/json".parse().unwrap() + )); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/mod.rs new file mode 100644 index 00000000000..a579436e91b --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/mod.rs @@ -0,0 +1,6 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod router; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/router.rs new file mode 100644 index 00000000000..94f99a98dfe --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest/router.rs @@ -0,0 +1,264 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::convert::Infallible; + +use crate::body::BoxBody; +use crate::routing::request_spec::Match; +use crate::routing::request_spec::RequestSpec; +use crate::routing::Route; +use crate::routing::Router; +use tower::Layer; +use tower::Service; + +use thiserror::Error; + +/// An AWS REST routing error. +#[derive(Debug, Error, PartialEq)] +pub enum Error { + /// Operation not found. + #[error("operation not found")] + NotFound, + /// Method was not allowed. + #[error("method was not allowed")] + MethodNotAllowed, +} + +/// A [`Router`] supporting [AWS restJson1] and [AWS restXml] protocols. +/// +/// [AWS restJson1]: https://awslabs.github.io/smithy/2.0/aws/protocols/aws-restjson1-protocol.html +/// [AWS restXml]: https://awslabs.github.io/smithy/2.0/aws/protocols/aws-restxml-protocol.html +#[derive(Debug, Clone)] +pub struct RestRouter { + routes: Vec<(RequestSpec, S)>, +} + +impl RestRouter { + /// Applies a [`Layer`] uniformly to all routes. + pub fn layer(self, layer: L) -> RestRouter + where + L: Layer, + { + RestRouter { + routes: self + .routes + .into_iter() + .map(|(request_spec, route)| (request_spec, layer.layer(route))) + .collect(), + } + } + + /// Applies type erasure to the inner route using [`Route::new`]. + pub fn boxed(self) -> RestRouter> + where + S: Service, Response = http::Response, Error = Infallible>, + S: Send + Clone + 'static, + S::Future: Send + 'static, + { + RestRouter { + routes: self.routes.into_iter().map(|(spec, s)| (spec, Route::new(s))).collect(), + } + } +} + +impl Router for RestRouter +where + S: Clone, +{ + type Service = S; + type Error = Error; + + fn match_route(&self, request: &http::Request) -> Result { + let mut method_allowed = true; + + for (request_spec, route) in &self.routes { + match request_spec.matches(request) { + // Match found. + Match::Yes => return Ok(route.clone()), + // Match found, but method disallowed. + Match::MethodNotAllowed => method_allowed = false, + // Continue looping to see if another route matches. + Match::No => continue, + } + } + + if method_allowed { + Err(Error::NotFound) + } else { + Err(Error::MethodNotAllowed) + } + } +} + +impl FromIterator<(RequestSpec, S)> for RestRouter { + #[inline] + fn from_iter>(iter: T) -> Self { + let mut routes: Vec<(RequestSpec, S)> = iter.into_iter().collect(); + + // Sort them once by specificity, with the more specific routes sorted before the less + // specific ones, so that when routing a request we can simply iterate through the routes + // and pick the first one that matches. + routes.sort_by_key(|(request_spec, _route)| std::cmp::Reverse(request_spec.rank())); + + Self { routes } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{protocol::test_helpers::req, routing::request_spec::*}; + + use http::Method; + + // This test is a rewrite of `mux.spec.ts`. + // https://github.com/awslabs/smithy-typescript/blob/fbf97a9bf4c1d8cf7f285ea7c24e1f0ef280142a/smithy-typescript-ssdk-libs/server-common/src/httpbinding/mux.spec.ts + #[test] + fn simple_routing() { + let request_specs: Vec<(RequestSpec, &'static str)> = vec![ + ( + RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Label, + PathSegment::Label, + ], + Vec::new(), + ), + "A", + ), + ( + RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("mg")), + PathSegment::Greedy, + PathSegment::Literal(String::from("z")), + ], + Vec::new(), + ), + "MiddleGreedy", + ), + ( + RequestSpec::from_parts( + Method::DELETE, + Vec::new(), + vec![ + QuerySegment::KeyValue(String::from("foo"), String::from("bar")), + QuerySegment::Key(String::from("baz")), + ], + ), + "Delete", + ), + ( + RequestSpec::from_parts( + Method::POST, + vec![PathSegment::Literal(String::from("query_key_only"))], + vec![QuerySegment::Key(String::from("foo"))], + ), + "QueryKeyOnly", + ), + ]; + + // Test both RestJson1 and RestXml routers. + let router: RestRouter<_> = request_specs.into_iter().collect(); + + let hits = vec![ + ("A", Method::GET, "/a/b/c"), + ("MiddleGreedy", Method::GET, "/mg/a/z"), + ("MiddleGreedy", Method::GET, "/mg/a/b/c/d/z?abc=def"), + ("Delete", Method::DELETE, "/?foo=bar&baz=quux"), + ("Delete", Method::DELETE, "/?foo=bar&baz"), + ("Delete", Method::DELETE, "/?foo=bar&baz=&"), + ("Delete", Method::DELETE, "/?foo=bar&baz=quux&baz=grault"), + ("QueryKeyOnly", Method::POST, "/query_key_only?foo=bar"), + ("QueryKeyOnly", Method::POST, "/query_key_only?foo"), + ("QueryKeyOnly", Method::POST, "/query_key_only?foo="), + ("QueryKeyOnly", Method::POST, "/query_key_only?foo=&"), + ]; + for (svc_name, method, uri) in &hits { + assert_eq!(router.match_route(&req(method, uri, None)).unwrap(), *svc_name); + } + + for (_, _, uri) in hits { + let res = router.match_route(&req(&Method::PATCH, uri, None)); + assert_eq!(res.unwrap_err(), Error::MethodNotAllowed); + } + + let misses = vec![ + (Method::GET, "/a"), + (Method::GET, "/a/b"), + (Method::GET, "/mg"), + (Method::GET, "/mg/q"), + (Method::GET, "/mg/z"), + (Method::GET, "/mg/a/b/z/c"), + (Method::DELETE, "/?foo=bar"), + (Method::DELETE, "/?foo=bar"), + (Method::DELETE, "/?baz=quux"), + (Method::POST, "/query_key_only?baz=quux"), + (Method::GET, "/"), + (Method::POST, "/"), + ]; + for (method, miss) in misses { + let res = router.match_route(&req(&method, miss, None)); + assert_eq!(res.unwrap_err(), Error::NotFound); + } + } + + #[tokio::test] + async fn basic_pattern_conflict_avoidance() { + let request_specs: Vec<(RequestSpec, &'static str)> = vec![ + ( + RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("a")), PathSegment::Label], + Vec::new(), + ), + "A1", + ), + ( + RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Label, + PathSegment::Literal(String::from("a")), + ], + Vec::new(), + ), + "A2", + ), + ( + RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("b")), PathSegment::Greedy], + Vec::new(), + ), + "B1", + ), + ( + RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("b")), PathSegment::Greedy], + vec![QuerySegment::Key(String::from("q"))], + ), + "B2", + ), + ]; + + let router: RestRouter<_> = request_specs.into_iter().collect(); + + let hits = vec![ + ("A1", Method::GET, "/a/foo"), + ("A2", Method::GET, "/a/foo/a"), + ("B1", Method::GET, "/b/foo/bar/baz"), + ("B2", Method::GET, "/b/foo?q=baz"), + ]; + for (svc_name, method, uri) in hits { + assert_eq!(router.match_route(&req(&method, uri, None)).unwrap(), svc_name); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/mod.rs new file mode 100644 index 00000000000..695d995ce18 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/mod.rs @@ -0,0 +1,11 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod rejection; +pub mod router; +pub mod runtime_error; + +/// [AWS restJson1](https://smithy.io/2.0/aws/protocols/aws-restjson1-protocol.html) protocol. +pub struct RestJson1; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/rejection.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/rejection.rs new file mode 100644 index 00000000000..f843c6209fd --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/rejection.rs @@ -0,0 +1,212 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Rejection types. +//! +//! This module contains types that are commonly used as the `E` error type in functions that +//! handle requests and responses that return `Result` throughout the framework. These +//! include functions to deserialize incoming requests and serialize outgoing responses. +//! +//! All types end with `Rejection`. There are two types: +//! +//! 1. [`RequestRejection`]s are used when the framework fails to deserialize the request into the +//! corresponding operation input. +//! 2. [`ResponseRejection`]s are used when the framework fails to serialize the operation +//! output into a response. +//! +//! They are called _rejection_ types and not _error_ types to signal that the input was _rejected_ +//! (as opposed to it causing a recoverable error that would need to be handled, or an +//! unrecoverable error). For example, a [`RequestRejection`] simply means that the request was +//! rejected; there isn't really anything wrong with the service itself that the service +//! implementer would need to handle. +//! +//! Rejection types are an _internal_ detail about the framework: they can be added, removed, and +//! modified at any time without causing breaking changes. They are not surfaced to clients or the +//! service implementer in any way (including this documentation): indeed, they can't be converted +//! into responses. They serve as a mechanism to keep track of all the possible errors that can +//! occur when processing a request or a response, in far more detail than what AWS protocols need +//! to. This is why they are so granular: other (possibly protocol-specific) error types (like +//! [`crate::protocol::rest_json_1::runtime_error::RuntimeError`]) can "group" them when exposing +//! errors to clients while the framework does not need to sacrifice fidelity in private error +//! handling routines, and future-proofing itself at the same time (for example, we might want to +//! record metrics about rejection types). +//! +//! Rejection types implement [`std::error::Error`], and some take in type-erased boxed errors +//! (`crate::Error`) to represent their underlying causes, so they can be composed with other types +//! that take in (possibly type-erased) [`std::error::Error`]s, like +//! [`crate::protocol::rest_json_1::runtime_error::RuntimeError`], thus allowing us to represent the +//! full error chain. +//! +//! This module hosts rejection types _specific_ to the [`crate::protocol::rest_json_1`] protocol, but +//! the paragraphs above apply to _all_ protocol-specific rejection types. +//! +//! Similarly, rejection type variants are exhaustively documented solely in this module if they have +//! direct counterparts in other protocols. This is to avoid documentation getting out of date. +//! +//! Consult `crate::protocol::$protocolName::rejection` for rejection types for other protocols. + +use crate::rejection::MissingContentTypeReason; +use aws_smithy_runtime_api::http::HttpError; +use std::num::TryFromIntError; +use thiserror::Error; + +/// Errors that can occur when serializing the operation output provided by the service implementer +/// into an HTTP response. +#[derive(Debug, Error)] +pub enum ResponseRejection { + /// Used when the service implementer provides an integer outside the 100-999 range for a + /// member targeted by `httpResponseCode`. + /// See . + #[error("invalid bound HTTP status code; status codes must be inside the 100-999 range: {0}")] + InvalidHttpStatusCode(TryFromIntError), + + /// Used when an invalid HTTP header name (a value that cannot be parsed as an + /// [`http::header::HeaderName`]) or HTTP header value (a value that cannot be parsed as an + /// [`http::header::HeaderValue`]) is provided for a shape member bound to an HTTP header with + /// `httpHeader` or `httpPrefixHeaders`. + /// Used when failing to serialize an `httpPayload`-bound struct into an HTTP response body. + #[error("error building HTTP response: {0}")] + Build(#[from] aws_smithy_types::error::operation::BuildError), + + /// Used when failing to serialize a struct into a `String` for the JSON-encoded HTTP response + /// body. + /// Fun fact: as of writing, this can only happen when date formatting + /// (`aws_smithy_types::date_time::DateTime:fmt`) fails, which can only happen if the + /// supplied timestamp is outside of the valid range when formatting using RFC-3339, i.e. a + /// date outside the `0001-01-01T00:00:00.000Z`-`9999-12-31T23:59:59.999Z` range is supplied. + #[error("error serializing JSON-encoded body: {0}")] + Serialization(#[from] aws_smithy_types::error::operation::SerializationError), + + /// Used when consuming an [`http::response::Builder`] into the constructed [`http::Response`] + /// when calling [`http::response::Builder::body`]. + /// This error can happen if an invalid HTTP header value (a value that cannot be parsed as an + /// `[http::header::HeaderValue]`) is used for the protocol-specific response `Content-Type` + /// header, or for additional protocol-specific headers (like `X-Amzn-Errortype` to signal + /// errors in RestJson1). + #[error("error building HTTP response: {0}")] + HttpBuild(#[from] http::Error), +} + +/// Errors that can occur when deserializing an HTTP request into an _operation input_, the input +/// that is passed as the first argument to operation handlers. +/// +/// This type allows us to easily keep track of all the possible errors that can occur in the +/// lifecycle of an incoming HTTP request. +/// +/// Many inner code-generated and runtime deserialization functions use this as their error type, +/// when they can only instantiate a subset of the variants (most likely a single one). This is a +/// deliberate design choice to keep code generation simple. After all, this type is an inner +/// detail of the framework the service implementer does not interact with. +/// +/// If a variant takes in a value, it represents the underlying cause of the error. +/// +/// The variants are _roughly_ sorted in the order in which the HTTP request is processed. +#[derive(Debug, Error)] +pub enum RequestRejection { + /// Used when failing to convert non-streaming requests into a byte slab with + /// `hyper::body::to_bytes`. + #[error("error converting non-streaming body to bytes: {0}")] + BufferHttpBodyBytes(crate::Error), + + /// Used when the request contained an `Accept` header with a MIME type, and the server cannot + /// return a response body adhering to that MIME type. + #[error("request contains invalid value for `Accept` header")] + NotAcceptable, + + /// Used when checking the `Content-Type` header. + /// This is bubbled up in the generated SDK when calling + /// [`crate::protocol::content_type_header_classifier_smithy`] in `from_request`. + #[error("expected `Content-Type` header not found: {0}")] + MissingContentType(#[from] MissingContentTypeReason), + + /// Used when failing to deserialize the HTTP body's bytes into a JSON document conforming to + /// the modeled input it should represent. + #[error("error deserializing request HTTP body as JSON: {0}")] + JsonDeserialize(#[from] aws_smithy_json::deserialize::error::DeserializeError), + + /// Used when failing to parse HTTP headers that are bound to input members with the `httpHeader` + /// or the `httpPrefixHeaders` traits. + #[error("error binding request HTTP headers: {0}")] + HeaderParse(#[from] aws_smithy_http::header::ParseError), + + // In theory, the next two errors should never happen because the router should have already + // rejected the request. + /// Used when the URI pattern has a literal after the greedy label, and it is not found in the + /// request's URL. + #[error("request URI does not match pattern because of literal suffix after greedy label was not found")] + UriPatternGreedyLabelPostfixNotFound, + /// Used when the `nom` parser's input does not match the URI pattern. + #[error("request URI does not match `@http` URI pattern: {0}")] + UriPatternMismatch(crate::Error), + + /// Used when percent-decoding URL query string. + /// Used when percent-decoding URI path label. + /// This is caused when calling + /// [`percent_encoding::percent_decode_str`](https://docs.rs/percent-encoding/latest/percent_encoding/fn.percent_decode_str.html). + /// This can happen when the percent-encoded data decodes to bytes that are + /// not a well-formed UTF-8 string. + #[error("request URI cannot be percent decoded into valid UTF-8")] + PercentEncodedUriNotValidUtf8(#[from] core::str::Utf8Error), + + /// Used when failing to deserialize strings from a URL query string and from URI path labels + /// into an [`aws_smithy_types::DateTime`]. + #[error("error parsing timestamp from request URI: {0}")] + DateTimeParse(#[from] aws_smithy_types::date_time::DateTimeParseError), + + /// Used when failing to deserialize strings from a URL query string and from URI path labels + /// into "primitive" types. + #[error("error parsing primitive type from request URI: {0}")] + PrimitiveParse(#[from] aws_smithy_types::primitive::PrimitiveParseError), + + /// Used when consuming the input struct builder, and constraint violations occur. + // This rejection is constructed directly in the code-generated SDK instead of in this crate. + #[error("request does not adhere to modeled constraints: {0}")] + ConstraintViolation(String), + + /// Typically happens when the request has headers that are not valid UTF-8. + #[error("failed to convert request: {0}")] + HttpConversion(#[from] HttpError), +} + +// Consider a conversion between `T` and `U` followed by a bubbling up of the conversion error +// through `Result<_, RequestRejection>`. This [`From`] implementation accomodates the special case +// where `T` and `U` are equal, in such cases `T`/`U` a enjoy `TryFrom` with +// `Err = std::convert::Infallible`. +// +// Note that when `!` stabilizes `std::convert::Infallible` will become an alias for `!` and there +// will be a blanket `impl From for T`. This will remove the need for this implementation. +// +// More details on this can be found in the following links: +// - https://doc.rust-lang.org/std/primitive.never.html +// - https://doc.rust-lang.org/std/convert/enum.Infallible.html#future-compatibility +impl From for RequestRejection { + fn from(_err: std::convert::Infallible) -> Self { + // We opt for this `match` here rather than [`unreachable`] to assure the reader that this + // code path is dead. + match _err {} + } +} + +// These converters are solely to make code-generation simpler. They convert from a specific error +// type (from a runtime/third-party crate or the standard library) into a variant of the +// [`crate::rejection::RequestRejection`] enum holding the type-erased boxed [`crate::Error`] +// type. Generated functions that use [crate::rejection::RequestRejection] can thus use `?` to +// bubble up instead of having to sprinkle things like [`Result::map_err`] everywhere. + +impl From>> for RequestRejection { + fn from(err: nom::Err>) -> Self { + Self::UriPatternMismatch(crate::Error::new(err.to_owned())) + } +} + +// `[crate::body::Body]` is `[hyper::Body]`, whose associated `Error` type is `[hyper::Error]`. We +// need this converter for when we convert the body into bytes in the framework, since protocol +// tests use `[crate::body::Body]` as their body type when constructing requests (and almost +// everyone will run a Hyper-based server in their services). +convert_to_request_rejection!(hyper::Error, BufferHttpBodyBytes); + +// Useful in general, but it also required in order to accept Lambda HTTP requests using +// `Router` since `lambda_http::Error` is a type alias for `Box`. +convert_to_request_rejection!(Box, BufferHttpBodyBytes); diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/router.rs new file mode 100644 index 00000000000..939b1bb6ec3 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/router.rs @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::body::BoxBody; +use crate::extension::RuntimeErrorExtension; +use crate::response::IntoResponse; +use crate::routing::{method_disallowed, UNKNOWN_OPERATION_EXCEPTION}; + +use super::RestJson1; + +pub use crate::protocol::rest::router::*; + +// TODO(https://github.com/smithy-lang/smithy/issues/2348): We're probably non-compliant here, but +// we have no tests to pin our implemenation against! +impl IntoResponse for Error { + fn into_response(self) -> http::Response { + match self { + Error::NotFound => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/json") + .header("X-Amzn-Errortype", UNKNOWN_OPERATION_EXCEPTION) + .extension(RuntimeErrorExtension::new( + UNKNOWN_OPERATION_EXCEPTION.to_string(), + )) + .body(crate::body::to_boxed("{}")) + .expect("invalid HTTP response for REST JSON 1 routing error; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"), + Error::MethodNotAllowed => method_disallowed(), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/runtime_error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/runtime_error.rs new file mode 100644 index 00000000000..291fa34ff3a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_json_1/runtime_error.rs @@ -0,0 +1,130 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Runtime error type. +//! +//! This module contains the [`RuntimeError`] type. +//! +//! As opposed to rejection types (see [`crate::protocol::rest_json_1::rejection`]), which are an internal detail about +//! the framework, `RuntimeError` is surfaced to clients in HTTP responses: indeed, it implements +//! [`RuntimeError::into_response`]. Rejections can be "grouped" and converted into a +//! specific `RuntimeError` kind: for example, all request rejections due to serialization issues +//! can be conflated under the [`RuntimeError::Serialization`] enum variant. +//! +//! The HTTP response representation of the specific `RuntimeError` is protocol-specific: for +//! example, the runtime error in the [`crate::protocol::rest_json_1`] protocol sets the `X-Amzn-Errortype` header. +//! +//! Generated code works always works with [`crate::rejection`] types when deserializing requests +//! and serializing response. Just before a response needs to be sent, the generated code looks up +//! and converts into the corresponding `RuntimeError`, and then it uses the its +//! [`RuntimeError::into_response`] method to render and send a response. +//! +//! This module hosts the `RuntimeError` type _specific_ to the [`crate::protocol::rest_json_1`] protocol, but +//! the paragraphs above apply to _all_ protocol-specific rejection types. +//! +//! Similarly, `RuntimeError` variants are exhaustively documented solely in this module if they have +//! direct counterparts in other protocols. This is to avoid documentation getting out of date. +//! +//! Consult `crate::protocol::$protocolName::runtime_error` for the `RuntimeError` type for other protocols. + +use super::rejection::RequestRejection; +use super::rejection::ResponseRejection; +use super::RestJson1; +use crate::extension::RuntimeErrorExtension; +use crate::response::IntoResponse; +use crate::runtime_error::InternalFailureException; +use crate::runtime_error::INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE; +use http::StatusCode; + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + /// Request failed to deserialize or response failed to serialize. + #[error("request failed to deserialize or response failed to serialize: {0}")] + Serialization(crate::Error), + /// As of writing, this variant can only occur upon failure to extract an + /// [`crate::extension::Extension`] from the request. + #[error("internal failure: {0}")] + InternalFailure(crate::Error), + /// Request contains an `Accept` header with a MIME type, and the server cannot return a response + /// body adhering to that MIME type. + // This is returned directly (i.e. without going through a [`RequestRejection`] first) in the + // generated SDK when calling [`crate::protocol::accept_header_classifier`] in + // `from_request`. + #[error("not acceptable request: request contains an `Accept` header with a MIME type, and the server cannot return a response body adhering to that MIME type")] + NotAcceptable, + /// The request does not contain the expected `Content-Type` header value. + #[error("unsupported media type: request does not contain the expected `Content-Type` header value")] + UnsupportedMediaType, + /// Operation input contains data that does not adhere to the modeled [constraint traits]. + /// [constraint traits]: + #[error("validation failure: operation input contains data that does not adhere to the modeled constraints: {0}")] + Validation(String), +} + +impl RuntimeError { + /// String representation of the `RuntimeError` kind. + /// Used as the value passed to construct an [`crate::extension::RuntimeErrorExtension`]. + /// Used as the value of the `X-Amzn-Errortype` header. + pub fn name(&self) -> &'static str { + match self { + Self::Serialization(_) => "SerializationException", + Self::InternalFailure(_) => "InternalFailureException", + Self::NotAcceptable => "NotAcceptableException", + Self::UnsupportedMediaType => "UnsupportedMediaTypeException", + Self::Validation(_) => "ValidationException", + } + } + + pub fn status_code(&self) -> StatusCode { + match self { + Self::Serialization(_) => StatusCode::BAD_REQUEST, + Self::InternalFailure(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE, + Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Validation(_) => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for InternalFailureException { + fn into_response(self) -> http::Response { + IntoResponse::::into_response(RuntimeError::InternalFailure(crate::Error::new(String::new()))) + } +} + +impl IntoResponse for RuntimeError { + fn into_response(self) -> http::Response { + let res = http::Response::builder() + .status(self.status_code()) + .header("Content-Type", "application/json") + .header("X-Amzn-Errortype", self.name()) + .extension(RuntimeErrorExtension::new(self.name().to_string())); + + let body = match self { + RuntimeError::Validation(reason) => crate::body::to_boxed(reason), + _ => crate::body::to_boxed("{}"), + }; + + res.body(body) + .expect(INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE) + } +} + +impl From for RuntimeError { + fn from(err: ResponseRejection) -> Self { + Self::Serialization(crate::Error::new(err)) + } +} + +impl From for RuntimeError { + fn from(err: RequestRejection) -> Self { + match err { + RequestRejection::MissingContentType(_reason) => Self::UnsupportedMediaType, + RequestRejection::ConstraintViolation(reason) => Self::Validation(reason), + RequestRejection::NotAcceptable => Self::NotAcceptable, + _ => Self::Serialization(crate::Error::new(err)), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/mod.rs new file mode 100644 index 00000000000..e16570567ea --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/mod.rs @@ -0,0 +1,11 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod rejection; +pub mod router; +pub mod runtime_error; + +/// [AWS restXml](https://smithy.io/2.0/aws/protocols/aws-restxml-protocol.html) protocol. +pub struct RestXml; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/rejection.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/rejection.rs new file mode 100644 index 00000000000..75af5c76916 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/rejection.rs @@ -0,0 +1,81 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! This module hosts _exactly_ the same as [`crate::protocol::rest_json_1::rejection`], except that +//! [`crate::protocol::rest_json_1::rejection::RequestRejection::JsonDeserialize`] is swapped for +//! [`RequestRejection::XmlDeserialize`]. + +use crate::rejection::MissingContentTypeReason; +use aws_smithy_runtime_api::http::HttpError; +use std::num::TryFromIntError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ResponseRejection { + #[error("invalid bound HTTP status code; status codes must be inside the 100-999 range: {0}")] + InvalidHttpStatusCode(TryFromIntError), + #[error("error building HTTP response: {0}")] + Build(#[from] aws_smithy_types::error::operation::BuildError), + #[error("error serializing XML-encoded body: {0}")] + Serialization(#[from] aws_smithy_types::error::operation::SerializationError), + #[error("error building HTTP response: {0}")] + HttpBuild(#[from] http::Error), +} + +#[derive(Debug, Error)] +pub enum RequestRejection { + #[error("error converting non-streaming body to bytes: {0}")] + BufferHttpBodyBytes(crate::Error), + + #[error("request contains invalid value for `Accept` header")] + NotAcceptable, + + #[error("expected `Content-Type` header not found: {0}")] + MissingContentType(#[from] MissingContentTypeReason), + + /// Used when failing to deserialize the HTTP body's bytes into a XML conforming to the modeled + /// input it should represent. + #[error("error deserializing request HTTP body as XML: {0}")] + XmlDeserialize(#[from] aws_smithy_xml::decode::XmlDecodeError), + + #[error("error binding request HTTP headers: {0}")] + HeaderParse(#[from] aws_smithy_http::header::ParseError), + + #[error("request URI does not match pattern because of literal suffix after greedy label was not found")] + UriPatternGreedyLabelPostfixNotFound, + #[error("request URI does not match `@http` URI pattern: {0}")] + UriPatternMismatch(crate::Error), + + #[error("request URI cannot be percent decoded into valid UTF-8")] + PercentEncodedUriNotValidUtf8(#[from] core::str::Utf8Error), + + #[error("error parsing timestamp from request URI: {0}")] + DateTimeParse(#[from] aws_smithy_types::date_time::DateTimeParseError), + + #[error("error parsing primitive type from request URI: {0}")] + PrimitiveParse(#[from] aws_smithy_types::primitive::PrimitiveParseError), + + #[error("request does not adhere to modeled constraints: {0}")] + ConstraintViolation(String), + + /// Typically happens when the request has headers that are not valid UTF-8. + #[error("failed to convert request: {0}")] + HttpConversion(#[from] HttpError), +} + +impl From for RequestRejection { + fn from(_err: std::convert::Infallible) -> Self { + match _err {} + } +} + +impl From>> for RequestRejection { + fn from(err: nom::Err>) -> Self { + Self::UriPatternMismatch(crate::Error::new(err.to_owned())) + } +} + +convert_to_request_rejection!(hyper::Error, BufferHttpBodyBytes); +convert_to_request_rejection!(Box, BufferHttpBodyBytes); diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/router.rs new file mode 100644 index 00000000000..e684ced4dec --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/router.rs @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::body::empty; +use crate::body::BoxBody; +use crate::extension::RuntimeErrorExtension; +use crate::response::IntoResponse; +use crate::routing::{method_disallowed, UNKNOWN_OPERATION_EXCEPTION}; + +use super::RestXml; + +pub use crate::protocol::rest::router::*; + +// TODO(https://github.com/smithy-lang/smithy/issues/2348): We're probably non-compliant here, but +// we have no tests to pin our implemenation against! +impl IntoResponse for Error { + fn into_response(self) -> http::Response { + match self { + Error::NotFound => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/xml") + .extension(RuntimeErrorExtension::new( + UNKNOWN_OPERATION_EXCEPTION.to_string(), + )) + .body(empty()) + .expect("invalid HTTP response for REST XML routing error; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"), + Error::MethodNotAllowed => method_disallowed(), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/runtime_error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/runtime_error.rs new file mode 100644 index 00000000000..c5722aa1015 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rest_xml/runtime_error.rs @@ -0,0 +1,89 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::protocol::rest_xml::RestXml; +use crate::response::IntoResponse; +use crate::runtime_error::InternalFailureException; +use crate::{extension::RuntimeErrorExtension, runtime_error::INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE}; +use http::StatusCode; + +use super::rejection::{RequestRejection, ResponseRejection}; + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Serialization`] + #[error("request failed to deserialize or response failed to serialize: {0}")] + Serialization(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::InternalFailure`] + #[error("internal failure: {0}")] + InternalFailure(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::NotAcceptable`] + #[error("not acceptable request: request contains an `Accept` header with a MIME type, and the server cannot return a response body adhering to that MIME type")] + NotAcceptable, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::UnsupportedMediaType`] + #[error("unsupported media type: request does not contain the expected `Content-Type` header value")] + UnsupportedMediaType, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Validation`] + #[error("validation failure: operation input contains data that does not adhere to the modeled constraints: {0}")] + Validation(String), +} + +impl RuntimeError { + pub fn name(&self) -> &'static str { + match self { + Self::Serialization(_) => "SerializationException", + Self::InternalFailure(_) => "InternalFailureException", + Self::NotAcceptable => "NotAcceptableException", + Self::UnsupportedMediaType => "UnsupportedMediaTypeException", + Self::Validation(_) => "ValidationException", + } + } + + pub fn status_code(&self) -> StatusCode { + match self { + Self::Serialization(_) => StatusCode::BAD_REQUEST, + Self::InternalFailure(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE, + Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Validation(_) => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for InternalFailureException { + fn into_response(self) -> http::Response { + IntoResponse::::into_response(RuntimeError::InternalFailure(crate::Error::new(String::new()))) + } +} + +impl IntoResponse for RuntimeError { + fn into_response(self) -> http::Response { + let res = http::Response::builder() + .status(self.status_code()) + .header("Content-Type", "application/xml") + .extension(RuntimeErrorExtension::new(self.name().to_string())); + + let body = crate::body::to_boxed("{}"); + + res.body(body) + .expect(INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE) + } +} + +impl From for RuntimeError { + fn from(err: ResponseRejection) -> Self { + Self::Serialization(crate::Error::new(err)) + } +} + +impl From for RuntimeError { + fn from(err: RequestRejection) -> Self { + match err { + RequestRejection::MissingContentType(_reason) => Self::UnsupportedMediaType, + RequestRejection::ConstraintViolation(reason) => Self::Validation(reason), + _ => Self::Serialization(crate::Error::new(err)), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/mod.rs new file mode 100644 index 00000000000..287a756446b --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/mod.rs @@ -0,0 +1,12 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod rejection; +pub mod router; +pub mod runtime_error; + +/// [Smithy RPC v2 CBOR](https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html) +/// protocol. +pub struct RpcV2Cbor; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/rejection.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/rejection.rs new file mode 100644 index 00000000000..2ec8b957af5 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/rejection.rs @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::num::TryFromIntError; + +use crate::rejection::MissingContentTypeReason; +use aws_smithy_runtime_api::http::HttpError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ResponseRejection { + #[error("invalid bound HTTP status code; status codes must be inside the 100-999 range: {0}")] + InvalidHttpStatusCode(TryFromIntError), + #[error("error serializing CBOR-encoded body: {0}")] + Serialization(#[from] aws_smithy_types::error::operation::SerializationError), + #[error("error building HTTP response: {0}")] + HttpBuild(#[from] http::Error), +} + +#[derive(Debug, Error)] +pub enum RequestRejection { + #[error("error converting non-streaming body to bytes: {0}")] + BufferHttpBodyBytes(crate::Error), + #[error("request contains invalid value for `Accept` header")] + NotAcceptable, + #[error("expected `Content-Type` header not found: {0}")] + MissingContentType(#[from] MissingContentTypeReason), + #[error("error deserializing request HTTP body as CBOR: {0}")] + CborDeserialize(#[from] aws_smithy_cbor::decode::DeserializeError), + // Unlike the other protocols, RPC v2 uses CBOR, a binary serialization format, so we take in a + // `Vec` here instead of `String`. + #[error("request does not adhere to modeled constraints")] + ConstraintViolation(Vec), + + /// Typically happens when the request has headers that are not valid UTF-8. + #[error("failed to convert request: {0}")] + HttpConversion(#[from] HttpError), +} + +impl From for RequestRejection { + fn from(_err: std::convert::Infallible) -> Self { + match _err {} + } +} + +convert_to_request_rejection!(hyper::Error, BufferHttpBodyBytes); +convert_to_request_rejection!(Box, BufferHttpBodyBytes); diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/router.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/router.rs new file mode 100644 index 00000000000..019bcda9127 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/router.rs @@ -0,0 +1,406 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::convert::Infallible; +use std::str::FromStr; +use std::sync::LazyLock; + +use http::header::ToStrError; +use http::HeaderMap; +use regex::Regex; +use thiserror::Error; +use tower::Layer; +use tower::Service; + +use crate::body::empty; +use crate::body::BoxBody; +use crate::extension::RuntimeErrorExtension; +use crate::protocol::aws_json_11::router::ROUTE_CUTOFF; +use crate::response::IntoResponse; +use crate::routing::tiny_map::TinyMap; +use crate::routing::Route; +use crate::routing::Router; +use crate::routing::{method_disallowed, UNKNOWN_OPERATION_EXCEPTION}; + +use super::RpcV2Cbor; + +pub use crate::protocol::rest::router::*; + +/// An RPC v2 CBOR routing error. +#[derive(Debug, Error)] +pub enum Error { + /// Method was not `POST`. + #[error("method not POST")] + MethodNotAllowed, + /// Requests for the `rpcv2Cbor` protocol MUST NOT contain an `x-amz-target` or `x-amzn-target` + /// header. + #[error("contains forbidden headers")] + ForbiddenHeaders, + /// Unable to parse `smithy-protocol` header into a valid wire format value. + #[error("failed to parse `smithy-protocol` header into a valid wire format value")] + InvalidWireFormatHeader(#[from] WireFormatError), + /// Operation not found. + #[error("operation not found")] + NotFound, +} + +/// A [`Router`] supporting the [Smithy RPC v2 CBOR] protocol. +/// +/// [Smithy RPC v2 CBOR]: https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html +#[derive(Debug, Clone)] +pub struct RpcV2CborRouter { + routes: TinyMap<&'static str, S, ROUTE_CUTOFF>, +} + +/// Requests for the `rpcv2Cbor` protocol MUST NOT contain an `x-amz-target` or `x-amzn-target` +/// header. An `rpcv2Cbor` request is malformed if it contains either of these headers. Server-side +/// implementations MUST reject such requests for security reasons. +const FORBIDDEN_HEADERS: &[&str] = &["x-amz-target", "x-amzn-target"]; + +/// Matches the `Identifier` ABNF rule in +/// . +const IDENTIFIER_PATTERN: &str = r#"((_+([A-Za-z]|[0-9]))|[A-Za-z])[A-Za-z0-9_]*"#; + +impl RpcV2CborRouter { + // TODO(https://github.com/smithy-lang/smithy-rs/issues/3748) Consider building a nom parser. + fn uri_path_regex() -> &'static Regex { + // Every request for the `rpcv2Cbor` protocol MUST be sent to a URL with the + // following form: `{prefix?}/service/{serviceName}/operation/{operationName}` + // + // * The optional `prefix` segment may span multiple path segments and is not + // utilized by the Smithy RPC v2 CBOR protocol. For example, a service could + // use a `v1` prefix for the following URL path: `v1/service/FooService/operation/BarOperation` + // * The `serviceName` segment MUST be replaced by the [`shape + // name`](https://smithy.io/2.0/spec/model.html#grammar-token-smithy-Identifier) + // of the service's [Shape ID](https://smithy.io/2.0/spec/model.html#shape-id) + // in the Smithy model. The `serviceName` produced by client implementations + // MUST NOT contain the namespace of the `service` shape. Service + // implementations SHOULD accept an absolute shape ID as the content of this + // segment with the `#` character replaced with a `.` character, routing it + // the same as if only the name was specified. For example, if the `service`'s + // absolute shape ID is `com.example#TheService`, a service should accept both + // `TheService` and `com.example.TheService` as values for the `serviceName` + // segment. + static PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"/service/({IDENTIFIER_PATTERN}\.)*(?P{IDENTIFIER_PATTERN})/operation/(?P{IDENTIFIER_PATTERN})$"#, + )) + .unwrap() + }); + + &PATH_REGEX + } + + pub fn wire_format_regex() -> &'static Regex { + static SMITHY_PROTOCOL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"^rpc-v2-(?P\w+)$"#).unwrap()); + + &SMITHY_PROTOCOL_REGEX + } + + pub fn boxed(self) -> RpcV2CborRouter> + where + S: Service, Response = http::Response, Error = Infallible>, + S: Send + Clone + 'static, + S::Future: Send + 'static, + { + RpcV2CborRouter { + routes: self.routes.into_iter().map(|(key, s)| (key, Route::new(s))).collect(), + } + } + + /// Applies a [`Layer`] uniformly to all routes. + pub fn layer(self, layer: L) -> RpcV2CborRouter + where + L: Layer, + { + RpcV2CborRouter { + routes: self + .routes + .into_iter() + .map(|(key, route)| (key, layer.layer(route))) + .collect(), + } + } +} + +// TODO(https://github.com/smithy-lang/smithy/issues/2348): We're probably non-compliant here, but +// we have no tests to pin our implemenation against! +impl IntoResponse for Error { + fn into_response(self) -> http::Response { + match self { + Error::MethodNotAllowed => method_disallowed(), + _ => http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/cbor") + .extension(RuntimeErrorExtension::new( + UNKNOWN_OPERATION_EXCEPTION.to_string(), + )) + .body(empty()) + .expect("invalid HTTP response for RPCv2 CBOR routing error; please file a bug report under https://github.com/awslabs/smithy-rs/issues"), + } + } +} + +/// Errors that can happen when parsing the wire format from the `smithy-protocol` header. +#[derive(Debug, Error)] +pub enum WireFormatError { + /// Header not found. + #[error("`smithy-protocol` header not found")] + HeaderNotFound, + /// Header value is not visible ASCII. + #[error("`smithy-protocol` header not visible ASCII")] + HeaderValueNotVisibleAscii(ToStrError), + /// Header value does not match the `rpc-v2-{format}` pattern. The actual parsed header value + /// is stored in the tuple struct. + // https://doc.rust-lang.org/std/fmt/index.html#escaping + #[error("`smithy-protocol` header does not match the `rpc-v2-{{format}}` pattern: `{0}`")] + HeaderValueNotValid(String), + /// Header value matches the `rpc-v2-{format}` pattern, but the `format` is not supported. The + /// actual parsed header value is stored in the tuple struct. + #[error("found unsupported `smithy-protocol` wire format: `{0}`")] + WireFormatNotSupported(String), +} + +/// Smithy RPC V2 requests have a `smithy-protocol` header with the value +/// `"rpc-v2-{format}"`, where `format` is one of the supported wire formats +/// by the protocol (see [`WireFormat`]). +fn parse_wire_format_from_header(headers: &HeaderMap) -> Result { + let header = headers.get("smithy-protocol").ok_or(WireFormatError::HeaderNotFound)?; + let header = header.to_str().map_err(WireFormatError::HeaderValueNotVisibleAscii)?; + let captures = RpcV2CborRouter::<()>::wire_format_regex() + .captures(header) + .ok_or_else(|| WireFormatError::HeaderValueNotValid(header.to_owned()))?; + + let format = captures + .name("format") + .ok_or_else(|| WireFormatError::HeaderValueNotValid(header.to_owned()))?; + + let wire_format_parse_res: Result = format.as_str().parse(); + wire_format_parse_res.map_err(|_| WireFormatError::WireFormatNotSupported(header.to_owned())) +} + +/// Supported wire formats by RPC V2. +enum WireFormat { + Cbor, +} + +struct WireFormatFromStrError; + +impl FromStr for WireFormat { + type Err = WireFormatFromStrError; + + fn from_str(format: &str) -> Result { + match format { + "cbor" => Ok(Self::Cbor), + _ => Err(WireFormatFromStrError), + } + } +} + +impl Router for RpcV2CborRouter { + type Service = S; + + type Error = Error; + + fn match_route(&self, request: &http::Request) -> Result { + // Only `Method::POST` is allowed. + if request.method() != http::Method::POST { + return Err(Error::MethodNotAllowed); + } + + // Some headers are not allowed. + let request_has_forbidden_header = FORBIDDEN_HEADERS + .iter() + .any(|&forbidden_header| request.headers().contains_key(forbidden_header)); + if request_has_forbidden_header { + return Err(Error::ForbiddenHeaders); + } + + // Wire format has to be specified and supported. + let _wire_format = parse_wire_format_from_header(request.headers())?; + + // Extract the service name and the operation name from the request URI. + let request_path = request.uri().path(); + let regex = Self::uri_path_regex(); + + tracing::trace!(%request_path, "capturing service and operation from URI"); + let captures = regex.captures(request_path).ok_or(Error::NotFound)?; + let (service, operation) = (&captures["service"], &captures["operation"]); + tracing::trace!(%service, %operation, "captured service and operation from URI"); + + // Lookup in the `TinyMap` for a route for the target. + let route = self + .routes + .get((format!("{service}.{operation}")).as_str()) + .ok_or(Error::NotFound)?; + Ok(route.clone()) + } +} + +impl FromIterator<(&'static str, S)> for RpcV2CborRouter { + #[inline] + fn from_iter>(iter: T) -> Self { + Self { + routes: iter.into_iter().collect(), + } + } +} + +#[cfg(test)] +mod tests { + use http::{HeaderMap, HeaderValue, Method}; + use regex::Regex; + + use crate::protocol::test_helpers::req; + + use super::{Error, Router, RpcV2CborRouter}; + + fn identifier_regex() -> Regex { + Regex::new(&format!("^{}$", super::IDENTIFIER_PATTERN)).unwrap() + } + + #[test] + fn valid_identifiers() { + let valid_identifiers = vec!["a", "_a", "_0", "__0", "variable123", "_underscored_variable"]; + + for id in &valid_identifiers { + assert!(identifier_regex().is_match(id), "'{id}' is incorrectly rejected"); + } + } + + #[test] + fn invalid_identifiers() { + let invalid_identifiers = vec![ + "0", + "123starts_with_digit", + "@invalid_start_character", + " space_in_identifier", + "invalid-character", + "invalid@character", + "no#hashes", + ]; + + for id in &invalid_identifiers { + assert!(!identifier_regex().is_match(id), "'{id}' is incorrectly accepted"); + } + } + + #[test] + fn uri_regex_works_accepts() { + let regex = RpcV2CborRouter::<()>::uri_path_regex(); + + for uri in [ + "/service/Service/operation/Operation", + "prefix/69/service/Service/operation/Operation", + // Here the prefix is up to the last occurrence of the string `/service`. + "prefix/69/service/Service/operation/Operation/service/Service/operation/Operation", + // Service implementations SHOULD accept an absolute shape ID as the content of this + // segment with the `#` character replaced with a `.` character, routing it the same as + // if only the name was specified. For example, if the `service`'s absolute shape ID is + // `com.example#TheService`, a service should accept both `TheService` and + // `com.example.TheService` as values for the `serviceName` segment. + "/service/aws.protocoltests.rpcv2Cbor.Service/operation/Operation", + "/service/namespace.Service/operation/Operation", + ] { + let captures = regex.captures(uri).unwrap(); + assert_eq!("Service", &captures["service"], "uri: {uri}"); + assert_eq!("Operation", &captures["operation"], "uri: {uri}"); + } + } + + #[test] + fn uri_regex_works_rejects() { + let regex = RpcV2CborRouter::<()>::uri_path_regex(); + + for uri in [ + "", + "foo", + "/servicee/Service/operation/Operation", + "/service/Service", + "/service/Service/operation/", + "/service/Service/operation/Operation/", + "/service/Service/operation/Operation/invalid-suffix", + "/service/namespace.foo#Service/operation/Operation", + "/service/namespace-Service/operation/Operation", + "/service/.Service/operation/Operation", + ] { + assert!(regex.captures(uri).is_none(), "uri: {uri}"); + } + } + + #[test] + fn wire_format_regex_works() { + let regex = RpcV2CborRouter::<()>::wire_format_regex(); + + let captures = regex.captures("rpc-v2-something").unwrap(); + assert_eq!("something", &captures["format"]); + + let captures = regex.captures("rpc-v2-SomethingElse").unwrap(); + assert_eq!("SomethingElse", &captures["format"]); + + let invalid = regex.captures("rpc-v1-something"); + assert!(invalid.is_none()); + } + + /// Helper function returning the only strictly required header. + fn headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("smithy-protocol", HeaderValue::from_static("rpc-v2-cbor")); + headers + } + + #[test] + fn simple_routing() { + let router: RpcV2CborRouter<_> = ["Service.Operation"].into_iter().map(|op| (op, ())).collect(); + let good_uri = "/prefix/service/Service/operation/Operation"; + + // The request should match. + let routing_result = router.match_route(&req(&Method::POST, good_uri, Some(headers()))); + assert!(routing_result.is_ok()); + + // The request would be valid if it used `Method::POST`. + let invalid_request = req(&Method::GET, good_uri, Some(headers())); + assert!(matches!( + router.match_route(&invalid_request), + Err(Error::MethodNotAllowed) + )); + + // The request would be valid if it did not have forbidden headers. + for forbidden_header_name in ["x-amz-target", "x-amzn-target"] { + let mut headers = headers(); + headers.insert(forbidden_header_name, HeaderValue::from_static("Service.Operation")); + let invalid_request = req(&Method::POST, good_uri, Some(headers)); + assert!(matches!( + router.match_route(&invalid_request), + Err(Error::ForbiddenHeaders) + )); + } + + for bad_uri in [ + // These requests would be valid if they used correct URIs. + "/prefix/Service/Service/operation/Operation", + "/prefix/service/Service/operation/Operation/suffix", + // These requests would be valid if their URI matched an existing operation. + "/prefix/service/ThisServiceDoesNotExist/operation/Operation", + "/prefix/service/Service/operation/ThisOperationDoesNotExist", + ] { + let invalid_request = &req(&Method::POST, bad_uri, Some(headers())); + assert!(matches!(router.match_route(invalid_request), Err(Error::NotFound))); + } + + // The request would be valid if it specified a supported wire format in the + // `smithy-protocol` header. + for header_name in ["bad-header", "rpc-v2-json", "foo-rpc-v2-cbor", "rpc-v2-cbor-foo"] { + let mut headers = HeaderMap::new(); + headers.insert("smithy-protocol", HeaderValue::from_static(header_name)); + let invalid_request = &req(&Method::POST, good_uri, Some(headers)); + assert!(matches!( + router.match_route(invalid_request), + Err(Error::InvalidWireFormatHeader(_)) + )); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/runtime_error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/runtime_error.rs new file mode 100644 index 00000000000..b3f01da3511 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/protocol/rpc_v2_cbor/runtime_error.rs @@ -0,0 +1,98 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::response::IntoResponse; +use crate::runtime_error::{InternalFailureException, INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE}; +use crate::{extension::RuntimeErrorExtension, protocol::rpc_v2_cbor::RpcV2Cbor}; +use bytes::Bytes; +use http::StatusCode; + +use super::rejection::{RequestRejection, ResponseRejection}; + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Serialization`] + #[error("request failed to deserialize or response failed to serialize: {0}")] + Serialization(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::InternalFailure`] + #[error("internal failure: {0}")] + InternalFailure(crate::Error), + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::NotAcceptable`] + #[error("not acceptable request: request contains an `Accept` header with a MIME type, and the server cannot return a response body adhering to that MIME type")] + NotAcceptable, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::UnsupportedMediaType`] + #[error("unsupported media type: request does not contain the expected `Content-Type` header value")] + UnsupportedMediaType, + /// See: [`crate::protocol::rest_json_1::runtime_error::RuntimeError::Validation`] + #[error( + "validation failure: operation input contains data that does not adhere to the modeled constraints: {0:?}" + )] + Validation(Vec), +} + +impl RuntimeError { + pub fn name(&self) -> &'static str { + match self { + Self::Serialization(_) => "SerializationException", + Self::InternalFailure(_) => "InternalFailureException", + Self::NotAcceptable => "NotAcceptableException", + Self::UnsupportedMediaType => "UnsupportedMediaTypeException", + Self::Validation(_) => "ValidationException", + } + } + + pub fn status_code(&self) -> StatusCode { + match self { + Self::Serialization(_) => StatusCode::BAD_REQUEST, + Self::InternalFailure(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE, + Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Validation(_) => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for InternalFailureException { + fn into_response(self) -> http::Response { + IntoResponse::::into_response(RuntimeError::InternalFailure(crate::Error::new(String::new()))) + } +} + +impl IntoResponse for RuntimeError { + fn into_response(self) -> http::Response { + let res = http::Response::builder() + .status(self.status_code()) + .header("Content-Type", "application/cbor") + .extension(RuntimeErrorExtension::new(self.name().to_string())); + + // https://cbor.nemo157.com/#type=hex&value=a0 + const EMPTY_CBOR_MAP: Bytes = Bytes::from_static(&[0xa0]); + + // TODO(https://github.com/smithy-lang/smithy-rs/issues/3716): we're not serializing + // `__type`. + let body = match self { + RuntimeError::Validation(reason) => crate::body::to_boxed(reason), + _ => crate::body::to_boxed(EMPTY_CBOR_MAP), + }; + + res.body(body) + .expect(INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE) + } +} + +impl From for RuntimeError { + fn from(err: ResponseRejection) -> Self { + Self::Serialization(crate::Error::new(err)) + } +} + +impl From for RuntimeError { + fn from(err: RequestRejection) -> Self { + match err { + RequestRejection::ConstraintViolation(reason) => Self::Validation(reason), + _ => Self::Serialization(crate::Error::new(err)), + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/rejection.rs b/rust-runtime/aws-smithy-legacy-http-server/src/rejection.rs new file mode 100644 index 00000000000..b4787685575 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/rejection.rs @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::response::IntoResponse; +use thiserror::Error; + +// This is used across different protocol-specific `rejection` modules. +#[derive(Debug, Error)] +pub enum MissingContentTypeReason { + #[error("headers taken by another extractor")] + HeadersTakenByAnotherExtractor, + #[error("invalid `Content-Type` header value mime type: {0}")] + MimeParseError(mime::FromStrError), + #[error("unexpected `Content-Type` header value; expected mime {expected_mime:?}, found mime {found_mime:?}")] + UnexpectedMimeType { + expected_mime: Option, + found_mime: Option, + }, +} + +pub mod any_rejections { + //! This module hosts enums, up to size 8, which implement [`IntoResponse`] when their variants implement + //! [`IntoResponse`]. + + use super::IntoResponse; + use thiserror::Error; + + macro_rules! any_rejection { + ($name:ident, $($var:ident),+) => ( + #[derive(Debug, Error)] + pub enum $name<$($var),*> { + $( + #[error("{0}")] + $var($var), + )* + } + + impl IntoResponse

for $name<$($var),*> + where + $($var: IntoResponse

,)* + { + #[allow(non_snake_case)] + fn into_response(self) -> http::Response { + match self { + $($name::$var ($var) => $var.into_response(),)* + } + } + } + ) + } + + // any_rejection!(One, A); + any_rejection!(Two, A, B); + any_rejection!(Three, A, B, C); + any_rejection!(Four, A, B, C, D); + any_rejection!(Five, A, B, C, D, E); + any_rejection!(Six, A, B, C, D, E, F); + any_rejection!(Seven, A, B, C, D, E, F, G); + any_rejection!(Eight, A, B, C, D, E, F, G, H); +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/request/connect_info.rs b/rust-runtime/aws-smithy-legacy-http-server/src/request/connect_info.rs new file mode 100644 index 00000000000..a69ab919481 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/request/connect_info.rs @@ -0,0 +1,57 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The [`ConnectInfo`] struct is included in [`http::Request`]s when +//! [`IntoMakeServiceWithConnectInfo`](crate::routing::IntoMakeServiceWithConnectInfo) is used. [`ConnectInfo`]'s +//! [`FromParts`] implementation allows it to be extracted from the [`http::Request`]. +//! +//! The [`example service`](https://github.com/smithy-lang/smithy-rs/blob/main/examples/pokemon-service/src/main.rs) +//! illustrates the use of [`IntoMakeServiceWithConnectInfo`](crate::routing::IntoMakeServiceWithConnectInfo) +//! and [`ConnectInfo`] with a service builder. + +use http::request::Parts; +use thiserror::Error; + +use crate::{body::BoxBody, response::IntoResponse}; + +use super::{internal_server_error, FromParts}; + +/// The [`ConnectInfo`] was not found in the [`http::Request`] extensions. +/// +/// Use [`IntoMakeServiceWithConnectInfo`](crate::routing::IntoMakeServiceWithConnectInfo) to ensure it's present. +#[non_exhaustive] +#[derive(Debug, Error)] +#[error( + "`ConnectInfo` is not present in the `http::Request` extensions - consider using `aws_smithy_legacy_http_server::routing::IntoMakeServiceWithConnectInfo`" +)] +pub struct MissingConnectInfo; + +impl IntoResponse for MissingConnectInfo { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +/// Extractor for getting connection information produced by a [`Connected`](crate::routing::Connected). +/// +/// Note this extractor requires the existence of [`ConnectInfo`] in the [`http::Extensions`]. This is +/// automatically inserted by the [`IntoMakeServiceWithConnectInfo`](crate::routing::IntoMakeServiceWithConnectInfo) +/// middleware, which can be applied using the `into_make_service_with_connect_info` method on your generated service. +#[derive(Clone, Debug)] +pub struct ConnectInfo( + /// The type produced via [`Connected`](crate::routing::Connected). + pub T, +); + +impl FromParts

for ConnectInfo +where + T: Send + Sync + 'static, +{ + type Rejection = MissingConnectInfo; + + fn from_parts(parts: &mut Parts) -> Result { + parts.extensions.remove().ok_or(MissingConnectInfo) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/request/extension.rs b/rust-runtime/aws-smithy-legacy-http-server/src/request/extension.rs new file mode 100644 index 00000000000..b7f10683eb0 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/request/extension.rs @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +//! Extension types. +//! +//! Extension types are types that are stored in and extracted from _both_ requests and +//! responses. +//! +//! There is only one _generic_ extension type _for requests_, [`Extension`]. +//! +//! On the other hand, the server SDK uses multiple concrete extension types for responses in order +//! to store a variety of information, like the operation that was executed, the operation error +//! that got returned, or the runtime error that happened, among others. The information stored in +//! these types may be useful to [`tower::Layer`]s that post-process the response: for instance, a +//! particular metrics layer implementation might want to emit metrics about the number of times an +//! an operation got executed. +//! +//! [extensions]: https://docs.rs/http/latest/http/struct.Extensions.html + +use std::ops::Deref; + +use thiserror::Error; + +use crate::{body::BoxBody, request::FromParts, response::IntoResponse}; + +use super::internal_server_error; + +/// Generic extension type stored in and extracted from [request extensions]. +/// +/// This is commonly used to share state across handlers. +/// +/// If the extension is missing it will reject the request with a `500 Internal +/// Server Error` response. +/// +/// [request extensions]: https://docs.rs/http/latest/http/struct.Extensions.html +#[derive(Debug, Clone)] +pub struct Extension(pub T); + +impl Deref for Extension { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// The extension has not been added to the [`Request`](http::Request) or has been previously removed. +#[non_exhaustive] +#[derive(Debug, Error)] +#[error("the `Extension` is not present in the `http::Request`")] +pub struct MissingExtension; + +impl IntoResponse for MissingExtension { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +impl FromParts for Extension +where + T: Send + Sync + 'static, +{ + type Rejection = MissingExtension; + + fn from_parts(parts: &mut http::request::Parts) -> Result { + parts.extensions.remove::().map(Extension).ok_or(MissingExtension) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/request/lambda.rs b/rust-runtime/aws-smithy-legacy-http-server/src/request/lambda.rs new file mode 100644 index 00000000000..98563fcaf93 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/request/lambda.rs @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The [`lambda_http`] types included in [`http::Request`]s when [`LambdaHandler`](crate::routing::LambdaHandler) is +//! used. Each are given a [`FromParts`] implementation for easy use within handlers. + +use lambda_http::request::RequestContext; +#[doc(inline)] +pub use lambda_http::{ + aws_lambda_events::apigw::{ApiGatewayProxyRequestContext, ApiGatewayV2httpRequestContext}, + Context, +}; +use thiserror::Error; + +use super::{internal_server_error, FromParts}; +use crate::{body::BoxBody, response::IntoResponse}; + +/// The [`Context`] was not found in the [`http::Request`] extensions. +/// +/// Use [`LambdaHandler`](crate::routing::LambdaHandler) to ensure it's present. +#[non_exhaustive] +#[derive(Debug, Error)] +#[error("`Context` is not present in the `http::Request` extensions - consider using `aws_smithy_legacy_http_server::routing::LambdaHandler`")] +pub struct MissingContext; + +impl IntoResponse for MissingContext { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +impl

FromParts

for Context { + type Rejection = MissingContext; + + fn from_parts(parts: &mut http::request::Parts) -> Result { + parts.extensions.remove().ok_or(MissingContext) + } +} + +#[derive(Debug, Error)] +enum MissingGatewayContextTypeV1 { + #[error("`RequestContext` is not present in the `http::Request` extensions - consider using `aws_smithy_legacy_http_server::routing::LambdaHandler`")] + MissingRequestContext, + #[error("`RequestContext::ApiGatewayV2` is present in the `http::Request` extensions - consider using the `aws_smithy_legacy_http_server::request::lambda::ApiGatewayV2httpRequestContext` extractor")] + VersionMismatch, +} + +/// The [`RequestContext::ApiGatewayV1`] was not found in the [`http::Request`] extensions. +/// +/// Use [`LambdaHandler`](crate::routing::LambdaHandler) to ensure it's present and ensure that you're using "ApiGatewayV1". +#[derive(Debug, Error)] +#[error("{inner}")] +pub struct MissingGatewayContextV1 { + inner: MissingGatewayContextTypeV1, +} + +impl IntoResponse for MissingGatewayContextV1 { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +impl

FromParts

for ApiGatewayProxyRequestContext { + type Rejection = MissingGatewayContextV1; + + fn from_parts(parts: &mut http::request::Parts) -> Result { + let context = parts.extensions.remove().ok_or(MissingGatewayContextV1 { + inner: MissingGatewayContextTypeV1::MissingRequestContext, + })?; + if let RequestContext::ApiGatewayV1(context) = context { + Ok(context) + } else { + Err(MissingGatewayContextV1 { + inner: MissingGatewayContextTypeV1::VersionMismatch, + }) + } + } +} + +#[derive(Debug, Error)] +enum MissingGatewayContextTypeV2 { + #[error("`RequestContext` is not present in the `http::Request` extensions - consider using `aws_smithy_legacy_http_server::routing::LambdaHandler`")] + MissingRequestContext, + #[error("`RequestContext::ApiGatewayV1` is present in the `http::Request` extensions - consider using the `aws_smithy_legacy_http_server::request::lambda::ApiGatewayProxyRequestContext` extractor")] + VersionMismatch, +} + +/// The [`RequestContext::ApiGatewayV2`] was not found in the [`http::Request`] extensions. +/// +/// Use [`LambdaHandler`](crate::routing::LambdaHandler) to ensure it's present and ensure that you're using "ApiGatewayV2". +#[derive(Debug, Error)] +#[error("{inner}")] +pub struct MissingGatewayContextV2 { + inner: MissingGatewayContextTypeV2, +} + +impl IntoResponse for MissingGatewayContextV2 { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +impl

FromParts

for ApiGatewayV2httpRequestContext { + type Rejection = MissingGatewayContextV2; + + fn from_parts(parts: &mut http::request::Parts) -> Result { + let context = parts.extensions.remove().ok_or(MissingGatewayContextV2 { + inner: MissingGatewayContextTypeV2::MissingRequestContext, + })?; + if let RequestContext::ApiGatewayV2(context) = context { + Ok(context) + } else { + Err(MissingGatewayContextV2 { + inner: MissingGatewayContextTypeV2::VersionMismatch, + }) + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/request/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/request/mod.rs new file mode 100644 index 00000000000..ce0ec601550 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/request/mod.rs @@ -0,0 +1,219 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2022 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +//! Types and traits for extracting data from requests. +//! +//! See [Accessing Un-modelled data](https://github.com/smithy-lang/smithy-rs/blob/main/design/src/server/from_parts.md) +//! a comprehensive overview. +//! +//! The following implementations exist: +//! * Tuples up to size 8, extracting each component. +//! * `Option`: `Some(T)` if extracting `T` is successful, `None` otherwise. +//! * `Result`: `Ok(T)` if extracting `T` is successful, `Err(T::Rejection)` otherwise. +//! +//! when `T: FromParts`. +//! + +use std::{ + convert::Infallible, + future::{ready, Future, Ready}, +}; + +use futures_util::{ + future::{try_join, MapErr, MapOk, TryJoin}, + TryFutureExt, +}; +use http::{request::Parts, Request, StatusCode}; + +use crate::{ + body::{empty, BoxBody}, + rejection::any_rejections, + response::IntoResponse, +}; + +pub mod connect_info; +pub mod extension; +#[cfg(feature = "aws-lambda")] +#[cfg_attr(docsrs, doc(cfg(feature = "aws-lambda")))] +pub mod lambda; +#[cfg(feature = "request-id")] +#[cfg_attr(docsrs, doc(cfg(feature = "request-id")))] +pub mod request_id; + +fn internal_server_error() -> http::Response { + let mut response = http::Response::new(empty()); + *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + response +} + +/// Provides a protocol aware extraction from a [`Request`]. This borrows the [`Parts`], in contrast to +/// [`FromRequest`] which consumes the entire [`http::Request`] including the body. +pub trait FromParts: Sized { + /// The type of the extraction failures. + type Rejection: IntoResponse; + + /// Extracts `self` from a [`Parts`] synchronously. + fn from_parts(parts: &mut Parts) -> Result; +} + +impl

FromParts

for () { + type Rejection = Infallible; + + fn from_parts(_parts: &mut Parts) -> Result { + Ok(()) + } +} + +impl FromParts

for (T,) +where + T: FromParts

, +{ + type Rejection = T::Rejection; + + fn from_parts(parts: &mut Parts) -> Result { + Ok((T::from_parts(parts)?,)) + } +} + +macro_rules! impl_from_parts { + ($error_name:ident, $($var:ident),+) => ( + impl FromParts

for ($($var),*) + where + $($var: FromParts

,)* + { + type Rejection = any_rejections::$error_name<$($var::Rejection),*>; + + fn from_parts(parts: &mut Parts) -> Result { + let tuple = ( + $($var::from_parts(parts).map_err(any_rejections::$error_name::$var)?,)* + ); + Ok(tuple) + } + } + ) +} + +impl_from_parts!(Two, A, B); +impl_from_parts!(Three, A, B, C); +impl_from_parts!(Four, A, B, C, D); +impl_from_parts!(Five, A, B, C, D, E); +impl_from_parts!(Six, A, B, C, D, E, F); +impl_from_parts!(Seven, A, B, C, D, E, F, G); +impl_from_parts!(Eight, A, B, C, D, E, F, G, H); + +/// Provides a protocol aware extraction from a [`Request`]. This consumes the +/// [`Request`], including the body, in contrast to [`FromParts`] which borrows the [`Parts`]. +/// +/// This should not be implemented by hand. Code generation should implement this for your operations input. To extract +/// items from a HTTP request [`FromParts`] should be used. +pub trait FromRequest: Sized { + /// The type of the extraction failures. + type Rejection: IntoResponse; + /// The type of the extraction [`Future`]. + type Future: Future>; + + /// Extracts `self` from a [`Request`] asynchronously. + fn from_request(request: Request) -> Self::Future; +} + +impl FromRequest for (T1,) +where + T1: FromRequest, +{ + type Rejection = T1::Rejection; + type Future = MapOk (T1,)>; + + fn from_request(request: Request) -> Self::Future { + T1::from_request(request).map_ok(|t1| (t1,)) + } +} + +impl FromRequest for (T1, T2) +where + T1: FromRequest, + T2: FromParts

, + T1::Rejection: std::fmt::Display, + T2::Rejection: std::fmt::Display, +{ + type Rejection = any_rejections::Two; + type Future = TryJoin Self::Rejection>, Ready>>; + + fn from_request(request: Request) -> Self::Future { + let (mut parts, body) = request.into_parts(); + let t2_result: Result> = T2::from_parts(&mut parts) + .map_err(|e| { + // The error is likely caused by a failure to construct a parameter from the + // `Request` required by the user handler. This typically occurs when the + // user handler expects a specific type, such as `Extension`, but + // either the `ExtensionLayer` has not been added, or it adds a different + // type to the extension bag, such as `Extension>`. + tracing::error!( + error = %e, + "additional parameter for the handler function could not be constructed"); + any_rejections::Two::B(e) + }); + try_join( + T1::from_request(Request::from_parts(parts, body)).map_err(|e| { + // `T1`, the first parameter of a handler function, represents the input parameter + // defined in the Smithy model. An error at this stage suggests that `T1` could not + // be constructed from the `Request`. + tracing::debug!(error = %e, "failed to deserialize request into operation's input"); + any_rejections::Two::A(e) + }), + ready(t2_result), + ) + } +} + +impl FromParts

for Option +where + T: FromParts

, +{ + type Rejection = Infallible; + + fn from_parts(parts: &mut Parts) -> Result { + Ok(T::from_parts(parts).ok()) + } +} + +impl FromParts

for Result +where + T: FromParts

, +{ + type Rejection = Infallible; + + fn from_parts(parts: &mut Parts) -> Result { + Ok(T::from_parts(parts)) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/request/request_id.rs b/rust-runtime/aws-smithy-legacy-http-server/src/request/request_id.rs new file mode 100644 index 00000000000..a97288841cc --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/request/request_id.rs @@ -0,0 +1,277 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! # Request IDs +//! +//! `aws-smithy-http-server` provides the [`ServerRequestId`]. +//! +//! ## `ServerRequestId` +//! +//! A [`ServerRequestId`] is an opaque random identifier generated by the server every time it receives a request. +//! It uniquely identifies the request within that service instance. It can be used to collate all logs, events and +//! data related to a single operation. +//! Use [`ServerRequestIdProviderLayer::new`] to use [`ServerRequestId`] in your handler. +//! +//! The [`ServerRequestId`] can be returned to the caller, who can in turn share the [`ServerRequestId`] to help the service owner in troubleshooting issues related to their usage of the service. +//! Use [`ServerRequestIdProviderLayer::new_with_response_header`] to use [`ServerRequestId`] in your handler and add it to the response headers. +//! +//! The [`ServerRequestId`] is not meant to be propagated to downstream dependencies of the service. You should rely on a distributed tracing implementation for correlation purposes (e.g. OpenTelemetry). +//! +//! ## Examples +//! +//! Your handler can now optionally take as input a [`ServerRequestId`]. +//! +//! ```rust,ignore +//! pub async fn handler( +//! _input: Input, +//! server_request_id: ServerRequestId, +//! ) -> Output { +//! /* Use server_request_id */ +//! todo!() +//! } +//! +//! let config = ServiceConfig::builder() +//! // Generate a server request ID and add it to the response header. +//! .layer(ServerRequestIdProviderLayer::new_with_response_header(HeaderName::from_static("x-request-id"))) +//! .build(); +//! let app = Service::builder(config) +//! .operation(handler) +//! .build().unwrap(); +//! +//! let bind: std::net::SocketAddr = format!("{}:{}", args.address, args.port) +//! .parse() +//! .expect("unable to parse the server bind address and port"); +//! let server = hyper::Server::bind(&bind).serve(app.into_make_service()); +//! ``` + +use std::future::Future; +use std::{ + fmt::Display, + task::{Context, Poll}, +}; + +use futures_util::TryFuture; +use http::request::Parts; +use http::{header::HeaderName, HeaderValue, Response}; +use thiserror::Error; +use tower::{Layer, Service}; +use uuid::Uuid; + +use crate::{body::BoxBody, response::IntoResponse}; + +use super::{internal_server_error, FromParts}; + +/// Opaque type for Server Request IDs. +/// +/// If it is missing, the request will be rejected with a `500 Internal Server Error` response. +#[derive(Clone, Debug)] +pub struct ServerRequestId { + id: Uuid, +} + +/// The server request ID has not been added to the [`Request`](http::Request) or has been previously removed. +#[non_exhaustive] +#[derive(Debug, Error)] +#[error("the `ServerRequestId` is not present in the `http::Request`")] +pub struct MissingServerRequestId; + +impl ServerRequestId { + pub fn new() -> Self { + Self { id: Uuid::new_v4() } + } + + pub(crate) fn to_header(&self) -> HeaderValue { + HeaderValue::from_str(&self.id.to_string()).expect("This string contains only valid ASCII") + } +} + +impl Display for ServerRequestId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.id.fmt(f) + } +} + +impl

FromParts

for ServerRequestId { + type Rejection = MissingServerRequestId; + + fn from_parts(parts: &mut Parts) -> Result { + parts.extensions.remove().ok_or(MissingServerRequestId) + } +} + +impl Default for ServerRequestId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub struct ServerRequestIdProvider { + inner: S, + header_key: Option, +} + +/// A layer that provides services with a unique request ID instance +#[derive(Debug)] +#[non_exhaustive] +pub struct ServerRequestIdProviderLayer { + header_key: Option, +} + +impl ServerRequestIdProviderLayer { + /// Generate a new unique request ID and do not add it as a response header + /// Use [`ServerRequestIdProviderLayer::new_with_response_header`] to also add it as a response header + pub fn new() -> Self { + Self { header_key: None } + } + + /// Generate a new unique request ID and add it as a response header + pub fn new_with_response_header(header_key: HeaderName) -> Self { + Self { + header_key: Some(header_key), + } + } +} + +impl Default for ServerRequestIdProviderLayer { + fn default() -> Self { + Self::new() + } +} + +impl Layer for ServerRequestIdProviderLayer { + type Service = ServerRequestIdProvider; + + fn layer(&self, inner: S) -> Self::Service { + ServerRequestIdProvider { + inner, + header_key: self.header_key.clone(), + } + } +} + +impl Service> for ServerRequestIdProvider +where + S: Service, Response = Response>, + S::Future: std::marker::Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = ServerRequestIdResponseFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: http::Request) -> Self::Future { + let request_id = ServerRequestId::new(); + match &self.header_key { + Some(header_key) => { + req.extensions_mut().insert(request_id.clone()); + ServerRequestIdResponseFuture { + response_package: Some(ResponsePackage { + request_id, + header_key: header_key.clone(), + }), + fut: self.inner.call(req), + } + } + None => { + req.extensions_mut().insert(request_id); + ServerRequestIdResponseFuture { + response_package: None, + fut: self.inner.call(req), + } + } + } + } +} + +impl IntoResponse for MissingServerRequestId { + fn into_response(self) -> http::Response { + internal_server_error() + } +} + +struct ResponsePackage { + request_id: ServerRequestId, + header_key: HeaderName, +} + +pin_project_lite::pin_project! { + pub struct ServerRequestIdResponseFuture { + response_package: Option, + #[pin] + fut: Fut, + } +} + +impl Future for ServerRequestIdResponseFuture +where + Fut: TryFuture>, +{ + type Output = Result; + + fn poll(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let fut = this.fut; + let response_package = this.response_package; + fut.try_poll(cx).map_ok(|mut res| { + if let Some(response_package) = response_package.take() { + res.headers_mut() + .insert(response_package.header_key, response_package.request_id.to_header()); + } + res + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::body::{Body, BoxBody}; + use crate::request::Request; + use http::HeaderValue; + use std::convert::Infallible; + use tower::{service_fn, ServiceBuilder, ServiceExt}; + + #[test] + fn test_request_id_parsed_by_header_value_infallible() { + ServerRequestId::new().to_header(); + } + + #[tokio::test] + async fn test_request_id_in_response_header() { + let svc = ServiceBuilder::new() + .layer(&ServerRequestIdProviderLayer::new_with_response_header( + HeaderName::from_static("x-request-id"), + )) + .service(service_fn(|_req: Request| async move { + Ok::<_, Infallible>(Response::new(BoxBody::default())) + })); + + let req = Request::new(Body::empty()); + + let res = svc.oneshot(req).await.unwrap(); + let request_id = res.headers().get("x-request-id").unwrap().to_str().unwrap(); + + assert!(HeaderValue::from_str(request_id).is_ok()); + } + + #[tokio::test] + async fn test_request_id_not_in_response_header() { + let svc = ServiceBuilder::new() + .layer(&ServerRequestIdProviderLayer::new()) + .service(service_fn(|_req: Request| async move { + Ok::<_, Infallible>(Response::new(BoxBody::default())) + })); + + let req = Request::new(Body::empty()); + + let res = svc.oneshot(req).await.unwrap(); + + assert!(res.headers().is_empty()); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/response.rs b/rust-runtime/aws-smithy-legacy-http-server/src/response.rs new file mode 100644 index 00000000000..75a5be97590 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/response.rs @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2022 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +use crate::body::BoxBody; + +pub type Response = http::Response; + +/// A protocol aware function taking `self` to [`http::Response`]. +pub trait IntoResponse { + /// Performs a conversion into a [`http::Response`]. + fn into_response(self) -> http::Response; +} + +impl

IntoResponse

for std::convert::Infallible { + fn into_response(self) -> http::Response { + match self {} + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service.rs new file mode 100644 index 00000000000..324b9d59dc4 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service.rs @@ -0,0 +1,91 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +use std::{ + convert::Infallible, + future::ready, + task::{Context, Poll}, +}; +use tower::Service; + +/// A [`MakeService`] that produces router services. +/// +/// [`MakeService`]: tower::make::MakeService +#[derive(Debug, Clone)] +pub struct IntoMakeService { + service: S, +} + +impl IntoMakeService { + pub fn new(service: S) -> Self { + Self { service } + } +} + +impl Service for IntoMakeService +where + S: Clone, +{ + type Response = S; + type Error = Infallible; + type Future = MakeRouteServiceFuture; + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _target: T) -> Self::Future { + MakeRouteServiceFuture::new(ready(Ok(self.service.clone()))) + } +} + +opaque_future! { + /// Response future for [`IntoMakeService`] services. + pub type MakeRouteServiceFuture = + std::future::Ready>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn traits() { + use crate::test_helpers::*; + + assert_send::>(); + assert_sync::>(); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service_with_connect_info.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service_with_connect_info.rs new file mode 100644 index 00000000000..3a43dc9e184 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/into_make_service_with_connect_info.rs @@ -0,0 +1,135 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +//! The [`IntoMakeServiceWithConnectInfo`] is a service factory which adjoins [`ConnectInfo`] to the requests. + +use std::{ + convert::Infallible, + fmt, + future::ready, + marker::PhantomData, + net::SocketAddr, + task::{Context, Poll}, +}; + +use hyper::server::conn::AddrStream; +use tower::{Layer, Service}; +use tower_http::add_extension::{AddExtension, AddExtensionLayer}; + +use crate::request::connect_info::ConnectInfo; + +/// A [`MakeService`] used to insert [`ConnectInfo`] into [`http::Request`]s. +/// +/// The `T` must be derivable from the underlying IO resource using the [`Connected`] trait. +/// +/// [`MakeService`]: tower::make::MakeService +pub struct IntoMakeServiceWithConnectInfo { + inner: S, + _connect_info: PhantomData C>, +} + +impl IntoMakeServiceWithConnectInfo { + pub fn new(svc: S) -> Self { + Self { + inner: svc, + _connect_info: PhantomData, + } + } +} + +impl fmt::Debug for IntoMakeServiceWithConnectInfo +where + S: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IntoMakeServiceWithConnectInfo") + .field("inner", &self.inner) + .finish() + } +} + +impl Clone for IntoMakeServiceWithConnectInfo +where + S: Clone, +{ + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + _connect_info: PhantomData, + } + } +} + +/// Trait that connected IO resources implement and use to produce information +/// about the connection. +/// +/// The goal for this trait is to allow users to implement custom IO types that +/// can still provide the same connection metadata. +pub trait Connected: Clone { + /// Create type holding information about the connection. + fn connect_info(target: T) -> Self; +} + +impl Connected<&AddrStream> for SocketAddr { + fn connect_info(target: &AddrStream) -> Self { + target.remote_addr() + } +} + +impl Service for IntoMakeServiceWithConnectInfo +where + S: Clone, + C: Connected, +{ + type Response = AddExtension>; + type Error = Infallible; + type Future = ResponseFuture; + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, target: T) -> Self::Future { + let connect_info = ConnectInfo(C::connect_info(target)); + let svc = AddExtensionLayer::new(connect_info).layer(self.inner.clone()); + ResponseFuture::new(ready(Ok(svc))) + } +} + +opaque_future! { + /// Response future for [`IntoMakeServiceWithConnectInfo`]. + pub type ResponseFuture = + std::future::Ready>, Infallible>>; +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/lambda_handler.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/lambda_handler.rs new file mode 100644 index 00000000000..5c44b01ebd4 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/lambda_handler.rs @@ -0,0 +1,128 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use http::uri; +use lambda_http::{Request, RequestExt}; +use std::{ + fmt::Debug, + task::{Context, Poll}, +}; +use tower::Service; + +type HyperRequest = http::Request; + +/// A [`Service`] that takes a `lambda_http::Request` and converts +/// it to `http::Request`. +/// +/// **This version is only guaranteed to be compatible with +/// [`lambda_http`](https://docs.rs/lambda_http) ^0.7.0.** Please ensure that your service crate's +/// `Cargo.toml` depends on a compatible version. +/// +/// [`Service`]: tower::Service +#[derive(Debug, Clone)] +pub struct LambdaHandler { + service: S, +} + +impl LambdaHandler { + pub fn new(service: S) -> Self { + Self { service } + } +} + +impl Service for LambdaHandler +where + S: Service, +{ + type Error = S::Error; + type Response = S::Response; + type Future = S::Future; + + #[inline] + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.service.poll_ready(cx) + } + + fn call(&mut self, event: Request) -> Self::Future { + self.service.call(convert_event(event)) + } +} + +/// Converts a `lambda_http::Request` into a `http::Request` +/// Issue: +/// +/// While converting the event the [API Gateway Stage] portion of the URI +/// is removed from the uri that gets returned as a new `http::Request`. +/// +/// [API Gateway Stage]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-stages.html +fn convert_event(request: Request) -> HyperRequest { + let raw_path: &str = request.extensions().raw_http_path(); + let path: &str = request.uri().path(); + + let (parts, body) = if !raw_path.is_empty() && raw_path != path { + let mut path = raw_path.to_owned(); // Clone only when we need to strip out the stage. + let (mut parts, body) = request.into_parts(); + + let uri_parts: uri::Parts = parts.uri.into(); + let path_and_query = uri_parts + .path_and_query + .expect("request URI does not have `PathAndQuery`"); + + if let Some(query) = path_and_query.query() { + path.push('?'); + path.push_str(query); + } + + parts.uri = uri::Uri::builder() + .authority(uri_parts.authority.expect("request URI does not have authority set")) + .scheme(uri_parts.scheme.expect("request URI does not have scheme set")) + .path_and_query(path) + .build() + .expect("unable to construct new URI"); + + (parts, body) + } else { + request.into_parts() + }; + + let body = match body { + lambda_http::Body::Empty => hyper::Body::empty(), + lambda_http::Body::Text(s) => hyper::Body::from(s), + lambda_http::Body::Binary(v) => hyper::Body::from(v), + }; + + http::Request::from_parts(parts, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use lambda_http::RequestExt; + + #[test] + fn traits() { + use crate::test_helpers::*; + + assert_send::>(); + assert_sync::>(); + } + + #[test] + fn raw_http_path() { + // lambda_http::Request doesn't have a fn `builder` + let event = http::Request::builder() + .uri("https://id.execute-api.us-east-1.amazonaws.com/prod/resources/1") + .body(()) + .expect("unable to build Request"); + let (parts, _) = event.into_parts(); + + // the lambda event will have a raw path which is the path without stage name in it + let event = + lambda_http::Request::from_parts(parts, lambda_http::Body::Empty).with_raw_http_path("/resources/1"); + let request = convert_event(event); + + assert_eq!(request.uri().path(), "/resources/1") + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/mod.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/mod.rs new file mode 100644 index 00000000000..ede1f5117b0 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/mod.rs @@ -0,0 +1,204 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! HTTP routing that adheres to the [Smithy specification]. +//! +//! [Smithy specification]: https://smithy.io/2.0/spec/http-bindings.html + +mod into_make_service; +mod into_make_service_with_connect_info; +#[cfg(feature = "aws-lambda")] +#[cfg_attr(docsrs, doc(cfg(feature = "aws-lambda")))] +mod lambda_handler; + +#[doc(hidden)] +pub mod request_spec; + +mod route; + +pub(crate) mod tiny_map; + +use std::{ + error::Error, + fmt, + future::{ready, Future, Ready}, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use futures_util::{ + future::{Either, MapOk}, + TryFutureExt, +}; +use http::Response; +use http_body::Body as HttpBody; +use tower::{util::Oneshot, Service, ServiceExt}; + +use crate::{ + body::{boxed, BoxBody}, + error::BoxError, + response::IntoResponse, +}; + +#[cfg(feature = "aws-lambda")] +#[cfg_attr(docsrs, doc(cfg(feature = "aws-lambda")))] +pub use self::lambda_handler::LambdaHandler; + +#[allow(deprecated)] +pub use self::{ + into_make_service::IntoMakeService, + into_make_service_with_connect_info::{Connected, IntoMakeServiceWithConnectInfo}, + route::Route, +}; + +pub(crate) const UNKNOWN_OPERATION_EXCEPTION: &str = "UnknownOperationException"; + +/// Constructs common response to method disallowed. +pub(crate) fn method_disallowed() -> http::Response { + let mut responses = http::Response::default(); + *responses.status_mut() = http::StatusCode::METHOD_NOT_ALLOWED; + responses +} + +/// An interface for retrieving an inner [`Service`] given a [`http::Request`]. +pub trait Router { + type Service; + type Error; + + /// Matches a [`http::Request`] to a target [`Service`]. + fn match_route(&self, request: &http::Request) -> Result; +} + +/// A [`Service`] using the [`Router`] `R` to redirect messages to specific routes. +/// +/// The `Protocol` parameter is used to determine the serialization of errors. +pub struct RoutingService { + router: R, + _protocol: PhantomData, +} + +impl fmt::Debug for RoutingService +where + R: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RoutingService") + .field("router", &self.router) + .field("_protocol", &self._protocol) + .finish() + } +} + +impl Clone for RoutingService +where + R: Clone, +{ + fn clone(&self) -> Self { + Self { + router: self.router.clone(), + _protocol: PhantomData, + } + } +} + +impl RoutingService { + /// Creates a [`RoutingService`] from a [`Router`]. + pub fn new(router: R) -> Self { + Self { + router, + _protocol: PhantomData, + } + } + + /// Maps a [`Router`] using a closure. + pub fn map(self, f: F) -> RoutingService + where + F: FnOnce(R) -> RNew, + { + RoutingService { + router: f(self.router), + _protocol: PhantomData, + } + } +} + +type EitherOneshotReady = Either< + MapOk>, fn(>>::Response) -> http::Response>, + Ready, >>::Error>>, +>; + +pin_project_lite::pin_project! { + pub struct RoutingFuture where S: Service> { + #[pin] + inner: EitherOneshotReady + } +} + +impl RoutingFuture +where + S: Service>, +{ + /// Creates a [`RoutingFuture`] from [`ServiceExt::oneshot`]. + pub(super) fn from_oneshot(future: Oneshot>) -> Self + where + S: Service, Response = http::Response>, + RespB: HttpBody + Send + 'static, + RespB::Error: Into, + { + Self { + inner: Either::Left(future.map_ok(|x| x.map(boxed))), + } + } + + /// Creates a [`RoutingFuture`] from [`Service::Response`]. + pub(super) fn from_response(response: http::Response) -> Self { + Self { + inner: Either::Right(ready(Ok(response))), + } + } +} + +impl Future for RoutingFuture +where + S: Service>, +{ + type Output = Result, S::Error>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.project().inner.poll(cx) + } +} + +impl Service> for RoutingService +where + R: Router, + R::Service: Service, Response = http::Response> + Clone, + R::Error: IntoResponse

+ Error, + RespB: HttpBody + Send + 'static, + RespB::Error: Into, +{ + type Response = Response; + type Error = >>::Error; + type Future = RoutingFuture; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + tracing::debug!("inside routing service call"); + match self.router.match_route(&req) { + // Successfully routed, use the routes `Service::call`. + Ok(ok) => RoutingFuture::from_oneshot(ok.oneshot(req)), + // Failed to route, use the `R::Error`s `IntoResponse

`. + Err(error) => { + tracing::debug!(%error, "failed to route"); + RoutingFuture::from_response(error.into_response()) + } + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/request_spec.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/request_spec.rs new file mode 100644 index 00000000000..bde01ddaa83 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/request_spec.rs @@ -0,0 +1,508 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::borrow::Cow; + +use http::Request; +use regex::Regex; + +#[derive(Debug, Clone)] +pub enum PathSegment { + Literal(String), + Label, + Greedy, +} + +#[derive(Debug, Clone)] +pub enum QuerySegment { + Key(String), + KeyValue(String, String), +} + +#[derive(Debug, Clone)] +pub enum HostPrefixSegment { + Literal(String), + Label, +} + +#[derive(Debug, Clone, Default)] +pub struct PathSpec(Vec); + +impl PathSpec { + pub fn from_vector_unchecked(path_segments: Vec) -> Self { + PathSpec(path_segments) + } +} + +#[derive(Debug, Clone, Default)] +pub struct QuerySpec(Vec); + +impl QuerySpec { + pub fn from_vector_unchecked(query_segments: Vec) -> Self { + QuerySpec(query_segments) + } +} + +#[derive(Debug, Clone, Default)] +pub struct PathAndQuerySpec { + path_segments: PathSpec, + query_segments: QuerySpec, +} + +impl PathAndQuerySpec { + pub fn new(path_segments: PathSpec, query_segments: QuerySpec) -> Self { + PathAndQuerySpec { + path_segments, + query_segments, + } + } +} + +#[derive(Debug, Clone)] +pub struct UriSpec { + host_prefix: Option>, + path_and_query: PathAndQuerySpec, +} + +impl UriSpec { + // TODO(https://github.com/smithy-lang/smithy-rs/issues/950): When we add support for the endpoint + // trait, this constructor will take in a first argument `host_prefix`. + pub fn new(path_and_query: PathAndQuerySpec) -> Self { + UriSpec { + host_prefix: None, + path_and_query, + } + } +} + +#[derive(Debug, Clone)] +pub struct RequestSpec { + method: http::Method, + uri_spec: UriSpec, + uri_path_regex: Regex, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum Match { + /// The request matches the URI pattern spec. + Yes, + /// The request matches the URI pattern spec, but the wrong HTTP method was used. `405 Method + /// Not Allowed` should be returned in the response. + MethodNotAllowed, + /// The request does not match the URI pattern spec. `404 Not Found` should be returned in the + /// response. + No, +} + +impl From<&PathSpec> for Regex { + fn from(uri_path_spec: &PathSpec) -> Self { + let sep = "/"; + let re = if uri_path_spec.0.is_empty() { + String::from(sep) + } else { + uri_path_spec + .0 + .iter() + .map(|segment_spec| match segment_spec { + PathSegment::Literal(literal) => Cow::Owned(regex::escape(literal)), + // TODO(https://github.com/awslabs/smithy/issues/975) URL spec says it should be ASCII but this regex accepts UTF-8: + // `*` instead of `+` because the empty string `""` can be bound to a label. + PathSegment::Label => Cow::Borrowed("[^/]*"), + PathSegment::Greedy => Cow::Borrowed(".*"), + }) + .fold(String::new(), |a, b| a + sep + &b) + }; + + Regex::new(&format!("^{re}$")).expect("invalid `Regex` from `PathSpec`; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues") + } +} + +impl RequestSpec { + pub fn new(method: http::Method, uri_spec: UriSpec) -> Self { + let uri_path_regex = (&uri_spec.path_and_query.path_segments).into(); + RequestSpec { + method, + uri_spec, + uri_path_regex, + } + } + + /// A measure of how "important" a `RequestSpec` is. The more specific a `RequestSpec` is, the + /// higher it ranks in importance. Specificity is measured by the number of segments plus the + /// number of query string literals in its URI pattern, so `/{Bucket}/{Key}?query` is more + /// specific than `/{Bucket}/{Key}`, which is more specific than `/{Bucket}`, which is more + /// specific than `/`. + /// + /// This rank effectively induces a total order, but we don't implement as `Ord` for + /// `RequestSpec` because it would appear in its public interface. + /// + /// # Why do we need this? + /// + /// Note that: + /// 1. the Smithy spec does not define how servers should route incoming requests in the + /// case of pattern conflicts; and + /// 2. the Smithy spec even outright rejects conflicting patterns that can be easily + /// disambiguated e.g. `/{a}` and `/{label}/b` cannot coexist. + /// + /// We can't to anything about (2) since the Smithy CLI will refuse to build a model with those + /// kind of conflicts. However, the Smithy CLI does allow _other_ conflicting patterns to + /// coexist, e.g. `/` and `/{label}`. We therefore have to take a stance on (1), since if we + /// route arbitrarily [we render basic usage + /// impossible](https://github.com/smithy-lang/smithy-rs/issues/1009). + /// So this ranking of routes implements some basic pattern conflict disambiguation with some + /// common sense. It's also the same behavior that [the TypeScript sSDK is implementing]. + /// + /// [the TypeScript sSDK is implementing]: https://github.com/awslabs/smithy-typescript/blob/d263078b81485a6a2013d243639c0c680343ff47/smithy-typescript-ssdk-libs/server-common/src/httpbinding/mux.ts#L59. + // TODO(https://github.com/awslabs/smithy/issues/1029#issuecomment-1002683552): Once Smithy + // updates the spec to define the behavior, update our implementation. + pub(crate) fn rank(&self) -> usize { + self.uri_spec.path_and_query.path_segments.0.len() + self.uri_spec.path_and_query.query_segments.0.len() + } + + pub(crate) fn matches(&self, req: &Request) -> Match { + if let Some(_host_prefix) = &self.uri_spec.host_prefix { + todo!("Look at host prefix"); + } + + if !self.uri_path_regex.is_match(req.uri().path()) { + return Match::No; + } + + if self.uri_spec.path_and_query.query_segments.0.is_empty() { + if self.method == req.method() { + return Match::Yes; + } else { + return Match::MethodNotAllowed; + } + } + + match req.uri().query() { + Some(query) => { + // We can't use `HashMap, Cow>` because a query string key can appear more + // than once e.g. `/?foo=bar&foo=baz`. We _could_ use a multiset e.g. the `hashbag` + // crate. + // We must deserialize into `Cow`s because `serde_urlencoded` might need to + // return an owned allocated `String` if it has to percent-decode a slice of the query string. + let res = serde_urlencoded::from_str::, Cow)>>(query); + + match res { + Err(error) => { + tracing::debug!(query, %error, "failed to deserialize query string"); + Match::No + } + Ok(query_map) => { + for query_segment in self.uri_spec.path_and_query.query_segments.0.iter() { + match query_segment { + QuerySegment::Key(key) => { + if !query_map.iter().any(|(k, _v)| k == key) { + return Match::No; + } + } + QuerySegment::KeyValue(key, expected_value) => { + let mut it = query_map.iter().filter(|(k, _v)| k == key).peekable(); + if it.peek().is_none() { + return Match::No; + } + + // The query key appears more than once. All of its values must + // coincide and be equal to the expected value. + if it.any(|(_k, v)| v != expected_value) { + return Match::No; + } + } + } + } + + if self.method == req.method() { + Match::Yes + } else { + Match::MethodNotAllowed + } + } + } + } + None => Match::No, + } + } + + // Helper function to build a `RequestSpec`. + #[cfg(test)] + pub fn from_parts( + method: http::Method, + path_segments: Vec, + query_segments: Vec, + ) -> Self { + Self::new( + method, + UriSpec { + host_prefix: None, + path_and_query: PathAndQuerySpec { + path_segments: PathSpec::from_vector_unchecked(path_segments), + query_segments: QuerySpec::from_vector_unchecked(query_segments), + }, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::test_helpers::req; + + use http::Method; + + #[test] + fn path_spec_into_regex() { + let cases = vec![ + (PathSpec(vec![]), "^/$"), + (PathSpec(vec![PathSegment::Literal(String::from("a"))]), "^/a$"), + ( + PathSpec(vec![PathSegment::Literal(String::from("a")), PathSegment::Label]), + "^/a/[^/]*$", + ), + ( + PathSpec(vec![PathSegment::Literal(String::from("a")), PathSegment::Greedy]), + "^/a/.*$", + ), + ( + PathSpec(vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Greedy, + PathSegment::Literal(String::from("suffix")), + ]), + "^/a/.*/suffix$", + ), + ]; + + for case in cases { + let re: Regex = (&case.0).into(); + assert_eq!(case.1, re.as_str()); + } + } + + #[test] + fn paths_must_match_spec_from_the_beginning_literal() { + let spec = RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("path"))], + Vec::new(), + ); + + let misses = vec![(Method::GET, "/beta/path"), (Method::GET, "/multiple/stages/in/path")]; + for (method, uri) in &misses { + assert_eq!(Match::No, spec.matches(&req(method, uri, None))); + } + } + + #[test] + fn paths_must_match_spec_from_the_beginning_label() { + let spec = RequestSpec::from_parts(Method::GET, vec![PathSegment::Label], Vec::new()); + + let misses = vec![ + (Method::GET, "/prefix/label"), + (Method::GET, "/label/suffix"), + (Method::GET, "/prefix/label/suffix"), + ]; + for (method, uri) in &misses { + assert_eq!(Match::No, spec.matches(&req(method, uri, None))); + } + } + + #[test] + fn greedy_labels_match_greedily() { + let spec = RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("mg")), + PathSegment::Greedy, + PathSegment::Literal(String::from("z")), + ], + Vec::new(), + ); + + let hits = vec![ + (Method::GET, "/mg/a/z"), + (Method::GET, "/mg/z/z"), + (Method::GET, "/mg/a/z/b/z"), + (Method::GET, "/mg/a/z/z/z"), + ]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, spec.matches(&req(method, uri, None))); + } + } + + #[test] + fn repeated_query_keys() { + let spec = RequestSpec::from_parts(Method::DELETE, Vec::new(), vec![QuerySegment::Key(String::from("foo"))]); + + let hits = vec![ + (Method::DELETE, "/?foo=bar&foo=bar"), + (Method::DELETE, "/?foo=bar&foo=baz"), + (Method::DELETE, "/?foo&foo"), + ]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, spec.matches(&req(method, uri, None))); + } + } + + fn key_value_spec() -> RequestSpec { + RequestSpec::from_parts( + Method::DELETE, + Vec::new(), + vec![QuerySegment::KeyValue(String::from("foo"), String::from("bar"))], + ) + } + + #[test] + fn repeated_query_keys_same_values_match() { + assert_eq!( + Match::Yes, + key_value_spec().matches(&req(&Method::DELETE, "/?foo=bar&foo=bar", None)) + ); + } + + #[test] + fn repeated_query_keys_distinct_values_does_not_match() { + assert_eq!( + Match::No, + key_value_spec().matches(&req(&Method::DELETE, "/?foo=bar&foo=baz", None)) + ); + } + + #[test] + fn encoded_query_string() { + let request_spec = + RequestSpec::from_parts(Method::DELETE, Vec::new(), vec![QuerySegment::Key("foo".to_owned())]); + + assert_eq!( + Match::Yes, + request_spec.matches(&req(&Method::DELETE, "/?foo=hello%20world", None)) + ); + } + + fn ab_spec() -> RequestSpec { + RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Literal(String::from("b")), + ], + Vec::new(), + ) + } + + // Empty segments _have meaning_ and should not be stripped away when doing routing or label + // extraction. + // See https://github.com/awslabs/smithy/issues/1024 for discussion. + + #[test] + fn empty_segments_in_the_middle_do_matter() { + assert_eq!(Match::Yes, ab_spec().matches(&req(&Method::GET, "/a/b", None))); + + let misses = vec![(Method::GET, "/a//b"), (Method::GET, "//////a//b")]; + for (method, uri) in &misses { + assert_eq!(Match::No, ab_spec().matches(&req(method, uri, None))); + } + } + + #[test] + fn empty_segments_in_the_middle_do_matter_label_spec() { + let label_spec = RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Label, + PathSegment::Literal(String::from("b")), + ], + Vec::new(), + ); + + let hits = vec![ + (Method::GET, "/a/label/b"), + (Method::GET, "/a//b"), // Label is bound to `""`. + ]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, label_spec.matches(&req(method, uri, None))); + } + + assert_eq!(Match::No, label_spec.matches(&req(&Method::GET, "/a///b", None))); + } + + #[test] + fn empty_segments_in_the_middle_do_matter_greedy_label_spec() { + let greedy_label_spec = RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("a")), + PathSegment::Greedy, + PathSegment::Literal(String::from("suffix")), + ], + Vec::new(), + ); + + let hits = vec![ + (Method::GET, "/a//suffix"), + (Method::GET, "/a///suffix"), + (Method::GET, "/a///a//b///suffix"), + ]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, greedy_label_spec.matches(&req(method, uri, None))); + } + } + + // The rationale is that `/index` points to the `index` resource, but `/index/` points to "the + // default resource under `index`", for example `/index/index.html`, so trailing slashes at the + // end of URIs _do_ matter. + #[test] + fn empty_segments_at_the_end_do_matter() { + let misses = vec![ + (Method::GET, "/a/b/"), + (Method::GET, "/a/b//"), + (Method::GET, "//a//b////"), + ]; + for (method, uri) in &misses { + assert_eq!(Match::No, ab_spec().matches(&req(method, uri, None))); + } + } + + #[test] + fn empty_segments_at_the_end_do_matter_label_spec() { + let label_spec = RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("a")), PathSegment::Label], + Vec::new(), + ); + + let misses = vec![(Method::GET, "/a"), (Method::GET, "/a//"), (Method::GET, "/a///")]; + for (method, uri) in &misses { + assert_eq!(Match::No, label_spec.matches(&req(method, uri, None))); + } + + // In the second example, the label is bound to `""`. + let hits = vec![(Method::GET, "/a/label"), (Method::GET, "/a/")]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, label_spec.matches(&req(method, uri, None))); + } + } + + #[test] + fn unsanitary_path() { + let spec = RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("ReDosLiteral")), + PathSegment::Label, + PathSegment::Literal(String::from("(a+)+")), + ], + Vec::new(), + ); + + assert_eq!( + Match::Yes, + spec.matches(&req(&Method::GET, "/ReDosLiteral/abc/(a+)+", None)) + ); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/route.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/route.rs new file mode 100644 index 00000000000..7eda401f61b --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/route.rs @@ -0,0 +1,132 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This code was copied and then modified from Tokio's Axum. + +/* Copyright (c) 2021 Tower Contributors + * + * Permission is hereby granted, free of charge, to any + * person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the + * Software without restriction, including without + * limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions + * of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + * SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +use crate::body::{Body, BoxBody}; +use http::{Request, Response}; +use std::{ + convert::Infallible, + fmt, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use tower::{ + util::{BoxCloneService, Oneshot}, + Service, ServiceExt, +}; + +/// A HTTP [`Service`] representing a single route. +/// +/// The construction of [`Route`] from a named HTTP [`Service`] `S`, erases the type of `S`. +pub struct Route { + service: BoxCloneService, Response, Infallible>, +} + +impl Route { + /// Constructs a new [`Route`] from a well-formed HTTP service which is cloneable. + pub fn new(svc: T) -> Self + where + T: Service, Response = Response, Error = Infallible> + Clone + Send + 'static, + T::Future: Send + 'static, + { + Self { + service: BoxCloneService::new(svc), + } + } +} + +impl Clone for Route { + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + } + } +} + +impl fmt::Debug for Route { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Route").finish() + } +} + +impl Service> for Route { + type Response = Response; + type Error = Infallible; + type Future = RouteFuture; + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, req: Request) -> Self::Future { + RouteFuture::new(self.service.clone().oneshot(req)) + } +} + +pin_project_lite::pin_project! { + /// Response future for [`Route`]. + pub struct RouteFuture { + #[pin] + future: Oneshot, Response, Infallible>, Request>, + } +} + +impl RouteFuture { + pub(crate) fn new(future: Oneshot, Response, Infallible>, Request>) -> Self { + RouteFuture { future } + } +} + +impl Future for RouteFuture { + type Output = Result, Infallible>; + + #[inline] + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.project().future.poll(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn traits() { + use crate::test_helpers::*; + + assert_send::>(); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/routing/tiny_map.rs b/rust-runtime/aws-smithy-legacy-http-server/src/routing/tiny_map.rs new file mode 100644 index 00000000000..4efe4c2d0db --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/routing/tiny_map.rs @@ -0,0 +1,199 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::{borrow::Borrow, collections::HashMap, hash::Hash}; + +/// A map implementation with fast iteration which switches backing storage from [`Vec`] to +/// [`HashMap`] when the number of entries exceeds `CUTOFF`. +#[derive(Clone, Debug)] +pub struct TinyMap { + inner: TinyMapInner, +} + +#[derive(Clone, Debug)] +enum TinyMapInner { + Vec(Vec<(K, V)>), + HashMap(HashMap), +} + +enum OrIterator { + Left(Left), + Right(Right), +} + +impl Iterator for OrIterator +where + Left: Iterator, + Right: Iterator, +{ + type Item = Left::Item; + + fn next(&mut self) -> Option { + match self { + Self::Left(left) => left.next(), + Self::Right(right) => right.next(), + } + } +} + +/// An owning iterator over the entries of a `TinyMap`. +/// +/// This struct is created by the [`into_iter`](IntoIterator::into_iter) method on [`TinyMap`] ( +/// provided by the [`IntoIterator`] trait). See its documentation for more. +pub struct IntoIter { + inner: OrIterator, std::collections::hash_map::IntoIter>, +} + +impl Iterator for IntoIter { + type Item = (K, V); + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +impl IntoIterator for TinyMap { + type Item = (K, V); + + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let inner = match self.inner { + TinyMapInner::Vec(vec) => OrIterator::Left(vec.into_iter()), + TinyMapInner::HashMap(hash_map) => OrIterator::Right(hash_map.into_iter()), + }; + IntoIter { inner } + } +} + +impl FromIterator<(K, V)> for TinyMap +where + K: Hash + Eq, +{ + fn from_iter>(iter: T) -> Self { + let mut vec = Vec::with_capacity(CUTOFF); + let mut iter = iter.into_iter().enumerate(); + + // Populate the `Vec` + while let Some((index, pair)) = iter.next() { + vec.push(pair); + + // If overflow `CUTOFF` then return a `HashMap` instead + if index == CUTOFF { + let inner = TinyMapInner::HashMap(vec.into_iter().chain(iter.map(|(_, pair)| pair)).collect()); + return TinyMap { inner }; + } + } + + TinyMap { + inner: TinyMapInner::Vec(vec), + } + } +} + +impl TinyMap +where + K: Eq + Hash, +{ + /// Returns a reference to the value corresponding to the key. + /// + /// The key may be borrowed form of map's key type, but [`Hash`] and [`Eq`] on the borrowed + /// form _must_ match those for the key type. + pub fn get(&self, key: &Q) -> Option<&V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + match &self.inner { + TinyMapInner::Vec(vec) => vec + .iter() + .find(|(key_inner, _)| key_inner.borrow() == key) + .map(|(_, value)| value), + TinyMapInner::HashMap(hash_map) => hash_map.get(key), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CUTOFF: usize = 5; + + const SMALL_VALUES: [(&str, usize); 3] = [("a", 0), ("b", 1), ("c", 2)]; + const MEDIUM_VALUES: [(&str, usize); 5] = [("a", 0), ("b", 1), ("c", 2), ("d", 3), ("e", 4)]; + const LARGE_VALUES: [(&str, usize); 10] = [ + ("a", 0), + ("b", 1), + ("c", 2), + ("d", 3), + ("e", 4), + ("f", 5), + ("g", 6), + ("h", 7), + ("i", 8), + ("j", 9), + ]; + + #[test] + fn collect_small() { + let tiny_map: TinyMap<_, _, CUTOFF> = SMALL_VALUES.into_iter().collect(); + assert!(matches!(tiny_map.inner, TinyMapInner::Vec(_))) + } + + #[test] + fn collect_medium() { + let tiny_map: TinyMap<_, _, CUTOFF> = MEDIUM_VALUES.into_iter().collect(); + assert!(matches!(tiny_map.inner, TinyMapInner::Vec(_))) + } + + #[test] + fn collect_large() { + let tiny_map: TinyMap<_, _, CUTOFF> = LARGE_VALUES.into_iter().collect(); + assert!(matches!(tiny_map.inner, TinyMapInner::HashMap(_))) + } + + #[test] + fn get_small_success() { + let tiny_map: TinyMap<_, _, CUTOFF> = SMALL_VALUES.into_iter().collect(); + SMALL_VALUES.into_iter().for_each(|(op, val)| { + assert_eq!(tiny_map.get(op), Some(&val)); + }); + } + + #[test] + fn get_medium_success() { + let tiny_map: TinyMap<_, _, CUTOFF> = MEDIUM_VALUES.into_iter().collect(); + MEDIUM_VALUES.into_iter().for_each(|(op, val)| { + assert_eq!(tiny_map.get(op), Some(&val)); + }); + } + + #[test] + fn get_large_success() { + let tiny_map: TinyMap<_, _, CUTOFF> = LARGE_VALUES.into_iter().collect(); + LARGE_VALUES.into_iter().for_each(|(op, val)| { + assert_eq!(tiny_map.get(op), Some(&val)); + }); + } + + #[test] + fn get_small_fail() { + let tiny_map: TinyMap<_, _, CUTOFF> = SMALL_VALUES.into_iter().collect(); + assert_eq!(tiny_map.get("x"), None) + } + + #[test] + fn get_medium_fail() { + let tiny_map: TinyMap<_, _, CUTOFF> = MEDIUM_VALUES.into_iter().collect(); + assert_eq!(tiny_map.get("y"), None) + } + + #[test] + fn get_large_fail() { + let tiny_map: TinyMap<_, _, CUTOFF> = LARGE_VALUES.into_iter().collect(); + assert_eq!(tiny_map.get("z"), None) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/runtime_error.rs b/rust-runtime/aws-smithy-legacy-http-server/src/runtime_error.rs new file mode 100644 index 00000000000..86350fe4c90 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/runtime_error.rs @@ -0,0 +1,13 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/// A _protocol-agnostic_ type representing an internal framework error. As of writing, this can only +/// occur upon failure to extract an [`crate::extension::Extension`] from the request. +/// This type is converted into protocol-specific error variants. For example, in the +/// [`crate::protocol::rest_json_1`] protocol, it is converted to the +/// [`crate::protocol::rest_json_1::runtime_error::RuntimeError::InternalFailure`] variant. +pub struct InternalFailureException; + +pub const INVALID_HTTP_RESPONSE_FOR_RUNTIME_ERROR_PANIC_MESSAGE: &str = "invalid HTTP response for `RuntimeError`; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues"; diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/service.rs b/rust-runtime/aws-smithy-legacy-http-server/src/service.rs new file mode 100644 index 00000000000..37767887a7b --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/service.rs @@ -0,0 +1,89 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! The shape of a [Smithy service] is modelled by the [`ServiceShape`] trait. Its associated types +//! [`ServiceShape::ID`], [`ServiceShape::VERSION`], [`ServiceShape::Protocol`], and [`ServiceShape::Operations`] map +//! to the services [Shape ID](https://smithy.io/2.0/spec/model.html#shape-id), the version field, the applied +//! [protocol trait](https://smithy.io/2.0/aws/protocols/index.html) (see [`protocol`](crate::protocol) module), and the +//! operations field. +//! +//! We generate an implementation on this for every service struct (exported from the root of the generated crate). +//! +//! As stated in the [operation module documentation](crate::operation) we also generate marker structs for +//! [`OperationShape`](crate::operation::OperationShape), these are coupled to the `S: ServiceShape` via the [`ContainsOperation`] trait. +//! +//! The model +//! +//! ```smithy +//! @restJson1 +//! service Shopping { +//! version: "1.0", +//! operations: [ +//! GetShopping, +//! PutShopping +//! ] +//! } +//! ``` +//! +//! is identified with the implementation +//! +//! ```rust,no_run +//! # use aws_smithy_legacy_http_server::shape_id::ShapeId; +//! # use aws_smithy_legacy_http_server::service::{ServiceShape, ContainsOperation}; +//! # use aws_smithy_legacy_http_server::protocol::rest_json_1::RestJson1; +//! # pub struct Shopping; +//! // For more information on these marker structs see `OperationShape` +//! struct GetShopping; +//! struct PutShopping; +//! +//! // This is a generated enumeration of all operations. +//! #[derive(PartialEq, Eq, Clone, Copy)] +//! pub enum Operation { +//! GetShopping, +//! PutShopping +//! } +//! +//! impl ServiceShape for Shopping { +//! const ID: ShapeId = ShapeId::new("namespace#Shopping", "namespace", "Shopping"); +//! const VERSION: Option<&'static str> = Some("1.0"); +//! type Protocol = RestJson1; +//! type Operations = Operation; +//! } +//! +//! impl ContainsOperation for Shopping { +//! const VALUE: Operation = Operation::GetShopping; +//! } +//! +//! impl ContainsOperation for Shopping { +//! const VALUE: Operation = Operation::PutShopping; +//! } +//! ``` +//! +//! [Smithy service]: https://smithy.io/2.0/spec/service-types.html#service + +use crate::shape_id::ShapeId; + +/// Models the [Smithy Service shape]. +/// +/// [Smithy Service shape]: https://smithy.io/2.0/spec/service-types.html#service +pub trait ServiceShape { + /// The [`ShapeId`] of the service. + const ID: ShapeId; + + /// The version of the service. + const VERSION: Option<&'static str>; + + /// The [Protocol] applied to this service. + /// + /// [Protocol]: https://smithy.io/2.0/spec/protocol-traits.html + type Protocol; + + /// An enumeration of all operations contained in this service. + type Operations; +} + +pub trait ContainsOperation: ServiceShape { + const VALUE: Self::Operations; +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/shape_id.rs b/rust-runtime/aws-smithy-legacy-http-server/src/shape_id.rs new file mode 100644 index 00000000000..cdc73c84129 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/shape_id.rs @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! A [`ShapeId`] represents a [Smithy Shape ID](https://smithy.io/2.0/spec/model.html#shape-id). +//! +//! # Example +//! +//! In the following model: +//! +//! ```smithy +//! namespace smithy.example +//! +//! operation CheckHealth {} +//! ``` +//! +//! - `absolute` is `"smithy.example#CheckHealth"` +//! - `namespace` is `"smithy.example"` +//! - `name` is `"CheckHealth"` + +pub use crate::request::extension::{Extension, MissingExtension}; + +/// Represents a [Smithy Shape ID](https://smithy.io/2.0/spec/model.html#shape-id). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ShapeId { + absolute: &'static str, + + namespace: &'static str, + name: &'static str, +} + +impl ShapeId { + /// Constructs a new [`ShapeId`]. This is used by the code-generator which preserves the invariants of the Shape ID format. + #[doc(hidden)] + pub const fn new(absolute: &'static str, namespace: &'static str, name: &'static str) -> Self { + Self { + absolute, + namespace, + name, + } + } + + /// Returns the namespace. + /// + /// See [Shape ID](https://smithy.io/2.0/spec/model.html#shape-id) for a breakdown of the syntax. + pub fn namespace(&self) -> &'static str { + self.namespace + } + + /// Returns the member name. + /// + /// See [Shape ID](https://smithy.io/2.0/spec/model.html#shape-id) for a breakdown of the syntax. + pub fn name(&self) -> &'static str { + self.name + } + + /// Returns the absolute shape ID. + /// + /// See [Shape ID](https://smithy.io/2.0/spec/model.html#shape-id) for a breakdown of the syntax. + pub fn absolute(&self) -> &'static str { + self.absolute + } +} diff --git a/rust-runtime/aws-smithy-legacy-http-server/src/test_helpers.rs b/rust-runtime/aws-smithy-legacy-http-server/src/test_helpers.rs new file mode 100644 index 00000000000..8a115abb137 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http-server/src/test_helpers.rs @@ -0,0 +1,7 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub(crate) fn assert_send() {} +pub(crate) fn assert_sync() {} diff --git a/rust-runtime/aws-smithy-legacy-http/Cargo.toml b/rust-runtime/aws-smithy-legacy-http/Cargo.toml new file mode 100644 index 00000000000..8f44f244ea0 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "aws-smithy-legacy-http" +version = "0.62.5" +authors = [ + "AWS Rust SDK Team ", + "Russell Cohen ", +] +description = "Smithy HTTP-0x logic for smithy-rs." +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/smithy-lang/smithy-rs" + +[features] +event-stream = ["aws-smithy-eventstream"] +rt-tokio = ["aws-smithy-types/rt-tokio"] + +[dependencies] +aws-smithy-eventstream = { path = "../aws-smithy-eventstream", optional = true } +aws-smithy-runtime-api = { path = "../aws-smithy-runtime-api", features = ["client", "http-02x"] } +aws-smithy-types = { path = "../aws-smithy-types", features = ["byte-stream-poll-next", "http-body-0-4-x"] } +bytes = "1.10.0" +bytes-utils = "0.1" +# TODO(hyper1) - Complete the breaking changes by updating to http 1.x ecosystem fully in this crate. Also remove hyper 0.14 from dev +http-02x = { package = "http", version = "0.2.9" } +http-1x = { package = "http", version = "1" } +http-body-04x = { package = "http-body", version = "0.4.5" } +percent-encoding = "2.3.1" +pin-project-lite = "0.2.14" +pin-utils = "0.1.0" +tracing = "0.1.40" + +# For an adapter to enable the `Stream` trait for `aws_smithy_types::byte_stream::ByteStream` +futures-core = "0.3.31" +futures-util = { version = "0.3.29", default-features = false } + +[dev-dependencies] +async-stream = "0.3" +futures-util = { version = "0.3.29", default-features = false } +hyper = { version = "0.14.26", features = ["stream"] } +proptest = "1" +tokio = { version = "1.23.1", features = [ + "macros", + "rt", + "rt-multi-thread", +] } + +[package.metadata.docs.rs] +all-features = true +targets = ["x86_64-unknown-linux-gnu"] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +rustdoc-args = ["--cfg", "docsrs"] +# End of docs.rs metadata diff --git a/rust-runtime/aws-smithy-legacy-http/LICENSE b/rust-runtime/aws-smithy-legacy-http/LICENSE new file mode 100644 index 00000000000..67db8588217 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/rust-runtime/aws-smithy-legacy-http/README.md b/rust-runtime/aws-smithy-legacy-http/README.md new file mode 100644 index 00000000000..36bb86c6f18 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/README.md @@ -0,0 +1,18 @@ +# aws-smithy-legacy-http + +**This is a legacy crate that provides support for `http@0.x` and `hyper@0.x`.** + +Core HTTP primitives for service clients generated by [smithy-rs](https://github.com/smithy-lang/smithy-rs) including: +- HTTP Body implementation +- Endpoint support +- HTTP header deserialization +- Event streams +- `ByteStream`: _(supported on crate feature `rt-tokio` only)_ a misuse-resistant abstraction for streaming binary data + +## Usage + +This crate is used when generating server SDKs without the `http-1x` codegen flag. For new projects, prefer using `aws-smithy-http` which supports `http@1.x` and `hyper@1.x`. + + +This crate is part of the [AWS SDK for Rust](https://awslabs.github.io/aws-sdk-rust/) and the [smithy-rs](https://github.com/smithy-lang/smithy-rs) code generator. In most cases, it should not be used directly. + diff --git a/rust-runtime/aws-smithy-legacy-http/additional-ci b/rust-runtime/aws-smithy-legacy-http/additional-ci new file mode 100755 index 00000000000..b44c6c05be7 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/additional-ci @@ -0,0 +1,12 @@ +#!/bin/bash +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +# This script contains additional CI checks to run for this specific package + +set -e + +echo "### Testing every combination of features (excluding --all-features)" +cargo hack test --feature-powerset --exclude-all-features diff --git a/rust-runtime/aws-smithy-legacy-http/external-types.toml b/rust-runtime/aws-smithy-legacy-http/external-types.toml new file mode 100644 index 00000000000..1e95ac80514 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/external-types.toml @@ -0,0 +1,21 @@ +allowed_external_types = [ + "aws_smithy_runtime_api::*", + "aws_smithy_types::*", + "bytes::bytes::Bytes", + "http::error::Error", + "http::header::map::HeaderMap", + "http::header::map::ValueIter", + "http::header::name::HeaderName", + "http::header::value::HeaderValue", + "http::request::Builder", + "http::request::Request", + "http::response::Builder", + "http::response::Response", + "http::uri::Uri", + + # TODO(https://github.com/smithy-lang/smithy-rs/issues/1193): Once tooling permits it, only allow the following types in the `event-stream` feature + "futures_core::stream::Stream", + + # TODO(https://github.com/smithy-lang/smithy-rs/issues/1193): Once tooling permits it, only allow the following types in the `event-stream` feature + "aws_smithy_eventstream::*", +] diff --git a/rust-runtime/aws-smithy-legacy-http/fuzz/.gitignore b/rust-runtime/aws-smithy-legacy-http/fuzz/.gitignore new file mode 100644 index 00000000000..a0925114d61 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/fuzz/.gitignore @@ -0,0 +1,3 @@ +target +corpus +artifacts diff --git a/rust-runtime/aws-smithy-legacy-http/fuzz/Cargo.toml b/rust-runtime/aws-smithy-legacy-http/fuzz/Cargo.toml new file mode 100644 index 00000000000..040c40e76c3 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/fuzz/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "aws-smithy-http-fuzz" +version = "0.0.0" +authors = ["Automatically generated"] +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +# Version pinned due to https://github.com/rust-fuzz/libfuzzer/issues/126 +libfuzzer-sys = "=0.4.7" +http = "0.2.3" + +[dependencies.aws-smithy-http] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "read_many_from_str" +path = "fuzz_targets/read_many_from_str.rs" +test = false +doc = false diff --git a/rust-runtime/aws-smithy-legacy-http/fuzz/fuzz_targets/read_many_from_str.rs b/rust-runtime/aws-smithy-legacy-http/fuzz/fuzz_targets/read_many_from_str.rs new file mode 100644 index 00000000000..bdd47f6eac5 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/fuzz/fuzz_targets/read_many_from_str.rs @@ -0,0 +1,19 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#![no_main] +use libfuzzer_sys::fuzz_target; + +use aws_smithy_http::header::read_many_from_str; +use http; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(req) = http::Request::builder().header("test", s).body(()) { + // Shouldn't panic + let _ = read_many_from_str::(req.headers().get_all("test").iter()); + } + } +}); diff --git a/rust-runtime/aws-smithy-legacy-http/proptest-regressions/label.txt b/rust-runtime/aws-smithy-legacy-http/proptest-regressions/label.txt new file mode 100644 index 00000000000..355d109468b --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/proptest-regressions/label.txt @@ -0,0 +1,10 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc dfac816a3fc3fff8523f1a1707da0065b72fc3c0d70fce001627a8e2e7ee5e0e # shrinks to s = ">" +cc 22bce3cd581f5f5a55e6ba18b1fb027481a496f6b35fee6dc4ef84659b99ddca # shrinks to s = "`" +cc be619cccfee48e3bf642cf0f82e98e00dceccbe10963fbaf3a622a68a55a3227 # shrinks to s = "?\"" +cc 3e0b2e6f64642d7c58e5d2fe9223f75238a874bd8c3812dcb3ecc721d9aa0243 # shrinks to s = " " diff --git a/rust-runtime/aws-smithy-legacy-http/proptest-regressions/query.txt b/rust-runtime/aws-smithy-legacy-http/proptest-regressions/query.txt new file mode 100644 index 00000000000..a337852437a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/proptest-regressions/query.txt @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b8ff8401495a7e4b4604f4438d8fc6b0ba63a58ddf58273ddcb3bb511e5cf91a # shrinks to s = "<" +cc 59ee40f6a097f80254a91d0ee7d6cde97a353f7ccdf83eddd1d437781019431f # shrinks to s = "\"" +cc 65e6e5f9082c6cbebf599af889721d30d8ee2388f2f7be372520aa86526c8379 # shrinks to s = ">" diff --git a/rust-runtime/aws-smithy-legacy-http/src/endpoint.rs b/rust-runtime/aws-smithy-legacy-http/src/endpoint.rs new file mode 100644 index 00000000000..3c5adc46426 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/endpoint.rs @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Code for resolving an endpoint (URI) that a request should be sent to + +use aws_smithy_runtime_api::client::endpoint::{error::InvalidEndpointError, EndpointPrefix}; +use std::borrow::Cow; +use std::result::Result as StdResult; +use std::str::FromStr; + +pub mod error; +pub use error::ResolveEndpointError; + +/// An endpoint-resolution-specific Result. Contains either an [`Endpoint`](aws_smithy_types::endpoint::Endpoint) or a [`ResolveEndpointError`]. +#[deprecated(since = "0.60.1", note = "Was never used.")] +pub type Result = std::result::Result; + +/// Apply `endpoint` to `uri` +/// +/// This method mutates `uri` by setting the `endpoint` on it +pub fn apply_endpoint( + uri: &mut http_1x::Uri, + endpoint: &http_1x::Uri, + prefix: Option<&EndpointPrefix>, +) -> StdResult<(), InvalidEndpointError> { + let prefix = prefix.map(EndpointPrefix::as_str).unwrap_or(""); + let authority = endpoint + .authority() + .as_ref() + .map(|auth| auth.as_str()) + .unwrap_or(""); + let authority = if !prefix.is_empty() { + Cow::Owned(format!("{}{}", prefix, authority)) + } else { + Cow::Borrowed(authority) + }; + let authority = http_1x::uri::Authority::from_str(&authority).map_err(|err| { + InvalidEndpointError::failed_to_construct_authority(authority.into_owned(), err) + })?; + let scheme = *endpoint + .scheme() + .as_ref() + .ok_or_else(InvalidEndpointError::endpoint_must_have_scheme)?; + let new_uri = http_1x::Uri::builder() + .authority(authority) + .scheme(scheme.clone()) + .path_and_query(merge_paths(endpoint, uri).as_ref()) + .build() + .map_err(InvalidEndpointError::failed_to_construct_uri)?; + *uri = new_uri; + Ok(()) +} + +fn merge_paths<'a>(endpoint: &'a http_1x::Uri, uri: &'a http_1x::Uri) -> Cow<'a, str> { + if let Some(query) = endpoint.path_and_query().and_then(|pq| pq.query()) { + tracing::warn!(query = %query, "query specified in endpoint will be ignored during endpoint resolution"); + } + let endpoint_path = endpoint.path(); + let uri_path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(""); + if endpoint_path.is_empty() { + Cow::Borrowed(uri_path_and_query) + } else { + let ep_no_slash = endpoint_path.strip_suffix('/').unwrap_or(endpoint_path); + let uri_path_no_slash = uri_path_and_query + .strip_prefix('/') + .unwrap_or(uri_path_and_query); + Cow::Owned(format!("{}/{}", ep_no_slash, uri_path_no_slash)) + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/endpoint/error.rs b/rust-runtime/aws-smithy-legacy-http/src/endpoint/error.rs new file mode 100644 index 00000000000..eeb703523c1 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/endpoint/error.rs @@ -0,0 +1,137 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Errors related to endpoint resolution and validation + +use std::error::Error; +use std::fmt; + +/// Endpoint resolution failed +#[derive(Debug)] +pub struct ResolveEndpointError { + message: String, + source: Option>, +} + +impl ResolveEndpointError { + /// Create an [`ResolveEndpointError`] with a message + pub fn message(message: impl Into) -> Self { + Self { + message: message.into(), + source: None, + } + } + + /// Add a source to the error + pub fn with_source(self, source: Option>) -> Self { + Self { source, ..self } + } + + /// Create a [`ResolveEndpointError`] from a message and a source + pub fn from_source( + message: impl Into, + source: impl Into>, + ) -> Self { + Self::message(message).with_source(Some(source.into())) + } +} + +impl fmt::Display for ResolveEndpointError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for ResolveEndpointError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|err| err.as_ref() as _) + } +} + +#[derive(Debug)] +pub(super) enum InvalidEndpointErrorKind { + EndpointMustHaveScheme, + FailedToConstructAuthority { + authority: String, + source: Box, + }, + FailedToConstructUri { + source: Box, + }, +} + +/// An error that occurs when an endpoint is found to be invalid. This usually occurs due to an +/// incomplete URI. +#[derive(Debug)] +pub struct InvalidEndpointError { + pub(super) kind: InvalidEndpointErrorKind, +} + +impl InvalidEndpointError { + /// Construct a build error for a missing scheme + pub fn endpoint_must_have_scheme() -> Self { + Self { + kind: InvalidEndpointErrorKind::EndpointMustHaveScheme, + } + } + + /// Construct a build error for an invalid authority + pub fn failed_to_construct_authority( + authority: impl Into, + source: impl Into>, + ) -> Self { + Self { + kind: InvalidEndpointErrorKind::FailedToConstructAuthority { + authority: authority.into(), + source: source.into(), + }, + } + } + + /// Construct a build error for an invalid URI + pub fn failed_to_construct_uri( + source: impl Into>, + ) -> Self { + Self { + kind: InvalidEndpointErrorKind::FailedToConstructUri { + source: source.into(), + }, + } + } +} + +impl From for InvalidEndpointError { + fn from(kind: InvalidEndpointErrorKind) -> Self { + Self { kind } + } +} + +impl fmt::Display for InvalidEndpointError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use InvalidEndpointErrorKind as ErrorKind; + match &self.kind { + ErrorKind::EndpointMustHaveScheme => write!(f, "endpoint must contain a valid scheme"), + ErrorKind::FailedToConstructAuthority { authority, source: _ } => write!( + f, + "endpoint must contain a valid authority when combined with endpoint prefix: {authority}" + ), + ErrorKind::FailedToConstructUri { .. } => write!(f, "failed to construct URI"), + } + } +} + +impl Error for InvalidEndpointError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + use InvalidEndpointErrorKind as ErrorKind; + match &self.kind { + ErrorKind::FailedToConstructUri { source } => Some(source.as_ref()), + ErrorKind::FailedToConstructAuthority { + authority: _, + source, + } => Some(source.as_ref()), + ErrorKind::EndpointMustHaveScheme => None, + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/event_stream.rs b/rust-runtime/aws-smithy-legacy-http/src/event_stream.rs new file mode 100644 index 00000000000..b74b85f474e --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/event_stream.rs @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Provides Sender/Receiver implementations for Event Stream codegen. + +use std::error::Error as StdError; + +mod receiver; +mod sender; + +/// A generic, boxed error that's `Send`, `Sync`, and `'static`. +pub type BoxError = Box; + +#[doc(inline)] +pub use sender::{EventStreamSender, MessageStreamAdapter, MessageStreamError}; + +#[doc(inline)] +pub use receiver::{InitialMessageType, Receiver, ReceiverError}; diff --git a/rust-runtime/aws-smithy-legacy-http/src/event_stream/receiver.rs b/rust-runtime/aws-smithy-legacy-http/src/event_stream/receiver.rs new file mode 100644 index 00000000000..e2b71faa8cb --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/event_stream/receiver.rs @@ -0,0 +1,569 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use aws_smithy_eventstream::frame::{ + DecodedFrame, MessageFrameDecoder, UnmarshallMessage, UnmarshalledMessage, +}; +use aws_smithy_runtime_api::client::result::{ConnectorError, SdkError}; +use aws_smithy_types::body::SdkBody; +use aws_smithy_types::event_stream::{Message, RawMessage}; +use bytes::Buf; +use bytes::Bytes; +use bytes_utils::SegmentedBuf; +use std::error::Error as StdError; +use std::fmt; +use std::marker::PhantomData; +use std::mem; +use tracing::trace; + +/// Wrapper around SegmentedBuf that tracks the state of the stream. +#[derive(Debug)] +enum RecvBuf { + /// Nothing has been buffered yet. + Empty, + /// Some data has been buffered. + /// The SegmentedBuf will automatically purge when it reads off the end of a chunk boundary. + Partial(SegmentedBuf), + /// The end of the stream has been reached, but there may still be some buffered data. + EosPartial(SegmentedBuf), + /// An exception terminated this stream. + Terminated, +} + +impl RecvBuf { + /// Returns true if there's more buffered data. + fn has_data(&self) -> bool { + match self { + RecvBuf::Empty | RecvBuf::Terminated => false, + RecvBuf::Partial(segments) | RecvBuf::EosPartial(segments) => segments.remaining() > 0, + } + } + + /// Returns true if the stream has ended. + fn is_eos(&self) -> bool { + matches!(self, RecvBuf::EosPartial(_) | RecvBuf::Terminated) + } + + /// Returns a mutable reference to the underlying buffered data. + fn buffered(&mut self) -> &mut SegmentedBuf { + match self { + RecvBuf::Empty => panic!("buffer must be populated before reading; this is a bug"), + RecvBuf::Partial(segmented) => segmented, + RecvBuf::EosPartial(segmented) => segmented, + RecvBuf::Terminated => panic!("buffer has been terminated; this is a bug"), + } + } + + /// Returns a new `RecvBuf` with additional data buffered. This will only allocate + /// if the `RecvBuf` was previously empty. + fn with_partial(self, partial: Bytes) -> Self { + match self { + RecvBuf::Empty => { + let mut segmented = SegmentedBuf::new(); + segmented.push(partial); + RecvBuf::Partial(segmented) + } + RecvBuf::Partial(mut segmented) => { + segmented.push(partial); + RecvBuf::Partial(segmented) + } + RecvBuf::EosPartial(_) | RecvBuf::Terminated => { + panic!("cannot buffer more data after the stream has ended or been terminated; this is a bug") + } + } + } + + /// Returns a `RecvBuf` that has reached end of stream. + fn ended(self) -> Self { + match self { + RecvBuf::Empty => RecvBuf::EosPartial(SegmentedBuf::new()), + RecvBuf::Partial(segmented) => RecvBuf::EosPartial(segmented), + RecvBuf::EosPartial(_) => panic!("already end of stream; this is a bug"), + RecvBuf::Terminated => panic!("stream terminated; this is a bug"), + } + } +} + +#[derive(Debug)] +enum ReceiverErrorKind { + /// The stream ended before a complete message frame was received. + UnexpectedEndOfStream, +} + +/// An error that occurs within an event stream receiver. +#[derive(Debug)] +pub struct ReceiverError { + kind: ReceiverErrorKind, +} + +impl fmt::Display for ReceiverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind { + ReceiverErrorKind::UnexpectedEndOfStream => write!(f, "unexpected end of stream"), + } + } +} + +impl StdError for ReceiverError {} + +/// Receives Smithy-modeled messages out of an Event Stream. +#[derive(Debug)] +pub struct Receiver { + unmarshaller: Box + Send + Sync>, + decoder: MessageFrameDecoder, + buffer: RecvBuf, + body: SdkBody, + /// Event Stream has optional initial response frames an with `:message-type` of + /// `initial-response`. If `try_recv_initial()` is called and the next message isn't an + /// initial response, then the message will be stored in `buffered_message` so that it can + /// be returned with the next call of `recv()`. + buffered_message: Option, + _phantom: PhantomData, +} + +// Used by `Receiver::try_recv_initial`, hence this enum is also doc hidden +#[doc(hidden)] +#[non_exhaustive] +pub enum InitialMessageType { + Request, + Response, +} + +impl InitialMessageType { + fn as_str(&self) -> &'static str { + match self { + InitialMessageType::Request => "initial-request", + InitialMessageType::Response => "initial-response", + } + } +} + +impl Receiver { + /// Creates a new `Receiver` with the given message unmarshaller and SDK body. + pub fn new( + unmarshaller: impl UnmarshallMessage + Send + Sync + 'static, + body: SdkBody, + ) -> Self { + Receiver { + unmarshaller: Box::new(unmarshaller), + decoder: MessageFrameDecoder::new(), + buffer: RecvBuf::Empty, + body, + buffered_message: None, + _phantom: Default::default(), + } + } + + fn unmarshall(&self, message: Message) -> Result, SdkError> { + match self.unmarshaller.unmarshall(&message) { + Ok(unmarshalled) => match unmarshalled { + UnmarshalledMessage::Event(event) => Ok(Some(event)), + UnmarshalledMessage::Error(err) => { + Err(SdkError::service_error(err, RawMessage::Decoded(message))) + } + }, + Err(err) => Err(SdkError::response_error(err, RawMessage::Decoded(message))), + } + } + + async fn buffer_next_chunk(&mut self) -> Result<(), SdkError> { + use http_body_04x::Body; + + if !self.buffer.is_eos() { + let next_chunk = self + .body + .data() + .await + .transpose() + .map_err(|err| SdkError::dispatch_failure(ConnectorError::io(err)))?; + let buffer = mem::replace(&mut self.buffer, RecvBuf::Empty); + if let Some(chunk) = next_chunk { + self.buffer = buffer.with_partial(chunk); + } else { + self.buffer = buffer.ended(); + } + } + Ok(()) + } + + async fn next_message(&mut self) -> Result, SdkError> { + while !self.buffer.is_eos() { + if self.buffer.has_data() { + if let DecodedFrame::Complete(message) = self + .decoder + .decode_frame(self.buffer.buffered()) + .map_err(|err| { + SdkError::response_error( + err, + // the buffer has been consumed + RawMessage::Invalid(None), + ) + })? + { + trace!(message = ?message, "received complete event stream message"); + return Ok(Some(message)); + } + } + + self.buffer_next_chunk().await?; + } + if self.buffer.has_data() { + trace!(remaining_data = ?self.buffer, "data left over in the event stream response stream"); + let buf = self.buffer.buffered(); + return Err(SdkError::response_error( + ReceiverError { + kind: ReceiverErrorKind::UnexpectedEndOfStream, + }, + RawMessage::invalid(Some(buf.copy_to_bytes(buf.remaining()))), + )); + } + Ok(None) + } + + /// Tries to receive the initial response message that has `:event-type` of a given `message_type`. + /// If a different event type is received, then it is buffered and `Ok(None)` is returned. + #[doc(hidden)] + pub async fn try_recv_initial( + &mut self, + message_type: InitialMessageType, + ) -> Result, SdkError> { + if let Some(message) = self.next_message().await? { + if let Some(event_type) = message + .headers() + .iter() + .find(|h| h.name().as_str() == ":event-type") + { + if event_type + .value() + .as_string() + .map(|s| s.as_str() == message_type.as_str()) + .unwrap_or(false) + { + return Ok(Some(message)); + } + } + // Buffer the message so that it can be returned by the next call to `recv()` + self.buffered_message = Some(message); + } + Ok(None) + } + + /// Asynchronously tries to receive a message from the stream. If the stream has ended, + /// it returns an `Ok(None)`. If there is a transport layer error, it will return + /// `Err(SdkError::DispatchFailure)`. Service-modeled errors will be a part of the returned + /// messages. + pub async fn recv(&mut self) -> Result, SdkError> { + if let Some(buffered) = self.buffered_message.take() { + return match self.unmarshall(buffered) { + Ok(message) => Ok(message), + Err(error) => { + self.buffer = RecvBuf::Terminated; + Err(error) + } + }; + } + if let Some(message) = self.next_message().await? { + match self.unmarshall(message) { + Ok(message) => Ok(message), + Err(error) => { + self.buffer = RecvBuf::Terminated; + Err(error) + } + } + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::{InitialMessageType, Receiver, UnmarshallMessage}; + use aws_smithy_eventstream::error::Error as EventStreamError; + use aws_smithy_eventstream::frame::{write_message_to, UnmarshalledMessage}; + use aws_smithy_runtime_api::client::result::SdkError; + use aws_smithy_types::body::SdkBody; + use aws_smithy_types::event_stream::{Header, HeaderValue, Message}; + use bytes::Bytes; + use hyper::body::Body; + use std::error::Error as StdError; + use std::io::{Error as IOError, ErrorKind}; + + fn encode_initial_response() -> Bytes { + let mut buffer = Vec::new(); + let message = Message::new(Bytes::new()) + .add_header(Header::new( + ":message-type", + HeaderValue::String("event".into()), + )) + .add_header(Header::new( + ":event-type", + HeaderValue::String("initial-response".into()), + )); + write_message_to(&message, &mut buffer).unwrap(); + buffer.into() + } + + fn encode_message(message: &str) -> Bytes { + let mut buffer = Vec::new(); + let message = Message::new(Bytes::copy_from_slice(message.as_bytes())); + write_message_to(&message, &mut buffer).unwrap(); + buffer.into() + } + + #[derive(Debug)] + struct FakeError; + impl std::fmt::Display for FakeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FakeError") + } + } + impl StdError for FakeError {} + + #[derive(Debug, Eq, PartialEq)] + struct TestMessage(String); + + #[derive(Debug)] + struct Unmarshaller; + impl UnmarshallMessage for Unmarshaller { + type Output = TestMessage; + type Error = EventStreamError; + + fn unmarshall( + &self, + message: &Message, + ) -> Result, EventStreamError> { + Ok(UnmarshalledMessage::Event(TestMessage( + std::str::from_utf8(&message.payload()[..]).unwrap().into(), + ))) + } + } + + #[tokio::test] + async fn receive_success() { + let chunks: Vec> = + vec![Ok(encode_message("one")), Ok(encode_message("two"))]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("two".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!(None, receiver.recv().await.unwrap()); + } + + #[tokio::test] + async fn receive_last_chunk_empty() { + let chunks: Vec> = vec![ + Ok(encode_message("one")), + Ok(encode_message("two")), + Ok(Bytes::from_static(&[])), + ]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("two".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!(None, receiver.recv().await.unwrap()); + } + + #[tokio::test] + async fn receive_last_chunk_not_full_message() { + let chunks: Vec> = vec![ + Ok(encode_message("one")), + Ok(encode_message("two")), + Ok(encode_message("three").split_to(10)), + ]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("two".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert!(matches!( + receiver.recv().await, + Err(SdkError::ResponseError { .. }), + )); + } + + #[tokio::test] + async fn receive_last_chunk_has_multiple_messages() { + let chunks: Vec> = vec![ + Ok(encode_message("one")), + Ok(encode_message("two")), + Ok(Bytes::from( + [encode_message("three"), encode_message("four")].concat(), + )), + ]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("two".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("three".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("four".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!(None, receiver.recv().await.unwrap()); + } + + proptest::proptest! { + #[test] + fn receive_multiple_messages_split_unevenly_across_chunks(b1: usize, b2: usize) { + let combined = Bytes::from([ + encode_message("one"), + encode_message("two"), + encode_message("three"), + encode_message("four"), + encode_message("five"), + encode_message("six"), + encode_message("seven"), + encode_message("eight"), + ].concat()); + + let midpoint = combined.len() / 2; + let (start, boundary1, boundary2, end) = ( + 0, + b1 % midpoint, + midpoint + b2 % midpoint, + combined.len() + ); + println!("[{}, {}], [{}, {}], [{}, {}]", start, boundary1, boundary1, boundary2, boundary2, end); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async move { + let chunks: Vec> = vec![ + Ok(Bytes::copy_from_slice(&combined[start..boundary1])), + Ok(Bytes::copy_from_slice(&combined[boundary1..boundary2])), + Ok(Bytes::copy_from_slice(&combined[boundary2..end])), + ]; + + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + for payload in &["one", "two", "three", "four", "five", "six", "seven", "eight"] { + assert_eq!( + TestMessage((*payload).into()), + receiver.recv().await.unwrap().unwrap() + ); + } + assert_eq!(None, receiver.recv().await.unwrap()); + }); + } + } + + #[tokio::test] + async fn receive_network_failure() { + let chunks: Vec> = vec![ + Ok(encode_message("one")), + Err(IOError::new(ErrorKind::ConnectionReset, FakeError)), + ]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert!(matches!( + receiver.recv().await, + Err(SdkError::DispatchFailure(_)) + )); + } + + #[tokio::test] + async fn receive_message_parse_failure() { + let chunks: Vec> = vec![ + Ok(encode_message("one")), + // A zero length message will be invalid. We need to provide a minimum of 12 bytes + // for the MessageFrameDecoder to actually start parsing it. + Ok(Bytes::from_static(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])), + ]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert!(matches!( + receiver.recv().await, + Err(SdkError::ResponseError { .. }) + )); + } + + #[tokio::test] + async fn receive_initial_response() { + let chunks: Vec> = + vec![Ok(encode_initial_response()), Ok(encode_message("one"))]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert!(receiver + .try_recv_initial(InitialMessageType::Response) + .await + .unwrap() + .is_some()); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + } + + #[tokio::test] + async fn receive_no_initial_response() { + let chunks: Vec> = + vec![Ok(encode_message("one")), Ok(encode_message("two"))]; + let chunk_stream = futures_util::stream::iter(chunks); + let body = SdkBody::from_body_0_4(Body::wrap_stream(chunk_stream)); + let mut receiver = Receiver::::new(Unmarshaller, body); + assert!(receiver + .try_recv_initial(InitialMessageType::Response) + .await + .unwrap() + .is_none()); + assert_eq!( + TestMessage("one".into()), + receiver.recv().await.unwrap().unwrap() + ); + assert_eq!( + TestMessage("two".into()), + receiver.recv().await.unwrap().unwrap() + ); + } + + fn assert_send_and_sync() {} + + #[tokio::test] + async fn receiver_is_send_and_sync() { + assert_send_and_sync::>(); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/event_stream/sender.rs b/rust-runtime/aws-smithy-legacy-http/src/event_stream/sender.rs new file mode 100644 index 00000000000..7749abedada --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/event_stream/sender.rs @@ -0,0 +1,377 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use aws_smithy_eventstream::frame::{write_message_to, MarshallMessage, SignMessage}; +use aws_smithy_eventstream::message_size_hint::MessageSizeHint; +use aws_smithy_runtime_api::client::result::SdkError; +use aws_smithy_types::error::ErrorMetadata; +use bytes::Bytes; +use futures_core::Stream; +use std::error::Error as StdError; +use std::fmt; +use std::fmt::Debug; +use std::marker::PhantomData; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tracing::trace; + +/// Input type for Event Streams. +pub struct EventStreamSender { + input_stream: Pin> + Send + Sync>>, +} + +impl Debug for EventStreamSender { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name_t = std::any::type_name::(); + let name_e = std::any::type_name::(); + write!(f, "EventStreamSender<{name_t}, {name_e}>") + } +} + +impl EventStreamSender { + /// Creates an `EventStreamSender` from a single item. + pub fn once(item: Result) -> Self { + Self::from(futures_util::stream::once(async move { item })) + } +} + +impl EventStreamSender { + #[doc(hidden)] + pub fn into_body_stream( + self, + marshaller: impl MarshallMessage + Send + Sync + 'static, + error_marshaller: impl MarshallMessage + Send + Sync + 'static, + signer: impl SignMessage + Send + Sync + 'static, + ) -> MessageStreamAdapter { + MessageStreamAdapter::new(marshaller, error_marshaller, signer, self.input_stream) + } +} + +impl From for EventStreamSender +where + S: Stream> + Send + Sync + 'static, +{ + fn from(stream: S) -> Self { + EventStreamSender { + input_stream: Box::pin(stream), + } + } +} + +/// An error that occurs within a message stream. +#[derive(Debug)] +pub struct MessageStreamError { + kind: MessageStreamErrorKind, + pub(crate) meta: ErrorMetadata, +} + +#[derive(Debug)] +enum MessageStreamErrorKind { + Unhandled(Box), +} + +impl MessageStreamError { + /// Creates the `MessageStreamError::Unhandled` variant from any error type. + pub fn unhandled(err: impl Into>) -> Self { + Self { + meta: Default::default(), + kind: MessageStreamErrorKind::Unhandled(err.into()), + } + } + + /// Creates the `MessageStreamError::Unhandled` variant from an [`ErrorMetadata`]. + pub fn generic(err: ErrorMetadata) -> Self { + Self { + meta: err.clone(), + kind: MessageStreamErrorKind::Unhandled(err.into()), + } + } + + /// Returns error metadata, which includes the error code, message, + /// request ID, and potentially additional information. + pub fn meta(&self) -> &ErrorMetadata { + &self.meta + } +} + +impl StdError for MessageStreamError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match &self.kind { + MessageStreamErrorKind::Unhandled(source) => Some(source.as_ref() as _), + } + } +} + +impl fmt::Display for MessageStreamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + MessageStreamErrorKind::Unhandled(_) => write!(f, "message stream error"), + } + } +} + +/// Adapts a `Stream` to a signed `Stream` by using the provided +/// message marshaller and signer implementations. +/// +/// This will yield an `Err(SdkError::ConstructionFailure)` if a message can't be +/// marshalled into an Event Stream frame, (e.g., if the message payload was too large). +#[allow(missing_debug_implementations)] +pub struct MessageStreamAdapter { + marshaller: Box + Send + Sync>, + error_marshaller: Box + Send + Sync>, + signer: Box, + stream: Pin> + Send>>, + end_signal_sent: bool, + _phantom: PhantomData, +} + +impl Unpin for MessageStreamAdapter {} + +impl MessageStreamAdapter { + /// Create a new `MessageStreamAdapter`. + pub fn new( + marshaller: impl MarshallMessage + Send + Sync + 'static, + error_marshaller: impl MarshallMessage + Send + Sync + 'static, + signer: impl SignMessage + Send + Sync + 'static, + stream: Pin> + Send>>, + ) -> Self { + MessageStreamAdapter { + marshaller: Box::new(marshaller), + error_marshaller: Box::new(error_marshaller), + signer: Box::new(signer), + stream, + end_signal_sent: false, + _phantom: Default::default(), + } + } +} + +impl Stream for MessageStreamAdapter { + type Item = + Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.stream.as_mut().poll_next(cx) { + Poll::Ready(message_option) => { + if let Some(message_result) = message_option { + let message = match message_result { + Ok(message) => self + .marshaller + .marshall(message) + .map_err(SdkError::construction_failure)?, + Err(message) => self + .error_marshaller + .marshall(message) + .map_err(SdkError::construction_failure)?, + }; + + trace!(unsigned_message = ?message, "signing event stream message"); + let message = self + .signer + .sign(message) + .map_err(SdkError::construction_failure)?; + + let mut buffer = Vec::with_capacity(message.size_hint()); + write_message_to(&message, &mut buffer) + .map_err(SdkError::construction_failure)?; + trace!(signed_message = ?buffer, "sending signed event stream message"); + Poll::Ready(Some(Ok(Bytes::from(buffer)))) + } else if !self.end_signal_sent { + self.end_signal_sent = true; + match self.signer.sign_empty() { + Some(sign) => { + let message = sign.map_err(SdkError::construction_failure)?; + let mut buffer = Vec::with_capacity(message.size_hint()); + write_message_to(&message, &mut buffer) + .map_err(SdkError::construction_failure)?; + trace!(signed_message = ?buffer, "sending signed empty message to terminate the event stream"); + Poll::Ready(Some(Ok(Bytes::from(buffer)))) + } + None => Poll::Ready(None), + } + } else { + Poll::Ready(None) + } + } + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::MarshallMessage; + use crate::event_stream::{EventStreamSender, MessageStreamAdapter}; + use async_stream::stream; + use aws_smithy_eventstream::error::Error as EventStreamError; + use aws_smithy_eventstream::frame::{ + read_message_from, write_message_to, NoOpSigner, SignMessage, SignMessageError, + }; + use aws_smithy_runtime_api::client::result::SdkError; + use aws_smithy_types::event_stream::{Header, HeaderValue, Message}; + use bytes::Bytes; + use futures_core::Stream; + use futures_util::stream::StreamExt; + use std::error::Error as StdError; + + #[derive(Debug, Eq, PartialEq)] + struct TestMessage(String); + + #[derive(Debug)] + struct Marshaller; + impl MarshallMessage for Marshaller { + type Input = TestMessage; + + fn marshall(&self, input: Self::Input) -> Result { + Ok(Message::new(input.0.as_bytes().to_vec())) + } + } + #[derive(Debug)] + struct ErrorMarshaller; + impl MarshallMessage for ErrorMarshaller { + type Input = TestServiceError; + + fn marshall(&self, _input: Self::Input) -> Result { + Err(read_message_from(&b""[..]).expect_err("this should always fail")) + } + } + + #[derive(Debug)] + struct TestServiceError; + impl std::fmt::Display for TestServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TestServiceError") + } + } + impl StdError for TestServiceError {} + + #[derive(Debug)] + struct TestSigner; + impl SignMessage for TestSigner { + fn sign(&mut self, message: Message) -> Result { + let mut buffer = Vec::new(); + write_message_to(&message, &mut buffer).unwrap(); + Ok(Message::new(buffer).add_header(Header::new("signed", HeaderValue::Bool(true)))) + } + + fn sign_empty(&mut self) -> Option> { + Some(Ok( + Message::new(&b""[..]).add_header(Header::new("signed", HeaderValue::Bool(true))) + )) + } + } + + fn check_send_sync(value: T) -> T { + value + } + + #[test] + fn event_stream_sender_send_sync() { + check_send_sync(EventStreamSender::from(stream! { + yield Result::<_, SignMessageError>::Ok(TestMessage("test".into())); + })); + } + + fn check_compatible_with_hyper_wrap_stream(stream: S) -> S + where + S: Stream> + Send + 'static, + O: Into + 'static, + E: Into> + 'static, + { + stream + } + + #[tokio::test] + async fn message_stream_adapter_success() { + let stream = stream! { + yield Ok(TestMessage("test".into())); + }; + let mut adapter = check_compatible_with_hyper_wrap_stream(MessageStreamAdapter::< + TestMessage, + TestServiceError, + >::new( + Marshaller, + ErrorMarshaller, + TestSigner, + Box::pin(stream), + )); + + let mut sent_bytes = adapter.next().await.unwrap().unwrap(); + let sent = read_message_from(&mut sent_bytes).unwrap(); + assert_eq!("signed", sent.headers()[0].name().as_str()); + assert_eq!(&HeaderValue::Bool(true), sent.headers()[0].value()); + let inner = read_message_from(&mut (&sent.payload()[..])).unwrap(); + assert_eq!(&b"test"[..], &inner.payload()[..]); + + let mut end_signal_bytes = adapter.next().await.unwrap().unwrap(); + let end_signal = read_message_from(&mut end_signal_bytes).unwrap(); + assert_eq!("signed", end_signal.headers()[0].name().as_str()); + assert_eq!(&HeaderValue::Bool(true), end_signal.headers()[0].value()); + assert_eq!(0, end_signal.payload().len()); + } + + #[tokio::test] + async fn message_stream_adapter_construction_failure() { + let stream = stream! { + yield Err(TestServiceError); + }; + let mut adapter = check_compatible_with_hyper_wrap_stream(MessageStreamAdapter::< + TestMessage, + TestServiceError, + >::new( + Marshaller, + ErrorMarshaller, + NoOpSigner {}, + Box::pin(stream), + )); + + let result = adapter.next().await.unwrap(); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + SdkError::ConstructionFailure(_) + )); + } + + #[tokio::test] + async fn event_stream_sender_once() { + let sender = EventStreamSender::once(Ok(TestMessage("test".into()))); + let mut adapter = MessageStreamAdapter::::new( + Marshaller, + ErrorMarshaller, + TestSigner, + sender.input_stream, + ); + + let mut sent_bytes = adapter.next().await.unwrap().unwrap(); + let sent = read_message_from(&mut sent_bytes).unwrap(); + assert_eq!("signed", sent.headers()[0].name().as_str()); + let inner = read_message_from(&mut (&sent.payload()[..])).unwrap(); + assert_eq!(&b"test"[..], &inner.payload()[..]); + + // Should get end signal next + let mut end_signal_bytes = adapter.next().await.unwrap().unwrap(); + let end_signal = read_message_from(&mut end_signal_bytes).unwrap(); + assert_eq!("signed", end_signal.headers()[0].name().as_str()); + assert_eq!(0, end_signal.payload().len()); + + // Stream should be exhausted + assert!(adapter.next().await.is_none()); + } + + // Verify the developer experience for this compiles + #[allow(unused)] + fn event_stream_input_ergonomics() { + fn check(input: impl Into>) { + let _: EventStreamSender = input.into(); + } + check(stream! { + yield Ok(TestMessage("test".into())); + }); + check(stream! { + yield Err(TestServiceError); + }); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/futures_stream_adapter.rs b/rust-runtime/aws-smithy-legacy-http/src/futures_stream_adapter.rs new file mode 100644 index 00000000000..74b1adb144a --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/futures_stream_adapter.rs @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use aws_smithy_types::body::SdkBody; +use aws_smithy_types::byte_stream::error::Error as ByteStreamError; +use aws_smithy_types::byte_stream::ByteStream; +use bytes::Bytes; +use futures_core::stream::Stream; +use std::pin::Pin; +use std::task::{Context, Poll}; + +/// A new-type wrapper to enable the impl of the `futures_core::stream::Stream` trait +/// +/// [`ByteStream`] no longer implements `futures_core::stream::Stream` so we wrap it in the +/// new-type to enable the trait when it is required. +/// +/// This is meant to be used by codegen code, and users should not need to use it directly. +#[derive(Debug)] +pub struct FuturesStreamCompatByteStream(ByteStream); + +impl FuturesStreamCompatByteStream { + /// Creates a new `FuturesStreamCompatByteStream` by wrapping `stream`. + pub fn new(stream: ByteStream) -> Self { + Self(stream) + } + + /// Returns [`SdkBody`] of the wrapped [`ByteStream`]. + pub fn into_inner(self) -> SdkBody { + self.0.into_inner() + } +} + +impl Stream for FuturesStreamCompatByteStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_next(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_core::stream::Stream; + + fn check_compatible_with_hyper_wrap_stream(stream: S) -> S + where + S: Stream> + Send + 'static, + O: Into + 'static, + E: Into> + 'static, + { + stream + } + + #[test] + fn test_byte_stream_stream_can_be_made_compatible_with_hyper_wrap_stream() { + let stream = ByteStream::from_static(b"Hello world"); + check_compatible_with_hyper_wrap_stream(FuturesStreamCompatByteStream::new(stream)); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/header.rs b/rust-runtime/aws-smithy-legacy-http/src/header.rs new file mode 100644 index 00000000000..4519bf490f8 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/header.rs @@ -0,0 +1,762 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Utilities for parsing information from headers + +use aws_smithy_types::date_time::Format; +use aws_smithy_types::primitive::Parse; +use aws_smithy_types::DateTime; +use http_02x::header::{HeaderMap, HeaderName, HeaderValue}; +use std::borrow::Cow; +use std::error::Error; +use std::fmt; +use std::str::FromStr; + +/// An error was encountered while parsing a header +#[derive(Debug)] +pub struct ParseError { + message: Cow<'static, str>, + source: Option>, +} + +impl ParseError { + /// Create a new parse error with the given `message` + pub fn new(message: impl Into>) -> Self { + Self { + message: message.into(), + source: None, + } + } + + /// Attach a source to this error. + pub fn with_source(self, source: impl Into>) -> Self { + Self { + source: Some(source.into()), + ..self + } + } +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "output failed to parse in headers: {}", self.message) + } +} + +impl Error for ParseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|err| err.as_ref() as _) + } +} + +/// Read all the dates from the header map at `key` according the `format` +/// +/// This is separate from `read_many` below because we need to invoke `DateTime::read` to take advantage +/// of comma-aware parsing +pub fn many_dates<'a>( + values: impl Iterator, + format: Format, +) -> Result, ParseError> { + let mut out = vec![]; + for header in values { + let mut header = header; + while !header.is_empty() { + let (v, next) = DateTime::read(header, format, ',').map_err(|err| { + ParseError::new(format!("header could not be parsed as date: {}", err)) + })?; + out.push(v); + header = next; + } + } + Ok(out) +} + +/// Returns an iterator over pairs where the first element is the unprefixed header name that +/// starts with the input `key` prefix, and the second element is the full header name. +pub fn headers_for_prefix<'a>( + header_names: impl Iterator, + key: &'a str, +) -> impl Iterator { + let lower_key = key.to_ascii_lowercase(); + header_names + .filter(move |k| k.starts_with(&lower_key)) + .map(move |k| (&k[key.len()..], k)) +} + +/// Convert a `HeaderValue` into a `Vec` where `T: FromStr` +pub fn read_many_from_str<'a, T: FromStr>( + values: impl Iterator, +) -> Result, ParseError> +where + T::Err: Error + Send + Sync + 'static, +{ + read_many(values, |v: &str| { + v.parse().map_err(|err| { + ParseError::new("failed during `FromString` conversion").with_source(err) + }) + }) +} + +/// Convert a `HeaderValue` into a `Vec` where `T: Parse` +pub fn read_many_primitive<'a, T: Parse>( + values: impl Iterator, +) -> Result, ParseError> { + read_many(values, |v: &str| { + T::parse_smithy_primitive(v) + .map_err(|err| ParseError::new("failed reading a list of primitives").with_source(err)) + }) +} + +/// Read many comma / header delimited values from HTTP headers for `FromStr` types +fn read_many<'a, T>( + values: impl Iterator, + f: impl Fn(&str) -> Result, +) -> Result, ParseError> { + let mut out = vec![]; + for header in values { + let mut header = header.as_bytes(); + while !header.is_empty() { + let (v, next) = read_one(header, &f)?; + out.push(v); + header = next; + } + } + Ok(out) +} + +/// Read exactly one or none from a headers iterator +/// +/// This function does not perform comma splitting like `read_many` +pub fn one_or_none<'a, T: FromStr>( + mut values: impl Iterator, +) -> Result, ParseError> +where + T::Err: Error + Send + Sync + 'static, +{ + let first = match values.next() { + Some(v) => v, + None => return Ok(None), + }; + match values.next() { + None => T::from_str(first.trim()) + .map_err(|err| ParseError::new("failed to parse string").with_source(err)) + .map(Some), + Some(_) => Err(ParseError::new( + "expected a single value but found multiple", + )), + } +} + +/// Given an HTTP request, set a request header if that header was not already set. +pub fn set_request_header_if_absent( + request: http_02x::request::Builder, + key: HeaderName, + value: V, +) -> http_02x::request::Builder +where + HeaderValue: TryFrom, + >::Error: Into, +{ + if !request + .headers_ref() + .map(|map| map.contains_key(&key)) + .unwrap_or(false) + { + request.header(key, value) + } else { + request + } +} + +/// Given an HTTP response, set a response header if that header was not already set. +pub fn set_response_header_if_absent( + response: http_02x::response::Builder, + key: HeaderName, + value: V, +) -> http_02x::response::Builder +where + HeaderValue: TryFrom, + >::Error: Into, +{ + if !response + .headers_ref() + .map(|map| map.contains_key(&key)) + .unwrap_or(false) + { + response.header(key, value) + } else { + response + } +} + +/// Functions for parsing multiple comma-delimited header values out of a +/// single header. This parsing adheres to +/// [RFC-7230's specification of header values](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6). +mod parse_multi_header { + use super::ParseError; + use std::borrow::Cow; + + fn trim(s: Cow<'_, str>) -> Cow<'_, str> { + match s { + Cow::Owned(s) => Cow::Owned(s.trim().into()), + Cow::Borrowed(s) => Cow::Borrowed(s.trim()), + } + } + + fn replace<'a>(value: Cow<'a, str>, pattern: &str, replacement: &str) -> Cow<'a, str> { + if value.contains(pattern) { + Cow::Owned(value.replace(pattern, replacement)) + } else { + value + } + } + + /// Reads a single value out of the given input, and returns a tuple containing + /// the parsed value and the remainder of the slice that can be used to parse + /// more values. + pub(crate) fn read_value(input: &[u8]) -> Result<(Cow<'_, str>, &[u8]), ParseError> { + for (index, &byte) in input.iter().enumerate() { + let current_slice = &input[index..]; + match byte { + b' ' | b'\t' => { /* skip whitespace */ } + b'"' => return read_quoted_value(¤t_slice[1..]), + _ => { + let (value, rest) = read_unquoted_value(current_slice)?; + return Ok((trim(value), rest)); + } + } + } + + // We only end up here if the entire header value was whitespace or empty + Ok((Cow::Borrowed(""), &[])) + } + + fn read_unquoted_value(input: &[u8]) -> Result<(Cow<'_, str>, &[u8]), ParseError> { + let next_delim = input.iter().position(|&b| b == b',').unwrap_or(input.len()); + let (first, next) = input.split_at(next_delim); + let first = std::str::from_utf8(first) + .map_err(|_| ParseError::new("header was not valid utf-8"))?; + Ok((Cow::Borrowed(first), then_comma(next).unwrap())) + } + + /// Reads a header value that is surrounded by quotation marks and may have escaped + /// quotes inside of it. + fn read_quoted_value(input: &[u8]) -> Result<(Cow<'_, str>, &[u8]), ParseError> { + for index in 0..input.len() { + match input[index] { + b'"' if index == 0 || input[index - 1] != b'\\' => { + let mut inner = Cow::Borrowed( + std::str::from_utf8(&input[0..index]) + .map_err(|_| ParseError::new("header was not valid utf-8"))?, + ); + inner = replace(inner, "\\\"", "\""); + inner = replace(inner, "\\\\", "\\"); + let rest = then_comma(&input[(index + 1)..])?; + return Ok((inner, rest)); + } + _ => {} + } + } + Err(ParseError::new( + "header value had quoted value without end quote", + )) + } + + fn then_comma(s: &[u8]) -> Result<&[u8], ParseError> { + if s.is_empty() { + Ok(s) + } else if s.starts_with(b",") { + Ok(&s[1..]) + } else { + Err(ParseError::new("expected delimiter `,`")) + } + } +} + +/// Read one comma delimited value for `FromStr` types +fn read_one<'a, T>( + s: &'a [u8], + f: &impl Fn(&str) -> Result, +) -> Result<(T, &'a [u8]), ParseError> { + let (value, rest) = parse_multi_header::read_value(s)?; + Ok((f(&value)?, rest)) +} + +/// Conditionally quotes and escapes a header value if the header value contains a comma or quote. +pub fn quote_header_value<'a>(value: impl Into>) -> Cow<'a, str> { + let value = value.into(); + if value.trim().len() != value.len() + || value.contains('"') + || value.contains(',') + || value.contains('(') + || value.contains(')') + { + Cow::Owned(format!( + "\"{}\"", + value.replace('\\', "\\\\").replace('"', "\\\"") + )) + } else { + value + } +} + +/// Given two [`HeaderMap`]s, merge them together and return the merged `HeaderMap`. If the +/// two `HeaderMap`s share any keys, values from the right `HeaderMap` be appended to the left `HeaderMap`. +pub fn append_merge_header_maps( + mut lhs: HeaderMap, + rhs: HeaderMap, +) -> HeaderMap { + let mut last_header_name_seen = None; + for (header_name, header_value) in rhs.into_iter() { + // For each yielded item that has None provided for the `HeaderName`, + // then the associated header name is the same as that of the previously + // yielded item. The first yielded item will have `HeaderName` set. + // https://docs.rs/http/latest/http/header/struct.HeaderMap.html#method.into_iter-2 + match (&mut last_header_name_seen, header_name) { + (_, Some(header_name)) => { + lhs.append(header_name.clone(), header_value); + last_header_name_seen = Some(header_name); + } + (Some(header_name), None) => { + lhs.append(header_name.clone(), header_value); + } + (None, None) => unreachable!(), + }; + } + + lhs +} + +#[cfg(test)] +mod test { + use super::quote_header_value; + use crate::header::{ + append_merge_header_maps, headers_for_prefix, many_dates, read_many_from_str, + read_many_primitive, set_request_header_if_absent, set_response_header_if_absent, + ParseError, + }; + use aws_smithy_runtime_api::http::Request; + use aws_smithy_types::error::display::DisplayErrorContext; + use aws_smithy_types::{date_time::Format, DateTime}; + use http_02x::header::{HeaderMap, HeaderName, HeaderValue}; + use std::collections::HashMap; + + #[test] + fn put_on_request_if_absent() { + let builder = http_02x::Request::builder().header("foo", "bar"); + let builder = set_request_header_if_absent(builder, HeaderName::from_static("foo"), "baz"); + let builder = + set_request_header_if_absent(builder, HeaderName::from_static("other"), "value"); + let req = builder.body(()).expect("valid request"); + assert_eq!( + req.headers().get_all("foo").iter().collect::>(), + vec!["bar"] + ); + assert_eq!( + req.headers().get_all("other").iter().collect::>(), + vec!["value"] + ); + } + + #[test] + fn put_on_response_if_absent() { + let builder = http_02x::Response::builder().header("foo", "bar"); + let builder = set_response_header_if_absent(builder, HeaderName::from_static("foo"), "baz"); + let builder = + set_response_header_if_absent(builder, HeaderName::from_static("other"), "value"); + let response = builder.body(()).expect("valid response"); + assert_eq!( + response.headers().get_all("foo").iter().collect::>(), + vec!["bar"] + ); + assert_eq!( + response + .headers() + .get_all("other") + .iter() + .collect::>(), + vec!["value"] + ); + } + + #[test] + fn parse_floats() { + let test_request = http_02x::Request::builder() + .header("X-Float-Multi", "0.0,Infinity,-Infinity,5555.5") + .header("X-Float-Error", "notafloat") + .body(()) + .unwrap(); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Float-Multi") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .expect("valid"), + vec![0.0, f32::INFINITY, f32::NEG_INFINITY, 5555.5] + ); + let message = format!( + "{}", + DisplayErrorContext( + read_many_primitive::( + test_request + .headers() + .get_all("X-Float-Error") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .expect_err("invalid") + ) + ); + let expected = "output failed to parse in headers: failed reading a list of primitives: failed to parse input as f32"; + assert!( + message.starts_with(expected), + "expected '{message}' to start with '{expected}'" + ); + } + + #[test] + fn test_many_dates() { + let test_request = http_02x::Request::builder() + .header("Empty", "") + .header("SingleHttpDate", "Wed, 21 Oct 2015 07:28:00 GMT") + .header( + "MultipleHttpDates", + "Wed, 21 Oct 2015 07:28:00 GMT,Thu, 22 Oct 2015 07:28:00 GMT", + ) + .header("SingleEpochSeconds", "1234.5678") + .header("MultipleEpochSeconds", "1234.5678,9012.3456") + .body(()) + .unwrap(); + let read = |name: &str, format: Format| { + many_dates( + test_request + .headers() + .get_all(name) + .iter() + .map(|v| v.to_str().unwrap()), + format, + ) + }; + let read_valid = |name: &str, format: Format| read(name, format).expect("valid"); + assert_eq!( + read_valid("Empty", Format::DateTime), + Vec::::new() + ); + assert_eq!( + read_valid("SingleHttpDate", Format::HttpDate), + vec![DateTime::from_secs_and_nanos(1445412480, 0)] + ); + assert_eq!( + read_valid("MultipleHttpDates", Format::HttpDate), + vec![ + DateTime::from_secs_and_nanos(1445412480, 0), + DateTime::from_secs_and_nanos(1445498880, 0) + ] + ); + assert_eq!( + read_valid("SingleEpochSeconds", Format::EpochSeconds), + vec![DateTime::from_secs_and_nanos(1234, 567_800_000)] + ); + assert_eq!( + read_valid("MultipleEpochSeconds", Format::EpochSeconds), + vec![ + DateTime::from_secs_and_nanos(1234, 567_800_000), + DateTime::from_secs_and_nanos(9012, 345_600_000) + ] + ); + } + + #[test] + fn read_many_strings() { + let test_request = http_02x::Request::builder() + .header("Empty", "") + .header("Foo", " foo") + .header("FooTrailing", "foo ") + .header("FooInQuotes", "\" foo \"") + .header("CommaInQuotes", "\"foo,bar\",baz") + .header("CommaInQuotesTrailing", "\"foo,bar\",baz ") + .header("QuoteInQuotes", "\"foo\\\",bar\",\"\\\"asdf\\\"\",baz") + .header( + "QuoteInQuotesWithSpaces", + "\"foo\\\",bar\", \"\\\"asdf\\\"\", baz", + ) + .header("JunkFollowingQuotes", "\"\\\"asdf\\\"\"baz") + .header("EmptyQuotes", "\"\",baz") + .header("EscapedSlashesInQuotes", "foo, \"(foo\\\\bar)\"") + .body(()) + .unwrap(); + let read = |name: &str| { + read_many_from_str::( + test_request + .headers() + .get_all(name) + .iter() + .map(|v| v.to_str().unwrap()), + ) + }; + let read_valid = |name: &str| read(name).expect("valid"); + assert_eq!(read_valid("Empty"), Vec::::new()); + assert_eq!(read_valid("Foo"), vec!["foo"]); + assert_eq!(read_valid("FooTrailing"), vec!["foo"]); + assert_eq!(read_valid("FooInQuotes"), vec![" foo "]); + assert_eq!(read_valid("CommaInQuotes"), vec!["foo,bar", "baz"]); + assert_eq!(read_valid("CommaInQuotesTrailing"), vec!["foo,bar", "baz"]); + assert_eq!( + read_valid("QuoteInQuotes"), + vec!["foo\",bar", "\"asdf\"", "baz"] + ); + assert_eq!( + read_valid("QuoteInQuotesWithSpaces"), + vec!["foo\",bar", "\"asdf\"", "baz"] + ); + assert!(read("JunkFollowingQuotes").is_err()); + assert_eq!(read_valid("EmptyQuotes"), vec!["", "baz"]); + assert_eq!( + read_valid("EscapedSlashesInQuotes"), + vec!["foo", "(foo\\bar)"] + ); + } + + #[test] + fn read_many_bools() { + let test_request = http_02x::Request::builder() + .header("X-Bool-Multi", "true,false") + .header("X-Bool-Multi", "true") + .header("X-Bool", "true") + .header("X-Bool-Invalid", "truth,falsy") + .header("X-Bool-Single", "true,false,true,true") + .header("X-Bool-Quoted", "true,\"false\",true,true") + .body(()) + .unwrap(); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Bool-Multi") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .expect("valid"), + vec![true, false, true] + ); + + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Bool") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![true] + ); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Bool-Single") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![true, false, true, true] + ); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Bool-Quoted") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![true, false, true, true] + ); + read_many_primitive::( + test_request + .headers() + .get_all("X-Bool-Invalid") + .iter() + .map(|v| v.to_str().unwrap()), + ) + .expect_err("invalid"); + } + + #[test] + fn check_read_many_i16() { + let test_request = http_02x::Request::builder() + .header("X-Multi", "123,456") + .header("X-Multi", "789") + .header("X-Num", "777") + .header("X-Num-Invalid", "12ef3") + .header("X-Num-Single", "1,2,3,-4,5") + .header("X-Num-Quoted", "1, \"2\",3,\"-4\",5") + .body(()) + .unwrap(); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Multi") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .expect("valid"), + vec![123, 456, 789] + ); + + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Num") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![777] + ); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Num-Single") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![1, 2, 3, -4, 5] + ); + assert_eq!( + read_many_primitive::( + test_request + .headers() + .get_all("X-Num-Quoted") + .iter() + .map(|v| v.to_str().unwrap()) + ) + .unwrap(), + vec![1, 2, 3, -4, 5] + ); + read_many_primitive::( + test_request + .headers() + .get_all("X-Num-Invalid") + .iter() + .map(|v| v.to_str().unwrap()), + ) + .expect_err("invalid"); + } + + #[test] + fn test_prefix_headers() { + let test_request = Request::try_from( + http_02x::Request::builder() + .header("X-Prefix-A", "123,456") + .header("X-Prefix-B", "789") + .header("X-Prefix-C", "777") + .header("X-Prefix-C", "777") + .body(()) + .unwrap(), + ) + .unwrap(); + let resp: Result>, ParseError> = + headers_for_prefix(test_request.headers().iter().map(|h| h.0), "X-Prefix-") + .map(|(key, header_name)| { + let values = test_request.headers().get_all(header_name); + read_many_primitive(values).map(|v| (key.to_string(), v)) + }) + .collect(); + let resp = resp.expect("valid"); + assert_eq!(resp.get("a"), Some(&vec![123_i16, 456_i16])); + } + + #[test] + fn test_quote_header_value() { + assert_eq!("", "e_header_value("")); + assert_eq!("foo", "e_header_value("foo")); + assert_eq!("\" foo\"", "e_header_value(" foo")); + assert_eq!("foo bar", "e_header_value("foo bar")); + assert_eq!("\"foo,bar\"", "e_header_value("foo,bar")); + assert_eq!("\",\"", "e_header_value(",")); + assert_eq!("\"\\\"foo\\\"\"", "e_header_value("\"foo\"")); + assert_eq!("\"\\\"f\\\\oo\\\"\"", "e_header_value("\"f\\oo\"")); + assert_eq!("\"(\"", "e_header_value("(")); + assert_eq!("\")\"", "e_header_value(")")); + } + + #[test] + fn test_append_merge_header_maps_with_shared_key() { + let header_name = HeaderName::from_static("some_key"); + let left_header_value = HeaderValue::from_static("lhs value"); + let right_header_value = HeaderValue::from_static("rhs value"); + + let mut left_hand_side_headers = HeaderMap::new(); + left_hand_side_headers.insert(header_name.clone(), left_header_value.clone()); + + let mut right_hand_side_headers = HeaderMap::new(); + right_hand_side_headers.insert(header_name.clone(), right_header_value.clone()); + + let merged_header_map = + append_merge_header_maps(left_hand_side_headers, right_hand_side_headers); + let actual_merged_values: Vec<_> = + merged_header_map.get_all(header_name).into_iter().collect(); + + let expected_merged_values = vec![left_header_value, right_header_value]; + + assert_eq!(actual_merged_values, expected_merged_values); + } + + #[test] + fn test_append_merge_header_maps_with_multiple_values_in_left_hand_map() { + let header_name = HeaderName::from_static("some_key"); + let left_header_value_1 = HeaderValue::from_static("lhs value 1"); + let left_header_value_2 = HeaderValue::from_static("lhs_value 2"); + let right_header_value = HeaderValue::from_static("rhs value"); + + let mut left_hand_side_headers = HeaderMap::new(); + left_hand_side_headers.insert(header_name.clone(), left_header_value_1.clone()); + left_hand_side_headers.append(header_name.clone(), left_header_value_2.clone()); + + let mut right_hand_side_headers = HeaderMap::new(); + right_hand_side_headers.insert(header_name.clone(), right_header_value.clone()); + + let merged_header_map = + append_merge_header_maps(left_hand_side_headers, right_hand_side_headers); + let actual_merged_values: Vec<_> = + merged_header_map.get_all(header_name).into_iter().collect(); + + let expected_merged_values = + vec![left_header_value_1, left_header_value_2, right_header_value]; + + assert_eq!(actual_merged_values, expected_merged_values); + } + + #[test] + fn test_append_merge_header_maps_with_empty_left_hand_map() { + let header_name = HeaderName::from_static("some_key"); + let right_header_value_1 = HeaderValue::from_static("rhs value 1"); + let right_header_value_2 = HeaderValue::from_static("rhs_value 2"); + + let left_hand_side_headers = HeaderMap::new(); + + let mut right_hand_side_headers = HeaderMap::new(); + right_hand_side_headers.insert(header_name.clone(), right_header_value_1.clone()); + right_hand_side_headers.append(header_name.clone(), right_header_value_2.clone()); + + let merged_header_map = + append_merge_header_maps(left_hand_side_headers, right_hand_side_headers); + let actual_merged_values: Vec<_> = + merged_header_map.get_all(header_name).into_iter().collect(); + + let expected_merged_values = vec![right_header_value_1, right_header_value_2]; + + assert_eq!(actual_merged_values, expected_merged_values); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/label.rs b/rust-runtime/aws-smithy-legacy-http/src/label.rs new file mode 100644 index 00000000000..5d24fca4426 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/label.rs @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Formatting values as Smithy +//! [httpLabel](https://smithy.io/2.0/spec/http-bindings.html#httplabel-trait) + +use crate::urlencode::BASE_SET; +use aws_smithy_types::date_time::{DateTimeFormatError, Format}; +use aws_smithy_types::DateTime; +use percent_encoding::AsciiSet; + +const GREEDY: &AsciiSet = &BASE_SET.remove(b'/'); + +/// The encoding strategy used when parsing an `httpLabel`. +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EncodingStrategy { + /// The default strategy when parsing an `httpLabel`. Only one path segment will be matched. + Default, + /// When parsing an `httpLabel`, this strategy will attempt to parse as many path segments as possible. + Greedy, +} + +/// Format a given `httpLabel` as a string according to an [`EncodingStrategy`] +pub fn fmt_string>(t: T, strategy: EncodingStrategy) -> String { + let uri_set = if strategy == EncodingStrategy::Greedy { + GREEDY + } else { + BASE_SET + }; + percent_encoding::utf8_percent_encode(t.as_ref(), uri_set).to_string() +} + +/// Format a given [`DateTime`] as a string according to an [`EncodingStrategy`] +pub fn fmt_timestamp(t: &DateTime, format: Format) -> Result { + Ok(fmt_string(t.fmt(format)?, EncodingStrategy::Default)) +} + +#[cfg(test)] +mod test { + use crate::label::{fmt_string, EncodingStrategy}; + use http_02x::Uri; + use proptest::proptest; + + #[test] + fn greedy_params() { + assert_eq!(fmt_string("a/b", EncodingStrategy::Default), "a%2Fb"); + assert_eq!(fmt_string("a/b", EncodingStrategy::Greedy), "a/b"); + } + + proptest! { + #[test] + fn test_encode_request(s: String) { + let _: Uri = format!("http://host.example.com/{}", fmt_string(&s, EncodingStrategy::Default)) + .parse() + .expect("all strings should be encoded properly"); + let _: Uri = format!("http://host.example.com/{}", fmt_string(&s, EncodingStrategy::Greedy)) + .parse() + .expect("all strings should be encoded properly"); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/lib.rs b/rust-runtime/aws-smithy-legacy-http/src/lib.rs new file mode 100644 index 00000000000..45278996f48 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/lib.rs @@ -0,0 +1,46 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Automatically managed default lints */ +#![cfg_attr(docsrs, feature(doc_cfg))] +/* End of automatically managed default lints */ +#![warn( + missing_docs, + rustdoc::missing_crate_level_docs, + unreachable_pub, + rust_2018_idioms +)] + +//! Core HTTP primitives for service clients generated by [smithy-rs](https://github.com/smithy-lang/smithy-rs) including: +//! - HTTP Body implementation +//! - Endpoint support +//! - HTTP header deserialization +//! - Event streams +//! +//! | Feature | Description | +//! |----------------|-------------| +//! | `rt-tokio` | Provides features that are dependent on `tokio` including the `ByteStream::from_path` util | +//! | `event-stream` | Provides Sender/Receiver implementations for Event Stream codegen. | + +#![allow(clippy::derive_partial_eq_without_eq)] + +pub mod endpoint; +// Marked as `doc(hidden)` because a type in the module is used both by this crate and by the code +// generator, but not by external users. Also, by the module being `doc(hidden)` instead of it being +// in `rust-runtime/inlineable`, each user won't have to pay the cost of running the module's tests +// when compiling their generated SDK. +#[doc(hidden)] +pub mod futures_stream_adapter; +pub mod header; +pub mod label; +pub mod operation; +pub mod query; +#[doc(hidden)] +pub mod query_writer; + +#[cfg(feature = "event-stream")] +pub mod event_stream; + +mod urlencode; diff --git a/rust-runtime/aws-smithy-legacy-http/src/operation.rs b/rust-runtime/aws-smithy-legacy-http/src/operation.rs new file mode 100644 index 00000000000..39f42d95365 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/operation.rs @@ -0,0 +1,13 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Deprecated metadata type. + +/// Metadata added to the [`ConfigBag`](aws_smithy_types::config_bag::ConfigBag) that identifies the API being called. +#[deprecated( + since = "0.60.2", + note = "Use aws_smithy_runtime_api::client::orchestrator::Metadata instead." +)] +pub type Metadata = aws_smithy_runtime_api::client::orchestrator::Metadata; diff --git a/rust-runtime/aws-smithy-legacy-http/src/query.rs b/rust-runtime/aws-smithy-legacy-http/src/query.rs new file mode 100644 index 00000000000..bb1cc44a851 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/query.rs @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Utilities for writing Smithy values into a query string. +//! +//! Formatting values into the query string as specified in +//! [httpQuery](https://smithy.io/2.0/spec/http-bindings.html#httpquery-trait) + +use crate::urlencode::BASE_SET; +use aws_smithy_types::date_time::{DateTimeFormatError, Format}; +use aws_smithy_types::DateTime; +use percent_encoding::utf8_percent_encode; + +/// Format a given string as a query string. +pub fn fmt_string>(t: T) -> String { + utf8_percent_encode(t.as_ref(), BASE_SET).to_string() +} + +/// Format a given [`DateTime`] as a query string. +pub fn fmt_timestamp(t: &DateTime, format: Format) -> Result { + Ok(fmt_string(t.fmt(format)?)) +} + +/// Simple abstraction to enable appending params to a string as query params. +/// +/// ```rust +/// use aws_smithy_http::query::Writer; +/// let mut s = String::from("www.example.com"); +/// let mut q = Writer::new(&mut s); +/// q.push_kv("key", "value"); +/// q.push_v("another_value"); +/// assert_eq!(s, "www.example.com?key=value&another_value"); +/// ``` +#[allow(missing_debug_implementations)] +pub struct Writer<'a> { + out: &'a mut String, + prefix: char, +} + +impl<'a> Writer<'a> { + /// Create a new query string writer. + pub fn new(out: &'a mut String) -> Self { + Writer { out, prefix: '?' } + } + + /// Add a new key and value pair to this writer. + pub fn push_kv(&mut self, k: &str, v: &str) { + self.out.push(self.prefix); + self.out.push_str(k); + self.out.push('='); + self.out.push_str(v); + self.prefix = '&'; + } + + /// Add a new value (which is its own key) to this writer. + pub fn push_v(&mut self, v: &str) { + self.out.push(self.prefix); + self.out.push_str(v); + self.prefix = '&'; + } +} + +#[cfg(test)] +mod test { + use crate::query::{fmt_string, Writer}; + use http_02x::Uri; + use proptest::proptest; + + #[test] + fn url_encode() { + assert_eq!(fmt_string("y̆").as_str(), "y%CC%86"); + assert_eq!(fmt_string(" ").as_str(), "%20"); + assert_eq!(fmt_string("foo/baz%20").as_str(), "foo%2Fbaz%2520"); + assert_eq!(fmt_string("&=").as_str(), "%26%3D"); + assert_eq!(fmt_string("🐱").as_str(), "%F0%9F%90%B1"); + // `:` needs to be encoded, but only for AWS services + assert_eq!(fmt_string("a:b"), "a%3Ab") + } + + #[test] + fn writer_sets_prefix_properly() { + let mut out = String::new(); + let mut writer = Writer::new(&mut out); + writer.push_v("a"); + writer.push_kv("b", "c"); + assert_eq!(out, "?a&b=c"); + } + + proptest! { + #[test] + fn test_encode_request(s: String) { + let _: Uri = format!("http://host.example.com/?{}", fmt_string(s)).parse().expect("all strings should be encoded properly"); + } + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/query_writer.rs b/rust-runtime/aws-smithy-legacy-http/src/query_writer.rs new file mode 100644 index 00000000000..7694da6b02f --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/query_writer.rs @@ -0,0 +1,177 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::query::fmt_string as percent_encode_query; +use http_02x::uri::InvalidUri; +use http_02x::Uri; + +/// Utility for updating the query string in a [`Uri`]. +#[allow(missing_debug_implementations)] +pub struct QueryWriter { + base_uri: Uri, + new_path_and_query: String, + prefix: Option, +} + +impl QueryWriter { + /// Creates a new `QueryWriter` from a string + pub fn new_from_string(uri: &str) -> Result { + Ok(Self::new(&Uri::try_from(uri)?)) + } + + /// Creates a new `QueryWriter` based off the given `uri`. + pub fn new(uri: &Uri) -> Self { + let new_path_and_query = uri + .path_and_query() + .map(|pq| pq.to_string()) + .unwrap_or_default(); + let prefix = if uri.query().is_none() { + Some('?') + } else if !uri.query().unwrap_or_default().is_empty() { + Some('&') + } else { + None + }; + QueryWriter { + base_uri: uri.clone(), + new_path_and_query, + prefix, + } + } + + /// Clears all query parameters. + pub fn clear_params(&mut self) { + if let Some(index) = self.new_path_and_query.find('?') { + self.new_path_and_query.truncate(index); + self.prefix = Some('?'); + } + } + + /// Inserts a new query parameter. The key and value are percent encoded + /// by `QueryWriter`. Passing in percent encoded values will result in double encoding. + pub fn insert(&mut self, k: &str, v: &str) { + self.insert_encoded(&percent_encode_query(k), &percent_encode_query(v)); + } + + /// Inserts a new already encoded query parameter. The key and value will be inserted + /// as is. + pub fn insert_encoded(&mut self, encoded_k: &str, encoded_v: &str) { + if let Some(prefix) = self.prefix { + self.new_path_and_query.push(prefix); + } + self.prefix = Some('&'); + self.new_path_and_query.push_str(encoded_k); + self.new_path_and_query.push('='); + self.new_path_and_query.push_str(encoded_v) + } + + /// Returns just the built query string. + pub fn build_query(self) -> String { + self.build_uri().query().unwrap_or_default().to_string() + } + + /// Returns a full [`Uri`] with the query string updated. + pub fn build_uri(self) -> Uri { + let mut parts = self.base_uri.into_parts(); + parts.path_and_query = Some( + self.new_path_and_query + .parse() + .expect("adding query should not invalidate URI"), + ); + Uri::from_parts(parts).expect("a valid URL in should always produce a valid URL out") + } +} + +#[cfg(test)] +mod test { + use super::QueryWriter; + use http_02x::Uri; + + #[test] + fn empty_uri() { + let uri = Uri::from_static("http://www.example.com"); + let mut query_writer = QueryWriter::new(&uri); + query_writer.insert("key", "val%ue"); + query_writer.insert("another", "value"); + assert_eq!( + query_writer.build_uri(), + Uri::from_static("http://www.example.com?key=val%25ue&another=value") + ); + } + + #[test] + fn uri_with_path() { + let uri = Uri::from_static("http://www.example.com/path"); + let mut query_writer = QueryWriter::new(&uri); + query_writer.insert("key", "val%ue"); + query_writer.insert("another", "value"); + assert_eq!( + query_writer.build_uri(), + Uri::from_static("http://www.example.com/path?key=val%25ue&another=value") + ); + } + + #[test] + fn uri_with_path_and_query() { + let uri = Uri::from_static("http://www.example.com/path?original=here"); + let mut query_writer = QueryWriter::new(&uri); + query_writer.insert("key", "val%ue"); + query_writer.insert("another", "value"); + assert_eq!( + query_writer.build_uri(), + Uri::from_static( + "http://www.example.com/path?original=here&key=val%25ue&another=value" + ) + ); + } + + #[test] + fn build_query() { + let uri = Uri::from_static("http://www.example.com"); + let mut query_writer = QueryWriter::new(&uri); + query_writer.insert("key", "val%ue"); + query_writer.insert("ano%ther", "value"); + assert_eq!("key=val%25ue&ano%25ther=value", query_writer.build_query()); + } + + #[test] + // This test ensures that the percent encoding applied to queries always produces a valid URI if + // the starting URI is valid + fn doesnt_panic_when_adding_query_to_valid_uri() { + let uri = Uri::from_static("http://www.example.com"); + + let mut problematic_chars = Vec::new(); + + for byte in u8::MIN..=u8::MAX { + match std::str::from_utf8(&[byte]) { + // If we can't make a str from the byte then we certainly can't make a URL from it + Err(_) => { + continue; + } + Ok(value) => { + let mut query_writer = QueryWriter::new(&uri); + query_writer.insert("key", value); + + if std::panic::catch_unwind(|| query_writer.build_uri()).is_err() { + problematic_chars.push(char::from(byte)); + }; + } + } + } + + if !problematic_chars.is_empty() { + panic!("we got some bad bytes here: {:#?}", problematic_chars) + } + } + + #[test] + fn clear_params() { + let uri = Uri::from_static("http://www.example.com/path?original=here&foo=1"); + let mut query_writer = QueryWriter::new(&uri); + query_writer.clear_params(); + query_writer.insert("new", "value"); + assert_eq!("new=value", query_writer.build_query()); + } +} diff --git a/rust-runtime/aws-smithy-legacy-http/src/urlencode.rs b/rust-runtime/aws-smithy-legacy-http/src/urlencode.rs new file mode 100644 index 00000000000..cf97935f608 --- /dev/null +++ b/rust-runtime/aws-smithy-legacy-http/src/urlencode.rs @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use percent_encoding::{AsciiSet, CONTROLS}; + +/// base set of characters that must be URL encoded +pub(crate) const BASE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'/') + // RFC-3986 §3.3 allows sub-delims (defined in section2.2) to be in the path component. + // This includes both colon ':' and comma ',' characters. + // Smithy protocol tests & AWS services percent encode these expected values. Signing + // will fail if these values are not percent encoded + .add(b':') + .add(b',') + .add(b'?') + .add(b'#') + .add(b'[') + .add(b']') + .add(b'{') + .add(b'}') + .add(b'|') + .add(b'@') + .add(b'!') + .add(b'$') + .add(b'&') + .add(b'\'') + .add(b'(') + .add(b')') + .add(b'*') + .add(b'+') + .add(b';') + .add(b'=') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'"') + .add(b'^') + .add(b'`') + .add(b'\\'); + +#[cfg(test)] +mod test { + use crate::urlencode::BASE_SET; + use percent_encoding::utf8_percent_encode; + + #[test] + fn set_includes_mandatory_characters() { + let chars = ":/?#[]@!$&'()*+,;=%"; + let escaped = utf8_percent_encode(chars, BASE_SET).to_string(); + assert_eq!( + escaped, + "%3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%25" + ); + + // sanity check that every character is escaped + assert_eq!(escaped.len(), chars.len() * 3); + } +}