mas_handlers/admin/v1/user_sessions/
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_storage::{pagination::Page, user::BrowserSessionFilter};
15use schemars::JsonSchema;
16use serde::Deserialize;
17use ulid::Ulid;
18
19use crate::{
20 admin::{
21 call_context::CallContext,
22 model::{Resource, UserSession},
23 params::Pagination,
24 response::{ErrorResponse, PaginatedResponse},
25 },
26 impl_from_error_for_route,
27};
28
29#[derive(Deserialize, JsonSchema, Clone, Copy)]
30#[serde(rename_all = "snake_case")]
31enum UserSessionStatus {
32 Active,
33 Finished,
34}
35
36impl std::fmt::Display for UserSessionStatus {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::Active => write!(f, "active"),
40 Self::Finished => write!(f, "finished"),
41 }
42 }
43}
44
45#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
46#[serde(rename = "UserSessionFilter")]
47#[aide(input_with = "Query<FilterParams>")]
48#[from_request(via(Query), rejection(RouteError))]
49pub struct FilterParams {
50 #[serde(rename = "filter[user]")]
52 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
53 user: Option<Ulid>,
54
55 #[serde(rename = "filter[status]")]
63 status: Option<UserSessionStatus>,
64}
65
66impl std::fmt::Display for FilterParams {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 let mut sep = '?';
69
70 if let Some(user) = self.user {
71 write!(f, "{sep}filter[user]={user}")?;
72 sep = '&';
73 }
74
75 if let Some(status) = self.status {
76 write!(f, "{sep}filter[status]={status}")?;
77 sep = '&';
78 }
79
80 let _ = sep;
81 Ok(())
82 }
83}
84
85#[derive(Debug, thiserror::Error, OperationIo)]
86#[aide(output_with = "Json<ErrorResponse>")]
87pub enum RouteError {
88 #[error(transparent)]
89 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
90
91 #[error("User ID {0} not found")]
92 UserNotFound(Ulid),
93
94 #[error("Invalid filter parameters")]
95 InvalidFilter(#[from] QueryRejection),
96}
97
98impl_from_error_for_route!(mas_storage::RepositoryError);
99
100impl IntoResponse for RouteError {
101 fn into_response(self) -> axum::response::Response {
102 let error = ErrorResponse::from_error(&self);
103 let status = match self {
104 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
105 Self::UserNotFound(_) => StatusCode::NOT_FOUND,
106 Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
107 };
108 (status, Json(error)).into_response()
109 }
110}
111
112pub fn doc(operation: TransformOperation) -> TransformOperation {
113 operation
114 .id("listUserSessions")
115 .summary("List user sessions")
116 .description("Retrieve a list of user sessions (browser sessions).
117Note that by default, all sessions, including finished ones are returned, with the oldest first.
118Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
119 .tag("user-session")
120 .response_with::<200, Json<PaginatedResponse<UserSession>>, _>(|t| {
121 let sessions = UserSession::samples();
122 let pagination = mas_storage::Pagination::first(sessions.len());
123 let page = Page {
124 edges: sessions.into(),
125 has_next_page: true,
126 has_previous_page: false,
127 };
128
129 t.description("Paginated response of user sessions")
130 .example(PaginatedResponse::new(
131 page,
132 pagination,
133 42,
134 UserSession::PATH,
135 ))
136 })
137 .response_with::<404, RouteError, _>(|t| {
138 let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
139 t.description("User was not found").example(response)
140 })
141}
142
143#[tracing::instrument(name = "handler.admin.v1.user_sessions.list", skip_all, err)]
144pub async fn handler(
145 CallContext { mut repo, .. }: CallContext,
146 Pagination(pagination): Pagination,
147 params: FilterParams,
148) -> Result<Json<PaginatedResponse<UserSession>>, RouteError> {
149 let base = format!("{path}{params}", path = UserSession::PATH);
150 let filter = BrowserSessionFilter::default();
151
152 let user = if let Some(user_id) = params.user {
154 let user = repo
155 .user()
156 .lookup(user_id)
157 .await?
158 .ok_or(RouteError::UserNotFound(user_id))?;
159
160 Some(user)
161 } else {
162 None
163 };
164
165 let filter = match &user {
166 Some(user) => filter.for_user(user),
167 None => filter,
168 };
169
170 let filter = match params.status {
171 Some(UserSessionStatus::Active) => filter.active_only(),
172 Some(UserSessionStatus::Finished) => filter.finished_only(),
173 None => filter,
174 };
175
176 let page = repo.browser_session().list(filter, pagination).await?;
177 let count = repo.browser_session().count(filter).await?;
178
179 Ok(Json(PaginatedResponse::new(
180 page.map(UserSession::from),
181 pagination,
182 count,
183 &base,
184 )))
185}
186
187#[cfg(test)]
188mod tests {
189 use chrono::Duration;
190 use hyper::{Request, StatusCode};
191 use insta::assert_json_snapshot;
192 use sqlx::PgPool;
193
194 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
195
196 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
197 async fn test_user_session_list(pool: PgPool) {
198 setup();
199 let mut state = TestState::from_pool(pool).await.unwrap();
200 let token = state.token_with_scope("urn:mas:admin").await;
201 let mut rng = state.rng();
202
203 let mut repo = state.repository().await.unwrap();
205 let alice = repo
206 .user()
207 .add(&mut rng, &state.clock, "alice".to_owned())
208 .await
209 .unwrap();
210 state.clock.advance(Duration::minutes(1));
211
212 let bob = repo
213 .user()
214 .add(&mut rng, &state.clock, "bob".to_owned())
215 .await
216 .unwrap();
217
218 repo.browser_session()
219 .add(&mut rng, &state.clock, &alice, None)
220 .await
221 .unwrap();
222
223 let session = repo
224 .browser_session()
225 .add(&mut rng, &state.clock, &bob, None)
226 .await
227 .unwrap();
228 state.clock.advance(Duration::minutes(1));
229 repo.browser_session()
230 .finish(&state.clock, session)
231 .await
232 .unwrap();
233
234 repo.save().await.unwrap();
235
236 let request = Request::get("/api/admin/v1/user-sessions")
237 .bearer(&token)
238 .empty();
239 let response = state.request(request).await;
240 response.assert_status(StatusCode::OK);
241 let body: serde_json::Value = response.json();
242 assert_json_snapshot!(body, @r###"
243 {
244 "meta": {
245 "count": 2
246 },
247 "data": [
248 {
249 "type": "user-session",
250 "id": "01FSHNB5309NMZYX8MFYH578R9",
251 "attributes": {
252 "created_at": "2022-01-16T14:41:00Z",
253 "finished_at": null,
254 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
255 "user_agent": null,
256 "last_active_at": null,
257 "last_active_ip": null
258 },
259 "links": {
260 "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
261 }
262 },
263 {
264 "type": "user-session",
265 "id": "01FSHNB530KEPHYQQXW9XPTX6Z",
266 "attributes": {
267 "created_at": "2022-01-16T14:41:00Z",
268 "finished_at": "2022-01-16T14:42:00Z",
269 "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
270 "user_agent": null,
271 "last_active_at": null,
272 "last_active_ip": null
273 },
274 "links": {
275 "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z"
276 }
277 }
278 ],
279 "links": {
280 "self": "/api/admin/v1/user-sessions?page[first]=10",
281 "first": "/api/admin/v1/user-sessions?page[first]=10",
282 "last": "/api/admin/v1/user-sessions?page[last]=10"
283 }
284 }
285 "###);
286
287 let request = Request::get(format!(
289 "/api/admin/v1/user-sessions?filter[user]={}",
290 alice.id
291 ))
292 .bearer(&token)
293 .empty();
294 let response = state.request(request).await;
295 response.assert_status(StatusCode::OK);
296 let body: serde_json::Value = response.json();
297 assert_json_snapshot!(body, @r###"
298 {
299 "meta": {
300 "count": 1
301 },
302 "data": [
303 {
304 "type": "user-session",
305 "id": "01FSHNB5309NMZYX8MFYH578R9",
306 "attributes": {
307 "created_at": "2022-01-16T14:41:00Z",
308 "finished_at": null,
309 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
310 "user_agent": null,
311 "last_active_at": null,
312 "last_active_ip": null
313 },
314 "links": {
315 "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
316 }
317 }
318 ],
319 "links": {
320 "self": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
321 "first": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
322 "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
323 }
324 }
325 "###);
326
327 let request = Request::get("/api/admin/v1/user-sessions?filter[status]=active")
329 .bearer(&token)
330 .empty();
331 let response = state.request(request).await;
332 response.assert_status(StatusCode::OK);
333 let body: serde_json::Value = response.json();
334 assert_json_snapshot!(body, @r###"
335 {
336 "meta": {
337 "count": 1
338 },
339 "data": [
340 {
341 "type": "user-session",
342 "id": "01FSHNB5309NMZYX8MFYH578R9",
343 "attributes": {
344 "created_at": "2022-01-16T14:41:00Z",
345 "finished_at": null,
346 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
347 "user_agent": null,
348 "last_active_at": null,
349 "last_active_ip": null
350 },
351 "links": {
352 "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
353 }
354 }
355 ],
356 "links": {
357 "self": "/api/admin/v1/user-sessions?filter[status]=active&page[first]=10",
358 "first": "/api/admin/v1/user-sessions?filter[status]=active&page[first]=10",
359 "last": "/api/admin/v1/user-sessions?filter[status]=active&page[last]=10"
360 }
361 }
362 "###);
363
364 let request = Request::get("/api/admin/v1/user-sessions?filter[status]=finished")
366 .bearer(&token)
367 .empty();
368 let response = state.request(request).await;
369 response.assert_status(StatusCode::OK);
370 let body: serde_json::Value = response.json();
371 assert_json_snapshot!(body, @r###"
372 {
373 "meta": {
374 "count": 1
375 },
376 "data": [
377 {
378 "type": "user-session",
379 "id": "01FSHNB530KEPHYQQXW9XPTX6Z",
380 "attributes": {
381 "created_at": "2022-01-16T14:41:00Z",
382 "finished_at": "2022-01-16T14:42:00Z",
383 "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
384 "user_agent": null,
385 "last_active_at": null,
386 "last_active_ip": null
387 },
388 "links": {
389 "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z"
390 }
391 }
392 ],
393 "links": {
394 "self": "/api/admin/v1/user-sessions?filter[status]=finished&page[first]=10",
395 "first": "/api/admin/v1/user-sessions?filter[status]=finished&page[first]=10",
396 "last": "/api/admin/v1/user-sessions?filter[status]=finished&page[last]=10"
397 }
398 }
399 "###);
400 }
401}