1use 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
24const CLIENT_TIMEOUT_HEADER: &str = "X-Client-Timeout-Ms";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
29pub struct CourseBlockFeatures {
30 pub chapter_locking_enabled: bool,
32 pub can_add_chatbot: bool,
34}
35
36#[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#[derive(Debug, Clone)]
46pub struct ValidationRequest<'a> {
47 pub pages: Vec<PageValidationRequest<'a>>,
48 pub course_features: CourseBlockFeatures,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum ExerciseContext {
55 TaskInstructions,
57 ReviewInstructions,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
63#[serde(untagged)]
64pub enum ValidationTarget {
65 Page { page_type: PageType },
67 Exercise { exercise_context: ExerciseContext },
69}
70
71#[derive(Debug, Clone, Serialize, ToSchema)]
73pub struct PageValidationRequest<'a> {
74 #[serde(flatten)]
75 pub target: ValidationTarget,
76 #[schema(value_type = Vec<Value>)]
79 pub blocks: &'a [GutenbergBlock],
80 #[serde(skip_serializing_if = "<[_]>::is_empty")]
83 #[schema(value_type = Vec<Uuid>)]
84 pub unchanged_client_ids: &'a [Uuid],
85}
86
87#[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#[derive(Deserialize, ToSchema)]
98pub struct ValidateBlocksResponse {
99 pages: Vec<ValidationOutcome>,
100}
101
102#[derive(Debug, Clone, PartialEq, Deserialize, ToSchema)]
104pub struct ValidationOutcome {
105 #[schema(value_type = Vec<Value>)]
107 pub blocks: Vec<GutenbergBlock>,
108 pub errors: Vec<BlockValidationError>,
109 pub warnings: Vec<BlockValidationError>,
111}
112
113#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
129pub struct BlockValidationError {
130 #[serde(rename = "clientId")]
132 pub client_id: Option<Uuid>,
133 pub path: Vec<Value>,
135 pub code: BlockValidationErrorCode,
136 pub message: String,
137 pub fix: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
143pub struct BlockSchemas {
144 pub version: String,
145 pub page_types: BTreeMap<PageType, Vec<String>>,
147 pub course_feature_gated_blocks: BTreeMap<String, CourseBlockFeature>,
149 pub rich_text_formats: Vec<RichTextFormat>,
150 pub blocks: BTreeMap<String, BlockSchema>,
151 pub max_request_bytes: usize,
153}
154
155#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
165pub struct BlockSchema {
166 pub title: String,
167 pub description: String,
168 #[schema(value_type = Object)]
170 pub attributes: Map<String, Value>,
171 pub allowed_parents: Option<Vec<String>>,
173 pub allowed_children: Option<Vec<String>>,
175 pub template: Option<Value>,
176 pub template_lock: Option<Value>,
177 pub example: Value,
178}
179
180#[derive(Debug, Clone, thiserror::Error)]
182pub enum ValidatorError {
183 #[error("the content validator is unavailable: {reason}")]
185 Unavailable {
186 reason: String,
187 retry_after: Option<Duration>,
188 },
189 #[error("the content validator couldn't take this many or this large blocks: {message}")]
191 TooLarge { message: String },
192 #[error("the content validator failed on these blocks: {message}")]
194 FailedOnInput { message: String },
195 #[error("the content validator rejected the request ({status}): {message}")]
197 Rejected { status: u16, message: String },
198}
199
200#[async_trait]
203pub trait BlockValidator: Send + Sync {
204 async fn validate_blocks(
206 &self,
207 request: &ValidationRequest<'_>,
208 ) -> Result<Vec<ValidationOutcome>, ValidatorError>;
209 async fn block_schemas(&self) -> Result<BlockSchemas, ValidatorError>;
211}
212
213pub struct CmsValidatorClient {
215 http: reqwest::Client,
216 cms_internal_url: Url,
217 secret: SecretString,
218 max_request_bytes: AtomicUsize,
220}
221
222impl CmsValidatorClient {
223 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 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
339pub 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
365fn 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 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}