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