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 0000000000000000000000000000000000000000..998a3adb77951ce5a8a6340b2d4d549c52649b3b --- /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 53a94c08ad866e4dacd6ded897e1d9bc38acb7d9..7a36278dc36f0fe8fed50dfff926bed586f0e9db 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 de0d36a2db9ecb7801ba75039624df7d6d4a9436..811a59dd0c24f28b5d8d31709a6ea90b6f69d7fa 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 8c91309f9b5badefc599a8a28bf4bb506472e8fa..c1056b9f0321c50e17044d9ffa0dfcda17942252 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 9de53b193054c01bd295887e38ba29513c6d5a17..5d053cf1afbb20eb166c391ee949cfc456378265 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 182e6b33219f84b58981f3a8d81ea0f6f3e781a5..7326bd7af7cf59a0391c559332fb4ed94869744a 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 80628dc68a628f9b055a410258a3fdf7e5870e33..95cd3c3b7a086ffac529c197c9f0bab5033ea92d 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 150922873f9ca27e02b368bc2e29a056672e8047..15f7771da96e4b1de4099b35c0a7ea6bd36f11cf 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 0000000000000000000000000000000000000000..1dac5ed1deba8a65fc1505579c6af5f185d2033a --- /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 465d9eae172ba625a3c7528dcd4f8d9ac5b736fd..33e6825286a637fd1a466475888449b54eaea7de 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 0000000000000000000000000000000000000000..1976ab0b283fc6ef4ca69517ebcacb41e7d1fd6f --- /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 6f6eb2ec5a76103208bb261ccf407c3717357cf9..2a28be2f7672a805402a2acb928c80aa9e21040b 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 da5a2f3377ed7ce381587f51c2c6f820b7d3b8ab..bd3e31a46acd216f793a7c4b4b80b754efad3a3b 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 0000000000000000000000000000000000000000..60a555396618c8316b73bcdfa9e655403f668169 --- /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 0000000000000000000000000000000000000000..3e8d57ea5fb6d93351352b755a238cdd5e50f17f --- /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 0000000000000000000000000000000000000000..24eb452aec934d90d905273fc4899b8c265d22ab --- /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 0000000000000000000000000000000000000000..103a26af3beb3e42dca6b86866f230b0a2bea198 --- /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 0000000000000000000000000000000000000000..8e49d40986ed07abc79d64566ffe08bdffd02bb7 --- /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 0000000000000000000000000000000000000000..713a137e7f0b68f99981fb47c8499ab218db027a --- /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 0000000000000000000000000000000000000000..ad5b81a1d1ec02717542ec086f638082482d5240 --- /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() {}