Skip to main content

rch_common/api/
schema.rs

1//! JSON Schema Generation for RCH API Types
2//!
3//! This module provides schema generation capabilities for API documentation
4//! and machine-readable specifications.
5//!
6//! # Generated Schemas
7//!
8//! - `api-response.schema.json` - The unified API response envelope
9//! - `api-error.schema.json` - Error response structure
10//! - `error-codes.json` - Machine-readable error code catalog
11//!
12//! # Example
13//!
14//! ```rust
15//! use rch_common::api::schema::{generate_api_response_schema, generate_error_catalog};
16//!
17//! // Generate API response schema
18//! let schema = generate_api_response_schema();
19//! println!("{}", serde_json::to_string_pretty(&schema).unwrap());
20//!
21//! // Generate error catalog
22//! let catalog = generate_error_catalog();
23//! println!("{}", serde_json::to_string_pretty(&catalog).unwrap());
24//! ```
25
26use crate::api::response::AnyJson;
27use crate::api::{ApiError, ApiResponse};
28use crate::errors::catalog::{ErrorCategory, ErrorCode};
29use schemars::Schema;
30use schemars::generate::SchemaSettings;
31use serde::{Deserialize, Serialize};
32
33/// Generate JSON Schema for the API response envelope.
34///
35/// Returns the schema for `ApiResponse<AnyJson>` which represents
36/// the generic response envelope where `data` can be any JSON value.
37#[must_use]
38pub fn generate_api_response_schema() -> Schema {
39    SchemaSettings::draft07()
40        .into_generator()
41        .into_root_schema_for::<ApiResponse<AnyJson>>()
42}
43
44/// Generate JSON Schema for API errors.
45#[must_use]
46pub fn generate_api_error_schema() -> Schema {
47    SchemaSettings::draft07()
48        .into_generator()
49        .into_root_schema_for::<ApiError>()
50}
51
52/// Machine-readable error code entry.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ErrorCodeEntry {
55    /// Error code in RCH-Exxx format.
56    pub code: String,
57    /// Numeric code (e.g., 100 for RCH-E100).
58    pub number: u16,
59    /// Error category.
60    pub category: ErrorCategory,
61    /// Human-readable error message.
62    pub message: String,
63    /// Remediation steps.
64    pub remediation: Vec<String>,
65    /// Documentation URL.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub doc_url: Option<String>,
68}
69
70/// Machine-readable error category entry.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ErrorCategoryEntry {
73    /// Category identifier.
74    pub id: String,
75    /// Human-readable name.
76    pub name: String,
77    /// Category description.
78    pub description: String,
79    /// Code range (e.g., "001-099").
80    pub code_range: String,
81}
82
83/// Complete error catalog for machine consumption.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ErrorCatalog {
86    /// Schema version for catalog format.
87    pub schema_version: String,
88    /// API version this catalog applies to.
89    pub api_version: String,
90    /// Error categories with descriptions.
91    pub categories: Vec<ErrorCategoryEntry>,
92    /// All error codes with full metadata.
93    pub errors: Vec<ErrorCodeEntry>,
94}
95
96/// Generate the complete error catalog as a structured object.
97#[must_use]
98pub fn generate_error_catalog() -> ErrorCatalog {
99    let categories = vec![
100        ErrorCategoryEntry {
101            id: "config".to_string(),
102            name: ErrorCategory::Config.name().to_string(),
103            description: ErrorCategory::Config.description().to_string(),
104            code_range: "001-099".to_string(),
105        },
106        ErrorCategoryEntry {
107            id: "network".to_string(),
108            name: ErrorCategory::Network.name().to_string(),
109            description: ErrorCategory::Network.description().to_string(),
110            code_range: "100-199".to_string(),
111        },
112        ErrorCategoryEntry {
113            id: "worker".to_string(),
114            name: ErrorCategory::Worker.name().to_string(),
115            description: ErrorCategory::Worker.description().to_string(),
116            code_range: "200-299".to_string(),
117        },
118        ErrorCategoryEntry {
119            id: "build".to_string(),
120            name: ErrorCategory::Build.name().to_string(),
121            description: ErrorCategory::Build.description().to_string(),
122            code_range: "300-399".to_string(),
123        },
124        ErrorCategoryEntry {
125            id: "transfer".to_string(),
126            name: ErrorCategory::Transfer.name().to_string(),
127            description: ErrorCategory::Transfer.description().to_string(),
128            code_range: "400-499".to_string(),
129        },
130        ErrorCategoryEntry {
131            id: "internal".to_string(),
132            name: ErrorCategory::Internal.name().to_string(),
133            description: ErrorCategory::Internal.description().to_string(),
134            code_range: "500-599".to_string(),
135        },
136    ];
137
138    let errors: Vec<ErrorCodeEntry> = ErrorCode::all()
139        .iter()
140        .map(|code| ErrorCodeEntry {
141            code: code.code_string(),
142            number: code.code_number(),
143            category: code.category(),
144            message: code.message().to_string(),
145            remediation: code
146                .remediation()
147                .iter()
148                .map(|s| (*s).to_string())
149                .collect(),
150            doc_url: code.doc_url().map(String::from),
151        })
152        .collect();
153
154    ErrorCatalog {
155        schema_version: "1.0".to_string(),
156        api_version: crate::api::API_VERSION.to_string(),
157        categories,
158        errors,
159    }
160}
161
162/// Schema export result containing all generated schemas.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct SchemaExportResult {
165    /// Number of schema files generated.
166    pub files_generated: usize,
167    /// List of generated file paths.
168    pub files: Vec<String>,
169    /// Output directory.
170    pub output_dir: String,
171}
172
173/// Export all schemas to the specified directory.
174///
175/// # Arguments
176///
177/// * `output_dir` - Directory to write schema files to
178///
179/// # Returns
180///
181/// Result containing export summary or error.
182///
183/// # Errors
184///
185/// Returns error if directory creation or file writing fails.
186pub fn export_schemas(output_dir: &std::path::Path) -> std::io::Result<SchemaExportResult> {
187    use std::fs;
188
189    // Ensure directory exists
190    fs::create_dir_all(output_dir)?;
191
192    let mut files = Vec::new();
193
194    // 1. API Response Schema
195    let api_response_schema = generate_api_response_schema();
196    let api_response_path = output_dir.join("api-response.schema.json");
197    fs::write(
198        &api_response_path,
199        serde_json::to_string_pretty(&api_response_schema)?,
200    )?;
201    files.push(api_response_path.display().to_string());
202
203    // 2. API Error Schema
204    let api_error_schema = generate_api_error_schema();
205    let api_error_path = output_dir.join("api-error.schema.json");
206    fs::write(
207        &api_error_path,
208        serde_json::to_string_pretty(&api_error_schema)?,
209    )?;
210    files.push(api_error_path.display().to_string());
211
212    // 3. Error Catalog (not a schema, but machine-readable error codes)
213    let error_catalog = generate_error_catalog();
214    let error_codes_path = output_dir.join("error-codes.json");
215    fs::write(
216        &error_codes_path,
217        serde_json::to_string_pretty(&error_catalog)?,
218    )?;
219    files.push(error_codes_path.display().to_string());
220
221    Ok(SchemaExportResult {
222        files_generated: files.len(),
223        files,
224        output_dir: output_dir.display().to_string(),
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_exported_contract_schemas_use_draft_7() {
234        let schemas = [
235            generate_api_response_schema(),
236            generate_api_error_schema(),
237            crate::proof_intent::proof_intent_schema(),
238            crate::bypass_record::bypass_record_schema(),
239            crate::worker_facts::worker_facts_schema(),
240            crate::repo_updater_contract::repo_updater_request_schema(),
241            crate::repo_updater_contract::repo_updater_response_schema(),
242            crate::repo_updater_contract::repo_updater_envelope_schema(),
243            crate::e2e::process_triage::process_triage_request_schema(),
244            crate::e2e::process_triage::process_triage_response_schema(),
245        ];
246        let values = schemas
247            .into_iter()
248            .map(|schema| serde_json::to_value(schema).unwrap())
249            .chain([
250                crate::redaction::RedactionPolicy::schema_json(),
251                crate::remediation_config::RemediationConfig::schema_json(),
252            ]);
253        for schema in values {
254            assert_eq!(schema["$schema"], "http://json-schema.org/draft-07/schema#");
255            assert!(schema.get("$defs").is_none());
256        }
257    }
258
259    #[test]
260    fn test_generate_api_response_schema() {
261        let schema = generate_api_response_schema();
262        let json = serde_json::to_string(&schema).unwrap();
263
264        // Verify key fields are present
265        assert!(json.contains("api_version"));
266        assert!(json.contains("success"));
267        assert!(json.contains("timestamp"));
268    }
269
270    #[test]
271    fn test_generate_api_error_schema() {
272        let schema = generate_api_error_schema();
273        let json = serde_json::to_string(&schema).unwrap();
274
275        // Verify key fields are present
276        assert!(json.contains("code"));
277        assert!(json.contains("category"));
278        assert!(json.contains("message"));
279        assert!(json.contains("remediation"));
280    }
281
282    #[test]
283    fn test_generate_error_catalog() {
284        let catalog = generate_error_catalog();
285
286        // Verify catalog structure
287        assert_eq!(catalog.schema_version, "1.0");
288        assert_eq!(catalog.api_version, crate::api::API_VERSION);
289        assert_eq!(catalog.categories.len(), 6);
290
291        // Verify all error codes are present
292        assert_eq!(catalog.errors.len(), ErrorCode::all().len());
293
294        // Verify first error (ConfigNotFound)
295        let first = &catalog.errors[0];
296        assert_eq!(first.code, "RCH-E001");
297        assert_eq!(first.number, 1);
298        assert_eq!(first.category, ErrorCategory::Config);
299        assert!(!first.remediation.is_empty());
300    }
301
302    #[test]
303    fn test_error_catalog_serialization() {
304        let catalog = generate_error_catalog();
305        let json = serde_json::to_string_pretty(&catalog).unwrap();
306
307        // Verify JSON structure
308        assert!(json.contains("\"schema_version\""));
309        assert!(json.contains("\"categories\""));
310        assert!(json.contains("\"errors\""));
311        assert!(json.contains("RCH-E001"));
312        assert!(json.contains("RCH-E100"));
313        assert!(json.contains("RCH-E500"));
314    }
315
316    #[test]
317    fn test_category_entries() {
318        let catalog = generate_error_catalog();
319
320        // Verify each category has correct range
321        let config_cat = catalog
322            .categories
323            .iter()
324            .find(|c| c.id == "config")
325            .unwrap();
326        assert_eq!(config_cat.code_range, "001-099");
327
328        let network_cat = catalog
329            .categories
330            .iter()
331            .find(|c| c.id == "network")
332            .unwrap();
333        assert_eq!(network_cat.code_range, "100-199");
334
335        let internal_cat = catalog
336            .categories
337            .iter()
338            .find(|c| c.id == "internal")
339            .unwrap();
340        assert_eq!(internal_cat.code_range, "500-599");
341    }
342
343    #[test]
344    fn test_export_schemas_to_temp_dir() {
345        let temp_dir = std::env::temp_dir().join("rch-schema-test");
346        let _ = std::fs::remove_dir_all(&temp_dir); // Clean up if exists
347
348        let result = export_schemas(&temp_dir).unwrap();
349
350        assert_eq!(result.files_generated, 3);
351        assert!(result.files.iter().any(|f| f.contains("api-response")));
352        assert!(result.files.iter().any(|f| f.contains("api-error")));
353        assert!(result.files.iter().any(|f| f.contains("error-codes")));
354
355        // Verify files exist and contain valid JSON
356        for file in &result.files {
357            let content = std::fs::read_to_string(file).unwrap();
358            let _: serde_json::Value = serde_json::from_str(&content).unwrap();
359        }
360
361        // Clean up
362        let _ = std::fs::remove_dir_all(&temp_dir);
363    }
364}