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}