Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
api_log.rs

1//! The Suotar API log tab: one row per HTTP call to the study registry, and what each covered.
2//!
3//! The stored bodies are scrubbed at write time — names, student numbers and email addresses read
4//! `[redacted]` while their keys survive — and are returned exactly as stored. What makes the log
5//! navigable is not the body but `credit_registration_ids`: every call resolves to the ledger rows
6//! it carried, and those hold the real values.
7
8use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
9use headless_lms_models::credit_registrations::{
10    self, AdminCreditRegistrationFilters, AdminCreditRegistrationSort, CreditRegistrationErrorCode,
11    CreditRegistrationState,
12};
13use headless_lms_models::suotar_api_calls::{
14    self, SuotarApiCall, SuotarApiCallFilters, SuotarApiCallPageRow, SuotarEndpoint,
15};
16use utoipa::ToSchema;
17
18use crate::prelude::*;
19
20use super::authorize_credit_registration_admin;
21
22/// How many ledger rows one call may resolve. A batch is capped well below this by the endpoint's
23/// own batch size.
24const MAX_REFERENCED_ROWS: i64 = 500;
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
27pub struct SuotarApiCallRow {
28    pub id: Uuid,
29    pub endpoint: SuotarEndpoint,
30    pub started_at: DateTime<Utc>,
31    pub duration_ms: Option<i32>,
32    /// `None` with `succeeded = false` means the request never got an answer: connect, TLS or
33    /// timeout.
34    pub http_status: Option<i32>,
35    pub succeeded: bool,
36    pub request_item_count: i32,
37    pub ok_item_count: i32,
38    pub error_item_count: i32,
39    /// The registry's own request-level code, an identifier rather than prose.
40    pub request_level_error_code: Option<String>,
41    pub worker_name: String,
42    pub credit_registration_ids: Vec<Uuid>,
43}
44
45#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
46pub struct SuotarApiCallsPage {
47    #[serde(flatten)]
48    pub page: Page<SuotarApiCallRow>,
49    /// The `worker_name` values in the log, for the filter.
50    pub worker_names: Vec<String>,
51}
52
53/// One ledger row a call carried, resolved from `credit_registration_ids`. This is what stands in
54/// for the redacted body: the identifiers are here, beside the item they belong to.
55#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
56pub struct SuotarApiCallLedgerReference {
57    pub credit_registration_id: Uuid,
58    /// The id the registry saw for this row, so a line of the stored body maps to a student.
59    pub request_item_id: String,
60    pub user_id: Uuid,
61    pub first_name: Option<String>,
62    pub last_name: Option<String>,
63    pub email: Option<String>,
64    pub student_number: Option<String>,
65    pub course_id: Uuid,
66    pub course_name: String,
67    pub state: CreditRegistrationState,
68    pub error_code: Option<CreditRegistrationErrorCode>,
69}
70
71/// A timeline entry written against this call, one per item the answer moved.
72#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
73pub struct SuotarApiCallEvent {
74    pub id: Uuid,
75    pub credit_registration_id: Uuid,
76    pub created_at: DateTime<Utc>,
77    pub kind: CreditRegistrationEventKind,
78    pub from_state: Option<CreditRegistrationState>,
79    pub to_state: Option<CreditRegistrationState>,
80    pub error_code: Option<CreditRegistrationErrorCode>,
81    /// The `{request, response}` pair for this one item, scrubbed at write time.
82    pub details: Option<serde_json::Value>,
83}
84
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct SuotarApiCallDetails {
87    pub call: SuotarApiCallRow,
88    /// Scrubbed and truncated when it was written; never un-scrubbed for display.
89    pub request_body_sample: Option<serde_json::Value>,
90    pub response_body_sample: Option<serde_json::Value>,
91    /// Our own wording; the registry's error prose is stored for nobody.
92    pub error_message: Option<String>,
93    pub ledger_references: Vec<SuotarApiCallLedgerReference>,
94    pub events: Vec<SuotarApiCallEvent>,
95}
96
97#[derive(Debug, Deserialize)]
98pub struct ListSuotarApiCallsQuery {
99    page: Option<u32>,
100    limit: Option<u32>,
101    endpoint: Option<SuotarEndpoint>,
102    succeeded: Option<bool>,
103    worker_name: Option<String>,
104    started_after: Option<DateTime<Utc>>,
105    started_before: Option<DateTime<Utc>>,
106    credit_registration_id: Option<Uuid>,
107}
108
109/**
110GET `/api/v0/main-frontend/credit-registration-admin/suotar-api-calls` - A page of the study
111registry call log, newest first.
112
113Filtering by `credit_registration_id` is how "find the call that carried this student" is answered:
114the bodies are scrubbed, so there is no student number in them to search.
115*/
116#[instrument(skip(pool))]
117#[utoipa::path(
118    get,
119    path = "/suotar-api-calls",
120    operation_id = "listSuotarApiCalls",
121    tag = "credit-registration-admin",
122    params(
123        ("page" = Option<u32>, Query, description = "Page number, from 1"),
124        ("limit" = Option<u32>, Query, description = "Rows per page"),
125        ("endpoint" = Option<SuotarEndpoint>, Query, description = "One study registry endpoint"),
126        ("succeeded" = Option<bool>, Query, description = "Only calls that did, or did not, succeed"),
127        ("worker_name" = Option<String>, Query, description = "The phase or manual action that made the call"),
128        ("started_after" = Option<DateTime<Utc>>, Query, description = "Started at or after"),
129        ("started_before" = Option<DateTime<Utc>>, Query, description = "Started at or before"),
130        ("credit_registration_id" = Option<Uuid>, Query, description = "Only calls that carried this ledger row")
131    ),
132    responses(
133        (status = 200, description = "A page of the call log", body = SuotarApiCallsPage)
134    )
135)]
136pub async fn list_suotar_api_calls(
137    user: AuthUser,
138    pool: web::Data<PgPool>,
139    query: web::Query<ListSuotarApiCallsQuery>,
140) -> ControllerResult<web::Json<SuotarApiCallsPage>> {
141    let mut conn = pool.acquire().await?;
142    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
143
144    let pagination = parse_pagination(query.page, query.limit, 50)?;
145    let filters = SuotarApiCallFilters {
146        endpoint: query.endpoint,
147        succeeded: query.succeeded,
148        worker_name: non_empty(query.worker_name.as_deref()).map(str::to_string),
149        started_after: query.started_after,
150        started_before: query.started_before,
151        credit_registration_id: query.credit_registration_id,
152    };
153    let rows =
154        suotar_api_calls::get_page(&mut conn, &filters, pagination.limit(), pagination.offset())
155            .await?;
156    let total_count = rows.first().map_or(0, |row| row.total_count);
157    let worker_names = suotar_api_calls::get_worker_names(&mut conn).await?;
158
159    token.authorized_ok(web::Json(SuotarApiCallsPage {
160        page: Page::new(
161            pagination,
162            rows.into_iter().map(to_call_row).collect(),
163            total_count,
164        ),
165        worker_names,
166    }))
167}
168
169/**
170GET `/api/v0/main-frontend/credit-registration-admin/suotar-api-calls/{suotar_api_call_id}` - One
171call with its stored bodies, the ledger rows it covered and the timeline entries it produced.
172*/
173#[instrument(skip(pool))]
174#[utoipa::path(
175    get,
176    path = "/suotar-api-calls/{suotar_api_call_id}",
177    operation_id = "getSuotarApiCall",
178    tag = "credit-registration-admin",
179    params(("suotar_api_call_id" = Uuid, Path, description = "Study registry call id")),
180    responses(
181        (status = 200, description = "The call and everything it touched", body = SuotarApiCallDetails),
182        (status = 404, description = "No such call")
183    )
184)]
185pub async fn get_suotar_api_call(
186    user: AuthUser,
187    pool: web::Data<PgPool>,
188    suotar_api_call_id: web::Path<Uuid>,
189) -> ControllerResult<web::Json<SuotarApiCallDetails>> {
190    let mut conn = pool.acquire().await?;
191    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
192
193    let call = suotar_api_calls::get_by_id(&mut conn, *suotar_api_call_id).await?;
194    let ledger_references =
195        resolve_ledger_references(&mut conn, &call.credit_registration_ids).await?;
196    let events = models::credit_registration_events::get_by_suotar_api_call_id(
197        &mut conn,
198        *suotar_api_call_id,
199    )
200    .await?
201    .into_iter()
202    .map(|event| SuotarApiCallEvent {
203        id: event.id,
204        credit_registration_id: event.credit_registration_id,
205        created_at: event.created_at,
206        kind: event.kind,
207        from_state: event.from_state,
208        to_state: event.to_state,
209        error_code: event.error_code,
210        details: event.details,
211    })
212    .collect();
213
214    token.authorized_ok(web::Json(SuotarApiCallDetails {
215        request_body_sample: call.request_body_sample.clone(),
216        response_body_sample: call.response_body_sample.clone(),
217        error_message: call.error_message.clone(),
218        call: to_call_row_from_full(&call),
219        ledger_references,
220        events,
221    }))
222}
223
224/// The ledger rows a call named, in the order the call listed them, so the reference table lines up
225/// with the stored body's items.
226async fn resolve_ledger_references(
227    conn: &mut PgConnection,
228    credit_registration_ids: &[Uuid],
229) -> Result<Vec<SuotarApiCallLedgerReference>, ControllerError> {
230    if credit_registration_ids.is_empty() {
231        return Ok(Vec::new());
232    }
233    let rows = credit_registrations::get_admin_facing(
234        conn,
235        &AdminCreditRegistrationFilters {
236            credit_registration_ids: Some(credit_registration_ids),
237            include_superseded: true,
238            ..AdminCreditRegistrationFilters::default()
239        },
240        AdminCreditRegistrationSort::default(),
241        MAX_REFERENCED_ROWS,
242        0,
243    )
244    .await?;
245    let mut by_id: std::collections::HashMap<Uuid, _> =
246        rows.into_iter().map(|row| (row.id, row)).collect();
247    Ok(credit_registration_ids
248        .iter()
249        .filter_map(|id| by_id.remove(id))
250        .map(|row| SuotarApiCallLedgerReference {
251            credit_registration_id: row.id,
252            request_item_id: row.request_item_id,
253            user_id: row.user_id,
254            first_name: row.first_name,
255            last_name: row.last_name,
256            email: row.email,
257            student_number: row.student_number,
258            course_id: row.course_id,
259            course_name: row.course_name,
260            state: row.state,
261            error_code: row.error_code,
262        })
263        .collect())
264}
265
266fn to_call_row(call: SuotarApiCallPageRow) -> SuotarApiCallRow {
267    SuotarApiCallRow {
268        id: call.id,
269        endpoint: call.endpoint,
270        started_at: call.started_at,
271        duration_ms: call.duration_ms,
272        http_status: call.http_status,
273        succeeded: call.succeeded,
274        request_item_count: call.request_item_count,
275        ok_item_count: call.ok_item_count,
276        error_item_count: call.error_item_count,
277        request_level_error_code: call.request_level_error_code,
278        worker_name: call.worker_name,
279        credit_registration_ids: call.credit_registration_ids,
280    }
281}
282
283/// The same summary, from the detail endpoint's full row rather than the bodyless listing one.
284fn to_call_row_from_full(call: &SuotarApiCall) -> SuotarApiCallRow {
285    SuotarApiCallRow {
286        id: call.id,
287        endpoint: call.endpoint,
288        started_at: call.started_at,
289        duration_ms: call.duration_ms,
290        http_status: call.http_status,
291        succeeded: call.succeeded,
292        request_item_count: call.request_item_count,
293        ok_item_count: call.ok_item_count,
294        error_item_count: call.error_item_count,
295        request_level_error_code: call.request_level_error_code.clone(),
296        worker_name: call.worker_name.clone(),
297        credit_registration_ids: call.credit_registration_ids.clone(),
298    }
299}
300
301pub fn _add_routes(cfg: &mut ServiceConfig) {
302    cfg.route("/suotar-api-calls", web::get().to(list_suotar_api_calls))
303        .route(
304            "/suotar-api-calls/{suotar_api_call_id}",
305            web::get().to(get_suotar_api_call),
306        );
307}