Skip to main content

headless_lms_mcp/
validator.rs

1//! Client for the CMS service's block validator, which runs the real Gutenberg registrations.
2//!
3//! Agent writes fail closed: if the validator is slow or down, the write is rejected with a
4//! retryable error and nothing unvalidated is staged.
5
6use std::collections::BTreeMap;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use headless_lms_utils::document_schema_processor::GutenbergBlock;
12use secrecy::{ExposeSecret, SecretString};
13use serde::{Deserialize, Serialize};
14use serde_json::value::RawValue;
15use serde_json::{Map, Value};
16use url::Url;
17use utoipa::ToSchema;
18use uuid::Uuid;
19
20pub use crate::engine::PageType;
21
22const VALIDATOR_TIMEOUT: Duration = Duration::from_secs(10);
23
24/// Tells the validator how long this client waits, so that it answers before then.
25const CLIENT_TIMEOUT_HEADER: &str = "X-Client-Timeout-Ms";
26
27/// The course settings that make blocks available, named as in the course settings.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
29pub struct CourseBlockFeatures {
30    /// Gates `moocfi/lock-chapter`.
31    pub chapter_locking_enabled: bool,
32    /// Gates `moocfi/chatbot`.
33    pub can_add_chatbot: bool,
34}
35
36/// The name of one of the course settings in [`CourseBlockFeatures`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum CourseBlockFeature {
40    ChapterLockingEnabled,
41    CanAddChatbot,
42}
43
44/// The pages one write call validates.
45#[derive(Debug, Clone)]
46pub struct ValidationRequest<'a> {
47    pub pages: Vec<PageValidationRequest<'a>>,
48    pub course_features: CourseBlockFeatures,
49}
50
51/// The exercise block a list of blocks is validated as the children of.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum ExerciseContext {
55    /// The instructions of an exercise task, the children of `moocfi/exercise-task`.
56    TaskInstructions,
57    /// An exercise's review instructions, the children of `moocfi/exercise-settings`.
58    ReviewInstructions,
59}
60
61/// Where the validated blocks sit, which decides the blocks they may use.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
63#[serde(untagged)]
64pub enum ValidationTarget {
65    /// The top level of a page.
66    Page { page_type: PageType },
67    /// The children of an exercise block; `blocks` are its inner blocks, not a page.
68    Exercise { exercise_context: ExerciseContext },
69}
70
71/// One list of blocks to validate: a page's, or an exercise's instructions.
72#[derive(Debug, Clone, Serialize, ToSchema)]
73pub struct PageValidationRequest<'a> {
74    #[serde(flatten)]
75    pub target: ValidationTarget,
76    /// Full top-level trees: nested blocks are checked in their ancestors' context. Untyped in the
77    /// schema because checking their shape is the validator's job.
78    #[schema(value_type = Vec<Value>)]
79    pub blocks: &'a [GutenbergBlock],
80    /// Blocks in `blocks` that are as published: the teacher wrote them, so the validator spares
81    /// them the rules on what agents may write and reports their other problems as warnings.
82    #[serde(skip_serializing_if = "<[_]>::is_empty")]
83    #[schema(value_type = Vec<Uuid>)]
84    pub unchanged_client_ids: &'a [Uuid],
85}
86
87/// The body of the CMS's `POST /api/agent/validate-blocks`: one HTTP request's share of the pages
88/// a write call validates, its pages already serialized.
89#[derive(Serialize, ToSchema)]
90pub struct ValidateBlocksRequest<'a> {
91    #[schema(value_type = Vec<PageValidationRequest>)]
92    pages: &'a [Box<RawValue>],
93    course_features: CourseBlockFeatures,
94}
95
96/// The answer of the CMS's `POST /api/agent/validate-blocks`, a verdict per page in request order.
97#[derive(Deserialize, ToSchema)]
98pub struct ValidateBlocksResponse {
99    pages: Vec<ValidationOutcome>,
100}
101
102/// The validator's verdict on one page: valid when `errors` is empty.
103#[derive(Debug, Clone, PartialEq, Deserialize, ToSchema)]
104pub struct ValidationOutcome {
105    /// The blocks in request order, normalised the way the editor stores them.
106    #[schema(value_type = Vec<Value>)]
107    pub blocks: Vec<GutenbergBlock>,
108    pub errors: Vec<BlockValidationError>,
109    /// Problems of unchanged blocks, which never fail a write.
110    pub warnings: Vec<BlockValidationError>,
111}
112
113/// What kind of rule a block breaks.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
115#[serde(rename_all = "snake_case")]
116pub enum BlockValidationErrorCode {
117    InvalidBlock,
118    DuplicateClientId,
119    UnknownBlock,
120    NotAllowedHere,
121    InvalidNesting,
122    InvalidAttribute,
123    ForbiddenAttribute,
124    DisallowedMarkup,
125}
126
127/// A problem the validator found in a block, with how to fix it.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
129pub struct BlockValidationError {
130    /// `None` only when the block has no usable client id; `path` then starts at the request.
131    #[serde(rename = "clientId")]
132    pub client_id: Option<Uuid>,
133    /// Attribute path inside the block, e.g. `["attributes", "content"]`.
134    pub path: Vec<Value>,
135    pub code: BlockValidationErrorCode,
136    pub message: String,
137    pub fix: String,
138}
139
140/// The CMS's export of block registrations, the single source of block schemas, from
141/// `GET /api/agent/block-schemas`.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
143pub struct BlockSchemas {
144    pub version: String,
145    /// The blocks agents may write on each page type.
146    pub page_types: BTreeMap<PageType, Vec<String>>,
147    /// Block name to the course setting that enables it.
148    pub course_feature_gated_blocks: BTreeMap<String, CourseBlockFeature>,
149    pub rich_text_formats: Vec<RichTextFormat>,
150    pub blocks: BTreeMap<String, BlockSchema>,
151    /// The largest `validate-blocks` request body the validator takes.
152    pub max_request_bytes: usize,
153}
154
155/// An inline format rich text may use, and the tag it is stored as.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
157pub struct RichTextFormat {
158    pub name: String,
159    pub tag: String,
160    pub description: String,
161}
162
163/// One block type's registration: its attributes, where it may be nested and an example.
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
165pub struct BlockSchema {
166    pub title: String,
167    pub description: String,
168    /// JSON Schema per attribute; rich text carries `x-rich-text` and `x-rich-text-formats`.
169    #[schema(value_type = Object)]
170    pub attributes: Map<String, Value>,
171    /// `"root"` means the page's top level; None means anywhere the parent allows.
172    pub allowed_parents: Option<Vec<String>>,
173    /// None means any block the page allows; empty means no inner blocks.
174    pub allowed_children: Option<Vec<String>>,
175    pub template: Option<Value>,
176    pub template_lock: Option<Value>,
177    pub example: Value,
178}
179
180/// Why the validator gave no verdict.
181#[derive(Debug, Clone, thiserror::Error)]
182pub enum ValidatorError {
183    /// Down, slow, warming up or overloaded. The agent can retry the same call.
184    #[error("the content validator is unavailable: {reason}")]
185    Unavailable {
186        reason: String,
187        retry_after: Option<Duration>,
188    },
189    /// The request is too slow or too big to validate. Not retryable as it is.
190    #[error("the content validator couldn't take this many or this large blocks: {message}")]
191    TooLarge { message: String },
192    /// The validator failed on this input. Not retryable.
193    #[error("the content validator failed on these blocks: {message}")]
194    FailedOnInput { message: String },
195    /// The validator refused the request itself (bad secret, malformed request). Not retryable.
196    #[error("the content validator rejected the request ({status}): {message}")]
197    Rejected { status: u16, message: String },
198}
199
200/// Checks the blocks agents write against the CMS's block registrations; [`CmsValidatorClient`]
201/// in production.
202#[async_trait]
203pub trait BlockValidator: Send + Sync {
204    /// The outcome of each page, in request order.
205    async fn validate_blocks(
206        &self,
207        request: &ValidationRequest<'_>,
208    ) -> Result<Vec<ValidationOutcome>, ValidatorError>;
209    /// The current block registrations; [`crate::state::McpState::block_schemas`] caches them.
210    async fn block_schemas(&self) -> Result<BlockSchemas, ValidatorError>;
211}
212
213/// HTTP client for the validator routes under the CMS's `/api/agent/`.
214pub struct CmsValidatorClient {
215    http: reqwest::Client,
216    cms_internal_url: Url,
217    secret: SecretString,
218    /// [`BlockSchemas::max_request_bytes`] of the latest schemas fetched; 0 before the first.
219    max_request_bytes: AtomicUsize,
220}
221
222impl CmsValidatorClient {
223    /// `cms_internal_url` is the CMS base as a [`Url::join`] base, e.g. `http://cms:3000/cms/`.
224    pub fn new(cms_internal_url: Url, secret: SecretString) -> Result<Self, reqwest::Error> {
225        Ok(Self {
226            http: reqwest::Client::builder()
227                .timeout(VALIDATOR_TIMEOUT)
228                .build()?,
229            cms_internal_url,
230            secret,
231            max_request_bytes: AtomicUsize::new(0),
232        })
233    }
234
235    /// The URL of the validator route `name`.
236    fn route(&self, name: &str) -> Result<Url, ValidatorError> {
237        self.cms_internal_url
238            .join(&format!("api/agent/{name}"))
239            .map_err(|e| ValidatorError::Unavailable {
240                reason: format!("CMS_INTERNAL_URL has no route {name}: {e}"),
241                retry_after: None,
242            })
243    }
244
245    async fn send<T: for<'de> Deserialize<'de>>(
246        &self,
247        request: reqwest::RequestBuilder,
248    ) -> Result<T, ValidatorError> {
249        let response = request
250            .bearer_auth(self.secret.expose_secret())
251            .header(
252                CLIENT_TIMEOUT_HEADER,
253                VALIDATOR_TIMEOUT.as_millis().to_string(),
254            )
255            .send()
256            .await
257            .map_err(|e| ValidatorError::Unavailable {
258                reason: if e.is_timeout() {
259                    "timed out".to_string()
260                } else {
261                    e.to_string()
262                },
263                retry_after: None,
264            })?;
265        let status = response.status();
266        if status.is_server_error() {
267            let retry_after = response
268                .headers()
269                .get(reqwest::header::RETRY_AFTER)
270                .and_then(|v| v.to_str().ok())
271                .and_then(|v| v.parse::<u64>().ok())
272                .map(Duration::from_secs);
273            return Err(ValidatorError::Unavailable {
274                reason: format!("status {status}"),
275                retry_after,
276            });
277        }
278        if !status.is_success() {
279            let body = response.text().await.unwrap_or_default();
280            let message = serde_json::from_str::<Value>(&body)
281                .ok()
282                .and_then(|value| Some(value.get("error")?.as_str()?.to_string()))
283                .unwrap_or(body);
284            return Err(match status {
285                reqwest::StatusCode::PAYLOAD_TOO_LARGE => ValidatorError::TooLarge { message },
286                reqwest::StatusCode::UNPROCESSABLE_ENTITY => {
287                    ValidatorError::FailedOnInput { message }
288                }
289                _ => ValidatorError::Rejected {
290                    status: status.as_u16(),
291                    message,
292                },
293            });
294        }
295        response
296            .json::<T>()
297            .await
298            .map_err(|e| ValidatorError::Unavailable {
299                reason: format!("unreadable response: {e}"),
300                retry_after: None,
301            })
302    }
303}
304
305#[async_trait]
306impl BlockValidator for CmsValidatorClient {
307    async fn validate_blocks(
308        &self,
309        request: &ValidationRequest<'_>,
310    ) -> Result<Vec<ValidationOutcome>, ValidatorError> {
311        let mut max_request_bytes = self.max_request_bytes.load(Ordering::Relaxed);
312        if max_request_bytes == 0 {
313            max_request_bytes = self.block_schemas().await?.max_request_bytes;
314        }
315        let url = self.route("validate-blocks")?;
316        let mut outcomes = Vec::with_capacity(request.pages.len());
317        for chunk in request_chunks(&request.pages, max_request_bytes)? {
318            let body = ValidateBlocksRequest {
319                pages: &chunk,
320                course_features: request.course_features,
321            };
322            let response: ValidateBlocksResponse =
323                self.send(self.http.post(url.clone()).json(&body)).await?;
324            outcomes.extend(response.pages);
325        }
326        Ok(outcomes)
327    }
328
329    async fn block_schemas(&self) -> Result<BlockSchemas, ValidatorError> {
330        let schemas: BlockSchemas = self
331            .send(self.http.get(self.route("block-schemas")?))
332            .await?;
333        self.max_request_bytes
334            .store(schemas.max_request_bytes, Ordering::Relaxed);
335        Ok(schemas)
336    }
337}
338
339/// Stands in when the validator isn't configured: every write fails closed with a retryable
340/// error, reads keep working.
341pub struct UnconfiguredValidator {
342    pub reason: String,
343}
344
345#[async_trait]
346impl BlockValidator for UnconfiguredValidator {
347    async fn validate_blocks(
348        &self,
349        _: &ValidationRequest<'_>,
350    ) -> Result<Vec<ValidationOutcome>, ValidatorError> {
351        Err(ValidatorError::Unavailable {
352            reason: self.reason.clone(),
353            retry_after: None,
354        })
355    }
356
357    async fn block_schemas(&self) -> Result<BlockSchemas, ValidatorError> {
358        Err(ValidatorError::Unavailable {
359            reason: self.reason.clone(),
360            retry_after: None,
361        })
362    }
363}
364
365/// The pages, each serialized once, in consecutive runs that each fit in one request; a page too
366/// large alone gets a request of its own, which the validator answers with
367/// [`ValidatorError::TooLarge`].
368fn request_chunks(
369    pages: &[PageValidationRequest<'_>],
370    max_request_bytes: usize,
371) -> Result<Vec<Vec<Box<RawValue>>>, ValidatorError> {
372    let mut chunks = Vec::new();
373    let mut chunk: Vec<Box<RawValue>> = Vec::new();
374    let mut bytes = 0;
375    for page in pages {
376        let page =
377            serde_json::value::to_raw_value(page).map_err(|e| ValidatorError::FailedOnInput {
378                message: format!("the blocks don't serialize: {e}"),
379            })?;
380        let page_bytes = page.get().len();
381        if !chunk.is_empty() && bytes + page_bytes > max_request_bytes {
382            chunks.push(std::mem::take(&mut chunk));
383            bytes = 0;
384        }
385        bytes += page_bytes;
386        chunk.push(page);
387    }
388    if !chunk.is_empty() {
389        chunks.push(chunk);
390    }
391    Ok(chunks)
392}
393
394#[cfg(test)]
395mod tests {
396    use pretty_assertions::assert_eq;
397    use serde_json::json;
398
399    use super::*;
400    use crate::test_fixtures::para;
401
402    fn is_retryable(error: &ValidatorError) -> bool {
403        matches!(error, ValidatorError::Unavailable { .. })
404    }
405
406    fn request(page_type: PageType, blocks: &[GutenbergBlock]) -> ValidationRequest<'_> {
407        ValidationRequest {
408            pages: vec![PageValidationRequest {
409                target: ValidationTarget::Page { page_type },
410                blocks,
411                unchanged_client_ids: &[],
412            }],
413            course_features: CourseBlockFeatures {
414                chapter_locking_enabled: false,
415                can_add_chatbot: false,
416            },
417        }
418    }
419
420    #[test]
421    fn large_batches_are_split_between_pages() {
422        const MAX_REQUEST_BYTES: usize = 1_000_000;
423        let big = vec![para("p1", &"x".repeat(MAX_REQUEST_BYTES / 2))];
424        let small = vec![para("p2", "y")];
425        let pages: Vec<PageValidationRequest> = [&big, &big, &small, &big]
426            .into_iter()
427            .map(|blocks| PageValidationRequest {
428                target: ValidationTarget::Page {
429                    page_type: PageType::CoursePage,
430                },
431                blocks,
432                unchanged_client_ids: &[],
433            })
434            .collect();
435        let sizes: Vec<usize> = request_chunks(&pages, MAX_REQUEST_BYTES)
436            .unwrap()
437            .iter()
438            .map(|c| c.len())
439            .collect();
440        assert_eq!(sizes, vec![1, 2, 1]);
441    }
442
443    fn client(server: &mockito::Server) -> CmsValidatorClient {
444        CmsValidatorClient::new(
445            Url::parse(&format!("{}/", server.url())).unwrap(),
446            SecretString::from("s3cret".to_string()),
447        )
448        .unwrap()
449    }
450
451    /// Block schemas with nothing in them but the request size limit.
452    async fn mock_block_schemas(server: &mut mockito::Server) -> mockito::Mock {
453        server
454            .mock("GET", "/api/agent/block-schemas")
455            .with_status(200)
456            .with_body(
457                json!({"version": "v", "page_types": {}, "course_feature_gated_blocks": {}, "rich_text_formats": [], "blocks": {}, "max_request_bytes": 1_000_000})
458                    .to_string(),
459            )
460            .create_async()
461            .await
462    }
463
464    #[test]
465    fn targets_serialize_as_page_type_or_exercise_context() {
466        let blocks = vec![para("p1", "Hi")];
467        let body = |target| {
468            serde_json::to_value(PageValidationRequest {
469                target,
470                blocks: &blocks,
471                unchanged_client_ids: &[],
472            })
473            .unwrap()
474        };
475        let page = body(ValidationTarget::Page {
476            page_type: PageType::TopLevelPage,
477        });
478        assert_eq!(page["page_type"], json!("top-level-page"));
479        assert!(page.get("exercise_context").is_none());
480        let task = body(ValidationTarget::Exercise {
481            exercise_context: ExerciseContext::TaskInstructions,
482        });
483        assert_eq!(task["exercise_context"], json!("task_instructions"));
484        assert!(task.get("page_type").is_none());
485    }
486
487    #[tokio::test]
488    async fn sends_batch_with_bearer_and_parses_outcome() {
489        let mut server = mockito::Server::new_async().await;
490        mock_block_schemas(&mut server).await;
491        let blocks = vec![para("p1", "Hi")];
492        let mock = server
493            .mock("POST", "/api/agent/validate-blocks")
494            .match_header("authorization", "Bearer s3cret")
495            .match_header("x-client-timeout-ms", "10000")
496            .match_body(mockito::Matcher::PartialJson(json!({"pages": [{"page_type": "course-front-page"}], "course_features": {"chapter_locking_enabled": false}})))
497            .with_status(200)
498            .with_body(json!({"pages": [{"blocks": [], "errors": [{"clientId": null, "path": ["attributes","content"], "code": "disallowed_markup", "message": "m", "fix": "f"}], "warnings": []}]}).to_string())
499            .create_async()
500            .await;
501        let request = request(PageType::CourseFrontPage, &blocks);
502        let outcomes = client(&server).validate_blocks(&request).await.unwrap();
503        mock.assert_async().await;
504        let [outcome] = outcomes.as_slice() else {
505            panic!("expected one page, got {outcomes:?}");
506        };
507        assert_eq!(outcome.errors[0].client_id, None);
508        assert_eq!(
509            outcome.errors[0].code,
510            BlockValidationErrorCode::DisallowedMarkup
511        );
512    }
513
514    #[tokio::test]
515    async fn warming_up_is_retryable() {
516        let mut server = mockito::Server::new_async().await;
517        mock_block_schemas(&mut server).await;
518        server
519            .mock("POST", "/api/agent/validate-blocks")
520            .with_status(503)
521            .with_header("retry-after", "1")
522            .with_body(r#"{"error":"The block validator is starting."}"#)
523            .create_async()
524            .await;
525        let blocks = vec![];
526        let request = request(PageType::CoursePage, &blocks);
527        let error = client(&server).validate_blocks(&request).await.unwrap_err();
528        assert!(is_retryable(&error));
529        assert!(
530            matches!(error, ValidatorError::Unavailable { retry_after: Some(d), .. } if d == Duration::from_secs(1))
531        );
532    }
533
534    #[tokio::test]
535    async fn bad_secret_is_not_retryable_and_down_is() {
536        let mut server = mockito::Server::new_async().await;
537        server
538            .mock("GET", "/api/agent/block-schemas")
539            .with_status(401)
540            .create_async()
541            .await;
542        assert!(!is_retryable(
543            &client(&server).block_schemas().await.unwrap_err()
544        ));
545        let unreachable = CmsValidatorClient::new(
546            Url::parse("http://127.0.0.1:9/").unwrap(),
547            SecretString::from("x".to_string()),
548        )
549        .unwrap();
550        assert!(is_retryable(
551            &unreachable.block_schemas().await.unwrap_err()
552        ));
553    }
554
555    #[tokio::test]
556    async fn too_large_and_failing_inputs_are_not_retryable() {
557        let mut server = mockito::Server::new_async().await;
558        mock_block_schemas(&mut server).await;
559        server
560            .mock("POST", "/api/agent/validate-blocks")
561            .with_status(413)
562            .with_body(r#"{"error":"Too big. Write fewer or smaller blocks per call."}"#)
563            .create_async()
564            .await;
565        let blocks = vec![];
566        let request = request(PageType::CoursePage, &blocks);
567        let client = client(&server);
568        let error = client.validate_blocks(&request).await.unwrap_err();
569        assert!(
570            matches!(&error, ValidatorError::TooLarge { message } if message.starts_with("Too big")),
571            "{error:?}"
572        );
573        server.reset();
574        server
575            .mock("POST", "/api/agent/validate-blocks")
576            .with_status(422)
577            .with_body(r#"{"error":"boom"}"#)
578            .create_async()
579            .await;
580        let error = client.validate_blocks(&request).await.unwrap_err();
581        assert!(!is_retryable(&error));
582        assert!(
583            matches!(error, ValidatorError::FailedOnInput { .. }),
584            "{error:?}"
585        );
586    }
587}