Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ version = "0.0.1"
[workspace.dependencies]
breach = { path = "./packages/breach", version = "0.0.1" }
breach-macros = { path = "./packages/breach-macros", version = "0.0.1" }
http = "1.4.0"
serde = "1.0.228"
serde_json = "1.0.149"
tokio = "1.49.0"
utoipa = "5.4.0"

[workspace.lints.rust]
unsafe_code = "deny"
Expand Down
4 changes: 2 additions & 2 deletions examples/axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ version.workspace = true
[dependencies]
anyhow = "1.0.101"
axum = "0.8.8"
breach.workspace = true
breach = { workspace = true, features = ["utoipa"] }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
utoipa = { version = "5.4.0", features = ["axum_extras", "uuid"] }
utoipa = { workspace = true, features = ["axum_extras", "uuid"] }
utoipa-axum = "0.2.0"
utoipa-scalar = { version = "0.3.0", features = ["axum"] }
uuid = { version = "1.20.0", features = ["serde", "v7"] }
Expand Down
5 changes: 3 additions & 2 deletions examples/axum/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use breach::HttpError;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;

#[derive(HttpError, Serialize)]
#[http(status = NOT_FOUND)]
#[derive(HttpError, Serialize, ToSchema)]
#[http(status = NOT_FOUND, utoipa)]
#[serde(rename_all = "camelCase")]
pub struct NotFoundError {
id: Uuid,
Expand Down
7 changes: 6 additions & 1 deletion examples/axum/src/user/errors.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use breach::HttpError;
use serde::Serialize;
use utoipa::ToSchema;

use crate::error::NotFoundError;

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

#[derive(HttpError, Serialize)]
#[http(utoipa)]
#[serde(
tag = "code",
rename_all = "camelCase",
Expand All @@ -34,6 +36,7 @@ impl From<UserValidationError> for CreateUserError {
}

#[derive(HttpError, Serialize)]
#[http(utoipa)]
#[serde(
tag = "code",
rename_all = "camelCase",
Expand All @@ -47,6 +50,7 @@ pub enum GetUserByIdError {
}

#[derive(HttpError, Serialize)]
#[http(utoipa)]
#[serde(
tag = "code",
rename_all = "camelCase",
Expand All @@ -67,6 +71,7 @@ impl From<UserValidationError> for UpdateUserError {
}

#[derive(HttpError, Serialize)]
#[http(utoipa)]
#[serde(
tag = "code",
rename_all = "camelCase",
Expand Down
20 changes: 12 additions & 8 deletions examples/axum/src/user/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl UserRoutes {
}

#[derive(HttpError, Serialize)]
#[http(axum)]
#[http(axum, utoipa)]
pub enum CreateUserRouteError {
CreateUser(CreateUserError),
}
Expand All @@ -47,7 +47,8 @@ impl From<CreateUserError> for CreateUserRouteError {
tags = ["User"],
request_body = CreateUser,
responses(
(status = CREATED, description = "The user has been created.", body = User)
(status = CREATED, description = "The user has been created.", body = User),
CreateUserRouteError,
)
)]
async fn create_user(
Expand All @@ -60,7 +61,7 @@ async fn create_user(
}

#[derive(HttpError, Serialize)]
#[http(axum)]
#[http(axum, utoipa)]
pub enum GetUserRouteError {
GetUserById(GetUserByIdError),
}
Expand All @@ -80,7 +81,8 @@ impl From<GetUserByIdError> for GetUserRouteError {
tags = ["User"],
params(UserPathParams),
responses(
(status = OK, description = "The user.", body = User)
(status = OK, description = "The user.", body = User),
GetUserRouteError,
)
)]
async fn user(
Expand All @@ -93,7 +95,7 @@ async fn user(
}

#[derive(HttpError, Serialize)]
#[http(axum)]
#[http(axum, utoipa)]
pub enum UpdateUserRouteError {
GetUserById(GetUserByIdError),

Expand Down Expand Up @@ -122,7 +124,8 @@ impl From<UpdateUserError> for UpdateUserRouteError {
params(UserPathParams),
request_body = UpdateUser,
responses(
(status = OK, description = "The user has been updated.", body = User)
(status = OK, description = "The user has been updated.", body = User),
UpdateUserRouteError,
)
)]
async fn update_user(
Expand All @@ -138,7 +141,7 @@ async fn update_user(
}

#[derive(HttpError, Serialize)]
#[http(axum)]
#[http(axum, utoipa)]
pub enum DeleteUserRouteError {
GetUserById(GetUserByIdError),

Expand Down Expand Up @@ -166,7 +169,8 @@ impl From<DeleteUserError> for DeleteUserRouteError {
tags = ["User"],
params(UserPathParams),
responses(
(status = NO_CONTENT, description = "The user has been deleted.")
(status = NO_CONTENT, description = "The user has been deleted."),
DeleteUserRouteError,
)
)]
async fn delete_user(
Expand Down
16 changes: 11 additions & 5 deletions examples/basic/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ use breach::{HttpError, http::StatusCode};
use serde::Serialize;
use serde_json::json;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ForbiddenError {
id: String,
}

#[derive(HttpError, Serialize)]
#[http(status = NOT_FOUND)]
#[serde(rename_all = "camelCase")]
Expand All @@ -18,9 +24,7 @@ struct NotFoundError {
)]
enum GetUserByIdError {
#[http(status = FORBIDDEN)]
Forbidden {
id: String,
},
Forbidden(ForbiddenError),

NotFound(NotFoundError),

Expand All @@ -37,15 +41,17 @@ enum GetUserByIdError {
enum UpdateUserError {
GetUserById(GetUserByIdError),

#[http(status = UNPROCESSABLE_CONTENT)]
#[http(status = UNPROCESSABLE_ENTITY)]
Validation,

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

fn main() {
let error = UpdateUserError::GetUserById(GetUserByIdError::Forbidden { id: "1".to_owned() });
let error = UpdateUserError::GetUserById(GetUserByIdError::Forbidden(ForbiddenError {
id: "1".to_owned(),
}));
assert_eq!(StatusCode::FORBIDDEN, error.status());
assert_eq!(
json!({
Expand Down
1 change: 1 addition & 0 deletions packages/breach-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ serde = []
utoipa = []

[dependencies]
http.workspace = true
proc-macro2 = "1.0.103"
quote = "1.0.42"
syn = "2.0.110"
Expand Down
33 changes: 23 additions & 10 deletions packages/breach-macros/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,30 @@ impl<'a> ToTokens for HttpError<'a> {
}
});

if let Some(attribute) = self.data.attribute()
&& attribute.axum
{
tokens.append_all(quote! {
#[automatically_derived]
impl #impl_generics ::axum::response::IntoResponse for #ident #type_generics #where_clause {
fn into_response(self) -> ::axum::response::Response {
(self.status(), Json(self)).into_response()
if let Some(attribute) = self.data.attribute() {
if attribute.axum {
tokens.append_all(quote! {
#[automatically_derived]
impl #impl_generics ::axum::response::IntoResponse for #ident #type_generics #where_clause {
fn into_response(self) -> ::axum::response::Response {
(self.status(), Json(self)).into_response()
}
}
}
});
});
}

if attribute.utoipa {
let responses = self.data.responses();

tokens.append_all(quote! {
#[automatically_derived]
impl #impl_generics ::utoipa::IntoResponses for #ident #type_generics #where_clause {
fn responses() -> ::std::collections::BTreeMap<String, ::utoipa::openapi::RefOr<::utoipa::openapi::response::Response>> {
#responses
}
}
});
}
}
}
}
57 changes: 50 additions & 7 deletions packages/breach-macros/src/http/attribute.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Attribute, Error, Ident, Result, spanned::Spanned};
use syn::{Attribute, Error, Result, spanned::Spanned};

use crate::status::Status;

pub struct HttpErrorAttribute {
status: Option<Ident>,
pub status: Option<Status>,
pub axum: bool,
pub utoipa: bool,
}

impl<'a> HttpErrorAttribute {
Expand Down Expand Up @@ -32,6 +35,7 @@ impl<'a> HttpErrorAttribute {
pub fn parse(attribute: &'a Attribute) -> Result<Self> {
let mut status = None;
let mut axum = false;
let mut utoipa = false;

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

Ok(())
} else if meta.path.is_ident("utoipa") {
utoipa = true;

Ok(())
} else {
Err(meta.error("unknown parameter"))
}
})?;

Ok(Self { status, axum })
Ok(Self {
status,
axum,
utoipa,
})
}

pub fn status(&self) -> TokenStream {
if let Some(status) = &self.status {
if status == "UNPROCESSABLE_CONTENT" {
quote!(::breach::http::StatusCode::UNPROCESSABLE_ENTITY)
} else {
quote!(::breach::http::StatusCode::#status)
let status = status.as_ident();

quote!(::breach::http::StatusCode::#status)
} else {
quote!(compile_error!("missing `#[http(status = ..)]` attribute"))
}
}

pub fn responses(&self, r#type: Option<TokenStream>) -> TokenStream {
if let Some(status) = &self.status {
let code = status.code.as_str();

let content = r#type.map(|r#type| {
// TODO: Attempt to infer content type from schema?
quote! {
.content(
"application/json",
::utoipa::openapi::content::ContentBuilder::new()
.schema(Some(<#r#type as ::utoipa::PartialSchema>::schema()))
.build()
)
}
});

quote! {
::std::collections::BTreeMap::from_iter([
(
#code.to_owned(),
::utoipa::openapi::RefOr::T(
::utoipa::openapi::response::ResponseBuilder::new()
#content
.build()
),
),
])
}
} else {
quote!(compile_error!("missing `#[http(status = ..)]` attribute"))
Expand Down
8 changes: 8 additions & 0 deletions packages/breach-macros/src/http/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ impl<'a> HttpErrorData<'a> {
HttpErrorData::Union(r#union) => r#union.status(),
}
}

pub fn responses(&self) -> TokenStream {
match self {
HttpErrorData::Struct(r#struct) => r#struct.responses(),
HttpErrorData::Enum(r#enum) => r#enum.responses(),
HttpErrorData::Union(r#union) => r#union.responses(),
}
}
}
Loading