From 7fabbb74c6b22d4e03d00e87356627cafb81f1a8 Mon Sep 17 00:00:00 2001 From: david-perez Date: Tue, 16 Nov 2021 18:30:08 +0100 Subject: [PATCH] Add server operation registry and router (#850) This commit adds two things: 1. A runtime router implementing `tower`'s [`Service`](https://docs.rs/tower-service/0.3.1/tower_service/trait.Service.html) that adheres to [Smithy's `http` trait specification](https://awslabs.github.io/smithy/1.0/spec/core/http-traits.html#http-trait), that is linear in the number of registered routes. 2. A code-generated "operation registry" that allows service implementers to provide Rust functions and declare them as the handlers for their service's operations. The framework will receive HTTP requests from the server and route them to the corresponding operation handler. --- .../generators/OperationRegistryGenerator.kt | 129 +++++++++ .../generators/ServerServiceGenerator.kt | 6 +- .../protocols/ServerHttpProtocolGenerator.kt | 136 ++++----- .../rust/codegen/rustlang/CargoDependency.kt | 2 +- .../smithy/rust/codegen/rustlang/RustTypes.kt | 1 - .../rust/codegen/smithy/RuntimeTypes.kt | 7 + .../aws-smithy-http-server/Cargo.toml | 10 +- .../aws-smithy-http-server/src/body.rs | 4 + .../src/clone_box_service.rs | 63 +++++ .../aws-smithy-http-server/src/error.rs | 6 +- .../aws-smithy-http-server/src/handler/mod.rs | 104 +++++++ .../aws-smithy-http-server/src/lib.rs | 19 +- .../aws-smithy-http-server/src/macros.rs | 46 +++ .../src/routing/future.rs | 62 ++++ .../src/routing/into_make_service.rs | 91 ++++++ .../aws-smithy-http-server/src/routing/mod.rs | 264 +++++++++++++++++ .../src/routing/operation_handler.rs | 62 ++++ .../src/routing/request_spec.rs | 265 ++++++++++++++++++ .../src/routing/route.rs | 133 +++++++++ .../src/test_helpers.rs | 2 + 20 files changed, 1335 insertions(+), 77 deletions(-) create mode 100644 codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/OperationRegistryGenerator.kt create mode 100644 rust-runtime/aws-smithy-http-server/src/clone_box_service.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/handler/mod.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/future.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/into_make_service.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/mod.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/operation_handler.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/request_spec.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/routing/route.rs create mode 100644 rust-runtime/aws-smithy-http-server/src/test_helpers.rs diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/OperationRegistryGenerator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/OperationRegistryGenerator.kt new file mode 100644 index 000000000..998a3adb7 --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/OperationRegistryGenerator.kt @@ -0,0 +1,129 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +package software.amazon.smithy.rust.codegen.server.smithy.generators + +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.rust.codegen.rustlang.* +import software.amazon.smithy.rust.codegen.server.smithy.protocols.ServerHttpProtocolGenerator +import software.amazon.smithy.rust.codegen.smithy.CodegenContext +import software.amazon.smithy.rust.codegen.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.smithy.RuntimeType.Companion.RequestSpecModule +import software.amazon.smithy.rust.codegen.smithy.generators.error.errorSymbol +import software.amazon.smithy.rust.codegen.smithy.protocols.HttpBindingResolver +import software.amazon.smithy.rust.codegen.smithy.protocols.HttpTraitHttpBindingResolver +import software.amazon.smithy.rust.codegen.smithy.protocols.ProtocolContentTypes +import software.amazon.smithy.rust.codegen.util.inputShape +import software.amazon.smithy.rust.codegen.util.outputShape +import software.amazon.smithy.rust.codegen.util.toSnakeCase + +/** + * OperationRegistryGenerator + */ +class OperationRegistryGenerator( + codegenContext: CodegenContext, + private val operations: List, +) { + private val serverCrate = "aws_smithy_http_server" + private val service = codegenContext.serviceShape + private val model = codegenContext.model + private val symbolProvider = codegenContext.symbolProvider + private val operationNames = operations.map { symbolProvider.toSymbol(it).name.toSnakeCase() } + private val runtimeConfig = codegenContext.runtimeConfig + private val codegenScope = arrayOf( + "Router" to RuntimeType.Router(runtimeConfig), + ) + private val httpBindingResolver: HttpBindingResolver = + HttpTraitHttpBindingResolver(codegenContext.model, ProtocolContentTypes.consistent("application/json")) + + fun render(writer: RustWriter) { + Attribute.Derives(setOf(RuntimeType.Debug, RuntimeType.DeriveBuilder)).render(writer) + Attribute.Custom("builder(pattern = \"owned\")").render(writer) + // Generic arguments of the `OperationRegistryBuilder`. + val operationsGenericArguments = operations.mapIndexed { i, _ -> "Fun$i, Fut$i"}.joinToString() + val operationRegistryName = "${service.getContextualName(service)}OperationRegistry<${operationsGenericArguments}>" + writer.rustBlock(""" + pub struct $operationRegistryName + where + ${operationsTraitBounds()} + """.trimIndent()) { + val members = operationNames + .mapIndexed { i, operationName -> "$operationName: Fun$i" } + .joinToString(separator = ",\n") + rust(members) + } + + writer.rustBlockTemplate(""" + impl<${operationsGenericArguments}> From<$operationRegistryName> for #{Router} + where + ${operationsTraitBounds()} + """.trimIndent(), *codegenScope) { + rustBlock("fn from(registry: ${operationRegistryName}) -> Self") { + val operationInOutWrappers = operations.map { + val operationName = symbolProvider.toSymbol(it).name + Pair("crate::operation::$operationName${ServerHttpProtocolGenerator.OPERATION_INPUT_WRAPPER_SUFFIX}", + "crate::operation::$operationName${ServerHttpProtocolGenerator.OPERATION_OUTPUT_WRAPPER_SUFFIX}") + } + val requestSpecsVarNames = operationNames.map { "${it}_request_spec" } + val routes = requestSpecsVarNames.zip(operationNames).zip(operationInOutWrappers) { (requestSpecVarName, operationName), (inputWrapper, outputWrapper) -> + ".route($requestSpecVarName, $serverCrate::routing::operation_handler::operation::<_, _, $inputWrapper, _, $outputWrapper>(registry.$operationName))" + }.joinToString(separator = "\n") + + val requestSpecs = requestSpecsVarNames.zip(operations) { requestSpecVarName, operation -> + "let $requestSpecVarName = ${operation.requestSpec()};" + }.joinToString(separator = "\n") + rustTemplate(""" + $requestSpecs + #{Router}::new() + $routes + """.trimIndent(), *codegenScope) + } + } + } + + private fun operationsTraitBounds(): String = operations + .mapIndexed { i, operation -> + val outputType = if (operation.errors.isNotEmpty()) { + "Result<${symbolProvider.toSymbol(operation.outputShape(model)).fullName}, ${operation.errorSymbol(symbolProvider).fullyQualifiedName()}>" + } else { + symbolProvider.toSymbol(operation.outputShape(model)).fullName + } + """ + Fun$i: FnOnce(${symbolProvider.toSymbol(operation.inputShape(model))}) -> Fut$i + Clone + Send + Sync + 'static, + Fut$i: std::future::Future + Send + """.trimIndent() + }.joinToString(separator = ",\n") + + private fun OperationShape.requestSpec(): String { + val httpTrait = httpBindingResolver.httpTrait(this) + val namespace = RequestSpecModule(runtimeConfig).fullyQualifiedName() + + // TODO Support the `endpoint` trait: https://awslabs.github.io/smithy/1.0/spec/core/endpoint-traits.html#endpoint-trait + + val pathSegments = httpTrait.uri.segments.map { + "$namespace::PathSegment::" + + if (it.isGreedyLabel) "Greedy" + else if (it.isLabel) "Label" + else "Literal(String::from(\"${it.content}\"))" + } + val querySegments = httpTrait.uri.queryLiterals.map { + "$namespace::QuerySegment::" + + if (it.value == "") "Key(String::from(\"${it.key}\"))" + else "KeyValue(String::from(\"${it.key}\"), String::from(\"${it.value}\"))" + } + + return """ + $namespace::RequestSpec::new( + http::Method::${httpTrait.method}, + $namespace::UriSpec { + host_prefix: None, + path_and_query: $namespace::PathAndQuerySpec { + path_segments: $namespace::PathSpec::from_vector_unchecked(vec![${pathSegments.joinToString()}]), + query_segments: $namespace::QuerySpec::from_vector_unchecked(vec![${querySegments.joinToString()}]) + } + } + )""".trimIndent() + } +} \ No newline at end of file diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerServiceGenerator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerServiceGenerator.kt index 53a94c08a..7a36278dc 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerServiceGenerator.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerServiceGenerator.kt @@ -36,7 +36,7 @@ class ServerServiceGenerator( */ fun render() { val operations = index.getContainedOperations(context.serviceShape).sortedBy { it.id } - operations.map { operation -> + for (operation in operations) { rustCrate.useShapeWriter(operation) { operationWriter -> protocolGenerator.serverRenderOperation( operationWriter, @@ -54,5 +54,9 @@ class ServerServiceGenerator( } } } + rustCrate.withModule(RustModule.public("operation_registry", "A registry of your service's operations.")) { writer -> + OperationRegistryGenerator(context, operations) + .render(writer) + } } } diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/protocols/ServerHttpProtocolGenerator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/protocols/ServerHttpProtocolGenerator.kt index de0d36a2d..811a59dd0 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/protocols/ServerHttpProtocolGenerator.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/protocols/ServerHttpProtocolGenerator.kt @@ -38,13 +38,7 @@ import software.amazon.smithy.rust.codegen.smithy.protocols.HttpBindingDescripto import software.amazon.smithy.rust.codegen.smithy.protocols.HttpBoundProtocolBodyGenerator import software.amazon.smithy.rust.codegen.smithy.protocols.HttpLocation import software.amazon.smithy.rust.codegen.smithy.protocols.Protocol -import software.amazon.smithy.rust.codegen.util.dq -import software.amazon.smithy.rust.codegen.util.expectTrait -import software.amazon.smithy.rust.codegen.util.getTrait -import software.amazon.smithy.rust.codegen.util.hasStreamingMember -import software.amazon.smithy.rust.codegen.util.inputShape -import software.amazon.smithy.rust.codegen.util.outputShape -import software.amazon.smithy.rust.codegen.util.toSnakeCase +import software.amazon.smithy.rust.codegen.util.* import java.util.logging.Logger /* @@ -77,7 +71,7 @@ class ServerHttpProtocolGenerator( /* * Generate all operation input parsers and output serializers for streaming and - * non-straming types. + * non-streaming types. */ private class ServerHttpProtocolImplGenerator( private val codegenContext: CodegenContext, @@ -109,37 +103,48 @@ private class ServerHttpProtocolImplGenerator( val outputSymbol = symbolProvider.toSymbol(operationShape.outputShape(model)) val operationName = symbolProvider.toSymbol(operationShape).name - // For streaming response bodies, we need to generate a different implementation of the parse traits. - // These will first offer the streaming input to the parser & potentially read the body into memory - // if an error occurred or if the streaming parser indicates that it needs the full data to proceed. - if (operationShape.outputShape(model).hasStreamingMember(model)) { - with(operationWriter) { - renderStreamingTraits(operationName, outputSymbol, operationShape) - } - } else { - with(operationWriter) { - renderNonStreamingTraits(operationName, inputSymbol, outputSymbol, operationShape) - } - } + operationWriter.renderTraits(operationName, inputSymbol, outputSymbol, operationShape) } /* - * Generation of non-streaming traits. A non-streaming trait requires the HTTP body to be fully read in - * memory before parsing or deserialization. From a server perspective we need a way to parse an HTTP - * request from `Bytes` and serialize a HTTP response to `Bytes`. These traits are the public entrypoint - * of the ser/de logic of the smithy-rs server. + * Generation of `FromRequest` and `IntoResponse`. They are currently only implemented for non-streaming request + * and response bodies, that is, models without streaming traits + * (https://awslabs.github.io/smithy/1.0/spec/core/stream-traits.html). + * For non-streaming request bodies, we require the HTTP body to be fully read in memory before parsing or + * deserialization. From a server perspective we need a way to parse an HTTP request from `Bytes` and serialize + * an HTTP response to `Bytes`. + * TODO Add support for streaming. + * These traits are the public entrypoint of the ser/de logic of the `aws-smithy-http-server` server. */ - private fun RustWriter.renderNonStreamingTraits( + private fun RustWriter.renderTraits( operationName: String?, inputSymbol: Symbol, outputSymbol: Symbol, operationShape: OperationShape ) { - // Implement Axum `FromRequest` trait for non streaming input types. + // Implement Axum `FromRequest` trait for input types. val inputName = "${operationName}${ServerHttpProtocolGenerator.OPERATION_INPUT_WRAPPER_SUFFIX}" + + val fromRequest = if (operationShape.inputShape(model).hasStreamingMember(model)) { + // For streaming request bodies, we need to generate a different implementation of the `FromRequest` trait. + // It will first offer the streaming input to the parser and potentially read the body into memory + // if an error occurred or if the streaming parser indicates that it needs the full data to proceed. + """ + async fn from_request(_req: &mut #{Axum}::extract::RequestParts) -> Result { + todo!("Streaming support for input shapes is not yet supported in `smithy-rs`") + } + """.trimIndent() + } else { + """ + async fn from_request(req: &mut #{Axum}::extract::RequestParts) -> Result { + #{SmithyHttpServer}::protocols::check_json_content_type(req)?; + Ok($inputName(#{parse_request}(req).await?)) + } + """.trimIndent() + } rustTemplate( """ - struct $inputName(#{I}); + pub(crate) struct $inputName(#{I}); ##[#{Axum}::async_trait] impl #{Axum}::extract::FromRequest for $inputName where @@ -149,30 +154,46 @@ private class ServerHttpProtocolImplGenerator( #{SmithyRejection}: From<::Error> { type Rejection = #{SmithyRejection}; - async fn from_request(req: &mut #{Axum}::extract::RequestParts) -> Result { - #{SmithyHttpServer}::protocols::check_json_content_type(req)?; - Ok($inputName(#{parse_request}(req).await?)) - } + $fromRequest }""".trimIndent(), *codegenScope, "I" to inputSymbol, "parse_request" to serverParseRequest(operationShape) ) - // Implement Axum `IntoResponse` for non streaming output types. + // Implement Axum `IntoResponse` for output types. val outputName = "${operationName}${ServerHttpProtocolGenerator.OPERATION_OUTPUT_WRAPPER_SUFFIX}" val errorSymbol = operationShape.errorSymbol(symbolProvider) - val handleSerializeOutput = """ - Ok(response) => response, - Err(e) => #{http}::Response::builder().body(Self::Body::from(e.to_string())).expect("unable to build response from output") - """.trimIndent() + // For streaming response bodies, we need to generate a different implementation of the `IntoResponse` trait. + // The body type will have to be a `StreamBody`. The service implementer will return a `Stream` from their handler. + val intoResponseStreaming = "todo!(\"Streaming support for output shapes is not yet supported in `smithy-rs`\")" if (operationShape.errors.isNotEmpty()) { + val intoResponseImpl = if (operationShape.outputShape(model).hasStreamingMember(model)) { + intoResponseStreaming + } else { + """ + match self { + Self::Output(o) => { + match #{serialize_response}(&o) { + Ok(response) => response, + Err(e) => #{http}::Response::builder().body(Self::Body::from(e.to_string())).expect("unable to build response from output") + } + }, + Self::Error(err) => { + match #{serialize_error}(&err) { + Ok(response) => response, + Err(e) => #{http}::Response::builder().body(Self::Body::from(e.to_string())).expect("unable to build response from error") + } + } + } + """.trimIndent() + } // The output of fallible operations is a `Result` which we convert into an isomorphic `enum` type we control // that can in turn be converted into a response. rustTemplate( """ - enum $outputName { + pub(crate) enum $outputName { Output(#{O}), Error(#{E}) } @@ -182,19 +203,7 @@ private class ServerHttpProtocolImplGenerator( type BodyError = ::Error; fn into_response(self) -> #{http}::Response { - match self { - Self::Output(o) => { - match #{serialize_response}(&o) { - $handleSerializeOutput - } - }, - Self::Error(err) => { - match #{serialize_error}(&err) { - Ok(response) => response, - Err(e) => #{http}::Response::builder().body(Self::Body::from(e.to_string())).expect("unable to build response from error") - } - } - } + $intoResponseImpl } }""".trimIndent(), *codegenScope, @@ -204,20 +213,28 @@ private class ServerHttpProtocolImplGenerator( "serialize_error" to serverSerializeError(operationShape) ) } else { + val handleSerializeOutput = if (operationShape.outputShape(model).hasStreamingMember(model)) { + intoResponseStreaming + } else { + """ + match #{serialize_response}(&self.0) { + Ok(response) => response, + Err(e) => #{http}::Response::builder().body(Self::Body::from(e.to_string())).expect("unable to build response from output") + } + """.trimIndent() + } // The output of non-fallible operations is a model type which we convert into a "wrapper" unit `struct` type // we control that can in turn be converted into a response. rustTemplate( """ - struct $outputName(#{O}); + pub(crate) struct $outputName(#{O}); ##[#{Axum}::async_trait] impl #{Axum}::response::IntoResponse for $outputName { type Body = #{SmithyHttpServer}::Body; type BodyError = ::Error; fn into_response(self) -> #{http}::Response { - match #{serialize_response}(&self.0) { - $handleSerializeOutput - } + $handleSerializeOutput } }""".trimIndent(), *codegenScope, @@ -268,17 +285,6 @@ private class ServerHttpProtocolImplGenerator( ) } - /* - * TODO: implement streaming traits - */ - private fun RustWriter.renderStreamingTraits( - operationName: String, - outputSymbol: Symbol, - operationShape: OperationShape - ) { - logger.warning("[rust-server-codegen] $operationName: streaming trait is not yet implemented") - } - private fun serverParseRequest(operationShape: OperationShape): RuntimeType { val fnName = "parse_${operationShape.id.name.toSnakeCase()}_request" val inputShape = operationShape.inputShape(model) diff --git a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/CargoDependency.kt b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/CargoDependency.kt index 8c91309f9..c1056b9f0 100644 --- a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/CargoDependency.kt +++ b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/CargoDependency.kt @@ -192,6 +192,7 @@ data class CargoDependency( val Axum: CargoDependency = CargoDependency("axum", CratesIo("0.3")) val Bytes: CargoDependency = CargoDependency("bytes", CratesIo("1")) val BytesUtils: CargoDependency = CargoDependency("bytes-utils", CratesIo("0.1.1")) + val DeriveBuilder = CargoDependency("derive_builder", CratesIo("0.10")) val FastRand: CargoDependency = CargoDependency("fastrand", CratesIo("1")) val Hex: CargoDependency = CargoDependency("hex", CratesIo("0.4.3")) val HttpBody: CargoDependency = CargoDependency("http-body", CratesIo("0.4")) @@ -213,7 +214,6 @@ data class CargoDependency( fun SmithyEventStream(runtimeConfig: RuntimeConfig) = runtimeConfig.runtimeCrate("eventstream") fun SmithyHttp(runtimeConfig: RuntimeConfig) = runtimeConfig.runtimeCrate("http") fun SmithyHttpServer(runtimeConfig: RuntimeConfig) = runtimeConfig.runtimeCrate("http-server") - fun SmithyHttpTower(runtimeConfig: RuntimeConfig) = runtimeConfig.runtimeCrate("http-tower") fun SmithyProtocolTestHelpers(runtimeConfig: RuntimeConfig) = runtimeConfig.runtimeCrate("protocol-test").copy(scope = DependencyScope.Dev) fun smithyJson(runtimeConfig: RuntimeConfig): CargoDependency = runtimeConfig.runtimeCrate("json") diff --git a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/RustTypes.kt b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/RustTypes.kt index 9de53b193..5d053cf1a 100644 --- a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/RustTypes.kt +++ b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/rustlang/RustTypes.kt @@ -317,7 +317,6 @@ sealed class Attribute { val container: Boolean = false ) : Attribute() { override fun render(writer: RustWriter) { - val bang = if (container) "!" else "" writer.raw("#$bang[$annotation]") symbols.forEach { diff --git a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/smithy/RuntimeTypes.kt b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/smithy/RuntimeTypes.kt index 182e6b332..7326bd7af 100644 --- a/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/smithy/RuntimeTypes.kt +++ b/codegen/src/main/kotlin/software/amazon/smithy/rust/codegen/smithy/RuntimeTypes.kt @@ -176,6 +176,13 @@ data class RuntimeType(val name: String?, val dependency: RustDependency?, val n val PartialEq = std.member("cmp::PartialEq") val StdError = RuntimeType("Error", dependency = null, namespace = "std::error") val String = RuntimeType("String", dependency = null, namespace = "std::string") + val DeriveBuilder = RuntimeType("Builder", dependency = CargoDependency.DeriveBuilder, namespace = "derive_builder") + + fun RequestSpecModule(runtimeConfig: RuntimeConfig) = + RuntimeType("request_spec", CargoDependency.SmithyHttpServer(runtimeConfig), "${runtimeConfig.crateSrcPrefix}_http_server::routing") + + fun Router(runtimeConfig: RuntimeConfig) = + RuntimeType("Router", CargoDependency.SmithyHttpServer(runtimeConfig), "${runtimeConfig.crateSrcPrefix}_http_server::routing") fun DateTime(runtimeConfig: RuntimeConfig) = RuntimeType("DateTime", CargoDependency.SmithyTypes(runtimeConfig), "${runtimeConfig.crateSrcPrefix}_types") diff --git a/rust-runtime/aws-smithy-http-server/Cargo.toml b/rust-runtime/aws-smithy-http-server/Cargo.toml index 80628dc68..95cd3c3b7 100644 --- a/rust-runtime/aws-smithy-http-server/Cargo.toml +++ b/rust-runtime/aws-smithy-http-server/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/awslabs/smithy-rs" keywords = ["smithy", "framework", "web", "api", "aws"] categories = ["asynchronous", "web-programming", "api-bindings"] description = """ -HTTP server runtime and utilities. +Server runtime for Smithy Rust Server Framework. NOTE: THIS IS HIGHLY EXPERIMENTAL AND SHOULD NOT BE USED YET. """ @@ -20,11 +20,19 @@ aws-smithy-http = { path = "../aws-smithy-http" } aws-smithy-types = { path = "../aws-smithy-types" } aws-smithy-json = { path = "../aws-smithy-json" } axum = { version = "0.3", features = [ "http1", "http2", "headers", "mime", "tower-log" ], default-features = false } +async-trait = "0.1" bytes = "1.1" +futures-util = { version = "0.3", default-features = false } http = "0.2" http-body = "0.4" hyper = { version = "0.14", features = ["server", "http1", "http2", "tcp"] } mime = "0.3" +pin-project = "1.0" +regex = "1.0" +serde_urlencoded = "0.7" +thiserror = "1" +tokio = { version = "1.0", features = ["full"] } +tower = { version = "0.4" } [dev-dependencies] pretty_assertions = "1" diff --git a/rust-runtime/aws-smithy-http-server/src/body.rs b/rust-runtime/aws-smithy-http-server/src/body.rs index 150922873..15f7771da 100644 --- a/rust-runtime/aws-smithy-http-server/src/body.rs +++ b/rust-runtime/aws-smithy-http-server/src/body.rs @@ -60,3 +60,7 @@ where { body.map_err(Error::new).boxed_unsync() } + +pub(crate) fn empty() -> BoxBody { + box_body(http_body::Empty::new()) +} diff --git a/rust-runtime/aws-smithy-http-server/src/clone_box_service.rs b/rust-runtime/aws-smithy-http-server/src/clone_box_service.rs new file mode 100644 index 000000000..1dac5ed1d --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/clone_box_service.rs @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +use futures_util::future::BoxFuture; +use std::task::{Context, Poll}; +use tower::{Service, ServiceExt}; + +/// A `Clone + Send` boxed `Service` +pub(crate) struct CloneBoxService( + Box>> + Send>, +); + +impl CloneBoxService { + pub(crate) fn new(inner: S) -> Self + where + S: Service + Clone + Send + 'static, + S::Future: Send + 'static, + { + let inner = inner.map_future(|f| Box::pin(f) as _); + CloneBoxService(Box::new(inner)) + } +} + +impl Service for CloneBoxService { + type Response = U; + type Error = E; + type Future = BoxFuture<'static, Result>; + + #[inline] + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.poll_ready(cx) + } + + #[inline] + fn call(&mut self, request: T) -> Self::Future { + self.0.call(request) + } +} + +impl Clone for CloneBoxService { + fn clone(&self) -> Self { + Self(self.0.clone_box()) + } +} + +trait CloneService: Service { + fn clone_box( + &self, + ) -> Box + Send>; +} + +impl CloneService for T +where + T: Service + Send + Clone + 'static, +{ + fn clone_box( + &self, + ) -> Box + Send> { + Box::new(self.clone()) + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/error.rs b/rust-runtime/aws-smithy-http-server/src/error.rs index 465d9eae1..33e682528 100644 --- a/rust-runtime/aws-smithy-http-server/src/error.rs +++ b/rust-runtime/aws-smithy-http-server/src/error.rs @@ -37,14 +37,14 @@ use crate::BoxError; use std::{error::Error as StdError, fmt}; -/// Errors that can happen when using `aws-smithy-http-server`. +/// Errors that can happen when using `aws-smithy-server`. #[derive(Debug)] pub struct Error { - pub inner: BoxError, + inner: BoxError, } impl Error { - pub fn new(error: impl Into) -> Self { + pub(crate) fn new(error: impl Into) -> Self { Self { inner: error.into() } } } diff --git a/rust-runtime/aws-smithy-http-server/src/handler/mod.rs b/rust-runtime/aws-smithy-http-server/src/handler/mod.rs new file mode 100644 index 000000000..1976ab0b2 --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/handler/mod.rs @@ -0,0 +1,104 @@ +/* + * 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. + */ + +//! Async functions that can be used to handle requests. +//! +//! For a function to be used as a handler it must implement the [`Handler`] trait. +//! We provide blanket implementations for functions that: +//! +//! - Are `async fn`s. +//! - Take one argument that can be converted (via [`std::convert::Into`]) into a type that +//! implements [`FromRequest`]. +//! - Returns a type that can be converted (via [`std::convert::Into`]) into a type that implements +//! [`IntoResponse`]. +//! - If a closure is used it must implement `Clone + Send` and be +//! `'static`. +//! - Returns a future that is `Send`. The most common way to accidentally make a +//! future `!Send` is to hold a `!Send` type across an await. + +use async_trait::async_trait; +use axum::extract::{FromRequest, RequestParts}; +use axum::response::IntoResponse; +use http::{Request, Response}; +use std::future::Future; + +use crate::body::{box_body, BoxBody}; + +pub(crate) mod sealed { + #![allow(unreachable_pub, missing_docs, missing_debug_implementations)] + + pub trait HiddenTrait {} + pub struct Hidden; + impl HiddenTrait for Hidden {} +} + +#[async_trait] +pub trait Handler: Clone + Send + Sized + 'static { + #[doc(hidden)] + type Sealed: sealed::HiddenTrait; + + async fn call(self, req: Request) -> Response; +} + +#[async_trait] +#[allow(non_snake_case)] +impl Handler for F +where + F: FnOnce(I) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + B: Send + 'static, + Res: From, + Res: IntoResponse, + I: From + Send, + T: FromRequest + Send, +{ + type Sealed = sealed::Hidden; + + async fn call(self, req: Request) -> Response { + let mut req = RequestParts::new(req); + + let wrapper = match T::from_request(&mut req).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response().map(box_body), + }; + + let input_inner: I = wrapper.into(); + + let output_inner: O = self(input_inner).await; + + let res: Res = output_inner.into(); + + res.into_response().map(box_body) + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/lib.rs b/rust-runtime/aws-smithy-http-server/src/lib.rs index 6f6eb2ec5..2a28be2f7 100644 --- a/rust-runtime/aws-smithy-http-server/src/lib.rs +++ b/rust-runtime/aws-smithy-http-server/src/lib.rs @@ -5,21 +5,30 @@ //! HTTP server runtime and utilities, loosely based on Axum. -#![cfg_attr(docsrs, feature(doc_cfg))] - #[macro_use] pub(crate) mod macros; -mod body; -mod error; +pub mod body; +mod clone_box_service; +pub mod error; +mod handler; + +// Only the code-generated operation registry should instantiate routers. +// We therefore hide it in the documentation. +#[doc(hidden)] +pub mod routing; +#[doc(hidden)] pub mod protocols; pub mod rejection; #[doc(inline)] -pub use self::body::{box_body, Body, BoxBody, HttpBody}; +pub use self::body::{Body, BoxBody, HttpBody}; #[doc(inline)] pub use self::error::Error; /// Alias for a type-erased error type. pub type BoxError = Box; + +#[cfg(test)] +mod test_helpers; diff --git a/rust-runtime/aws-smithy-http-server/src/macros.rs b/rust-runtime/aws-smithy-http-server/src/macros.rs index da5a2f337..bd3e31a46 100644 --- a/rust-runtime/aws-smithy-http-server/src/macros.rs +++ b/rust-runtime/aws-smithy-http-server/src/macros.rs @@ -171,5 +171,51 @@ macro_rules! composite_rejection { } } } + } +} + +/// Define a type that implements [`std::future::Future`]. +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;) => { + $(#[$m])* + #[pin_project::pin_project] + 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) + } + } }; } diff --git a/rust-runtime/aws-smithy-http-server/src/routing/future.rs b/rust-runtime/aws-smithy-http-server/src/routing/future.rs new file mode 100644 index 000000000..60a555396 --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/routing/future.rs @@ -0,0 +1,62 @@ +/* + * 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. + */ + +//! Future types. + +use crate::body::BoxBody; +use futures_util::future::Either; +use http::{Request, Response}; +use std::{convert::Infallible, future::ready}; +use tower::util::Oneshot; + +pub use super::{into_make_service::IntoMakeService, route::RouteFuture}; + +type OneshotRoute = Oneshot, Request>; +type ReadyResponse = std::future::Ready, Infallible>>; + +opaque_future! { + /// Response future for [`Router`](super::Router). + pub type RouterFuture = + futures_util::future::Either, ReadyResponse>; +} + +impl RouterFuture { + pub(super) fn from_oneshot(future: Oneshot, Request>) -> Self { + Self::new(Either::Left(future)) + } + + pub(super) fn from_response(response: Response) -> Self { + Self::new(Either::Right(ready(Ok(response)))) + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/routing/into_make_service.rs b/rust-runtime/aws-smithy-http-server/src/routing/into_make_service.rs new file mode 100644 index 000000000..3e8d57ea5 --- /dev/null +++ b/rust-runtime/aws-smithy-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(super) 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-http-server/src/routing/mod.rs b/rust-runtime/aws-smithy-http-server/src/routing/mod.rs new file mode 100644 index 000000000..24eb452ae --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/routing/mod.rs @@ -0,0 +1,264 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +//! An HTTP router that adheres to the [Smithy specification]. +//! +//! The router is a [`tower::Service`] that routes incoming requests to other `Service`s +//! based on the requests' URI and HTTP method. +//! It currently does not support Smithy's [endpoint trait]. +//! +//! **This router should not be used directly**; it should only be used by generated code from the +//! Smithy model. +//! +//! [Smithy specification]: https://awslabs.github.io/smithy/1.0/spec/core/http-traits.html#http-trait +//! [endpoint trait]: https://awslabs.github.io/smithy/1.0/spec/core/endpoint-traits.html#endpoint-trait + +use self::{future::RouterFuture, request_spec::RequestSpec}; +use crate::body::{Body, BoxBody}; +use http::{Request, Response, StatusCode}; +use std::{ + convert::Infallible, + task::{Context, Poll}, +}; +use tower::{Service, ServiceExt}; + +pub mod future; +mod into_make_service; +pub mod operation_handler; +pub mod request_spec; +mod route; + +pub use self::{into_make_service::IntoMakeService, route::Route}; + +#[derive(Debug)] +pub struct Router { + routes: Vec>, +} + +impl Clone for Router { + fn clone(&self) -> Self { + Self { routes: self.routes.clone() } + } +} + +impl Default for Router +where + B: Send + 'static, +{ + fn default() -> Self { + Self::new() + } +} + +impl Router +where + B: Send + 'static, +{ + /// Create a new `Router`. + /// + /// Unless you add additional routes this will respond to `404 Not Found` to + /// all requests. + pub fn new() -> Self { + Self { routes: Default::default() } + } + + /// Add a route to the router. + pub fn route(mut self, request_spec: RequestSpec, svc: T) -> Self + where + T: Service, Response = Response, Error = Infallible> + Clone + Send + 'static, + T::Future: Send + 'static, + { + self.routes.push(Route::new(svc, request_spec)); + self + } + + /// Convert this router into a [`MakeService`], that is a [`Service`] whose + /// response is another service. + /// + /// This is useful when running your application with hyper's + /// [`Server`](hyper::server::Server). + /// + /// [`MakeService`]: tower::make::MakeService + pub fn into_make_service(self) -> IntoMakeService { + IntoMakeService::new(self) + } +} + +impl Service> for Router +where + B: Send + 'static, +{ + type Response = Response; + type Error = Infallible; + type Future = RouterFuture; + + #[inline] + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, req: Request) -> Self::Future { + let mut method_not_allowed = false; + + for route in &self.routes { + match route.matches(&req) { + request_spec::Match::Yes => { + return RouterFuture::from_oneshot(route.clone().oneshot(req)); + } + request_spec::Match::MethodNotAllowed => method_not_allowed = true, + // Continue looping to see if another route matches. + request_spec::Match::No => continue, + } + } + + let status_code = if method_not_allowed { StatusCode::METHOD_NOT_ALLOWED } else { StatusCode::NOT_FOUND }; + RouterFuture::from_response(Response::builder().status(status_code).body(crate::body::empty()).unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{body::box_body, routing::request_spec::*}; + use futures_util::Future; + use http::Method; + use std::pin::Pin; + + /// Helper function to build a `Request`. Used in other test modules. + pub fn req(method: &Method, uri: &str) -> Request<()> { + Request::builder().method(method).uri(uri).body(()).unwrap() + } + + /// A service that returns its name and the request's URI in the response body. + #[derive(Clone)] + struct NamedEchoUriService(String); + + impl Service> for NamedEchoUriService { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, req: Request) -> Self::Future { + let body = box_body(Body::from(format!("{} :: {}", self.0, String::from(req.uri().to_string())))); + let fut = async { Ok(Response::builder().status(&http::StatusCode::OK).body(body).unwrap()) }; + Box::pin(fut) + } + } + + // Returns a `Response`'s body as a `String`, without consuming the response. + async fn get_body_as_str(res: &mut Response) -> String + where + B: http_body::Body + std::marker::Unpin, + B::Error: std::fmt::Debug, + { + let body_mut = res.body_mut(); + let body_bytes = hyper::body::to_bytes(body_mut).await.unwrap(); + String::from(std::str::from_utf8(&body_bytes).unwrap()) + } + + // 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 + #[tokio::test] + async fn simple_routing() { + let request_specs: Vec<(RequestSpec, &str)> = vec![ + ( + RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("a")), PathSegment::Label, PathSegment::Label], + vec![], + ), + "A", + ), + ( + RequestSpec::from_parts( + Method::GET, + vec![ + PathSegment::Literal(String::from("mg")), + PathSegment::Greedy, + PathSegment::Literal(String::from("z")), + ], + vec![], + ), + "MiddleGreedy", + ), + ( + RequestSpec::from_parts( + Method::DELETE, + vec![], + 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", + ), + ]; + + let mut router = Router::new(); + for (spec, svc_name) in request_specs { + let svc = NamedEchoUriService(String::from(svc_name)); + router = router.route(spec, svc.clone()); + } + + 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 { + let mut res = router.call(req(method, uri)).await.unwrap(); + let actual_body = get_body_as_str(&mut res).await; + + assert_eq!(format!("{} :: {}", svc_name, uri), actual_body); + } + + for (_, _, uri) in hits { + let res = router.call(req(&Method::PATCH, uri)).await.unwrap(); + assert_eq!(StatusCode::METHOD_NOT_ALLOWED, res.status()); + } + + 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.call(req(&method, miss)).await.unwrap(); + assert_eq!(StatusCode::NOT_FOUND, res.status()); + } + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/routing/operation_handler.rs b/rust-runtime/aws-smithy-http-server/src/routing/operation_handler.rs new file mode 100644 index 000000000..103a26af3 --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/routing/operation_handler.rs @@ -0,0 +1,62 @@ +use crate::{body::BoxBody, handler::Handler}; +use futures_util::{ + future::{BoxFuture, Map}, + FutureExt, +}; +use http::{Request, Response}; +use std::{ + convert::Infallible, + marker::PhantomData, + task::{Context, Poll}, +}; +use tower::Service; + +/// Struct that holds a handler, that is, a function provided by the user that implements the +/// Smithy operation. +pub struct OperationHandler { + handler: H, + #[allow(clippy::type_complexity)] + _marker: PhantomData (B, T, I, Res)>, +} + +impl Clone for OperationHandler +where + H: Clone, +{ + fn clone(&self) -> Self { + Self { handler: self.handler.clone(), _marker: PhantomData } + } +} + +/// Construct an [`OperationHandler`] out of a function implementing the operation. +pub fn operation(handler: H) -> OperationHandler { + OperationHandler { handler, _marker: PhantomData } +} + +impl Service> for OperationHandler +where + H: Handler, + B: Send + 'static, +{ + type Response = Response; + type Error = Infallible; + type Future = OperationHandlerFuture; + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + let future = Handler::call(self.handler.clone(), req).map(Ok::<_, Infallible> as _); + OperationHandlerFuture::new(future) + } +} + +type WrapResultInResponseFn = fn(Response) -> Result, Infallible>; + +opaque_future! { + /// Response future for [`OperationHandler`]. + pub type OperationHandlerFuture = + Map>, WrapResultInResponseFn>; +} diff --git a/rust-runtime/aws-smithy-http-server/src/routing/request_spec.rs b/rust-runtime/aws-smithy-http-server/src/routing/request_spec.rs new file mode 100644 index 000000000..8e49d4098 --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/routing/request_spec.rs @@ -0,0 +1,265 @@ +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 { + pub path_segments: PathSpec, + pub query_segments: QuerySpec, +} + +#[derive(Debug, Clone)] +pub struct UriSpec { + pub host_prefix: Option>, + pub path_and_query: PathAndQuerySpec, +} + +#[derive(Debug, Clone)] +pub struct RequestSpec { + method: http::Method, + uri_spec: UriSpec, + uri_path_regex: Regex, +} + +#[derive(Debug, PartialEq)] +pub 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 = uri_path_spec + .0 + .iter() + .map(|segment_spec| match segment_spec { + PathSegment::Literal(literal) => literal, + // TODO URL spec says it should be ASCII but this regex accepts UTF-8: + // https://github.com/awslabs/smithy/issues/975 + PathSegment::Label => "[^/]+", + PathSegment::Greedy => ".*", + }) + .fold(String::new(), |a, b| a + sep + b); + + Regex::new(&format!("{}$", re)).unwrap() + } +} + +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 } + } + + pub(super) 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<&str, &str>` 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. + let res = serde_urlencoded::from_str::>(query); + + match res { + Err(_) => 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::super::tests::req; + use super::*; + use http::Method; + + #[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![], + ); + + 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))); + } + } + + #[test] + fn repeated_query_keys() { + let spec = RequestSpec::from_parts(Method::DELETE, vec![], 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))); + } + } + + fn key_value_spec() -> RequestSpec { + RequestSpec::from_parts( + Method::DELETE, + vec![], + 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"))); + } + + #[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"))); + } + + fn ab_spec() -> RequestSpec { + RequestSpec::from_parts( + Method::GET, + vec![PathSegment::Literal(String::from("a")), PathSegment::Literal(String::from("b"))], + vec![], + ) + } + + #[test] + fn empty_segments_in_the_middle_dont_matter() { + let hits = vec![(Method::GET, "/a/b"), (Method::GET, "/a//b"), (Method::GET, "//////a//b")]; + for (method, uri) in &hits { + assert_eq!(Match::Yes, ab_spec().matches(&req(method, uri))); + } + } + + // 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))); + } + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/routing/route.rs b/rust-runtime/aws-smithy-http-server/src/routing/route.rs new file mode 100644 index 000000000..713a137e7 --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/routing/route.rs @@ -0,0 +1,133 @@ +/* + * 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}, + clone_box_service::CloneBoxService, +}; +use http::{Request, Response}; +use pin_project::pin_project; +use std::{ + convert::Infallible, + fmt, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use tower::Service; +use tower::{util::Oneshot, ServiceExt}; + +use super::request_spec::{Match, RequestSpec}; + +/// How routes are stored inside a [`Router`](super::Router). +pub struct Route { + service: CloneBoxService, Response, Infallible>, + request_spec: RequestSpec, +} + +impl Route { + pub(super) fn new(svc: T, request_spec: RequestSpec) -> Self + where + T: Service, Response = Response, Error = Infallible> + Clone + Send + 'static, + T::Future: Send + 'static, + { + Self { service: CloneBoxService::new(svc), request_spec } + } + + pub(super) fn matches(&self, req: &Request) -> Match { + self.request_spec.matches(req) + } +} + +impl Clone for Route { + fn clone(&self) -> Self { + Self { service: self.service.clone(), request_spec: self.request_spec.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)) + } +} + +/// Response future for [`Route`]. +#[pin_project] +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-http-server/src/test_helpers.rs b/rust-runtime/aws-smithy-http-server/src/test_helpers.rs new file mode 100644 index 000000000..ad5b81a1d --- /dev/null +++ b/rust-runtime/aws-smithy-http-server/src/test_helpers.rs @@ -0,0 +1,2 @@ +pub(crate) fn assert_send() {} +pub(crate) fn assert_sync() {} -- GitLab