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