mas_handlers/admin/v1/user_emails/
list.rs1use aide::{OperationIo, transform::TransformOperation};
7use axum::{Json, response::IntoResponse};
8use axum_extra::extract::{Query, QueryRejection};
9use axum_macros::FromRequestParts;
10use hyper::StatusCode;
11use mas_axum_utils::record_error;
12use mas_storage::{Page, user::UserEmailFilter};
13use schemars::JsonSchema;
14use serde::Deserialize;
15use ulid::Ulid;
16
17use crate::{
18 admin::{
19 call_context::CallContext,
20 model::{Resource, UserEmail},
21 params::{IncludeCount, Pagination},
22 response::{ErrorResponse, PaginatedResponse},
23 },
24 impl_from_error_for_route,
25};
26
27#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
28#[serde(rename = "UserEmailFilter")]
29#[aide(input_with = "Query<FilterParams>")]
30#[from_request(via(Query), rejection(RouteError))]
31pub struct FilterParams {
32 #[serde(rename = "filter[user]")]
34 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
35 user: Option<Ulid>,
36
37 #[serde(rename = "filter[email]")]
39 email: Option<String>,
40}
41
42impl std::fmt::Display for FilterParams {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 let mut sep = '?';
45
46 if let Some(user) = self.user {
47 write!(f, "{sep}filter[user]={user}")?;
48 sep = '&';
49 }
50
51 if let Some(email) = &self.email {
52 write!(f, "{sep}filter[email]={email}")?;
53 sep = '&';
54 }
55
56 let _ = sep;
57 Ok(())
58 }
59}
60
61#[derive(Debug, thiserror::Error, OperationIo)]
62#[aide(output_with = "Json<ErrorResponse>")]
63pub enum RouteError {
64 #[error(transparent)]
65 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
66
67 #[error("User ID {0} not found")]
68 UserNotFound(Ulid),
69
70 #[error("Invalid filter parameters")]
71 InvalidFilter(#[from] QueryRejection),
72}
73
74impl_from_error_for_route!(mas_storage::RepositoryError);
75
76impl IntoResponse for RouteError {
77 fn into_response(self) -> axum::response::Response {
78 let error = ErrorResponse::from_error(&self);
79 let sentry_event_id = record_error!(self, Self::Internal(_));
80 let status = match self {
81 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
82 Self::UserNotFound(_) => StatusCode::NOT_FOUND,
83 Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
84 };
85 (status, sentry_event_id, Json(error)).into_response()
86 }
87}
88
89pub fn doc(operation: TransformOperation) -> TransformOperation {
90 operation
91 .id("listUserEmails")
92 .summary("List user emails")
93 .description("Retrieve a list of user emails.")
94 .tag("user-email")
95 .response_with::<200, Json<PaginatedResponse<UserEmail>>, _>(|t| {
96 let emails = UserEmail::samples();
97 let pagination = mas_storage::Pagination::first(emails.len());
98 let page = Page {
99 edges: emails
100 .into_iter()
101 .map(|node| mas_storage::pagination::Edge {
102 cursor: node.id(),
103 node,
104 })
105 .collect(),
106 has_next_page: true,
107 has_previous_page: false,
108 };
109
110 t.description("Paginated response of user emails")
111 .example(PaginatedResponse::for_page(
112 page,
113 pagination,
114 Some(42),
115 UserEmail::PATH,
116 ))
117 })
118 .response_with::<404, RouteError, _>(|t| {
119 let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
120 t.description("User was not found").example(response)
121 })
122}
123
124#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)]
125pub async fn handler(
126 CallContext { mut repo, .. }: CallContext,
127 Pagination(pagination, include_count): Pagination,
128 params: FilterParams,
129) -> Result<Json<PaginatedResponse<UserEmail>>, RouteError> {
130 let base = format!("{path}{params}", path = UserEmail::PATH);
131 let base = include_count.add_to_base(&base);
132 let filter = UserEmailFilter::default();
133
134 let user = if let Some(user_id) = params.user {
136 let user = repo
137 .user()
138 .lookup(user_id)
139 .await?
140 .ok_or(RouteError::UserNotFound(user_id))?;
141
142 Some(user)
143 } else {
144 None
145 };
146
147 let filter = match &user {
148 Some(user) => filter.for_user(user),
149 None => filter,
150 };
151
152 let filter = match ¶ms.email {
153 Some(email) => filter.for_email(email),
154 None => filter,
155 };
156
157 let response = match include_count {
158 IncludeCount::True => {
159 let page = repo
160 .user_email()
161 .list(filter, pagination)
162 .await?
163 .map(UserEmail::from);
164 let count = repo.user_email().count(filter).await?;
165 PaginatedResponse::for_page(page, pagination, Some(count), &base)
166 }
167 IncludeCount::False => {
168 let page = repo
169 .user_email()
170 .list(filter, pagination)
171 .await?
172 .map(UserEmail::from);
173 PaginatedResponse::for_page(page, pagination, None, &base)
174 }
175 IncludeCount::Only => {
176 let count = repo.user_email().count(filter).await?;
177 PaginatedResponse::for_count_only(count, &base)
178 }
179 };
180
181 Ok(Json(response))
182}
183
184#[cfg(test)]
185mod tests {
186 use hyper::{Request, StatusCode};
187 use sqlx::PgPool;
188
189 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
190
191 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
192 async fn test_list(pool: PgPool) {
193 setup();
194 let mut state = TestState::from_pool(pool).await.unwrap();
195 let token = state.token_with_scope("urn:mas:admin").await;
196 let mut rng = state.rng();
197
198 let mut repo = state.repository().await.unwrap();
200 let alice = repo
201 .user()
202 .add(&mut rng, &state.clock, "alice".to_owned())
203 .await
204 .unwrap();
205 let bob = repo
206 .user()
207 .add(&mut rng, &state.clock, "bob".to_owned())
208 .await
209 .unwrap();
210
211 repo.user_email()
212 .add(
213 &mut rng,
214 &state.clock,
215 &alice,
216 "alice@example.com".to_owned(),
217 )
218 .await
219 .unwrap();
220 repo.user_email()
221 .add(&mut rng, &state.clock, &bob, "bob@example.com".to_owned())
222 .await
223 .unwrap();
224 repo.save().await.unwrap();
225
226 let request = Request::get("/api/admin/v1/user-emails")
227 .bearer(&token)
228 .empty();
229 let response = state.request(request).await;
230 response.assert_status(StatusCode::OK);
231 let body: serde_json::Value = response.json();
232 insta::assert_json_snapshot!(body, @r#"
233 {
234 "meta": {
235 "count": 2
236 },
237 "data": [
238 {
239 "type": "user-email",
240 "id": "01FSHN9AG09NMZYX8MFYH578R9",
241 "attributes": {
242 "created_at": "2022-01-16T14:40:00Z",
243 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
244 "email": "alice@example.com"
245 },
246 "links": {
247 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
248 },
249 "meta": {
250 "page": {
251 "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
252 }
253 }
254 },
255 {
256 "type": "user-email",
257 "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
258 "attributes": {
259 "created_at": "2022-01-16T14:40:00Z",
260 "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
261 "email": "bob@example.com"
262 },
263 "links": {
264 "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z"
265 },
266 "meta": {
267 "page": {
268 "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z"
269 }
270 }
271 }
272 ],
273 "links": {
274 "self": "/api/admin/v1/user-emails?page[first]=10",
275 "first": "/api/admin/v1/user-emails?page[first]=10",
276 "last": "/api/admin/v1/user-emails?page[last]=10"
277 }
278 }
279 "#);
280
281 let request = Request::get(format!(
283 "/api/admin/v1/user-emails?filter[user]={}",
284 alice.id
285 ))
286 .bearer(&token)
287 .empty();
288 let response = state.request(request).await;
289 response.assert_status(StatusCode::OK);
290 let body: serde_json::Value = response.json();
291 insta::assert_json_snapshot!(body, @r#"
292 {
293 "meta": {
294 "count": 1
295 },
296 "data": [
297 {
298 "type": "user-email",
299 "id": "01FSHN9AG09NMZYX8MFYH578R9",
300 "attributes": {
301 "created_at": "2022-01-16T14:40:00Z",
302 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
303 "email": "alice@example.com"
304 },
305 "links": {
306 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
307 },
308 "meta": {
309 "page": {
310 "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
311 }
312 }
313 }
314 ],
315 "links": {
316 "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
317 "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
318 "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
319 }
320 }
321 "#);
322
323 let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com")
325 .bearer(&token)
326 .empty();
327 let response = state.request(request).await;
328 response.assert_status(StatusCode::OK);
329 let body: serde_json::Value = response.json();
330 insta::assert_json_snapshot!(body, @r#"
331 {
332 "meta": {
333 "count": 1
334 },
335 "data": [
336 {
337 "type": "user-email",
338 "id": "01FSHN9AG09NMZYX8MFYH578R9",
339 "attributes": {
340 "created_at": "2022-01-16T14:40:00Z",
341 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
342 "email": "alice@example.com"
343 },
344 "links": {
345 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
346 },
347 "meta": {
348 "page": {
349 "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
350 }
351 }
352 }
353 ],
354 "links": {
355 "self": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
356 "first": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
357 "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10"
358 }
359 }
360 "#);
361
362 let request = Request::get("/api/admin/v1/user-emails?count=false")
364 .bearer(&token)
365 .empty();
366 let response = state.request(request).await;
367 response.assert_status(StatusCode::OK);
368 let body: serde_json::Value = response.json();
369 insta::assert_json_snapshot!(body, @r#"
370 {
371 "data": [
372 {
373 "type": "user-email",
374 "id": "01FSHN9AG09NMZYX8MFYH578R9",
375 "attributes": {
376 "created_at": "2022-01-16T14:40:00Z",
377 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
378 "email": "alice@example.com"
379 },
380 "links": {
381 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
382 },
383 "meta": {
384 "page": {
385 "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
386 }
387 }
388 },
389 {
390 "type": "user-email",
391 "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
392 "attributes": {
393 "created_at": "2022-01-16T14:40:00Z",
394 "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
395 "email": "bob@example.com"
396 },
397 "links": {
398 "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z"
399 },
400 "meta": {
401 "page": {
402 "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z"
403 }
404 }
405 }
406 ],
407 "links": {
408 "self": "/api/admin/v1/user-emails?count=false&page[first]=10",
409 "first": "/api/admin/v1/user-emails?count=false&page[first]=10",
410 "last": "/api/admin/v1/user-emails?count=false&page[last]=10"
411 }
412 }
413 "#);
414
415 let request = Request::get("/api/admin/v1/user-emails?count=only")
417 .bearer(&token)
418 .empty();
419 let response = state.request(request).await;
420 response.assert_status(StatusCode::OK);
421 let body: serde_json::Value = response.json();
422 insta::assert_json_snapshot!(body, @r###"
423 {
424 "meta": {
425 "count": 2
426 },
427 "links": {
428 "self": "/api/admin/v1/user-emails?count=only"
429 }
430 }
431 "###);
432
433 let request = Request::get(format!(
435 "/api/admin/v1/user-emails?count=false&filter[user]={}",
436 alice.id
437 ))
438 .bearer(&token)
439 .empty();
440 let response = state.request(request).await;
441 response.assert_status(StatusCode::OK);
442 let body: serde_json::Value = response.json();
443 insta::assert_json_snapshot!(body, @r#"
444 {
445 "data": [
446 {
447 "type": "user-email",
448 "id": "01FSHN9AG09NMZYX8MFYH578R9",
449 "attributes": {
450 "created_at": "2022-01-16T14:40:00Z",
451 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
452 "email": "alice@example.com"
453 },
454 "links": {
455 "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
456 },
457 "meta": {
458 "page": {
459 "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
460 }
461 }
462 }
463 ],
464 "links": {
465 "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
466 "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
467 "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10"
468 }
469 }
470 "#);
471
472 let request = Request::get(format!(
474 "/api/admin/v1/user-emails?count=only&filter[user]={}",
475 alice.id
476 ))
477 .bearer(&token)
478 .empty();
479 let response = state.request(request).await;
480 response.assert_status(StatusCode::OK);
481 let body: serde_json::Value = response.json();
482 insta::assert_json_snapshot!(body, @r#"
483 {
484 "meta": {
485 "count": 1
486 },
487 "links": {
488 "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=only"
489 }
490 }
491 "#);
492 }
493}