Skip to content

Commit

Permalink
Enhance file uploads (#1113)
Browse files Browse the repository at this point in the history
This commit adds more test cases for file uploads and enhances docs for
file uploads.
  • Loading branch information
juhaku authored Oct 10, 2024
1 parent 18e96c9 commit 9d076c4
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 0 deletions.
1 change: 1 addition & 0 deletions utoipa-gen/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

### Changed

* Enhance file uploads (https://github.com/juhaku/utoipa/pull/1113)
* Move `schemas` into `ToSchema` for schemas (https://github.com/juhaku/utoipa/pull/1112)
* Refactor `KnownFormat`
* Add path rewrite support (https://github.com/juhaku/utoipa/pull/1110)
Expand Down
4 changes: 4 additions & 0 deletions utoipa-gen/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,8 @@ impl ComponentSchema {
let default = pop_feature!(features => Feature::Default(_));
let title = pop_feature!(features => Feature::Title(_));
let deprecated = pop_feature!(features => Feature::Deprecated(_)).try_to_token_stream()?;
let content_encoding = pop_feature!(features => Feature::ContentEncoding(_));
let content_media_type = pop_feature!(features => Feature::ContentMediaType(_));

let child = type_tree
.children
Expand Down Expand Up @@ -1078,6 +1080,8 @@ impl ComponentSchema {
tokens.extend(min_items.to_token_stream())
}

content_encoding.to_tokens(tokens)?;
content_media_type.to_tokens(tokens)?;
default.to_tokens(tokens)?;
title.to_tokens(tokens)?;
example.to_tokens(tokens)?;
Expand Down
2 changes: 2 additions & 0 deletions utoipa-gen/src/component/schema/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ impl Parse for UnnamedFieldStructFeatures {
As,
Deprecated,
Description,
ContentEncoding,
ContentMediaType,
Bound
)))
}
Expand Down
72 changes: 72 additions & 0 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ static CONFIG: once_cell::sync::Lazy<utoipa_config::Config> =
/// * `deprecated` Can be used to mark the field as deprecated in the generated OpenAPI spec but
/// not in the code. If you'd like to mark the field as deprecated in the code as well use
/// Rust's own `#[deprecated]` attribute instead.
/// * `content_encoding = ...` Can be used to define content encoding used for underlying schema object.
/// See [`Object::content_encoding`][schema_object_encoding]
/// * `content_media_type = ...` Can be used to define MIME type of a string for underlying schema object.
/// See [`Object::content_media_type`][schema_object_media_type]
///
/// # Enum Optional Configuration Options for `#[schema(...)]`
///
Expand Down Expand Up @@ -1529,6 +1533,73 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream {
/// }
/// ```
///
/// # Defining file uploads
///
/// File uploads can be defined in accordance to Open API specification [file uploads][file_uploads].
///
///
/// _**Example sending `jpg` and `png` images as `application/octet-stream`.**_
/// ```rust
/// #[utoipa::path(
/// post,
/// request_body(
/// content(
/// ("image/png"),
/// ("image/jpg"),
/// ),
/// ),
/// path = "/test_images"
/// )]
/// async fn test_images(_body: Vec<u8>) {}
/// ```
///
/// _**Example of sending `multipart` form.**_
/// ```rust
/// #[derive(utoipa::ToSchema)]
/// struct MyForm {
/// order_id: i32,
/// #[schema(content_media_type = "application/octet-stream")]
/// file_bytes: Vec<u8>,
/// }
///
/// #[utoipa::path(
/// post,
/// request_body(content = inline(MyForm), content_type = "multipart/form-data"),
/// path = "/test_multipart"
/// )]
/// async fn test_multipart(_body: MyForm) {}
/// ```
///
/// _**Example of sending arbitrary binary content as `application/octet-stream`.**_
/// ```rust
/// #[utoipa::path(
/// post,
/// request_body = Vec<u8>,
/// path = "/test-octet-stream",
/// responses(
/// (status = 200, description = "success response")
/// ),
/// )]
/// async fn test_octet_stream(_body: Vec<u8>) {}
/// ```
///
/// _**Example of sending `png` image as `base64` encoded.**_
/// ```rust
/// #[derive(utoipa::ToSchema)]
/// #[schema(content_encoding = "base64")]
/// struct MyPng(String);
///
/// #[utoipa::path(
/// post,
/// request_body(content = inline(MyPng), content_type = "image/png"),
/// path = "/test_png",
/// responses(
/// (status = 200, description = "success response")
/// ),
/// )]
/// async fn test_png(_body: MyPng) {}
/// ```
///
/// # Examples
///
/// _**More complete example.**_
Expand Down Expand Up @@ -1738,6 +1809,7 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream {
/// [include_str]: https://doc.rust-lang.org/std/macro.include_str.html
/// [server_derive_syntax]: derive.OpenApi.html#servers-attribute-syntax
/// [server]: openapi/server/struct.Server.html
/// [file_uploads]: <https://spec.openapis.org/oas/v3.1.0.html#considerations-for-file-uploads>
pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
let path_attribute = syn::parse_macro_input!(attr as PathAttr);

Expand Down
174 changes: 174 additions & 0 deletions utoipa-gen/tests/path_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2789,3 +2789,177 @@ fn derive_into_params_with_ignored_field() {
])
)
}

#[test]
fn derive_octet_stream_request_body() {
#![allow(dead_code)]

#[utoipa::path(
post,
request_body = Vec<u8>,
path = "/test-octet-stream",
responses(
(status = 200, description = "success response")
),
)]
async fn test_octet_stream(_body: Vec<u8>) {}

let operation = serde_json::to_value(__path_test_octet_stream::operation())
.expect("Operation is JSON serializable");
let request_body = operation
.pointer("/requestBody")
.expect("must have request body");

assert_json_eq!(
&request_body,
json!({
"content": {
"application/octet-stream": {
"schema": {
"items": {
"type": "integer",
"format": "int32",
"minimum": 0,
},
"type": "array",
},
},
},
"required": true,
})
);
}

#[test]
fn derive_img_png_request_body() {
#![allow(dead_code)]

#[derive(utoipa::ToSchema)]
#[schema(content_encoding = "base64")]
struct MyPng(String);

#[utoipa::path(
post,
request_body(content = inline(MyPng), content_type = "image/png"),
path = "/test_png",
responses(
(status = 200, description = "success response")
),
)]
async fn test_png(_body: MyPng) {}

let operation =
serde_json::to_value(__path_test_png::operation()).expect("Operation is JSON serializable");
let request_body = operation
.pointer("/requestBody")
.expect("must have request body");

assert_json_eq!(
&request_body,
json!({
"content": {
"image/png": {
"schema": {
"type": "string",
"contentEncoding": "base64"
},
},
},
"required": true,
})
);
}

#[test]
fn derive_multipart_form_data() {
#![allow(dead_code)]

#[derive(utoipa::ToSchema)]
struct MyForm {
order_id: i32,
#[schema(content_media_type = "application/octet-stream")]
file_bytes: Vec<u8>,
}

#[utoipa::path(
post,
request_body(content = inline(MyForm), content_type = "multipart/form-data"),
path = "/test_multipart",
responses(
(status = 200, description = "success response")
),
)]
async fn test_multipart(_body: MyForm) {}

let operation = serde_json::to_value(__path_test_multipart::operation())
.expect("Operation is JSON serializable");
let request_body = operation
.pointer("/requestBody")
.expect("must have request body");

assert_json_eq!(
&request_body,
json!({
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"order_id": {
"type": "integer",
"format": "int32"
},
"file_bytes": {
"type": "array",
"items": {
"type": "integer",
"format": "int32",
"minimum": 0,
},
"contentMediaType": "application/octet-stream"
},
},
"required": ["order_id", "file_bytes"]
},
},
},
"required": true,
})
);
}

#[test]
fn derive_images_as_application_octet_stream() {
#![allow(dead_code)]

#[utoipa::path(
post,
request_body(
content(
("image/png"),
("image/jpg"),
),
),
path = "/test_images",
responses(
(status = 200, description = "success response")
),
)]
async fn test_multipart(_body: Vec<u8>) {}

let operation = serde_json::to_value(__path_test_multipart::operation())
.expect("Operation is JSON serializable");
let request_body = operation
.pointer("/requestBody")
.expect("must have request body");

assert_json_eq!(
&request_body,
json!({
"content": {
"image/jpg": {},
"image/png": {},
},
})
);
}
1 change: 1 addition & 0 deletions utoipa/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ to look into changes introduced to **`utoipa-gen`**.

### Changed

* Enhance file uploads (https://github.com/juhaku/utoipa/pull/1113)
* Move `schemas` into `ToSchema` for schemas (https://github.com/juhaku/utoipa/pull/1112)
* List only `utoipa` related changes in `utoipa` CHANGELOG
* Remove commit commit id from changelogs (https://github.com/juhaku/utoipa/pull/1077)
Expand Down
32 changes: 32 additions & 0 deletions utoipa/src/openapi/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,24 @@ builder! {
#[serde(skip_serializing_if = "Option::is_none")]
pub xml: Option<Xml>,

/// The `content_encoding` keyword specifies the encoding used to store the contents, as specified in
/// [RFC 2054, part 6.1](https://tools.ietf.org/html/rfc2045) and [RFC 4648](RFC 2054, part 6.1).
///
/// Typically this is either unset for _`string`_ content types which then uses the content
/// encoding of the underlying JSON document. If the content is in _`binary`_ format such as an image or an audio
/// set it to `base64` to encode it as _`Base64`_.
///
/// See more details at <https://json-schema.org/understanding-json-schema/reference/non_json_data#contentencoding>
#[serde(skip_serializing_if = "String::is_empty", default)]
pub content_encoding: String,

/// The _`content_media_type`_ keyword specifies the MIME type of the contents of a string,
/// as described in [RFC 2046](https://tools.ietf.org/html/rfc2046).
///
/// See more details at <https://json-schema.org/understanding-json-schema/reference/non_json_data#contentmediatype>
#[serde(skip_serializing_if = "String::is_empty", default)]
pub content_media_type: String,

/// Optional extensions `x-something`.
#[serde(skip_serializing_if = "Option::is_none", flatten)]
pub extensions: Option<Extensions>,
Expand All @@ -1679,6 +1697,8 @@ impl Default for Array {
min_items: Default::default(),
xml: Default::default(),
extensions: Default::default(),
content_encoding: Default::default(),
content_media_type: Default::default(),
}
}
}
Expand Down Expand Up @@ -1807,6 +1827,18 @@ impl ArrayBuilder {
set_value!(self xml xml)
}

/// Set of change [`Object::content_encoding`]. Typically left empty but could be `base64` for
/// example.
pub fn content_encoding<S: Into<String>>(mut self, content_encoding: S) -> Self {
set_value!(self content_encoding content_encoding.into())
}

/// Set of change [`Object::content_media_type`]. Value must be valid MIME type e.g.
/// `application/json`.
pub fn content_media_type<S: Into<String>>(mut self, content_media_type: S) -> Self {
set_value!(self content_media_type content_media_type.into())
}

/// Add openapi extensions (`x-something`) for [`Array`].
pub fn extensions(mut self, extensions: Option<Extensions>) -> Self {
set_value!(self extensions extensions)
Expand Down

0 comments on commit 9d076c4

Please sign in to comment.