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