Skip to main content

reinhardt_rest/metadata/
schema.rs

1//! OpenAPI 3.0 schema generation from field metadata
2
3use super::fields::FieldInfo;
4use super::types::FieldType;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7#[cfg(test)]
8use serde_json::json;
9use std::collections::HashMap;
10
11/// OpenAPI 3.0 schema representation
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
13pub struct FieldSchema {
14	/// The JSON Schema type (e.g., `"string"`, `"integer"`, `"object"`).
15	#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
16	pub schema_type: Option<String>,
17	/// The format hint (e.g., `"date-time"`, `"email"`, `"uri"`).
18	#[serde(skip_serializing_if = "Option::is_none")]
19	pub format: Option<String>,
20	/// A description of what this field represents.
21	#[serde(skip_serializing_if = "Option::is_none")]
22	pub description: Option<String>,
23	/// The minimum numeric value allowed.
24	#[serde(skip_serializing_if = "Option::is_none")]
25	pub minimum: Option<f64>,
26	/// The maximum numeric value allowed.
27	#[serde(skip_serializing_if = "Option::is_none")]
28	pub maximum: Option<f64>,
29	/// The minimum string length allowed.
30	#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
31	pub min_length: Option<usize>,
32	/// The maximum string length allowed.
33	#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
34	pub max_length: Option<usize>,
35	/// A regex pattern the value must match.
36	#[serde(skip_serializing_if = "Option::is_none")]
37	pub pattern: Option<String>,
38	/// Allowed enum values for choice fields.
39	#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
40	pub enum_values: Option<Vec<String>>,
41	/// Schema for array items.
42	#[serde(skip_serializing_if = "Option::is_none")]
43	pub items: Option<Box<FieldSchema>>,
44	/// Schemas for object properties.
45	#[serde(skip_serializing_if = "Option::is_none")]
46	pub properties: Option<HashMap<String, FieldSchema>>,
47	/// List of required property names within an object schema.
48	#[serde(skip_serializing_if = "Option::is_none")]
49	pub required: Option<Vec<String>>,
50	/// The default value for this field.
51	#[serde(skip_serializing_if = "Option::is_none")]
52	pub default: Option<Value>,
53	/// Whether this field is read-only.
54	#[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
55	pub read_only: Option<bool>,
56	/// Whether this field is write-only.
57	#[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
58	pub write_only: Option<bool>,
59	/// Whether this field accepts null values.
60	#[serde(skip_serializing_if = "Option::is_none")]
61	pub nullable: Option<bool>,
62}
63
64/// Generates an OpenAPI schema from field metadata
65///
66/// # Examples
67///
68/// ```
69/// use reinhardt_rest::metadata::{FieldInfoBuilder, FieldType, generate_field_schema};
70///
71/// let field = FieldInfoBuilder::new(FieldType::String)
72///     .required(true)
73///     .min_length(3)
74///     .max_length(50)
75///     .build();
76///
77/// let schema = generate_field_schema(&field);
78/// assert_eq!(schema.schema_type, Some("string".to_string()));
79/// assert_eq!(schema.min_length, Some(3));
80/// assert_eq!(schema.max_length, Some(50));
81/// ```
82pub fn generate_field_schema(field: &FieldInfo) -> FieldSchema {
83	let mut schema = FieldSchema::default();
84
85	// Map FieldType to OpenAPI type and format
86	match &field.field_type {
87		FieldType::Boolean => {
88			schema.schema_type = Some("boolean".to_string());
89		}
90		FieldType::String => {
91			schema.schema_type = Some("string".to_string());
92		}
93		FieldType::Integer => {
94			schema.schema_type = Some("integer".to_string());
95			schema.format = Some("int64".to_string());
96		}
97		FieldType::Float => {
98			schema.schema_type = Some("number".to_string());
99			schema.format = Some("float".to_string());
100		}
101		FieldType::Decimal => {
102			schema.schema_type = Some("number".to_string());
103			schema.format = Some("double".to_string());
104		}
105		FieldType::Date => {
106			schema.schema_type = Some("string".to_string());
107			schema.format = Some("date".to_string());
108		}
109		FieldType::DateTime => {
110			schema.schema_type = Some("string".to_string());
111			schema.format = Some("date-time".to_string());
112		}
113		FieldType::Time => {
114			schema.schema_type = Some("string".to_string());
115			schema.format = Some("time".to_string());
116		}
117		FieldType::Duration => {
118			schema.schema_type = Some("string".to_string());
119			schema.format = Some("duration".to_string());
120		}
121		FieldType::Email => {
122			schema.schema_type = Some("string".to_string());
123			schema.format = Some("email".to_string());
124		}
125		FieldType::Url => {
126			schema.schema_type = Some("string".to_string());
127			schema.format = Some("uri".to_string());
128		}
129		FieldType::Uuid => {
130			schema.schema_type = Some("string".to_string());
131			schema.format = Some("uuid".to_string());
132		}
133		FieldType::Choice => {
134			schema.schema_type = Some("string".to_string());
135			if let Some(choices) = &field.choices {
136				schema.enum_values = Some(choices.iter().map(|c| c.value.clone()).collect());
137			}
138		}
139		FieldType::MultipleChoice => {
140			schema.schema_type = Some("array".to_string());
141			if let Some(choices) = &field.choices {
142				let item_schema = FieldSchema {
143					schema_type: Some("string".to_string()),
144					enum_values: Some(choices.iter().map(|c| c.value.clone()).collect()),
145					..Default::default()
146				};
147				schema.items = Some(Box::new(item_schema));
148			}
149		}
150		FieldType::File => {
151			schema.schema_type = Some("string".to_string());
152			schema.format = Some("binary".to_string());
153		}
154		FieldType::Image => {
155			schema.schema_type = Some("string".to_string());
156			schema.format = Some("binary".to_string());
157		}
158		FieldType::List => {
159			schema.schema_type = Some("array".to_string());
160			if let Some(child) = &field.child {
161				schema.items = Some(Box::new(generate_field_schema(child)));
162			}
163		}
164		FieldType::NestedObject => {
165			schema.schema_type = Some("object".to_string());
166			if let Some(children) = &field.children {
167				let mut properties = HashMap::new();
168				let mut required_fields = Vec::new();
169
170				for (name, child_field) in children {
171					properties.insert(name.clone(), generate_field_schema(child_field));
172					if child_field.required {
173						required_fields.push(name.clone());
174					}
175				}
176
177				schema.properties = Some(properties);
178				if !required_fields.is_empty() {
179					schema.required = Some(required_fields);
180				}
181			}
182		}
183		FieldType::Field => {
184			// Generic field type
185			schema.schema_type = Some("string".to_string());
186		}
187	}
188
189	// Add constraints
190	if let Some(min_length) = field.min_length {
191		schema.min_length = Some(min_length);
192	}
193	if let Some(max_length) = field.max_length {
194		schema.max_length = Some(max_length);
195	}
196	if let Some(min_value) = field.min_value {
197		schema.minimum = Some(min_value);
198	}
199	if let Some(max_value) = field.max_value {
200		schema.maximum = Some(max_value);
201	}
202
203	// Add description from help_text or label
204	if let Some(help_text) = &field.help_text {
205		schema.description = Some(help_text.clone());
206	} else if let Some(label) = &field.label {
207		schema.description = Some(label.clone());
208	}
209
210	// Add default value
211	if let Some(default_value) = &field.default_value {
212		schema.default = Some(default_value.clone());
213	}
214
215	// Add read-only flag
216	if let Some(true) = field.read_only {
217		schema.read_only = Some(true);
218	}
219
220	// Extract regex pattern from validators for OpenAPI schema
221	if let Some(validators) = &field.validators {
222		for validator in validators {
223			if let Some(pattern) = validator.extract_pattern() {
224				schema.pattern = Some(pattern);
225				break;
226			}
227		}
228	}
229
230	schema
231}
232
233/// Generates a complete OpenAPI schema object from a map of fields
234///
235/// # Examples
236///
237/// ```
238/// use reinhardt_rest::metadata::{FieldInfoBuilder, FieldType, generate_object_schema};
239/// use std::collections::HashMap;
240///
241/// let mut fields = HashMap::new();
242/// fields.insert(
243///     "name".to_string(),
244///     FieldInfoBuilder::new(FieldType::String)
245///         .required(true)
246///         .build()
247/// );
248/// fields.insert(
249///     "age".to_string(),
250///     FieldInfoBuilder::new(FieldType::Integer)
251///         .required(false)
252///         .build()
253/// );
254///
255/// let schema = generate_object_schema(&fields);
256/// assert_eq!(schema.schema_type, Some("object".to_string()));
257/// assert_eq!(schema.required, Some(vec!["name".to_string()]));
258/// ```
259pub fn generate_object_schema(fields: &HashMap<String, FieldInfo>) -> FieldSchema {
260	let mut schema = FieldSchema {
261		schema_type: Some("object".to_string()),
262		..Default::default()
263	};
264
265	let mut properties = HashMap::new();
266	let mut required_fields = Vec::new();
267
268	for (name, field) in fields {
269		properties.insert(name.clone(), generate_field_schema(field));
270		if field.required {
271			required_fields.push(name.clone());
272		}
273	}
274
275	schema.properties = Some(properties);
276	if !required_fields.is_empty() {
277		required_fields.sort(); // Ensure consistent ordering
278		schema.required = Some(required_fields);
279	}
280
281	schema
282}
283
284#[cfg(test)]
285mod tests {
286	use super::*;
287	use crate::metadata::fields::FieldInfoBuilder;
288	use crate::metadata::types::ChoiceInfo;
289	use crate::metadata::validators::FieldValidator;
290
291	#[test]
292	fn test_generate_string_schema() {
293		let field = FieldInfoBuilder::new(FieldType::String)
294			.min_length(3)
295			.max_length(50)
296			.build();
297
298		let schema = generate_field_schema(&field);
299		assert_eq!(schema.schema_type, Some("string".to_string()));
300		assert_eq!(schema.min_length, Some(3));
301		assert_eq!(schema.max_length, Some(50));
302	}
303
304	#[test]
305	fn test_generate_integer_schema() {
306		let field = FieldInfoBuilder::new(FieldType::Integer)
307			.min_value(1.0)
308			.max_value(100.0)
309			.build();
310
311		let schema = generate_field_schema(&field);
312		assert_eq!(schema.schema_type, Some("integer".to_string()));
313		assert_eq!(schema.format, Some("int64".to_string()));
314		assert_eq!(schema.minimum, Some(1.0));
315		assert_eq!(schema.maximum, Some(100.0));
316	}
317
318	#[test]
319	fn test_generate_email_schema() {
320		let field = FieldInfoBuilder::new(FieldType::Email).build();
321
322		let schema = generate_field_schema(&field);
323		assert_eq!(schema.schema_type, Some("string".to_string()));
324		assert_eq!(schema.format, Some("email".to_string()));
325	}
326
327	#[test]
328	fn test_generate_datetime_schema() {
329		let field = FieldInfoBuilder::new(FieldType::DateTime).build();
330
331		let schema = generate_field_schema(&field);
332		assert_eq!(schema.schema_type, Some("string".to_string()));
333		assert_eq!(schema.format, Some("date-time".to_string()));
334	}
335
336	#[test]
337	fn test_generate_choice_schema() {
338		let choices = vec![
339			ChoiceInfo {
340				value: "active".to_string(),
341				display_name: "Active".to_string(),
342			},
343			ChoiceInfo {
344				value: "inactive".to_string(),
345				display_name: "Inactive".to_string(),
346			},
347		];
348
349		let field = FieldInfoBuilder::new(FieldType::Choice)
350			.choices(choices)
351			.build();
352
353		let schema = generate_field_schema(&field);
354		assert_eq!(schema.schema_type, Some("string".to_string()));
355		assert_eq!(
356			schema.enum_values,
357			Some(vec!["active".to_string(), "inactive".to_string()])
358		);
359	}
360
361	#[test]
362	fn test_generate_list_schema() {
363		let child = FieldInfoBuilder::new(FieldType::String)
364			.min_length(1)
365			.build();
366
367		let field = FieldInfoBuilder::new(FieldType::List).child(child).build();
368
369		let schema = generate_field_schema(&field);
370		assert_eq!(schema.schema_type, Some("array".to_string()));
371		assert!(schema.items.is_some());
372
373		let items = schema.items.unwrap();
374		assert_eq!(items.schema_type, Some("string".to_string()));
375		assert_eq!(items.min_length, Some(1));
376	}
377
378	#[test]
379	fn test_generate_nested_object_schema() {
380		let mut children = HashMap::new();
381		children.insert(
382			"name".to_string(),
383			FieldInfoBuilder::new(FieldType::String)
384				.required(true)
385				.build(),
386		);
387		children.insert(
388			"age".to_string(),
389			FieldInfoBuilder::new(FieldType::Integer).build(),
390		);
391
392		let field = FieldInfoBuilder::new(FieldType::NestedObject)
393			.children(children)
394			.build();
395
396		let schema = generate_field_schema(&field);
397		assert_eq!(schema.schema_type, Some("object".to_string()));
398		assert!(schema.properties.is_some());
399
400		let properties = schema.properties.unwrap();
401		assert_eq!(properties.len(), 2);
402		assert!(properties.contains_key("name"));
403		assert!(properties.contains_key("age"));
404
405		assert_eq!(schema.required, Some(vec!["name".to_string()]));
406	}
407
408	#[test]
409	fn test_generate_schema_with_description() {
410		let field = FieldInfoBuilder::new(FieldType::String)
411			.help_text("Enter your username")
412			.build();
413
414		let schema = generate_field_schema(&field);
415		assert_eq!(schema.description, Some("Enter your username".to_string()));
416	}
417
418	#[test]
419	fn test_generate_schema_with_label_fallback() {
420		let field = FieldInfoBuilder::new(FieldType::String)
421			.label("Username")
422			.build();
423
424		let schema = generate_field_schema(&field);
425		assert_eq!(schema.description, Some("Username".to_string()));
426	}
427
428	#[test]
429	fn test_generate_schema_with_default_value() {
430		let field = FieldInfoBuilder::new(FieldType::String)
431			.default_value(json!("default_text"))
432			.build();
433
434		let schema = generate_field_schema(&field);
435		assert_eq!(schema.default, Some(json!("default_text")));
436	}
437
438	#[test]
439	fn test_generate_schema_with_read_only() {
440		let field = FieldInfoBuilder::new(FieldType::Integer)
441			.read_only(true)
442			.build();
443
444		let schema = generate_field_schema(&field);
445		assert_eq!(schema.read_only, Some(true));
446	}
447
448	#[test]
449	fn test_generate_schema_with_regex_pattern() {
450		let validator = FieldValidator {
451			validator_type: "regex".to_string(),
452			options: Some(json!({"pattern": "^[a-zA-Z0-9_]+$"})),
453			message: Some("Invalid format".to_string()),
454		};
455
456		let field = FieldInfoBuilder::new(FieldType::String)
457			.add_validator(validator)
458			.build();
459
460		let schema = generate_field_schema(&field);
461		assert_eq!(schema.pattern, Some("^[a-zA-Z0-9_]+$".to_string()));
462	}
463
464	#[test]
465	fn test_generate_object_schema_basic() {
466		let mut fields = HashMap::new();
467		fields.insert(
468			"name".to_string(),
469			FieldInfoBuilder::new(FieldType::String)
470				.required(true)
471				.build(),
472		);
473		fields.insert(
474			"email".to_string(),
475			FieldInfoBuilder::new(FieldType::Email)
476				.required(true)
477				.build(),
478		);
479		fields.insert(
480			"age".to_string(),
481			FieldInfoBuilder::new(FieldType::Integer).build(),
482		);
483
484		let schema = generate_object_schema(&fields);
485		assert_eq!(schema.schema_type, Some("object".to_string()));
486		assert!(schema.properties.is_some());
487
488		let properties = schema.properties.unwrap();
489		assert_eq!(properties.len(), 3);
490
491		let required = schema.required.unwrap();
492		assert_eq!(required.len(), 2);
493		assert!(required.contains(&"name".to_string()));
494		assert!(required.contains(&"email".to_string()));
495	}
496
497	#[test]
498	fn test_generate_object_schema_empty() {
499		let fields = HashMap::new();
500		let schema = generate_object_schema(&fields);
501
502		assert_eq!(schema.schema_type, Some("object".to_string()));
503		assert!(schema.properties.is_some());
504		assert_eq!(schema.properties.unwrap().len(), 0);
505		assert!(schema.required.is_none());
506	}
507
508	#[test]
509	fn test_schema_serialization() {
510		let field = FieldInfoBuilder::new(FieldType::String)
511			.min_length(3)
512			.max_length(50)
513			.build();
514
515		let schema = generate_field_schema(&field);
516		let json = serde_json::to_string(&schema).unwrap();
517
518		assert!(json.contains("\"type\":\"string\""));
519		assert!(json.contains("\"minLength\":3"));
520		assert!(json.contains("\"maxLength\":50"));
521	}
522}