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