Skip to content

Commit 60fe6e2

Browse files
feat: serde deserialization and serialization (#34)
1 parent e144d3f commit 60fe6e2

30 files changed

Lines changed: 499 additions & 82 deletions

File tree

Cargo.lock

Lines changed: 3 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ fortifier = { path = "./packages/fortifier", version = "0.0.1" }
1515
fortifier-macros = { path = "./packages/fortifier-macros", version = "0.0.1" }
1616
indexmap = "2.12.0"
1717
phonenumber = "0.3.7"
18+
pretty_assertions = "1.4.1"
1819
regex = "1.12.2"
1920
serde = "1.0.228"
2021
serde_json = "1.0.145"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod entities;
2+
pub mod schemas;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
pub mod email_address {
2+
use serde::Serialize;
3+
use utoipa::ToSchema;
4+
use uuid::Uuid;
5+
6+
#[derive(Serialize, ToSchema)]
7+
pub struct Model {
8+
pub id: Uuid,
9+
pub email_addres: String,
10+
pub label: String,
11+
}
12+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use fortifier::Validate;
2+
use serde::Deserialize;
3+
use utoipa::ToSchema;
4+
use uuid::Uuid;
5+
6+
#[derive(Deserialize, ToSchema, Validate)]
7+
#[serde(rename_all = "camelCase")]
8+
pub struct CreateEmailAddress {
9+
#[validate(email_address)]
10+
pub email_address: String,
11+
12+
#[validate(length(min = 1, max = 256))]
13+
pub label: String,
14+
}
15+
16+
#[derive(Deserialize, ToSchema, Validate)]
17+
#[serde(rename_all = "camelCase")]
18+
pub struct UpdateEmailAddress {
19+
#[validate(email_address)]
20+
pub email_address: Option<String>,
21+
22+
#[validate(length(min = 1, max = 256))]
23+
pub label: Option<String>,
24+
}
25+
26+
#[derive(Deserialize, ToSchema, Validate)]
27+
#[serde(
28+
tag = "type",
29+
rename_all = "camelCase",
30+
rename_all_fields = "camelCase"
31+
)]
32+
pub enum ChangeEmailAddressRelation {
33+
Create(CreateEmailAddress),
34+
Update {
35+
#[validate(skip)]
36+
id: Uuid,
37+
38+
#[serde(flatten)]
39+
data: UpdateEmailAddress,
40+
},
41+
Delete {
42+
#[validate(skip)]
43+
id: Uuid,
44+
},
45+
}

examples/server/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod email_address;
12
mod routes;
23
mod user;
34

examples/server/src/user/entities.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ pub mod user {
66
#[derive(Serialize, ToSchema)]
77
pub struct Model {
88
pub id: Uuid,
9-
pub email_address: String,
109
pub name: String,
1110
}
1211
}

examples/server/src/user/routes.rs

Lines changed: 154 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
use axum::{Json, http::StatusCode, response::IntoResponse};
1+
use axum::{Json, extract::Path, http::StatusCode, response::IntoResponse};
22
use fortifier::{Validate, ValidationErrors};
3+
use serde::Deserialize;
34
use thiserror::Error;
5+
use utoipa::IntoParams;
46
use utoipa_axum::{router::OpenApiRouter, routes};
57
use uuid::Uuid;
68

7-
use crate::user::{
8-
entities::user,
9-
schemas::{CreateUser, CreateUserValidationError},
9+
use crate::{
10+
email_address::entities::email_address,
11+
user::{
12+
entities::user,
13+
schemas::{
14+
CreateUser, CreateUserValidationError, UpdateUser, UpdateUserValidationError,
15+
UserWithEmailAddresses,
16+
},
17+
},
1018
};
1119

1220
pub struct UserRoutes;
@@ -16,7 +24,9 @@ impl UserRoutes {
1624
where
1725
S: Clone + Send + Sync + 'static,
1826
{
19-
OpenApiRouter::new().routes(routes!(create_user))
27+
OpenApiRouter::new()
28+
.routes(routes!(create_user))
29+
.routes(routes!(user, update_user, delete_user))
2030
}
2131
}
2232

@@ -28,7 +38,11 @@ enum CreateUserError {
2838

2939
impl IntoResponse for CreateUserError {
3040
fn into_response(self) -> axum::response::Response {
31-
todo!()
41+
match self {
42+
CreateUserError::UnprocessableContent(errors) => {
43+
(StatusCode::UNPROCESSABLE_ENTITY, Json(errors)).into_response()
44+
}
45+
}
3246
}
3347
}
3448

@@ -41,21 +55,149 @@ impl IntoResponse for CreateUserError {
4155
tags = ["User"],
4256
request_body = CreateUser,
4357
responses(
44-
(status = 201, description = "The created user.", body = user::Model),
45-
(status = 400, description = "Validation error.", body = ValidationErrors<CreateUserValidationError>),
46-
// (status = 500, description = "Internal server error.", body = ErrorBody),
58+
(status = CREATED, description = "The created user.", body = UserWithEmailAddresses),
59+
(status = UNPROCESSABLE_ENTITY, description = "Validation error.", body = ValidationErrors<CreateUserValidationError>),
4760
)
4861
)]
4962
async fn create_user(
5063
Json(data): Json<CreateUser>,
51-
) -> Result<(StatusCode, Json<user::Model>), CreateUserError> {
64+
) -> Result<(StatusCode, Json<UserWithEmailAddresses>), CreateUserError> {
5265
data.validate().await?;
5366

5467
let user = user::Model {
5568
id: Uuid::now_v7(),
56-
email_address: data.email_address,
5769
name: data.name,
5870
};
5971

60-
Ok((StatusCode::CREATED, Json(user)))
72+
let mut email_addresses = Vec::with_capacity(data.email_addresses.len());
73+
for email_address_data in data.email_addresses {
74+
email_addresses.push(email_address::Model {
75+
id: Uuid::now_v7(),
76+
email_addres: email_address_data.email_address,
77+
label: email_address_data.label,
78+
});
79+
}
80+
81+
Ok((
82+
StatusCode::CREATED,
83+
Json(UserWithEmailAddresses {
84+
model: user,
85+
email_addresses,
86+
}),
87+
))
88+
}
89+
90+
#[derive(Deserialize, IntoParams)]
91+
#[serde(rename_all = "camelCase")]
92+
pub struct UserPathParams {
93+
user_id: Uuid,
94+
}
95+
96+
#[utoipa::path(
97+
get,
98+
path = "/users/{userId}",
99+
operation_id = "getUser",
100+
summary = "Get user",
101+
description = "Get a user.",
102+
tags = ["User"],
103+
params(
104+
UserPathParams,
105+
),
106+
responses(
107+
(status = OK, description = "The user.", body = UserWithEmailAddresses),
108+
(status = NOT_FOUND, description = "Not found error."),
109+
)
110+
)]
111+
async fn user(
112+
Path(UserPathParams { user_id }): Path<UserPathParams>,
113+
) -> Result<Json<UserWithEmailAddresses>, StatusCode> {
114+
// TODO
115+
116+
let user = user::Model {
117+
id: user_id,
118+
name: "".to_owned(),
119+
};
120+
121+
let email_addresses = vec![];
122+
123+
Ok(Json(UserWithEmailAddresses {
124+
model: user,
125+
email_addresses,
126+
}))
127+
}
128+
129+
#[derive(Debug, Error)]
130+
enum UpdateUserError {
131+
#[error(transparent)]
132+
UnprocessableContent(#[from] ValidationErrors<UpdateUserValidationError>),
133+
}
134+
135+
impl IntoResponse for UpdateUserError {
136+
fn into_response(self) -> axum::response::Response {
137+
match self {
138+
UpdateUserError::UnprocessableContent(errors) => {
139+
(StatusCode::UNPROCESSABLE_ENTITY, Json(errors)).into_response()
140+
}
141+
}
142+
}
143+
}
144+
145+
#[utoipa::path(
146+
patch,
147+
path = "/users/{userId}",
148+
operation_id = "updateUser",
149+
summary = "Update user",
150+
description = "Update a user.",
151+
tags = ["User"],
152+
params(
153+
UserPathParams,
154+
),
155+
request_body = UpdateUser,
156+
responses(
157+
(status = OK, description = "The updated user.", body = UserWithEmailAddresses),
158+
(status = UNPROCESSABLE_ENTITY, description = "Validation error.", body = ValidationErrors<UpdateUserValidationError>),
159+
)
160+
)]
161+
async fn update_user(
162+
Path(UserPathParams { user_id }): Path<UserPathParams>,
163+
Json(data): Json<UpdateUser>,
164+
) -> Result<Json<UserWithEmailAddresses>, UpdateUserError> {
165+
data.validate().await?;
166+
167+
// TODO
168+
169+
let user = user::Model {
170+
id: user_id,
171+
name: "".to_owned(),
172+
};
173+
174+
let email_addresses = vec![];
175+
176+
Ok(Json(UserWithEmailAddresses {
177+
model: user,
178+
email_addresses,
179+
}))
180+
}
181+
182+
#[utoipa::path(
183+
delete,
184+
path = "/users/{userId}",
185+
operation_id = "deleteUser",
186+
summary = "Delete user",
187+
description = "Delete a user.",
188+
tags = ["User"],
189+
params(
190+
UserPathParams,
191+
),
192+
responses(
193+
(status = NO_CONTENT, description = "The user was deleted.",),
194+
(status = NOT_FOUND, description = "Not found error."),
195+
)
196+
)]
197+
async fn delete_user(
198+
Path(UserPathParams { user_id: _user_id }): Path<UserPathParams>,
199+
) -> Result<StatusCode, StatusCode> {
200+
// TODO
201+
202+
Ok(StatusCode::NO_CONTENT)
61203
}
Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,41 @@
11
use fortifier::Validate;
2-
use serde::Deserialize;
2+
use serde::{Deserialize, Serialize};
33
use utoipa::ToSchema;
44

5+
use crate::{
6+
email_address::{
7+
entities::email_address,
8+
schemas::{
9+
ChangeEmailAddressRelation, ChangeEmailAddressRelationValidationError,
10+
CreateEmailAddress, CreateEmailAddressValidationError,
11+
},
12+
},
13+
user::entities::user,
14+
};
15+
16+
#[derive(Serialize, ToSchema)]
17+
#[serde(rename_all = "camelCase")]
18+
pub struct UserWithEmailAddresses {
19+
#[serde(flatten)]
20+
pub model: user::Model,
21+
22+
pub email_addresses: Vec<email_address::Model>,
23+
}
24+
525
#[derive(Deserialize, ToSchema, Validate)]
26+
#[serde(rename_all = "camelCase")]
627
pub struct CreateUser {
7-
#[validate(email_address)]
8-
pub email_address: String,
28+
#[validate(length(min = 1, max = 256))]
29+
pub name: String,
30+
31+
pub email_addresses: Vec<CreateEmailAddress>,
32+
}
933

34+
#[derive(Deserialize, ToSchema, Validate)]
35+
#[serde(rename_all = "camelCase")]
36+
pub struct UpdateUser {
1037
#[validate(length(min = 1, max = 256))]
1138
pub name: String,
39+
40+
pub email_addresses: Vec<ChangeEmailAddressRelation>,
1241
}

packages/fortifier-macros/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ syn = "2.0.110"
2828

2929
[dev-dependencies]
3030
email_address.workspace = true
31-
fortifier = { workspace = true, features = ["all-validations", "indexmap"] }
31+
fortifier = { workspace = true, features = [
32+
"all-validations",
33+
"indexmap",
34+
"serde",
35+
] }
3236
indexmap.workspace = true
3337
phonenumber.workspace = true
38+
pretty_assertions.workspace = true
3439
regex.workspace = true
40+
serde.workspace = true
41+
serde_json.workspace = true
3542
trybuild = "1.0.114"
3643
url.workspace = true
3744

0 commit comments

Comments
 (0)