mas_handlers/admin/v1/user_emails/
list.rs

1// Copyright 2024 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use 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    /// Retrieve the items for the given user
36    #[serde(rename = "filter[user]")]
37    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
38    user: Option<Ulid>,
39
40    /// Retrieve the user email with the given email address
41    #[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    // Load the user from the filter
131    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 &params.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        // Provision two users, two emails
179        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        // Filter by user
252        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        // Filter by email
289        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}