openai_tools/
structured_output.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::structured_output::Schema;
27//!
28//! // Create a schema for a person object
29//! let mut schema = Schema::chat_json_schema("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::structured_output::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::structured_output::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::structured_output::{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::structured_output::{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::structured_output::{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::structured_output::{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::structured_output::{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::structured_output::Schema;
408///
409/// // Create a schema for extracting contact information
410/// let mut contact_schema = Schema::chat_json_schema("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, Default, Deserialize, Serialize)]
432pub struct Schema {
433    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
434    type_name: Option<String>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub name: Option<String>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub schema: Option<JsonItem>,
439}
440
441impl Schema {
442    /// Creates a new `JsonSchema` with the specified name.
443    ///
444    /// This creates an empty object schema that can be populated with properties
445    /// using the various `add_*` methods.
446    ///
447    /// # Arguments
448    ///
449    /// * `name` - A unique identifier for this schema
450    ///
451    /// # Returns
452    ///
453    /// A new `JsonSchema` instance with an empty object schema.
454    ///
455    /// # Example
456    ///
457    /// ```rust
458    /// use openai_tools::structured_output::Schema;
459    ///
460    /// let schema = Schema::chat_json_schema("user_profile".to_string());
461    /// // Schema is now ready for property addition
462    /// ```
463    pub fn responses_text_schema() -> Self {
464        Self {
465            type_name: Some("text".to_string()),
466            name: None,
467            schema: None,
468        }
469    }
470
471    /// Creates a new `JsonSchema` with the specified name (alternative constructor).
472    ///
473    /// This method is functionally identical to `new()` but provides an alternative
474    /// naming convention for schema creation.
475    ///
476    /// # Arguments
477    ///
478    /// * `name` - A unique identifier for this schema
479    ///
480    /// # Returns
481    ///
482    /// A new `JsonSchema` instance with an empty object schema.
483    pub fn responses_json_schema(name: String) -> Self {
484        Self {
485            type_name: Some("json_schema".to_string()),
486            name: Some(name.to_string()),
487            schema: Some(JsonItem::default()),
488        }
489    }
490
491    /// Creates a new `JsonSchema` for chat completions with the specified name.
492    ///
493    /// This method creates a schema specifically designed for use with OpenAI's chat
494    /// completion API. Unlike `responses_json_schema()`, this method doesn't set a
495    /// type name, making it suitable for chat-based structured outputs.
496    ///
497    /// # Arguments
498    ///
499    /// * `name` - A unique identifier for this schema
500    ///
501    /// # Returns
502    ///
503    /// A new `JsonSchema` instance with an empty object schema optimized for chat completions.
504    ///
505    /// # Example
506    ///
507    /// ```rust
508    /// use openai_tools::structured_output::Schema;
509    ///
510    /// let mut chat_schema = Schema::chat_json_schema("chat_response".to_string());
511    /// chat_schema.add_property(
512    ///     "response".to_string(),
513    ///     "string".to_string(),
514    ///     Some("The AI's response message".to_string())
515    /// );
516    /// ```
517    pub fn chat_json_schema(name: String) -> Self {
518        Self {
519            type_name: None,
520            name: Some(name.to_string()),
521            schema: Some(JsonItem::default()),
522        }
523    }
524
525    /// Adds a simple property to the schema.
526    ///
527    /// This method adds a property with a basic type (string, number, boolean, etc.)
528    /// to the schema. The property is automatically marked as required.
529    ///
530    /// # Arguments
531    ///
532    /// * `prop_name` - The name of the property
533    /// * `type_name` - The JSON type (e.g., "string", "number", "boolean")
534    /// * `description` - Optional description explaining the property's purpose
535    ///
536    /// # Example
537    ///
538    /// ```rust
539    /// use openai_tools::structured_output::Schema;
540    ///
541    /// let mut schema = Schema::chat_json_schema("person".to_string());
542    ///
543    /// // Add string property with description
544    /// schema.add_property(
545    ///     "name".to_string(),
546    ///     "string".to_string(),
547    ///     Some("The person's full name".to_string())
548    /// );
549    ///
550    /// // Add number property without description
551    /// schema.add_property(
552    ///     "age".to_string(),
553    ///     "number".to_string(),
554    ///     None
555    /// );
556    ///
557    /// // Add boolean property
558    /// schema.add_property(
559    ///     "is_active".to_string(),
560    ///     "boolean".to_string(),
561    ///     Some("Whether the person is currently active".to_string())
562    /// );
563    /// ```
564    pub fn add_property(
565        &mut self,
566        prop_name: String,
567        type_name: String,
568        description: Option<String>,
569    ) {
570        let new_item = ItemType::new(type_name, description);
571        self.schema
572            .as_mut()
573            .unwrap()
574            .add_property(prop_name, new_item);
575    }
576
577    /// Adds an array property with string elements to the schema.
578    ///
579    /// This method creates an array property where each element is an object
580    /// with string properties. All specified properties in the array elements
581    /// are marked as required.
582    ///
583    /// # Arguments
584    ///
585    /// * `prop_name` - The name of the array property
586    /// * `items` - Vector of (property_name, description) tuples for array elements
587    ///
588    /// # Example
589    ///
590    /// ```rust
591    /// use openai_tools::structured_output::Schema;
592    ///
593    /// let mut schema = Schema::chat_json_schema("user_profile".to_string());
594    ///
595    /// // Add an array of address objects
596    /// schema.add_array(
597    ///     "addresses".to_string(),
598    ///     vec![
599    ///         ("street".to_string(), "Street address".to_string()),
600    ///         ("city".to_string(), "City name".to_string()),
601    ///         ("state".to_string(), "State or province".to_string()),
602    ///         ("zip_code".to_string(), "Postal code".to_string()),
603    ///     ]
604    /// );
605    ///
606    /// // This creates an array where each element must have all four properties
607    /// ```
608    ///
609    /// # Note
610    ///
611    /// Currently, this method only supports arrays of objects with string properties.
612    /// For more complex array structures, you may need to manually construct the
613    /// schema using `JsonItem` and `ItemType`.
614    pub fn add_array(&mut self, prop_name: String, items: Vec<(String, String)>) {
615        let mut array_item = JsonItem::default();
616        for (name, description) in items.iter() {
617            let item = ItemType::new(String::from("string"), Option::from(description.clone()));
618            array_item.add_property(name.clone(), item);
619        }
620        self.schema
621            .as_mut()
622            .unwrap()
623            .add_array(prop_name, array_item);
624    }
625
626    /// Creates a deep clone of this `JsonSchema` instance.
627    ///
628    /// This method performs a deep copy of the entire schema structure,
629    /// including all nested properties and their definitions.
630    ///
631    /// # Returns
632    ///
633    /// A new `JsonSchema` instance that is an exact copy of this one.
634    ///
635    /// # Example
636    ///
637    /// ```rust
638    /// use openai_tools::structured_output::Schema;
639    ///
640    /// let mut original = Schema::chat_json_schema("template".to_string());
641    /// original.add_property("id".to_string(), "number".to_string(), None);
642    ///
643    /// let mut copy = original.clone();
644    /// copy.add_property("name".to_string(), "string".to_string(), None);
645    ///
646    /// // original still only has "id", copy has both "id" and "name"
647    /// ```
648    pub fn clone(&self) -> Self {
649        Self {
650            type_name: self.type_name.clone(),
651            name: self.name.clone(),
652            schema: self.schema.clone(),
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[tokio::test]
662    async fn test_build_json_schema_simple() {
663        let mut json_schema = Schema::chat_json_schema(String::from("test-schema"));
664        json_schema.add_property(String::from("test_property"), String::from("string"), None);
665
666        let schema_string = serde_json::to_string(&json_schema).unwrap();
667        println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
668
669        assert_eq!(
670            schema_string,
671            r#"{"name":"test-schema","schema":{"type":"object","properties":{"test_property":{"type":"string"}},"required":["test_property"],"additionalProperties":false}}"#
672        );
673    }
674
675    #[tokio::test]
676    async fn test_build_json_schema_with_description() {
677        let mut json_schema = Schema::chat_json_schema(String::from("test-schema"));
678        json_schema.add_property(
679            String::from("email"),
680            String::from("string"),
681            Some(String::from("The email address that appears in the input")),
682        );
683
684        let schema_string = serde_json::to_string(&json_schema).unwrap();
685        println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
686
687        assert_eq!(
688            schema_string,
689            r#"{"name":"test-schema","schema":{"type":"object","properties":{"email":{"type":"string","description":"The email address that appears in the input"}},"required":["email"],"additionalProperties":false}}"#,
690        );
691    }
692
693    #[tokio::test]
694    async fn test_build_json_schema_add_array() {
695        let mut json_schema = Schema::chat_json_schema(String::from("test-schema"));
696        json_schema.add_property(
697            String::from("test-property"),
698            String::from("string"),
699            Some(String::from("This is a test property")),
700        );
701        json_schema.add_array(
702            String::from("test-array"),
703            vec![
704                (
705                    String::from("test-array-property-1"),
706                    String::from("This is test array property 1."),
707                ),
708                (
709                    String::from("test-array-property-2"),
710                    String::from("This is test array property 2."),
711                ),
712            ],
713        );
714        let schema_string = serde_json::to_string(&json_schema).unwrap();
715
716        println!("{}", serde_json::to_string_pretty(&json_schema).unwrap());
717
718        assert_eq!(
719            schema_string,
720            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}}"#
721        );
722    }
723}