mas_handlers/admin/v1/user_emails/
list.rs1use aide::{OperationIo, transform::TransformOperation};
7use axum::{
8 Json,
9 extract::{Query, rejection::QueryRejection},
10 response::IntoResponse,
11};
12use axum_macros::FromRequestParts;
13use hyper::StatusCode;
14use mas_axum_utils::record_error;
15use mas_storage::{Page, user::UserEmailFilter};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use ulid::Ulid;
19
20use crate::{
21 admin::{
22 call_context::CallContext,
23 model::{Resource, UserEmail},
24 params::Pagination,
25 response::{ErrorResponse, PaginatedResponse},
26 },
27 impl_from_error_for_route,
28};
29
30#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
31#[serde(rename = "UserEmailFilter")]
32#[aide(input_with = "Query<FilterParams>")]
33#[from_request(via(Query), rejection(RouteError))]
34pub struct FilterParams {
35 #[serde(rename = "filter[user]")]
37 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
38 user: Option<Ulid>,
39
40 #[serde(rename = "filter[email]")]
42 email: Option<String>,
43}
44
45impl std::fmt::Display for FilterParams {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 let mut sep = '?';
48
49 if let Some(user) = self.user {
50 write!(f, "{sep}filter[user]={user}")?;
51 sep = '&';
52 }
53
54 if let Some(email) = &self.email {
55 write!(f, "{sep}filter[email]={email}")?;
56 sep = '&';
57 }
58
59 let _ = sep;
60 Ok(())
61 }
62}
63
64#[derive(Debug, thiserror::Error, OperationIo)]
65#[aide(output_with = "Json<ErrorResponse>")]
66pub enum RouteError {
67 #[error(transparent)]
68 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
69
70 #[error("User ID {0} not found")]
71 UserNotFound(Ulid),
72
73 #[error("Invalid filter parameters")]
74 InvalidFilter(#[from] QueryRejection),
75}
76
77impl_from_error_for_route!(mas_storage::RepositoryError);
78
79impl IntoResponse for RouteError {
80 fn into_response(self) -> axum::response::Response {
81 let error = ErrorResponse::from_error(&self);
82 let sentry_event_id = record_error!(self, Self::Internal(_));
83 let status = match self {
84 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
85 Self::UserNotFound(_) => StatusCode::NOT_FOUND,
86 Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
87 };
88 (status, sentry_event_id, Json(error)).into_response()
89 }
90}
91
92pub fn doc(operation: TransformOperation) -> TransformOperation {
93 operation
94 .id("listUserEmails")
95 .summary("List user emails")
96 .description("Retrieve a list of user emails.")
97 .tag("user-email")
98 .response_with::<200, Json<PaginatedResponse<UserEmail>>, _>(|t| {
99 let emails = UserEmail::samples();
100 let pagination = mas_storage::Pagination::first(emails.len());
101 let page = Page {
102 edges: emails.into(),
103 has_next_page: true,
104 has_previous_page: false,
105 };
106
107 t.description("Paginated response of user emails")
108 .example(PaginatedResponse::new(
109 page,
110 pagination,
111 42,
112 UserEmail::PATH,
113 ))
114 })
115 .response_with::<404, RouteError, _>(|t| {
116 let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
117 t.description("User was not found").example(response)
118 })
119}
120
121#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)]
122pub async fn handler(
123 CallContext { mut repo, .. }: CallContext,
124 Pagination(pagination): Pagination,
125 params: FilterParams,
126) -> Result<Json<PaginatedResponse<UserEmail>>, RouteError> {
127 let base = format!("{path}{params}", path = UserEmail::PATH);
128 let filter = UserEmailFilter::default();
129
130 let user = if let Some(user_id) = params.user {
132 let user = repo
133 .user()
134 .lookup(user_id)
135 .await?
136 .ok_or(RouteError::UserNotFound(user_id))?;
137
138 Some(user)
139 } else {
140 None
141 };
142
143 let filter = match &user {
144 Some(user) => filter.for_user(user),
145 None => filter,
146 };
147
148 let filter = match ¶ms.email {
149 Some(email) => filter.for_email(email),
150 None => filter,
151 };
152
153 let page = repo.user_email().list(filter, pagination).await?;
154 let count = repo.user_email().count(filter).await?;
155
156 Ok(Json(PaginatedResponse::new(
157 page.map(UserEmail::from),
158 pagination,
159 count,
160 &base,
161 )))
162}
163
164#[cfg(test)]
165mod tests {
166 use hyper::{Request, StatusCode};
167 use sqlx::PgPool;
168
169 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
170
171 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
172 async fn test_list(pool: PgPool) {
173 setup();
174 let mut state = TestState::from_pool(pool).await.unwrap();
175 let token = state.token_with_scope("urn:mas:admin").await;
176 let mut rng = state.rng();
177
178 let mut repo = state.repository().await.unwrap();
180 let alice = repo
181 .user()
182 .add(&mut rng, &state.clock, "alice".to_owned())
183 .await
184 .unwrap();
185 let bob = repo
186 .user()
187 .add(&mut rng, &state.clock, "bob".to_owned())
188 .await
189 .unwrap();
190
191 repo.user_email()
192 .add(
193 &mut rng,
194 &state.clock,
195 &alice,
196 "alice@example.com".to_owned(),
197 )
198 .await
199 .unwrap();
200 repo.user_email()
201 .add(&mut rng, &state.clock, &bob, "bob@example.com".to_owned())
202 .await
203 .unwrap();
204 repo.save().await.unwrap();
205
206 let request = Request::get("/api/admin/v1/user-emails")
207 .bearer(&token)
208 .empty();
209 let response = state.request(request).await;
210 response.assert_status(StatusCode::OK);
211 let body: serde_json::Value = response.json();
212 insta::assert_json_snapshot!(body, @r###"
213 {
214 "meta": {
215 "count": 2
216 },
217 "data": [
218 {
219 "type": "user-email",
220 "id": "01FSHN9AG09NMZYX8MFYH578R9",
221 "attributes": {
222 "created_at": "2022-01-16T14:40:00Z",
223 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
224 "email": "alice@example.com"
225 },
226 "links": {
227 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
228 }
229 },
230 {
231 "type": "user-email",
232 "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
233 "attributes": {
234 "created_at": "2022-01-16T14:40:00Z",
235 "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
236 "email": "bob@example.com"
237 },
238 "links": {
239 "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z"
240 }
241 }
242 ],
243 "links": {
244 "self": "/api/admin/v1/user-emails?page[first]=10",
245 "first": "/api/admin/v1/user-emails?page[first]=10",
246 "last": "/api/admin/v1/user-emails?page[last]=10"
247 }
248 }
249 "###);
250
251 let request = Request::get(format!(
253 "/api/admin/v1/user-emails?filter[user]={}",
254 alice.id
255 ))
256 .bearer(&token)
257 .empty();
258 let response = state.request(request).await;
259 response.assert_status(StatusCode::OK);
260 let body: serde_json::Value = response.json();
261 insta::assert_json_snapshot!(body, @r###"
262 {
263 "meta": {
264 "count": 1
265 },
266 "data": [
267 {
268 "type": "user-email",
269 "id": "01FSHN9AG09NMZYX8MFYH578R9",
270 "attributes": {
271 "created_at": "2022-01-16T14:40:00Z",
272 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
273 "email": "alice@example.com"
274 },
275 "links": {
276 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
277 }
278 }
279 ],
280 "links": {
281 "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
282 "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
283 "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
284 }
285 }
286 "###);
287
288 let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com")
290 .bearer(&token)
291 .empty();
292 let response = state.request(request).await;
293 response.assert_status(StatusCode::OK);
294 let body: serde_json::Value = response.json();
295 insta::assert_json_snapshot!(body, @r###"
296 {
297 "meta": {
298 "count": 1
299 },
300 "data": [
301 {
302 "type": "user-email",
303 "id": "01FSHN9AG09NMZYX8MFYH578R9",
304 "attributes": {
305 "created_at": "2022-01-16T14:40:00Z",
306 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
307 "email": "alice@example.com"
308 },
309 "links": {
310 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
311 }
312 }
313 ],
314 "links": {
315 "self": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
316 "first": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
317 "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10"
318 }
319 }
320 "###);
321 }
322}