From 6e0e554defc7c4ce7dd122053a241de9391bed6f Mon Sep 17 00:00:00 2001 From: Andrei Kononov Date: Wed, 24 Jul 2024 01:28:53 +0400 Subject: [PATCH] Implement ToMediaTypes/ToResponses for additional types (strings, bytes, Form) --- .github/workflows/ci.yml | 2 +- .../src/operation/parameters.rs | 3 +- .../src/operation/request_body/axum.rs | 9 +++ okapi-operation/CHANGELOG.md | 3 +- okapi-operation/Cargo.toml | 5 +- okapi-operation/docs/axum_integration.md | 4 +- okapi-operation/src/axum_integration/mod.rs | 9 ++- .../src/axum_integration/trait_impls.rs | 44 +++++++++++- okapi-operation/src/lib.rs | 4 +- okapi-operation/src/to_media_types.rs | 71 +++++++++++++++++-- okapi-operation/src/to_responses.rs | 52 ++++++++++++++ okapi-operation/tests/axum_integration.rs | 27 +++++++ rustfmt.toml | 6 ++ 13 files changed, 220 insertions(+), 19 deletions(-) create mode 100644 rustfmt.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28e9d3..a81a6f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: - uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: stable + toolchain: nightly override: true - run: rustup component add rustfmt - uses: actions-rs/cargo@v1 diff --git a/okapi-operation-macro/src/operation/parameters.rs b/okapi-operation-macro/src/operation/parameters.rs index 21aeccb..18de269 100644 --- a/okapi-operation-macro/src/operation/parameters.rs +++ b/okapi-operation-macro/src/operation/parameters.rs @@ -3,6 +3,7 @@ use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use syn::Meta; +use super::cookie::{Cookie, COOKIE_ATTRIBUTE_NAME}; use crate::{ operation::{ header::{Header, HEADER_ATTRIBUTE_NAME}, @@ -13,8 +14,6 @@ use crate::{ utils::{meta_to_meta_list, nested_meta_to_meta}, }; -use super::cookie::{Cookie, COOKIE_ATTRIBUTE_NAME}; - // TODO: support cookie parameters // TODO: support parameters from function signature diff --git a/okapi-operation-macro/src/operation/request_body/axum.rs b/okapi-operation-macro/src/operation/request_body/axum.rs index 6a150bc..d17133c 100644 --- a/okapi-operation-macro/src/operation/request_body/axum.rs +++ b/okapi-operation-macro/src/operation/request_body/axum.rs @@ -6,8 +6,17 @@ use super::{RequestBody, RequestBodyAttrs}; use crate::error::Error; lazy_static::lazy_static! { + // NOTE: `Form` is not enabled because it have different content types + // based on method https://docs.rs/axum/latest/axum/struct.Form.html#as-extractor static ref KNOWN_BODY_TYPES: HashSet<&'static str> = [ + // std types + "String", + + // axum types "Json", + + // 3rd party types + "Bytes", ].into_iter().collect(); } diff --git a/okapi-operation/CHANGELOG.md b/okapi-operation/CHANGELOG.md index f1bf629..10da0cc 100644 --- a/okapi-operation/CHANGELOG.md +++ b/okapi-operation/CHANGELOG.md @@ -6,7 +6,8 @@ This project follows the [Semantic Versioning standard](https://semver.org/). ### Added - Feature `axum` as replacement for `axum-integration` (now considered deprecated); - Request body detection from function arguments for specific frameworks (i.e. axum); - - `#[body]` attribute as replacement for `#[request_body]` (now considered deprecated). + - `#[body]` attribute as replacement for `#[request_body]` (now considered deprecated); + - `ToMediaTypes`/`ToResponses` implementations for string-like types (String, &'static str, ...) and bytes-like types (Bytes, Vec, ...), implemented in axum, and `axum::form::Form`. ## [0.3.0-rc2] - 2024-07-18 diff --git a/okapi-operation/Cargo.toml b/okapi-operation/Cargo.toml index 079afc4..74da139 100644 --- a/okapi-operation/Cargo.toml +++ b/okapi-operation/Cargo.toml @@ -14,17 +14,18 @@ repository = "https://github.com/Flowneee/okapi-operation" okapi-operation-macro = { path = "../okapi-operation-macro", version = "0.1", optional = true } anyhow = "1" +bytes = "1.4" http = "1" +indexmap = "2.2.6" +mime = "0.3" okapi = { version = "0.7.0-rc.1", features = ["preserve_order"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -bytes = "1.4" axum = { version = "0.7", optional = true } tower = { version = "0.4", default-features = false, optional = true } paste = { version = "1", optional = true } serde_yaml = { version = "0.8", optional = true } -indexmap = "2.2.6" [dev-dependencies] axum = "0.7" diff --git a/okapi-operation/docs/axum_integration.md b/okapi-operation/docs/axum_integration.md index b807309..ddf6996 100644 --- a/okapi-operation/docs/axum_integration.md +++ b/okapi-operation/docs/axum_integration.md @@ -72,7 +72,7 @@ If you need to customize builder template, you can either: ```no_run use axum::{extract::Query, Json}; -use okapi_operation::{axum_integratExternal documentationion::*, *}; +use okapi_operation::{axum_integration::*, *}; use serde::Deserialize; #[tokio::main] @@ -100,4 +100,6 @@ Request body and some parameters can be automatically detected from function arg Supported request bodies: +* [`String`] (as `text/plain`) * [`axum::extract::Json`] +* [`bytes::Bytes`] (as `application/octet_stream`) diff --git a/okapi-operation/src/axum_integration/mod.rs b/okapi-operation/src/axum_integration/mod.rs index 69149b2..83603cc 100644 --- a/okapi-operation/src/axum_integration/mod.rs +++ b/okapi-operation/src/axum_integration/mod.rs @@ -19,8 +19,11 @@ mod router; mod trait_impls; mod utils; -use axum::response::{IntoResponse, Response}; -use axum::{extract::State, Json}; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; use http::{ header::{self, ACCEPT}, HeaderMap, HeaderValue, StatusCode, @@ -143,4 +146,4 @@ macro_rules! openapi_service { } } -// tests in tests/axum_integration_macros.rs because of https://github.com/rust-lang/rust/issues/52234 +// tests in tests/axum_integration.rs because of https://github.com/rust-lang/rust/issues/52234 diff --git a/okapi-operation/src/axum_integration/trait_impls.rs b/okapi-operation/src/axum_integration/trait_impls.rs index 58eff77..f3401dd 100644 --- a/okapi-operation/src/axum_integration/trait_impls.rs +++ b/okapi-operation/src/axum_integration/trait_impls.rs @@ -1,5 +1,43 @@ -use crate::{impl_to_media_types_for_wrapper, impl_to_responses_for_wrapper}; +use axum::{response::Html, Form, Json}; +use mime::{APPLICATION_JSON, APPLICATION_WWW_FORM_URLENCODED, TEXT_HTML}; +use okapi::{ + map, + openapi3::{MediaType, RefOr, Response, Responses}, + Map, +}; -impl_to_media_types_for_wrapper!(axum::Json, "application/json"); +use crate::{ + impl_to_media_types_for_wrapper, impl_to_responses_for_wrapper, Components, ToMediaTypes, + ToResponses, +}; -impl_to_responses_for_wrapper!(axum::Json); +// Json +impl_to_media_types_for_wrapper!(Json, APPLICATION_JSON.to_string()); +impl_to_responses_for_wrapper!(Json); + +// Form +impl_to_media_types_for_wrapper!(Form, APPLICATION_WWW_FORM_URLENCODED.to_string()); +impl_to_responses_for_wrapper!(Form); + +// Html +impl ToMediaTypes for Html { + fn generate(_components: &mut Components) -> Result, anyhow::Error> { + Ok(map! { + TEXT_HTML.to_string() => MediaType::default() + }) + } +} + +impl ToResponses for Html { + fn generate(components: &mut Components) -> Result { + Ok(Responses { + responses: map! { + "200".into() => RefOr::Object(Response { + content: ::generate(components)?, + ..Default::default() + }), + }, + ..Default::default() + }) + } +} diff --git a/okapi-operation/src/lib.rs b/okapi-operation/src/lib.rs index 0c4c8f0..73c810c 100644 --- a/okapi-operation/src/lib.rs +++ b/okapi-operation/src/lib.rs @@ -15,6 +15,8 @@ pub use okapi_operation_macro::openapi; #[cfg(feature = "axum")] pub mod axum_integration; +use okapi::openapi3::Operation; + pub use self::{ builder::OpenApiBuilder, components::{Components, ComponentsBuilder}, @@ -22,8 +24,6 @@ pub use self::{ to_responses::ToResponses, }; -use okapi::openapi3::Operation; - mod builder; mod components; mod to_media_types; diff --git a/okapi-operation/src/to_media_types.rs b/okapi-operation/src/to_media_types.rs index 7452710..86d2290 100644 --- a/okapi-operation/src/to_media_types.rs +++ b/okapi-operation/src/to_media_types.rs @@ -21,7 +21,7 @@ pub trait ToMediaTypes { /// ``` #[macro_export] macro_rules! impl_to_media_types_for_wrapper { - ($ty:path, $mime:literal) => { + ($ty:path, $mime:expr) => { impl $crate::ToMediaTypes for $ty { fn generate( components: &mut $crate::Components, @@ -41,8 +41,71 @@ macro_rules! impl_to_media_types_for_wrapper { }; } -impl ToMediaTypes for () { - fn generate(_components: &mut Components) -> Result, anyhow::Error> { - Ok(okapi::map! {}) +macro_rules! forward_impl_to_media_types { + ($ty_for:ty, $ty_base:ty) => { + impl $crate::ToMediaTypes for $ty_for { + fn generate( + components: &mut $crate::Components, + ) -> Result< + $crate::okapi::Map, + $crate::anyhow::Error, + > { + <$ty_base as $crate::ToMediaTypes>::generate(components) + } + } + }; +} + +mod impls { + use std::borrow::Cow; + + use bytes::{Bytes, BytesMut}; + use mime::{APPLICATION_OCTET_STREAM, TEXT_PLAIN}; + use okapi::{ + map, + openapi3::SchemaObject, + schemars::schema::{InstanceType, SingleOrVec}, + }; + + use super::*; + + impl ToMediaTypes for () { + fn generate(_components: &mut Components) -> Result, anyhow::Error> { + Ok(map! {}) + } + } + + impl ToMediaTypes for String { + fn generate(_components: &mut Components) -> Result, anyhow::Error> { + Ok(map! { + TEXT_PLAIN.to_string() => MediaType::default() + }) + } + } + forward_impl_to_media_types!(&'static str, String); + forward_impl_to_media_types!(Cow<'static, str>, String); + + impl ToMediaTypes for Vec { + fn generate(_components: &mut Components) -> Result, anyhow::Error> { + // In schemars Bytes defined as array of integers, but OpenAPI recommend + // use string type with binary format + // https://swagger.io/docs/specification/describing-request-body/file-upload/ + let schema = SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + format: Some("binary".into()), + ..SchemaObject::default() + }; + + Ok(map! { + APPLICATION_OCTET_STREAM.to_string() => MediaType { + schema: Some(schema), + ..MediaType::default() + }, + }) + } } + forward_impl_to_media_types!(&'static [u8], Vec); + forward_impl_to_media_types!(Cow<'static, [u8]>, Vec); + forward_impl_to_media_types!(Bytes, Vec); + forward_impl_to_media_types!(BytesMut, Vec); } diff --git a/okapi-operation/src/to_responses.rs b/okapi-operation/src/to_responses.rs index 3fa8826..9703ba7 100644 --- a/okapi-operation/src/to_responses.rs +++ b/okapi-operation/src/to_responses.rs @@ -39,8 +39,26 @@ macro_rules! impl_to_responses_for_wrapper { }; } +macro_rules! forward_impl_to_responses { + ($ty_for:ty, $ty_base:ty) => { + impl $crate::ToResponses for $ty_for { + fn generate( + components: &mut $crate::Components, + ) -> Result<$crate::okapi::openapi3::Responses, $crate::anyhow::Error> { + <$ty_base as $crate::ToResponses>::generate(components) + } + } + }; +} + mod impls { + use std::borrow::Cow; + + use bytes::{Bytes, BytesMut}; + use okapi::openapi3::Response; + use super::*; + use crate::ToMediaTypes; impl ToResponses for () { fn generate(_components: &mut Components) -> Result { @@ -84,4 +102,38 @@ mod impls { Ok(ok) } } + + impl ToResponses for String { + fn generate(components: &mut Components) -> Result { + Ok(Responses { + responses: okapi::map! { + "200".into() => RefOr::Object(Response { + content: ::generate(components)?, + ..Default::default() + }) + }, + ..Default::default() + }) + } + } + forward_impl_to_responses!(&'static str, String); + forward_impl_to_responses!(Cow<'static, str>, String); + + impl ToResponses for Vec { + fn generate(components: &mut Components) -> Result { + Ok(Responses { + responses: okapi::map! { + "200".into() => RefOr::Object(Response { + content: ::generate(components)?, + ..Default::default() + }) + }, + ..Default::default() + }) + } + } + forward_impl_to_responses!(&'static [u8], Vec); + forward_impl_to_responses!(Cow<'static, [u8]>, Vec); + forward_impl_to_responses!(Bytes, Vec); + forward_impl_to_responses!(BytesMut, Vec); } diff --git a/okapi-operation/tests/axum_integration.rs b/okapi-operation/tests/axum_integration.rs index 841ef77..ea6050e 100644 --- a/okapi-operation/tests/axum_integration.rs +++ b/okapi-operation/tests/axum_integration.rs @@ -39,6 +39,33 @@ mod openapi { assert_eq!(body_schema, expected_schema); } + + #[test] + fn string_body_detection() { + #[openapi] + async fn handle(_arg: String) {} + + let schema = Router::<()>::new() + .route("/", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("Schema generation shoildn't fail"); + + let operation = schema.paths["/"] + .clone() + .get + .expect("GET / should be present") + .request_body + .expect("GET / request body should be present"); + let RefOr::Object(request_body) = operation else { + panic!("GET / request body should be RefOr::Object"); + }; + + assert!( + request_body.content["text/plain"].clone().schema.is_none(), + "String body (text/plain) shouldn't have schema" + ); + } } #[cfg(feature = "axum")] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..fcee439 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +unstable_features = true + +group_imports = "StdExternalCrate" +imports_granularity = "Crate" +newline_style = "Unix" +reorder_imports = true