Skip to content

Commit 61cb542

Browse files
feat: add utoipa (#1)
1 parent c14cd16 commit 61cb542

20 files changed

Lines changed: 522 additions & 100 deletions

File tree

Cargo.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ version = "0.0.1"
1212
[workspace.dependencies]
1313
breach = { path = "./packages/breach", version = "0.0.1" }
1414
breach-macros = { path = "./packages/breach-macros", version = "0.0.1" }
15+
http = "1.4.0"
1516
serde = "1.0.228"
1617
serde_json = "1.0.149"
1718
tokio = "1.49.0"
19+
utoipa = "5.4.0"
1820

1921
[workspace.lints.rust]
2022
unsafe_code = "deny"

examples/axum/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ version.workspace = true
1212
[dependencies]
1313
anyhow = "1.0.101"
1414
axum = "0.8.8"
15-
breach.workspace = true
15+
breach = { workspace = true, features = ["utoipa"] }
1616
serde = { workspace = true, features = ["derive"] }
1717
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
18-
utoipa = { version = "5.4.0", features = ["axum_extras", "uuid"] }
18+
utoipa = { workspace = true, features = ["axum_extras", "uuid"] }
1919
utoipa-axum = "0.2.0"
2020
utoipa-scalar = { version = "0.3.0", features = ["axum"] }
2121
uuid = { version = "1.20.0", features = ["serde", "v7"] }

examples/axum/src/error.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use breach::HttpError;
22
use serde::Serialize;
3+
use utoipa::ToSchema;
34
use uuid::Uuid;
45

5-
#[derive(HttpError, Serialize)]
6-
#[http(status = NOT_FOUND)]
6+
#[derive(HttpError, Serialize, ToSchema)]
7+
#[http(status = NOT_FOUND, utoipa)]
78
#[serde(rename_all = "camelCase")]
89
pub struct NotFoundError {
910
id: Uuid,

examples/axum/src/user/errors.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use breach::HttpError;
22
use serde::Serialize;
3+
use utoipa::ToSchema;
34

45
use crate::error::NotFoundError;
56

6-
#[derive(Serialize)]
7+
#[derive(Serialize, ToSchema)]
78
#[serde(
89
tag = "code",
910
rename_all = "camelCase",
@@ -14,6 +15,7 @@ pub enum UserValidationError {
1415
}
1516

1617
#[derive(HttpError, Serialize)]
18+
#[http(utoipa)]
1719
#[serde(
1820
tag = "code",
1921
rename_all = "camelCase",
@@ -34,6 +36,7 @@ impl From<UserValidationError> for CreateUserError {
3436
}
3537

3638
#[derive(HttpError, Serialize)]
39+
#[http(utoipa)]
3740
#[serde(
3841
tag = "code",
3942
rename_all = "camelCase",
@@ -47,6 +50,7 @@ pub enum GetUserByIdError {
4750
}
4851

4952
#[derive(HttpError, Serialize)]
53+
#[http(utoipa)]
5054
#[serde(
5155
tag = "code",
5256
rename_all = "camelCase",
@@ -67,6 +71,7 @@ impl From<UserValidationError> for UpdateUserError {
6771
}
6872

6973
#[derive(HttpError, Serialize)]
74+
#[http(utoipa)]
7075
#[serde(
7176
tag = "code",
7277
rename_all = "camelCase",

examples/axum/src/user/routes.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ impl UserRoutes {
2727
}
2828

2929
#[derive(HttpError, Serialize)]
30-
#[http(axum)]
30+
#[http(axum, utoipa)]
3131
pub enum CreateUserRouteError {
3232
CreateUser(CreateUserError),
3333
}
@@ -47,7 +47,8 @@ impl From<CreateUserError> for CreateUserRouteError {
4747
tags = ["User"],
4848
request_body = CreateUser,
4949
responses(
50-
(status = CREATED, description = "The user has been created.", body = User)
50+
(status = CREATED, description = "The user has been created.", body = User),
51+
CreateUserRouteError,
5152
)
5253
)]
5354
async fn create_user(
@@ -60,7 +61,7 @@ async fn create_user(
6061
}
6162

6263
#[derive(HttpError, Serialize)]
63-
#[http(axum)]
64+
#[http(axum, utoipa)]
6465
pub enum GetUserRouteError {
6566
GetUserById(GetUserByIdError),
6667
}
@@ -80,7 +81,8 @@ impl From<GetUserByIdError> for GetUserRouteError {
8081
tags = ["User"],
8182
params(UserPathParams),
8283
responses(
83-
(status = OK, description = "The user.", body = User)
84+
(status = OK, description = "The user.", body = User),
85+
GetUserRouteError,
8486
)
8587
)]
8688
async fn user(
@@ -93,7 +95,7 @@ async fn user(
9395
}
9496

9597
#[derive(HttpError, Serialize)]
96-
#[http(axum)]
98+
#[http(axum, utoipa)]
9799
pub enum UpdateUserRouteError {
98100
GetUserById(GetUserByIdError),
99101

@@ -122,7 +124,8 @@ impl From<UpdateUserError> for UpdateUserRouteError {
122124
params(UserPathParams),
123125
request_body = UpdateUser,
124126
responses(
125-
(status = OK, description = "The user has been updated.", body = User)
127+
(status = OK, description = "The user has been updated.", body = User),
128+
UpdateUserRouteError,
126129
)
127130
)]
128131
async fn update_user(
@@ -138,7 +141,7 @@ async fn update_user(
138141
}
139142

140143
#[derive(HttpError, Serialize)]
141-
#[http(axum)]
144+
#[http(axum, utoipa)]
142145
pub enum DeleteUserRouteError {
143146
GetUserById(GetUserByIdError),
144147

@@ -166,7 +169,8 @@ impl From<DeleteUserError> for DeleteUserRouteError {
166169
tags = ["User"],
167170
params(UserPathParams),
168171
responses(
169-
(status = NO_CONTENT, description = "The user has been deleted.")
172+
(status = NO_CONTENT, description = "The user has been deleted."),
173+
DeleteUserRouteError,
170174
)
171175
)]
172176
async fn delete_user(

examples/basic/src/main.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ use breach::{HttpError, http::StatusCode};
33
use serde::Serialize;
44
use serde_json::json;
55

6+
#[derive(Serialize)]
7+
#[serde(rename_all = "camelCase")]
8+
struct ForbiddenError {
9+
id: String,
10+
}
11+
612
#[derive(HttpError, Serialize)]
713
#[http(status = NOT_FOUND)]
814
#[serde(rename_all = "camelCase")]
@@ -18,9 +24,7 @@ struct NotFoundError {
1824
)]
1925
enum GetUserByIdError {
2026
#[http(status = FORBIDDEN)]
21-
Forbidden {
22-
id: String,
23-
},
27+
Forbidden(ForbiddenError),
2428

2529
NotFound(NotFoundError),
2630

@@ -37,15 +41,17 @@ enum GetUserByIdError {
3741
enum UpdateUserError {
3842
GetUserById(GetUserByIdError),
3943

40-
#[http(status = UNPROCESSABLE_CONTENT)]
44+
#[http(status = UNPROCESSABLE_ENTITY)]
4145
Validation,
4246

4347
#[http(status = INTERNAL_SERVER_ERROR)]
4448
Internal(#[serde(skip)] anyhow::Error),
4549
}
4650

4751
fn main() {
48-
let error = UpdateUserError::GetUserById(GetUserByIdError::Forbidden { id: "1".to_owned() });
52+
let error = UpdateUserError::GetUserById(GetUserByIdError::Forbidden(ForbiddenError {
53+
id: "1".to_owned(),
54+
}));
4955
assert_eq!(StatusCode::FORBIDDEN, error.status());
5056
assert_eq!(
5157
json!({

packages/breach-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ serde = []
2020
utoipa = []
2121

2222
[dependencies]
23+
http.workspace = true
2324
proc-macro2 = "1.0.103"
2425
quote = "1.0.42"
2526
syn = "2.0.110"

packages/breach-macros/src/http.rs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,30 @@ impl<'a> ToTokens for HttpError<'a> {
4242
}
4343
});
4444

45-
if let Some(attribute) = self.data.attribute()
46-
&& attribute.axum
47-
{
48-
tokens.append_all(quote! {
49-
#[automatically_derived]
50-
impl #impl_generics ::axum::response::IntoResponse for #ident #type_generics #where_clause {
51-
fn into_response(self) -> ::axum::response::Response {
52-
(self.status(), Json(self)).into_response()
45+
if let Some(attribute) = self.data.attribute() {
46+
if attribute.axum {
47+
tokens.append_all(quote! {
48+
#[automatically_derived]
49+
impl #impl_generics ::axum::response::IntoResponse for #ident #type_generics #where_clause {
50+
fn into_response(self) -> ::axum::response::Response {
51+
(self.status(), Json(self)).into_response()
52+
}
5353
}
54-
}
55-
});
54+
});
55+
}
56+
57+
if attribute.utoipa {
58+
let responses = self.data.responses();
59+
60+
tokens.append_all(quote! {
61+
#[automatically_derived]
62+
impl #impl_generics ::utoipa::IntoResponses for #ident #type_generics #where_clause {
63+
fn responses() -> ::std::collections::BTreeMap<String, ::utoipa::openapi::RefOr<::utoipa::openapi::response::Response>> {
64+
#responses
65+
}
66+
}
67+
});
68+
}
5669
}
5770
}
5871
}

packages/breach-macros/src/http/attribute.rs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use proc_macro2::TokenStream;
22
use quote::quote;
3-
use syn::{Attribute, Error, Ident, Result, spanned::Spanned};
3+
use syn::{Attribute, Error, Result, spanned::Spanned};
4+
5+
use crate::status::Status;
46

57
pub struct HttpErrorAttribute {
6-
status: Option<Ident>,
8+
pub status: Option<Status>,
79
pub axum: bool,
10+
pub utoipa: bool,
811
}
912

1013
impl<'a> HttpErrorAttribute {
@@ -32,6 +35,7 @@ impl<'a> HttpErrorAttribute {
3235
pub fn parse(attribute: &'a Attribute) -> Result<Self> {
3336
let mut status = None;
3437
let mut axum = false;
38+
let mut utoipa = false;
3539

3640
attribute.parse_nested_meta(|meta| {
3741
if meta.path.is_ident("status") {
@@ -41,21 +45,60 @@ impl<'a> HttpErrorAttribute {
4145
} else if meta.path.is_ident("axum") {
4246
axum = true;
4347

48+
Ok(())
49+
} else if meta.path.is_ident("utoipa") {
50+
utoipa = true;
51+
4452
Ok(())
4553
} else {
4654
Err(meta.error("unknown parameter"))
4755
}
4856
})?;
4957

50-
Ok(Self { status, axum })
58+
Ok(Self {
59+
status,
60+
axum,
61+
utoipa,
62+
})
5163
}
5264

5365
pub fn status(&self) -> TokenStream {
5466
if let Some(status) = &self.status {
55-
if status == "UNPROCESSABLE_CONTENT" {
56-
quote!(::breach::http::StatusCode::UNPROCESSABLE_ENTITY)
57-
} else {
58-
quote!(::breach::http::StatusCode::#status)
67+
let status = status.as_ident();
68+
69+
quote!(::breach::http::StatusCode::#status)
70+
} else {
71+
quote!(compile_error!("missing `#[http(status = ..)]` attribute"))
72+
}
73+
}
74+
75+
pub fn responses(&self, r#type: Option<TokenStream>) -> TokenStream {
76+
if let Some(status) = &self.status {
77+
let code = status.code.as_str();
78+
79+
let content = r#type.map(|r#type| {
80+
// TODO: Attempt to infer content type from schema?
81+
quote! {
82+
.content(
83+
"application/json",
84+
::utoipa::openapi::content::ContentBuilder::new()
85+
.schema(Some(<#r#type as ::utoipa::PartialSchema>::schema()))
86+
.build()
87+
)
88+
}
89+
});
90+
91+
quote! {
92+
::std::collections::BTreeMap::from_iter([
93+
(
94+
#code.to_owned(),
95+
::utoipa::openapi::RefOr::T(
96+
::utoipa::openapi::response::ResponseBuilder::new()
97+
#content
98+
.build()
99+
),
100+
),
101+
])
59102
}
60103
} else {
61104
quote!(compile_error!("missing `#[http(status = ..)]` attribute"))

0 commit comments

Comments
 (0)