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)
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)
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 },
299 "links": {
300 "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
301 }
302 },
303 {
304 "type": "compat-session",
305 "id": "01FSHNCZP0PPF7X0EVMJNECPZW",
306 "attributes": {
307 "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
308 "device_id": "ZXyvelQWW9",
309 "user_session_id": null,
310 "redirect_uri": null,
311 "created_at": "2022-01-16T14:42:00Z",
312 "user_agent": null,
313 "last_active_at": null,
314 "last_active_ip": null,
315 "finished_at": "2022-01-16T14:43:00Z"
316 },
317 "links": {
318 "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
319 }
320 }
321 ],
322 "links": {
323 "self": "/api/admin/v1/compat-sessions?page[first]=10",
324 "first": "/api/admin/v1/compat-sessions?page[first]=10",
325 "last": "/api/admin/v1/compat-sessions?page[last]=10"
326 }
327 }
328 "###);
329
330 let request = Request::get(format!(
332 "/api/admin/v1/compat-sessions?filter[user]={}",
333 alice.id
334 ))
335 .bearer(&token)
336 .empty();
337 let response = state.request(request).await;
338 response.assert_status(StatusCode::OK);
339 let body: serde_json::Value = response.json();
340 assert_json_snapshot!(body, @r###"
341 {
342 "meta": {
343 "count": 1
344 },
345 "data": [
346 {
347 "type": "compat-session",
348 "id": "01FSHNB530AAPR7PEV8KNBZD5Y",
349 "attributes": {
350 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
351 "device_id": "LoieH5Iecx",
352 "user_session_id": null,
353 "redirect_uri": null,
354 "created_at": "2022-01-16T14:41:00Z",
355 "user_agent": null,
356 "last_active_at": null,
357 "last_active_ip": null,
358 "finished_at": null
359 },
360 "links": {
361 "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
362 }
363 }
364 ],
365 "links": {
366 "self": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
367 "first": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
368 "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
369 }
370 }
371 "###);
372
373 let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active")
375 .bearer(&token)
376 .empty();
377 let response = state.request(request).await;
378 response.assert_status(StatusCode::OK);
379 let body: serde_json::Value = response.json();
380 assert_json_snapshot!(body, @r###"
381 {
382 "meta": {
383 "count": 1
384 },
385 "data": [
386 {
387 "type": "compat-session",
388 "id": "01FSHNB530AAPR7PEV8KNBZD5Y",
389 "attributes": {
390 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
391 "device_id": "LoieH5Iecx",
392 "user_session_id": null,
393 "redirect_uri": null,
394 "created_at": "2022-01-16T14:41:00Z",
395 "user_agent": null,
396 "last_active_at": null,
397 "last_active_ip": null,
398 "finished_at": null
399 },
400 "links": {
401 "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
402 }
403 }
404 ],
405 "links": {
406 "self": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10",
407 "first": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10",
408 "last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10"
409 }
410 }
411 "###);
412
413 let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished")
415 .bearer(&token)
416 .empty();
417 let response = state.request(request).await;
418 response.assert_status(StatusCode::OK);
419 let body: serde_json::Value = response.json();
420 assert_json_snapshot!(body, @r###"
421 {
422 "meta": {
423 "count": 1
424 },
425 "data": [
426 {
427 "type": "compat-session",
428 "id": "01FSHNCZP0PPF7X0EVMJNECPZW",
429 "attributes": {
430 "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
431 "device_id": "ZXyvelQWW9",
432 "user_session_id": null,
433 "redirect_uri": null,
434 "created_at": "2022-01-16T14:42:00Z",
435 "user_agent": null,
436 "last_active_at": null,
437 "last_active_ip": null,
438 "finished_at": "2022-01-16T14:43:00Z"
439 },
440 "links": {
441 "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
442 }
443 }
444 ],
445 "links": {
446 "self": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10",
447 "first": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10",
448 "last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10"
449 }
450 }
451 "###);
452 }
453}