diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index 22c93c56..8191e175 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -150,6 +150,9 @@ pub trait TypedPath: std::fmt::Display { /// The path with optional captures such as `/users/:id`. const PATH: &'static str; + /// The parameter types this path requires. + type Parameters; + /// Convert the path into a `Uri`. /// /// # Panics diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index b1416c8b..84609c80 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -21,9 +21,9 @@ pub(crate) fn expand(item_struct: ItemStruct) -> syn::Result { let Attrs { path } = parse_attrs(attrs)?; match fields { - syn::Fields::Named(_) => { + syn::Fields::Named(fields) => { let segments = parse_path(&path)?; - Ok(expand_named_fields(ident, path, &segments)) + Ok(expand_named_fields(ident, fields, path, &segments)) } syn::Fields::Unnamed(fields) => { let segments = parse_path(&path)?; @@ -63,14 +63,23 @@ fn parse_attrs(attrs: &[syn::Attribute]) -> syn::Result { }) } -fn expand_named_fields(ident: &syn::Ident, path: LitStr, segments: &[Segment]) -> TokenStream { +fn expand_named_fields( + ident: &syn::Ident, + fields: &syn::FieldsNamed, + path: LitStr, + segments: &[Segment], +) -> TokenStream { let format_str = format_str_from_path(segments); let captures = captures_from_path(segments); + let params = fields.named.iter().map(|field| &field.ty); + let typed_path_impl = quote_spanned! {path.span()=> #[automatically_derived] impl ::axum_extra::routing::TypedPath for #ident { const PATH: &'static str = #path; + + type Parameters = (#(#params,)*); } }; @@ -156,10 +165,14 @@ fn expand_unnamed_fields( let format_str = format_str_from_path(segments); let captures = captures_from_path(segments); + let params = fields.unnamed.iter().map(|field| &field.ty); + let typed_path_impl = quote_spanned! {path.span()=> #[automatically_derived] impl ::axum_extra::routing::TypedPath for #ident { const PATH: &'static str = #path; + + type Parameters = (#(#params,)*); } }; @@ -224,6 +237,8 @@ fn expand_unit_fields(ident: &syn::Ident, path: LitStr) -> syn::Result { + impl<$($ty,)*> DescribeRequest for ($($ty,)*) + where + $($ty: DescribeRequest,)* + { + fn describe(operation: &mut Operation, components: &mut Components) { + $( $ty::describe(operation, components); )* + } + } + }; +} + +all_the_tuples!(impl_tuples); + +impl DescribeRequest for Json +where + T: JsonSchema, +{ + fn describe(operation: &mut Operation, components: &mut Components) { + let RootSchema { + mut schema, + definitions, + meta_schema: _, + } = schemars::schema_for!(T); + + components.schemas.extend( + definitions + .into_iter() + .filter_map(|(k, schema)| match schema { + Schema::Bool(_) => None, + Schema::Object(obj) => Some((k, obj)), + }), + ); + + schema.object().properties = std::mem::take(&mut schema.object().properties) + .into_iter() + .map(|(key, schema)| match schema { + Schema::Bool(_) => (key, schema), + Schema::Object(mut obj) => { + if let Some(reference) = &mut obj.reference { + *reference = reference.replace("/definitions/", "/components/schemas/"); + } + (key, Schema::Object(obj)) + } + }) + .collect(); + + let request_body = RequestBody { + content: schemars::Map::from_iter([( + mime::APPLICATION_JSON.to_string(), + MediaType { + schema: Some(schema), + ..Default::default() + }, + )]), + required: true, + ..Default::default() + }; + + operation.request_body = Some(RefOr::Object(request_body)); + } +} diff --git a/axum-openapi/src/describe_response.rs b/axum-openapi/src/describe_response.rs new file mode 100644 index 00000000..43ff1f12 --- /dev/null +++ b/axum-openapi/src/describe_response.rs @@ -0,0 +1,74 @@ +use axum::{ + body::HttpBody, + extract::FromRequest, + handler::Handler, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{self, MethodRouter}, + Json, Router, +}; +use okapi::openapi3::{ + self, Components, Info, MediaType, OpenApi, Operation, Parameter, RefOr, RequestBody, +}; +use schemars::{ + schema::{RootSchema, Schema}, + JsonSchema, +}; +use std::{ + collections::BTreeMap, convert::Infallible, future::Future, marker::PhantomData, sync::Arc, +}; + +pub trait DescribeResponse { + fn describe(operation: &mut Operation, components: &mut Components); +} + +impl DescribeResponse for () { + fn describe(operation: &mut Operation, components: &mut Components) { + Ok::describe(operation, components) + } +} + +macro_rules! impl_tuples { + ( $($ty:ident),* $(,)? ) => { + impl<$($ty,)*> DescribeResponse for ($($ty,)*) + where + $($ty: DescribeResponse,)* + { + fn describe(operation: &mut Operation, components: &mut Components) { + $( $ty::describe(operation, components); )* + } + } + }; +} + +all_the_tuples!(impl_tuples); + +macro_rules! status { + ( + $name:ident, $variant:ident + ) => { + #[derive(Copy, Clone)] + pub struct $name; + + impl IntoResponse for $name { + fn into_response(self) -> Response { + StatusCode::$variant.into_response() + } + } + + impl DescribeResponse for $name { + fn describe(operation: &mut Operation, _: &mut Components) { + operation.responses.responses.insert( + StatusCode::$variant.as_u16().to_string(), + RefOr::Object(openapi3::Response { + description: "Successful response".to_owned(), + ..Default::default() + }), + ); + } + } + }; +} + +status!(Ok, OK); +status!(Created, CREATED); diff --git a/axum-openapi/src/lib.rs b/axum-openapi/src/lib.rs index 1a4b4655..6b4e67c8 100644 --- a/axum-openapi/src/lib.rs +++ b/axum-openapi/src/lib.rs @@ -1,16 +1,57 @@ -#![allow(missing_debug_implementations)] +#![allow(missing_debug_implementations, dead_code, unused_imports)] +#![deny(unreachable_pub)] use axum::{ - async_trait, body::HttpBody, + extract::FromRequest, handler::Handler, - http::Request, + http::{Request, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post}, - Router, + routing::{self, MethodRouter}, + Json, Router, +}; +use okapi::openapi3::{ + self, Components, Info, MediaType, OpenApi, Operation, Parameter, RefOr, RequestBody, +}; +use schemars::{ + schema::{RootSchema, Schema}, + JsonSchema, +}; +use std::{ + collections::BTreeMap, convert::Infallible, future::Future, marker::PhantomData, sync::Arc, +}; + +#[macro_use] +mod macros { + macro_rules! all_the_tuples { + ($name:ident) => { + $name!(T1); + $name!(T1, T2); + $name!(T1, T2, T3); + $name!(T1, T2, T3, T4); + $name!(T1, T2, T3, T4, T5); + $name!(T1, T2, T3, T4, T5, T6); + $name!(T1, T2, T3, T4, T5, T6, T7); + $name!(T1, T2, T3, T4, T5, T6, T7, T8); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15); + $name!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16); + }; + } +} + +mod describe_request; +mod describe_response; + +pub use self::{ + describe_request::DescribeRequest, + describe_response::{Created, DescribeResponse, Ok}, }; -use okapi::openapi3::{Info, OpenApi, Operation}; -use std::{future::Future, marker::PhantomData, sync::Arc}; pub struct OpenApiRouter { router: Router, @@ -26,129 +67,276 @@ where router: Default::default(), schema: OpenApi { info, + openapi: "3.0.0".to_owned(), + components: Some(Components::default()), ..Default::default() }, } } - pub fn get(mut self, path: &str, handler: H) -> Self - where - H: OpenApiHandler, - T: 'static, - { - let mut operation = Operation::default(); - handler.clone().to_operation(&mut operation); - self.schema.paths.entry(path.to_owned()).or_default().get = Some(operation); - self.router = self.router.route(path, get(handler)); - self + pub fn into_parts(self) -> (Router, OpenApi) { + (self.router, self.schema) } - pub fn post(mut self, path: &str, handler: H) -> Self - where - H: OpenApiHandler, - T: 'static, - { - let mut operation = Operation::default(); - handler.clone().to_operation(&mut operation); - self.schema.paths.entry(path.to_owned()).or_default().post = Some(operation); - self.router = self.router.route(path, post(handler)); + pub fn route(mut self, path: &str, handler: OpenApiHandler) -> Self { + let OpenApiHandler { + svc, + method, + operation, + components, + } = handler; + + self.router = self.router.route(path, svc); + + extend_components(self.schema.components.as_mut().unwrap(), components); + + let path = path + .split('/') + .map(|segment| { + if let Some(param) = segment.strip_prefix(':') { + format!("{{{}}}", param) + } else { + // TODO(david): wildcards + segment.to_owned() + } + }) + .collect::>() + .join("/"); + + let path_item = self.schema.paths.entry(path).or_default(); + match method { + Method::Get => path_item.get = Some(operation), + Method::Post => path_item.post = Some(operation), + } + self } } -pub trait OpenApiHandler: Handler { - fn to_operation(self, operation: &mut Operation); +fn extend_components(current: &mut Components, new: Components) { + let Components { + schemas, + responses, + parameters, + examples, + request_bodies, + headers, + security_schemes, + links, + callbacks, + extensions, + } = current; - fn map_operation(self, f: F) -> MapOperation + schemas.extend(new.schemas); + responses.extend(new.responses); + parameters.extend(new.parameters); + examples.extend(new.examples); + request_bodies.extend(new.request_bodies); + headers.extend(new.headers); + security_schemes.extend(new.security_schemes); + links.extend(new.links); + callbacks.extend(new.callbacks); + extensions.extend(new.extensions); +} + +macro_rules! method { + ($fn_name:ident, $method:ident) => { + pub fn $fn_name(handler: H) -> OpenApiHandler + where + H: Handler + HandlerResponse, + T: DescribeRequest + 'static, + H::Response: DescribeResponse, + B: Send + 'static, + { + let mut operation = Operation::default(); + let mut components = Components::default(); + T::describe(&mut operation, &mut components); + H::Response::describe(&mut operation, &mut components); + OpenApiHandler { + svc: routing::$fn_name(handler), + method: Method::$method, + operation, + components, + } + } + }; +} + +method!(get, Get); +method!(post, Post); + +// TODO(david): the remaining methods +enum Method { + Get, + Post, +} + +pub struct OpenApiHandler { + svc: MethodRouter, + method: Method, + operation: Operation, + components: Components, +} + +impl OpenApiHandler { + pub fn operation_id(self, id: S) -> Self where - F: FnOnce(&mut Operation), + S: Into, { - MapOperation { - handler: self, - f, - _marker: PhantomData, + self.map_operation(|op, _| { + op.operation_id = Some(id.into()); + }) + } + + pub fn summary(self, summary: S) -> Self + where + S: Into, + { + self.map_operation(|op, _| { + op.summary = Some(summary.into()); + }) + } + + pub fn description(self, description: S) -> Self + where + S: Into, + { + self.map_operation(|op, _| { + op.description = Some(description.into()); + }) + } + + pub fn map_operation(mut self, f: F) -> Self + where + F: FnOnce(&mut Operation, &mut Components), + { + f(&mut self.operation, &mut self.components); + self + } +} + +pub trait HandlerResponse { + type Response; +} + +impl HandlerResponse<()> for F +where + F: FnOnce() -> Fut, + Fut: Future, +{ + type Response = Fut::Output; +} + +macro_rules! impl_tuples { + ( $($ty:ident),* $(,)? ) => { + impl HandlerResponse<($($ty,)*)> for F + where + F: FnOnce($($ty,)*) -> Fut, + Fut: Future, + { + type Response = Fut::Output; } - } + }; } -pub struct MapOperation { - handler: H, - f: F, - _marker: PhantomData<(T, B)>, -} - -impl Clone for MapOperation -where - H: Clone, - F: Clone, -{ - fn clone(&self) -> Self { - Self { - handler: self.handler.clone(), - f: self.f.clone(), - _marker: PhantomData, - } - } -} - -impl OpenApiHandler for MapOperation -where - H: OpenApiHandler + Handler, - F: FnOnce(&mut Operation) + Clone + Send + 'static, - T: Send + 'static, - B: Send + 'static, -{ - fn to_operation(self, operation: &mut Operation) { - (self.f)(operation); - } -} - -impl Handler for MapOperation -where - H: Handler, - F: Clone + Send + 'static, - T: Send + 'static, - B: Send + 'static, -{ - type Future = H::Future; - - fn call(self, req: Request) -> Self::Future { - self.handler.call(req) - } -} - -impl OpenApiHandler<(), B> for F -where - F: FnOnce() -> Fut + Clone + Send + 'static, - Fut: Future + Send, - Res: IntoResponse, - B: Send + 'static, -{ - fn to_operation(self, operation: &mut Operation) { - todo!() - } -} +all_the_tuples!(impl_tuples); #[cfg(test)] mod tests { use super::*; + use assert_json_diff::assert_json_eq; use axum::body::Body; + use serde::Deserialize; + use serde_json::json; #[test] fn test_something() { + #[derive(Deserialize, JsonSchema)] + struct UsersCreate { + account: Account, + } + + #[derive(Deserialize, JsonSchema)] + struct Account { + username: String, + } + async fn users_show() {} - async fn users_create() {} + async fn users_create(Json(_): Json) -> Created { + Created + } - let _router: OpenApiRouter = OpenApiRouter::new(Info::default()) - .post( - "/users", - users_create - .map_operation(|_| {}) - .map_operation(|mut operation| { - operation.summary = Some("Create a new user".to_owned()); - }), - ) - .get("/users/:id", users_show.map_operation(|_operation| {})); + let (router, schema) = OpenApiRouter::::new(Info::default()) + .route("/users/:id", get(users_show).operation_id("users_show")) + .route("/users", post(users_create).operation_id("users_create")) + .into_parts(); + + assert_json_eq!( + schema, + json!({ + "openapi": "3.0.0", + "info": { + "title": "", + "version": "" + }, + "paths": { + "/users": { + "post": { + "operationId": "users_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "UsersCreate", + "type": "object", + "required": [ + "account", + ], + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + } + } + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful response", + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "users_show", + "responses": { + "200": { + "description": "Successful response", + } + } + } + } + }, + "components": { + "schemas": { + "Account": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string" + } + } + } + } + } + }), + ); } } diff --git a/axum/src/handler/future.rs b/axum/src/handler/future.rs index d126dd76..61e939a6 100644 --- a/axum/src/handler/future.rs +++ b/axum/src/handler/future.rs @@ -2,7 +2,11 @@ use crate::response::Response; use futures_util::future::Map; -use std::convert::Infallible; +use http::Request; +use pin_project_lite::pin_project; +use std::{convert::Infallible, future::Future, pin::Pin, task::Context}; +use tower::util::Oneshot; +use tower_service::Service; opaque_future! { /// The response future for [`IntoService`](super::IntoService). @@ -12,3 +16,36 @@ opaque_future! { fn(Response) -> Result, >; } + +pin_project! { + /// The response future for [`Layered`](super::Layered). + pub struct LayeredFuture + where + S: Service>, + { + #[pin] + inner: Map>, fn(Result) -> Response>, + } +} + +impl LayeredFuture +where + S: Service>, +{ + pub(super) fn new( + inner: Map>, fn(Result) -> Response>, + ) -> Self { + Self { inner } + } +} + +impl Future for LayeredFuture +where + S: Service>, +{ + type Output = Response; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> std::task::Poll { + self.project().inner.poll(cx) + } +} diff --git a/axum/src/handler/mod.rs b/axum/src/handler/mod.rs index e39ea8ca..8b8f0735 100644 --- a/axum/src/handler/mod.rs +++ b/axum/src/handler/mod.rs @@ -99,6 +99,7 @@ pub use self::into_service::IntoService; /// /// See the [module docs](crate::handler) for more details. pub trait Handler: Clone + Send + Sized + 'static { + /// The type of future calling this handler returns. type Future: Future + Send + 'static; /// Call the handler with the given request. @@ -334,15 +335,18 @@ where ResBody: HttpBody + Send + 'static, ResBody::Error: Into, { - type Future = Pin + Send + 'static>>; + type Future = future::LayeredFuture; fn call(self, req: Request) -> Self::Future { - Box::pin(async move { - match self.svc.oneshot(req).await { + use futures_util::future::{FutureExt, Map}; + + let future: Map<_, fn(Result) -> _> = + self.svc.oneshot(req).map(|result| match result { Ok(res) => res.map(boxed), Err(res) => res.into_response(), - } - }) + }); + + future::LayeredFuture::new(future) } } diff --git a/axum/src/macros.rs b/axum/src/macros.rs index d26cc87c..5cdeb88f 100644 --- a/axum/src/macros.rs +++ b/axum/src/macros.rs @@ -1,5 +1,3 @@ -//! Internal macros - macro_rules! opaque_future { ($(#[$m:meta])* pub type $name:ident = $actual:ty;) => { opaque_future! {