Skip to main content

headless_lms_server/controllers/mock_suotar/
commands.rs

1//! The control surface's command, health and world-dump routes.
2
3use serde_json::json;
4use sqlx::PgPool;
5
6use crate::mock_suotar::commands::{
7    CommandResult, MockSuotarCommand, WORLD_DUMP_CALL_LIMIT, execute,
8};
9use crate::mock_suotar::default_world;
10use crate::mock_suotar::store::{EntityHash, MockSuotarStore};
11use crate::mock_suotar::world::{
12    MockAttainment, MockCourseUnit, MockEnrolment, MockPerson, MockSubmission,
13};
14use crate::prelude::*;
15
16pub async fn health(
17    app_conf: web::Data<ApplicationConfiguration>,
18    store: web::Data<MockSuotarStore>,
19    pool: web::Data<PgPool>,
20) -> ControllerResult<HttpResponse> {
21    super::assert_enabled(&app_conf);
22    let token = skip_authorize();
23    let db_generation = default_world::db_generation_marker(&pool).await;
24    let generation = match store.live_generation().await {
25        Ok(generation) => generation,
26        Err(error) => return token.authorized_ok(internal_error(&error)),
27    };
28    let body = match &generation {
29        Some(generation) => {
30            let (counts, preamble) = match (
31                store.counts(generation).await,
32                store.preamble(generation).await,
33            ) {
34                (Ok(counts), Ok(preamble)) => (counts, preamble),
35                (Err(error), _) | (_, Err(error)) => {
36                    return token.authorized_ok(internal_error(&error));
37                }
38            };
39            json!({
40                "enabled": true,
41                "generation": generation,
42                "dbGeneration": db_generation,
43                "worldDbGeneration": preamble.db_generation,
44                "generationMatches": preamble.db_generation.is_some()
45                    && preamble.db_generation == db_generation,
46                "counts": counts,
47                "defaults": preamble.defaults,
48            })
49        }
50        // Never installs one: a health check that built a world could not report an empty one.
51        None => json!({
52            "enabled": true,
53            "generation": serde_json::Value::Null,
54            "dbGeneration": db_generation,
55            "worldDbGeneration": serde_json::Value::Null,
56            "generationMatches": false,
57            "counts": serde_json::Value::Null,
58            "defaults": serde_json::Value::Null,
59        }),
60    };
61    token.authorized_ok(HttpResponse::Ok().json(body))
62}
63
64pub async fn world(
65    app_conf: web::Data<ApplicationConfiguration>,
66    store: web::Data<MockSuotarStore>,
67) -> ControllerResult<HttpResponse> {
68    super::assert_enabled(&app_conf);
69    let token = skip_authorize();
70    let Some(generation) = (match store.live_generation().await {
71        Ok(generation) => generation,
72        Err(error) => return token.authorized_ok(internal_error(&error)),
73    }) else {
74        return token.authorized_ok(HttpResponse::Ok().json(json!({ "generation": null })));
75    };
76    match dump(&store, &generation).await {
77        Ok(body) => token.authorized_ok(HttpResponse::Ok().json(body)),
78        Err(error) => token.authorized_ok(internal_error(&error)),
79    }
80}
81
82async fn dump(store: &MockSuotarStore, generation: &str) -> anyhow::Result<serde_json::Value> {
83    let preamble = store.preamble(generation).await?;
84    let counts = store.counts(generation).await?;
85    Ok(json!({
86        "generation": generation,
87        "defaults": preamble.defaults,
88        "persons": store.all_json::<MockPerson>(generation, EntityHash::Persons).await?,
89        "courseUnits": store.all_json::<MockCourseUnit>(generation, EntityHash::CourseUnits).await?,
90        "enrolments": store.all_json::<MockEnrolment>(generation, EntityHash::Enrolments).await?,
91        "attainments": store.all_json::<MockAttainment>(generation, EntityHash::Attainments).await?,
92        "submissions": store.all_json::<MockSubmission>(generation, EntityHash::Submissions).await?,
93        "sisuViolations": store.all_json::<Vec<String>>(generation, EntityHash::SisuViolations).await?,
94        "faults": store.faults(generation).await?,
95        "calls": store.recent_calls(generation, WORLD_DUMP_CALL_LIMIT).await?,
96        "callLogLen": counts.call_log_len,
97    }))
98}
99
100pub async fn command(
101    app_conf: web::Data<ApplicationConfiguration>,
102    store: web::Data<MockSuotarStore>,
103    pool: web::Data<PgPool>,
104    body: web::Bytes,
105) -> ControllerResult<HttpResponse> {
106    super::assert_enabled(&app_conf);
107    let token = skip_authorize();
108    let parsed: MockSuotarCommand = match serde_json::from_slice(&body) {
109        Ok(parsed) => parsed,
110        Err(error) => {
111            return token.authorized_ok(HttpResponse::BadRequest().json(CommandResult::Error {
112                command: None,
113                code: "unknownCommand".to_string(),
114                message: error.to_string(),
115            }));
116        }
117    };
118    let result = execute(&store, &pool, parsed).await;
119    token.authorized_ok(match &result {
120        CommandResult::Ok { .. } => HttpResponse::Ok().json(&result),
121        CommandResult::NotImplemented { .. } => HttpResponse::NotImplemented().json(&result),
122        CommandResult::Error { code, .. } if code == "internalError" => {
123            HttpResponse::InternalServerError().json(&result)
124        }
125        CommandResult::Error { .. } => HttpResponse::BadRequest().json(&result),
126    })
127}
128
129fn internal_error(error: &anyhow::Error) -> HttpResponse {
130    error!("mock Suotar control failure: {error:?}");
131    HttpResponse::InternalServerError().json(CommandResult::Error {
132        command: None,
133        code: "internalError".to_string(),
134        message: error.to_string(),
135    })
136}
137
138/// Nothing here is exported to utoipa or `bindings.ts`; no mock's DTOs are.
139pub fn _add_routes(cfg: &mut ServiceConfig) {
140    cfg.route("/command", web::post().to(command))
141        .route("/health", web::get().to(health))
142        .route("/world", web::get().to(world));
143}