openai_tools/json_schema.rs
1//! # JSON Schema Builder
2//!
3//! This module provides functionality for building JSON schemas that can be used with
4//! OpenAI's structured output features. JSON schemas define the expected structure
5//! and format of data, allowing AI models to generate responses that conform to
6//! specific formats.
7//!
8//! ## Features
9//!
10//! - **Type Safety**: Strongly typed schema construction
11//! - **Flexible Properties**: Support for various JSON types (string, number, boolean, array, object)
12//! - **Nested Structures**: Support for complex nested objects and arrays
13//! - **Validation**: Built-in validation through required fields and type constraints
14//! - **Serialization**: Direct serialization to JSON for API consumption
15//!
16//! ## Common Use Cases
17//!
18//! - **Data Extraction**: Extract structured information from unstructured text
19//! - **Form Generation**: Generate forms with specific field requirements
20//! - **API Responses**: Ensure consistent response formats
21//! - **Configuration**: Define configuration schema for applications
22//!
23//! ## Example
24//!
25//! ```rust
26//! use openai_tools::json_schema::JsonSchema;
27//!
28//! // Create a schema for a person object
29//! let mut schema = JsonSchema::new("person".to_string());
30//!
31//! // Add basic properties
32//! schema.add_property(
33//! "name".to_string(),
34//! "string".to_string(),
35//! Some("The person's full name".to_string())
36//! );
37//! schema.add_property(
38//! "age".to_string(),
39//! "number".to_string(),
40//! Some("The person's age in years".to_string())
41//! );
42//!
43//! // Add an array property
44//! schema.add_array(
45//! "hobbies".to_string(),
46//! vec![
47//! ("name".to_string(), "The name of the hobby".to_string()),
48//! ("level".to_string(), "Skill level (beginner, intermediate, advanced)".to_string()),
49//! ]
50//! );
51//!
52//! // Convert to JSON for use with OpenAI API
53//! let json_string = serde_json::to_string(&schema).unwrap();
54//! println!("{}", json_string);
55//! ```
56
57use fxhash::FxHashMap;
58use serde::{Deserialize, Serialize};
59
60/// Represents a single property or item type within a JSON schema.
61///
62/// This structure defines the type and characteristics of individual properties
63/// in a JSON schema. It can represent simple types (string, number, boolean)
64/// or complex types (arrays, objects) with nested structures.
65///
66/// # Fields
67///
68/// * `type_name` - The JSON type of this item (e.g., "string", "number", "array", "object")
69/// * `description` - Optional human-readable description of the property
70/// * `items` - For array types, defines the structure of array elements
71///
72/// # Supported Types
73///
74/// - **"string"**: Text values
75/// - **"number"**: Numeric values (integers and floats)
76/// - **"boolean"**: True/false values
77/// - **"array"**: Lists of items with defined structure
78/// - **"object"**: Complex nested objects
79///
80/// # Example
81///
82/// ```rust
83/// use openai_tools::json_schema::ItemType;
84///
85/// // Simple string property
86/// let name_prop = ItemType::new(
87/// "string".to_string(),
88/// Some("The user's full name".to_string())
89/// );
90///
91/// // Numeric property
92/// let age_prop = ItemType::new(
93/// "number".to_string(),
94/// Some("Age in years".to_string())
95/// );
96/// ```
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct ItemType {
99 #[serde(rename = "type")]
100 pub type_name: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub description: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub items: Option<Box<JsonItem>>,
105}
106
107impl ItemType {
108 /// Creates a new `ItemType` with the specified type and optional description.
109 ///
110 /// # Arguments
111 ///
112 /// * `type_name` - The JSON type name (e.g., "string", "number", "boolean", "array", "object")
113 /// * `description` - Optional description explaining the purpose of this property
114 ///
115 /// # Returns
116 ///
117 /// A new `ItemType` instance with the specified type and description.
118 ///
119 /// # Example
120 ///
121 /// ```rust
122 /// use openai_tools::json_schema::ItemType;
123 ///
124 /// // Create a string property with description
125 /// let email = ItemType::new(
126 /// "string".to_string(),
127 /// Some("A valid email address".to_string())
128 /// );
129 ///
130 /// // Create a number property without description
131 /// let count = ItemType::new("number".to_string(), None);
132 /// ```
133 pub fn new(type_name: String, description: Option<String>) -> Self {
134 Self {
135 type_name: type_name.to_string(),
136 description: description,
137 items: None,
138 }
139 }
140
141 /// Creates a deep clone of this `ItemType` instance.
142 ///
143 /// This method performs a deep copy of all nested structures, including
144 /// any complex `items` that may be present for array types.
145 ///
146 /// # Returns
147 ///
148 /// A new `ItemType` instance that is an exact copy of this one.
149 ///
150 /// # Note
151 ///
152 /// This method is more explicit than the auto-derived `Clone` trait
153 /// and ensures proper deep copying of nested Box<JsonItem> structures.
154 pub fn clone(&self) -> Self {
155 let mut items: JsonItem = JsonItem::default();
156 if let Some(item) = &self.items {
157 let mut _properties: FxHashMap<String, ItemType> = FxHashMap::default();
158 for (key, value) in item.properties.iter() {
159 _properties.insert(key.clone(), value.clone());
160 }
161 items.type_name = item.type_name.clone();
162 items.properties = _properties;
163 items.required = item.required.clone();
164 items.additional_properties = item.additional_properties;
165 }
166
167 Self {
168 type_name: self.type_name.clone(),
169 description: self.description.clone(),
170 items: if self.items.is_some() {
171 Option::from(Box::new(items))
172 } else {
173 None
174 },
175 }
176 }
177}
178
179/// Represents a JSON object structure with properties, requirements, and constraints.
180///
181/// This structure defines a JSON object schema including its properties, which fields
182/// are required, and whether additional properties are allowed. It's used to build
183/// complex nested object structures within JSON schemas.
184///
185/// # Fields
186///
187/// * `type_name` - Always "object" for object schemas
188/// * `properties` - Map of property names to their type definitions
189/// * `required` - List of property names that must be present
190/// * `additional_properties` - Whether properties not defined in schema are allowed
191///
192/// # Schema Validation
193///
194/// - **Required Fields**: Properties listed in `required` must be present in valid JSON
195/// - **Type Validation**: Each property must match its defined type
196/// - **Additional Properties**: When false, only defined properties are allowed
197///
198/// # Example
199///
200/// ```rust
201/// use openai_tools::json_schema::{JsonItem, ItemType};
202/// use fxhash::FxHashMap;
203///
204/// // Create properties map
205/// let mut properties = FxHashMap::default();
206/// properties.insert(
207/// "name".to_string(),
208/// ItemType::new("string".to_string(), Some("Person's name".to_string()))
209/// );
210/// properties.insert(
211/// "age".to_string(),
212/// ItemType::new("number".to_string(), Some("Person's age".to_string()))
213/// );
214///
215/// // Create object schema
216/// let person_schema = JsonItem::new("object", properties);
217/// ```
218#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct JsonItem {
220 #[serde(rename = "type")]
221 pub type_name: String,
222 pub properties: FxHashMap<String, ItemType>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub required: Option<Vec<String>>,
225 #[serde(rename = "additionalProperties")]
226 pub additional_properties: bool,
227}
228
229impl JsonItem {
230 /// Creates a new `JsonItem` object schema with the specified properties.
231 ///
232 /// All properties provided will automatically be marked as required.
233 /// Additional properties are disabled by default for strict validation.
234 ///
235 /// # Arguments
236 ///
237 /// * `type_name` - The type name (typically "object")
238 /// * `properties` - Map of property names to their type definitions
239 ///
240 /// # Returns
241 ///
242 /// A new `JsonItem` with all provided properties marked as required.
243 ///
244 /// # Example
245 ///
246 /// ```rust
247 /// use openai_tools::json_schema::{JsonItem, ItemType};
248 /// use fxhash::FxHashMap;
249 ///
250 /// let mut props = FxHashMap::default();
251 /// props.insert("id".to_string(), ItemType::new("number".to_string(), None));
252 /// props.insert("title".to_string(), ItemType::new("string".to_string(), None));
253 ///
254 /// let schema = JsonItem::new("object", props);
255 /// // Both "id" and "title" will be required
256 /// ```
257 pub fn new(type_name: &str, properties: FxHashMap<String, ItemType>) -> Self {
258 let mut required = Vec::new();
259 for key in properties.keys() {
260 required.push(key.clone());
261 }
262 Self {
263 type_name: type_name.to_string(),
264 properties,
265 required: if required.is_empty() {
266 None
267 } else {
268 Option::from(required)
269 },
270 additional_properties: false,
271 }
272 }
273
274 /// Creates a default empty object schema.
275 ///
276 /// Returns a new `JsonItem` representing an empty object with no properties,
277 /// no required fields, and additional properties disabled.
278 ///
279 /// # Returns
280 ///
281 /// A new empty `JsonItem` ready for property addition.
282 ///
283 /// # Example
284 ///
285 /// ```rust
286 /// use openai_tools::json_schema::{JsonItem, ItemType};
287 ///
288 /// let mut schema = JsonItem::default();
289 /// // Add properties later using add_property()
290 /// ```
291 pub fn default() -> Self {
292 Self {
293 type_name: "object".to_string(),
294 properties: FxHashMap::default(),
295 required: None,
296 additional_properties: false,
297 }
298 }
299
300 /// Adds a property to this object schema and marks it as required.
301 ///
302 /// This method adds a new property to the schema and automatically
303 /// updates the required fields list to include this property.
304 ///
305 /// # Arguments
306 ///
307 /// * `prop_name` - The name of the property to add
308 /// * `item` - The type definition for this property
309 ///
310 /// # Example
311 ///
312 /// ```rust
313 /// use openai_tools::json_schema::{JsonItem, ItemType};
314 ///
315 /// let mut schema = JsonItem::default();
316 /// let string_prop = ItemType::new("string".to_string(), Some("A name".to_string()));
317 ///
318 /// schema.add_property("name".to_string(), string_prop);
319 /// // "name" is now a required property
320 /// ```
321 pub fn add_property(&mut self, prop_name: String, item: ItemType) {
322 self.properties.insert(prop_name.to_string(), item.clone());
323 if self.required.is_none() {
324 self.required = Option::from(vec![prop_name.to_string()]);
325 } else {
326 let mut required = self.required.clone().unwrap();
327 required.push(prop_name.to_string());
328 self.required = Option::from(required);
329 }
330 }
331
332 /// Adds an array property with the specified item structure.
333 ///
334 /// This method creates an array property where each element conforms
335 /// to the provided `JsonItem` structure. The array property is automatically
336 /// marked as required.
337 ///
338 /// # Arguments
339 ///
340 /// * `prop_name` - The name of the array property
341 /// * `items` - The schema definition for array elements
342 ///
343 /// # Example
344 ///
345 /// ```rust
346 /// use openai_tools::json_schema::{JsonItem, ItemType};
347 /// use fxhash::FxHashMap;
348 ///
349 /// let mut schema = JsonItem::default();
350 ///
351 /// // Define structure for array items
352 /// let mut item_props = FxHashMap::default();
353 /// item_props.insert("id".to_string(), ItemType::new("number".to_string(), None));
354 /// item_props.insert("name".to_string(), ItemType::new("string".to_string(), None));
355 /// let item_schema = JsonItem::new("object", item_props);
356 ///
357 /// schema.add_array("items".to_string(), item_schema);
358 /// ```
359 pub fn add_array(&mut self, prop_name: String, items: JsonItem) {
360 let mut prop = ItemType::new(String::from("array"), None);
361 prop.items = Option::from(Box::new(items));
362 self.properties.insert(prop_name.to_string(), prop);
363 self.required = Option::from(vec![prop_name.to_string()]);
364 }
365
366 /// Creates a deep clone of this `JsonItem` instance.
367 ///
368 /// This method performs a deep copy of all properties and nested structures,
369 /// ensuring that modifications to the clone don't affect the original.
370 ///
371 /// # Returns
372 ///
373 /// A new `JsonItem` instance that is an exact copy of this one.
374 pub fn clone(&self) -> Self {
375 let mut properties: FxHashMap<String, ItemType> = FxHashMap::default();
376 for (key, value) in self.properties.iter() {
377 properties.insert(key.clone(), value.clone());
378 }
379 Self {
380 type_name: self.type_name.clone(),
381 properties: properties,
382 required: self.required.clone(),
383 additional_properties: self.additional_properties,
384 }
385 }
386}
387
388/// Complete JSON schema definition with name and structure.
389///
390/// This is the top-level structure for defining JSON schemas that can be used
391/// with OpenAI's structured output features. It combines a schema name with
392/// the actual schema definition to create a complete, reusable schema.
393///
394/// # Fields
395///
396/// * `name` - A unique identifier for this schema
397/// * `schema` - The actual schema definition describing the structure
398///
399/// # Usage with OpenAI API
400///
401/// This structure can be directly serialized and used with OpenAI's chat completion
402/// API to ensure structured responses that conform to the defined schema.
403///
404/// # Example
405///
406/// ```rust
407/// use openai_tools::json_schema::JsonSchema;
408///
409/// // Create a schema for extracting contact information
410/// let mut contact_schema = JsonSchema::new("contact_info".to_string());
411///
412/// contact_schema.add_property(
413/// "name".to_string(),
414/// "string".to_string(),
415/// Some("Full name of the person".to_string())
416/// );
417/// contact_schema.add_property(
418/// "email".to_string(),
419/// "string".to_string(),
420/// Some("Email address".to_string())
421/// );
422/// contact_schema.add_property(
423/// "phone".to_string(),
424/// "string".to_string(),
425/// Some("Phone number".to_string())
426/// );
427///
428/// // Serialize for API use
429/// let json_string = serde_json::to_string(&contact_schema).unwrap();
430/// ```
431#[derive(Debug, Clone, Deserialize, Serialize)]
432pub struct JsonSchema {
433 pub name: String,
434 pub schema: JsonItem,
435}
436
437impl JsonSchema {
438 /// Creates a new `JsonSchema` with the specified name.
439 ///
440 /// This creates an empty object schema that can be populated with properties
441 /// using the various `add_*` methods.
442 ///
443 /// # Arguments
444 ///
445 /// * `name` - A unique identifier for this schema
446 ///
447 /// # Returns
448 ///
449 /// A new `JsonSchema` instance with an empty object schema.
450 ///
451 /// # Example
452 ///
453 /// ```rust
454 /// use openai_tools::json_schema::JsonSchema;
455 ///
456 /// let schema = JsonSchema::new("user_profile".to_string());
457 /// // Schema is now ready for property addition
458 /// ```
459 pub fn new(name: String) -> Self {
460 let schema = JsonItem::default();
461 Self {
462 name: name.to_string(),
463 schema,
464 }
465 }
466
467 /// Creates a new `JsonSchema` with the specified name (alternative constructor).
468 ///
469 /// This method is functionally identical to `new()` but provides an alternative
470 /// naming convention for schema creation.
471 ///
472 /// # Arguments
473 ///
474 /// * `name` - A unique identifier for this schema
475 ///
476 /// # Returns
477 ///
478 /// A new `JsonSchema` instance with an empty object schema.
479 pub fn new_schema(name: String) -> Self {
480 Self {
481 name: name.to_string(),
482 schema: JsonItem::default(),
483 }
484 }
485
486 /// Adds a simple property to the schema.
487 ///
488 /// This method adds a property with a basic type (string, number, boolean, etc.)
489 /// to the schema. The property is automatically marked as required.
490 ///
491 /// # Arguments
492 ///
493 /// * `prop_name` - The name of the property
494 /// * `type_name` - The JSON type (e.g., "string", "number", "boolean")
495 /// * `description` - Optional description explaining the property's purpose
496 ///
497 /// # Example
498 ///
499 /// ```rust
500 /// use openai_tools::json_schema::JsonSchema;
501 ///
502 /// let mut schema = JsonSchema::new("person".to_string());
503 ///
504 /// // Add string property with description
505 /// schema.add_property(
506 /// "name".to_string(),
507 /// "string".to_string(),
508 /// Some("The person's full name".to_string())
509 /// );
510 ///
511 /// // Add number property without description
512 /// schema.add_property(
513 /// "age".to_string(),
514 /// "number".to_string(),
515 /// None
516 /// );
517 ///
518 /// // Add boolean property
519 /// schema.add_property(
520 /// "is_active".to_string(),
521 /// "boolean".to_string(),
522 /// Some("Whether the person is currently active".to_string())
523 /// );
524 /// ```
525 pub fn add_property(
526 &mut self,
527 prop_name: String,
528 type_name: String,
529 description: Option<String>,
530 ) {
531 let new_item = ItemType::new(type_name, description);
532 self.schema.add_property(prop_name, new_item);
533 }
534
535 /// Adds an array property with string elements to the schema.
536 ///
537 /// This method creates an array property where each element is an object
538 /// with string properties. All specified properties in the array elements
539 /// are marked as required.
540 ///
541 /// # Arguments
542 ///
543 /// * `prop_name` - The name of the array property
544 /// * `items` - Vector of (property_name, description) tuples for array elements
545 ///
546 /// # Example
547 ///
548 /// ```rust
549 /// use openai_tools::json_schema::JsonSchema;
550 ///
551 /// let mut schema = JsonSchema::new("user_profile".to_string());
552 ///
553 /// // Add an array of address objects
554 /// schema.add_array(
555 /// "addresses".to_string(),
556 /// vec![
557 /// ("street".to_string(), "Street address".to_string()),
558 /// ("city".to_string(), "City name".to_string()),
559 /// ("state".to_string(), "State or province".to_string()),
560 /// ("zip_code".to_string(), "Postal code".to_string()),
561 /// ]
562 /// );
563 ///
564 /// // This creates an array where each element must have all four properties
565 /// ```
566 ///
567 /// # Note
568 ///
569 /// Currently, this method only supports arrays of objects with string properties.
570 /// For more complex array structures, you may need to manually construct the
571 /// schema using `JsonItem` and `ItemType`.
572 pub fn add_array(&mut self, prop_name: String, items: Vec<(String, String)>) {
573 let mut array_item = JsonItem::default();
574 for (name, description) in items.iter() {
575 let item = ItemType::new(String::from("string"), Option::from(description.clone()));
576 array_item.add_property(name.clone(), item);
577 }
578 self.schema.add_array(prop_name, array_item);
579 }
580
581 /// Creates a deep clone of this `JsonSchema` instance.
582 ///
583 /// This method performs a deep copy of the entire schema structure,
584 /// including all nested properties and their definitions.
585 ///
586 /// # Returns
587 ///
588 /// A new `JsonSchema` instance that is an exact copy of this one.
589 ///
590 /// # Example
591 ///
592 /// ```rust
593 /// use openai_tools::json_schema::JsonSchema;
594 ///
595 /// let mut original = JsonSchema::new("template".to_string());
596 /// original.add_property("id".to_string(), "number".to_string(), None);
597 ///
598 /// let mut copy = original.clone();
599 /// copy.add_property("name".to_string(), "string".to_string(), None);
600 ///
601 /// // original still only has "id", copy has both "id" and "name"
602 /// ```
603 pub fn clone(&self) -> Self {
604 Self {
605 name: self.name.clone(),
606 schema: self.schema.clone(),
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[tokio::test]
616 async fn test_build_json_schema_simple() {
617 let mut json_schema = JsonSchema::new(String::from("test-schema"));
618 json_schema.add_property(String::from("test_property"), String::from("string"), None);
619
620 let schema_string = serde_json::to_string(&json_schema).unwrap();
621 println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
622
623 assert_eq!(
624 schema_string,
625 r#"{"name":"test-schema","schema":{"type":"object","properties":{"test_property":{"type":"string"}},"required":["test_property"],"additionalProperties":false}}"#
626 );
627 }
628
629 #[tokio::test]
630 async fn test_build_json_schema_with_description() {
631 let mut json_schema = JsonSchema::new(String::from("test-schema"));
632 json_schema.add_property(
633 String::from("email"),
634 String::from("string"),
635 Some(String::from("The email address that appears in the input")),
636 );
637
638 let schema_string = serde_json::to_string(&json_schema).unwrap();
639 println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
640
641 assert_eq!(
642 schema_string,
643 r#"{"name":"test-schema","schema":{"type":"object","properties":{"email":{"type":"string","description":"The email address that appears in the input"}},"required":["email"],"additionalProperties":false}}"#,
644 );
645 }
646
647 #[tokio::test]
648 async fn test_build_json_schema_add_array() {
649 let mut json_schema = JsonSchema::new(String::from("test-schema"));
650 json_schema.add_property(
651 String::from("test-property"),
652 String::from("string"),
653 Some(String::from("This is a test property")),
654 );
655 json_schema.add_array(
656 String::from("test-array"),
657 vec![
658 (
659 String::from("test-array-property-1"),
660 String::from("This is test array property 1."),
661 ),
662 (
663 String::from("test-array-property-2"),
664 String::from("This is test array property 2."),
665 ),
666 ],
667 );
668 let schema_string = serde_json::to_string(&json_schema).unwrap();
669
670 println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
671
672 assert_eq!(
673 schema_string,
674 r#"{"name":"test-schema","schema":{"type":"object","properties":{"test-property":{"type":"string","description":"This is a test property"},"test-array":{"type":"array","items":{"type":"object","properties":{"test-array-property-2":{"type":"string","description":"This is test array property 2."},"test-array-property-1":{"type":"string","description":"This is test array property 1."}},"required":["test-array-property-1","test-array-property-2"],"additionalProperties":false}}},"required":["test-array"],"additionalProperties":false}}"#
675 );
676 }
677}