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}