Skip to main content

ag_protocol/
schema.rs

1//! JSON Schema generation and transport-compatibility normalization for the
2//! structured response protocol.
3
4use serde_json::Value;
5
6use super::model::{AgentResponse, questions_field_description};
7
8/// Selects how a provider transport lists `required` schema properties.
9///
10/// Providers disagree on what a valid schema looks like. Codex rejects schemas
11/// whose `properties` contain keys missing from `required`, so it needs every
12/// key listed. Validators that enforce `required` literally, such as Claude,
13/// must only demand `answer`; listing optional keys there rejects ordinary
14/// replies that omit `questions` or `summary`, even though the parser accepts
15/// them through `#[serde(default)]`.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum SchemaRequiredPolicy {
18    /// Lists every `properties` key in `required` for Codex compatibility.
19    AllProperties,
20    /// Lists only the minimum protocol keys the parser insists on.
21    MinimumProtocolKeys,
22}
23
24/// Returns the JSON Schema used for structured assistant output.
25///
26/// The returned value is passed directly to providers that support enforced
27/// output schemas. It starts from the self-descriptive response schema and then
28/// applies compatibility normalization required by schema-enforcing agents.
29/// `required_policy` selects how strictly optional protocol keys are demanded.
30pub fn agent_response_output_schema(required_policy: SchemaRequiredPolicy) -> Value {
31    let mut value = agent_response_json_schema();
32    normalize_schema_for_transport(&mut value, required_policy);
33
34    value
35}
36
37/// Returns a pretty-printed JSON Schema string for prompt instruction
38/// templating.
39///
40/// This keeps the raw `schemars` metadata intact so inline prompt guidance can
41/// show a fully self-descriptive schema document.
42pub fn agent_response_json_schema_json() -> String {
43    let schema = agent_response_json_schema();
44
45    stringify_schema_json(&schema)
46}
47
48/// Returns a pretty-printed, transport-normalized JSON Schema string.
49///
50/// Provider adapters use this serialized schema document when their native
51/// structured-output API accepts JSON text. `required_policy` selects the
52/// provider-compatible `required` field normalization.
53pub fn agent_response_output_schema_json(required_policy: SchemaRequiredPolicy) -> String {
54    let schema = agent_response_output_schema(required_policy);
55
56    stringify_schema_json(&schema)
57}
58
59/// Returns the self-descriptive JSON Schema for the response payload.
60///
61/// This preserves the raw `schemars` output, including metadata such as
62/// `title` and `description`, so prompt templates can show models the richest
63/// possible schema contract.
64fn agent_response_json_schema() -> Value {
65    let schema = schemars::schema_for!(AgentResponse);
66    let mut schema_value = serde_json::to_value(schema).unwrap_or(Value::Null);
67
68    inject_dynamic_schema_guidance(&mut schema_value);
69    inject_additional_properties_false(&mut schema_value);
70    inject_minimum_required_protocol_key(&mut schema_value);
71
72    schema_value
73}
74
75/// Injects dynamic prompt guidance that depends on runtime constants into the
76/// schema metadata shown to providers.
77fn inject_dynamic_schema_guidance(schema: &mut Value) {
78    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
79        return;
80    };
81    let Some(questions_property) = properties
82        .get_mut("questions")
83        .and_then(Value::as_object_mut)
84    else {
85        return;
86    };
87
88    questions_property.insert(
89        "description".to_string(),
90        Value::String(questions_field_description()),
91    );
92}
93
94/// Recursively injects `additionalProperties: false` into every schema object
95/// that declares `properties` and does not already set `additionalProperties`.
96///
97/// The wire-format structs omit `#[serde(deny_unknown_fields)]` so
98/// deserialization tolerates extra fields that LLM providers sometimes add.
99/// This function restores the `additionalProperties: false` constraint in the
100/// generated JSON Schema so prompt-level guidance still tells models not to
101/// add extra fields. Pre-existing `additionalProperties` values (e.g. on
102/// map-like schema fields) are preserved.
103fn inject_additional_properties_false(value: &mut Value) {
104    match value {
105        Value::Object(object) => {
106            if object.contains_key("properties") && !object.contains_key("additionalProperties") {
107                object.insert("additionalProperties".to_string(), Value::Bool(false));
108            }
109
110            for nested_value in object.values_mut() {
111                inject_additional_properties_false(nested_value);
112            }
113        }
114        Value::Array(array) => {
115            for nested_value in array {
116                inject_additional_properties_false(nested_value);
117            }
118        }
119        _ => {}
120    }
121}
122
123/// Ensures the top-level `required` array includes `answer` so the prompt
124/// schema rejects `{}` the same way the parser does.
125///
126/// The parser requires at least one recognized protocol key (`answer`,
127/// `questions`, or `summary`). The schema is intentionally stricter: it
128/// requires `answer` specifically, because models should always include it.
129/// If a model omits `answer` but includes another recognized key, the
130/// parser still accepts the payload gracefully thanks to `#[serde(default)]`.
131fn inject_minimum_required_protocol_key(schema: &mut Value) {
132    let Some(object) = schema.as_object_mut() else {
133        return;
134    };
135
136    let required = object
137        .entry("required")
138        .or_insert_with(|| Value::Array(Vec::new()));
139
140    let Some(required_array) = required.as_array_mut() else {
141        return;
142    };
143
144    let already_listed = required_array
145        .iter()
146        .any(|value| value.as_str() == Some("answer"));
147
148    if !already_listed {
149        required_array.push(Value::String("answer".to_string()));
150    }
151}
152
153/// Normalizes one schema tree for transport-level provider compatibility.
154///
155/// Claude rejects schemas with a top-level `$schema` URI when its validator
156/// cannot resolve that meta-schema. Codex rejects schemas that use `oneOf` for
157/// enum-like constants. Schemars can emit both shapes, so this normalizer
158/// strips transport-only metadata and rewrites enum fragments to string `enum`
159/// definitions. `required_policy` decides whether optional properties are also
160/// forced into `required`.
161fn normalize_schema_for_transport(value: &mut Value, required_policy: SchemaRequiredPolicy) {
162    match value {
163        Value::Object(object) => {
164            object.remove("$schema");
165
166            for nested_value in object.values_mut() {
167                normalize_schema_for_transport(nested_value, required_policy);
168            }
169
170            normalize_ref_object_for_codex(object);
171            if required_policy == SchemaRequiredPolicy::AllProperties {
172                normalize_required_for_codex(object);
173            }
174
175            let one_of_values = object
176                .get("oneOf")
177                .and_then(Value::as_array)
178                .map(|items| {
179                    items
180                        .iter()
181                        .filter_map(Value::as_object)
182                        .map(|item| item.get("const").and_then(Value::as_str))
183                        .collect::<Option<Vec<_>>>()
184                })
185                .map(|option| {
186                    option.map(|values| {
187                        values
188                            .into_iter()
189                            .map(ToString::to_string)
190                            .collect::<Vec<_>>()
191                    })
192                });
193
194            if let Some(Some(enum_variants)) = one_of_values {
195                object.remove("oneOf");
196                object.insert("type".to_string(), Value::String("string".to_string()));
197                object.insert(
198                    "enum".to_string(),
199                    Value::Array(enum_variants.into_iter().map(Value::String).collect()),
200                );
201            }
202        }
203        Value::Array(array) => {
204            for nested_value in array {
205                normalize_schema_for_transport(nested_value, required_policy);
206            }
207        }
208        _ => {}
209    }
210}
211
212/// Rewrites one `$ref` schema object to Codex-compatible form.
213///
214/// Codex rejects sibling keywords alongside `$ref` (for example
215/// `{ "$ref": "...", "description": "..." }`), so this keeps only the
216/// reference key when present.
217fn normalize_ref_object_for_codex(object: &mut serde_json::Map<String, Value>) {
218    let Some(reference) = object.get("$ref").cloned() else {
219        return;
220    };
221
222    object.clear();
223    object.insert("$ref".to_string(), reference);
224}
225
226/// Ensures all `properties` keys appear in `required` for Codex compatibility.
227///
228/// Codex rejects schemas where `properties` contains keys not listed in
229/// `required`. Schemars omits optional fields from `required`, so this
230/// normalizer adds any missing property keys.
231fn normalize_required_for_codex(object: &mut serde_json::Map<String, Value>) {
232    let Some(properties) = object.get("properties").and_then(Value::as_object) else {
233        return;
234    };
235
236    let property_keys: Vec<String> = properties.keys().cloned().collect();
237    if property_keys.is_empty() {
238        return;
239    }
240
241    let required = object
242        .entry("required")
243        .or_insert_with(|| Value::Array(Vec::new()));
244
245    let Some(required_array) = required.as_array_mut() else {
246        return;
247    };
248
249    for key in &property_keys {
250        let already_listed = required_array
251            .iter()
252            .any(|value| value.as_str() == Some(key));
253
254        if !already_listed {
255            required_array.push(Value::String(key.clone()));
256        }
257    }
258}
259
260/// Pretty-prints one schema document for prompt or transport wiring.
261fn stringify_schema_json(schema: &Value) -> String {
262    serde_json::to_string_pretty(schema).unwrap_or("null".to_string())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    /// Builds a schema object with required top-level response fields.
271    fn test_agent_response_output_schema_contains_required_fields() {
272        // Arrange / Act
273        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
274        let required_fields = schema
275            .get("required")
276            .and_then(Value::as_array)
277            .expect("schema required fields should exist");
278        let properties = schema
279            .get("properties")
280            .and_then(Value::as_object)
281            .expect("schema properties should exist");
282
283        // Assert
284        assert!(
285            required_fields
286                .iter()
287                .any(|value| value.as_str() == Some("answer"))
288        );
289        assert!(
290            required_fields
291                .iter()
292                .any(|value| value.as_str() == Some("questions"))
293        );
294        assert!(
295            required_fields
296                .iter()
297                .any(|value| value.as_str() == Some("review_comment_outcomes"))
298        );
299        assert!(
300            required_fields
301                .iter()
302                .any(|value| value.as_str() == Some("summary"))
303        );
304        assert!(properties.contains_key("answer"));
305        assert!(properties.contains_key("questions"));
306        assert!(properties.contains_key("review_comment_outcomes"));
307        assert!(properties.contains_key("summary"));
308    }
309
310    #[test]
311    /// Ensures all transport schema object properties are listed in
312    /// `required`.
313    fn test_agent_response_output_schema_all_properties_are_required() {
314        // Arrange / Act
315        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
316
317        // Assert
318        assert!(
319            all_properties_in_required(&schema),
320            "every object with `properties` should list all keys in `required`"
321        );
322    }
323
324    #[test]
325    /// Ensures the minimum-key policy demands only `answer`, so validators that
326    /// enforce `required` literally still accept replies that omit optional
327    /// protocol keys.
328    fn test_agent_response_output_schema_minimum_policy_requires_only_answer() {
329        // Arrange / Act
330        let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
331        let required_fields = schema
332            .get("required")
333            .and_then(Value::as_array)
334            .expect("schema required fields should exist");
335
336        // Assert
337        assert_eq!(
338            required_fields,
339            &vec![Value::String("answer".to_string())],
340            "only `answer` should be required; demanding optional response fields rejects \
341             ordinary replies that omit them"
342        );
343    }
344
345    #[test]
346    /// Ensures generated schema avoids `oneOf` so Codex `outputSchema`
347    /// validation accepts the payload.
348    fn test_agent_response_output_schema_does_not_contain_one_of() {
349        // Arrange / Act
350        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
351
352        // Assert
353        assert!(!contains_schema_key(&schema, "oneOf"));
354    }
355
356    #[test]
357    /// Ensures generated transport schemas omit `$schema` metadata so Claude
358    /// native schema validation does not need a bundled meta-schema resolver.
359    fn test_agent_response_output_schema_does_not_contain_schema_metadata() {
360        // Arrange / Act
361        let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
362
363        // Assert
364        assert!(!contains_schema_key(&schema, "$schema"));
365    }
366
367    #[test]
368    /// Ensures the prompt schema requires `answer` so empty objects are
369    /// rejected by schema validation the same way the parser rejects them.
370    fn test_agent_response_json_schema_requires_answer_key() {
371        // Arrange / Act
372        let schema = agent_response_json_schema();
373        let required_fields = schema
374            .get("required")
375            .and_then(Value::as_array)
376            .expect("schema required fields should exist");
377
378        // Assert
379        assert!(
380            required_fields
381                .iter()
382                .any(|value| value.as_str() == Some("answer")),
383            "prompt schema should require `answer` to align with parser key-presence check"
384        );
385    }
386
387    #[test]
388    /// Ensures every schema object with `properties` declares
389    /// `additionalProperties: false` so prompt guidance and transport
390    /// enforcement tell models not to add extra fields.
391    fn test_agent_response_json_schema_sets_additional_properties_false() {
392        // Arrange / Act
393        let schema = agent_response_json_schema();
394
395        // Assert
396        assert!(
397            all_properties_objects_deny_additional(&schema),
398            "every object with `properties` should set `additionalProperties: false`"
399        );
400    }
401
402    #[test]
403    /// `inject_additional_properties_false` preserves a pre-existing
404    /// `additionalProperties` value instead of overwriting it.
405    fn test_inject_additional_properties_false_preserves_existing_value() {
406        // Arrange
407        let mut schema = serde_json::json!({
408            "type": "object",
409            "properties": {
410                "extra": { "type": "object", "additionalProperties": { "type": "string" } }
411            }
412        });
413
414        // Act
415        inject_additional_properties_false(&mut schema);
416
417        // Assert - top-level gets injected (was absent)
418        assert_eq!(schema["additionalProperties"], Value::Bool(false));
419        // Assert - nested keeps its original map-type constraint (was present)
420        assert_eq!(
421            schema["properties"]["extra"]["additionalProperties"],
422            serde_json::json!({ "type": "string" })
423        );
424    }
425
426    #[test]
427    /// Ensures no schema object uses `$ref` with sibling keys.
428    fn test_agent_response_output_schema_ref_objects_have_no_sibling_keywords() {
429        // Arrange / Act
430        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
431
432        // Assert
433        assert!(!contains_ref_with_sibling_keywords(&schema));
434    }
435
436    #[test]
437    /// Exposes a parseable pretty JSON schema string for prompt templating.
438    fn test_agent_response_json_schema_json_is_parseable_value() {
439        // Arrange / Act
440        let schema_json = agent_response_json_schema_json();
441        let parsed_schema: Value =
442            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
443        let schema_value = agent_response_json_schema();
444
445        // Assert
446        assert_eq!(parsed_schema, schema_value);
447    }
448
449    #[test]
450    /// Keeps response schemas self-descriptive so inline schema docs include
451    /// explicit top-level `schemars` metadata.
452    fn test_agent_response_json_schema_preserves_explicit_payload_metadata() {
453        // Arrange / Act
454        let schema = agent_response_json_schema();
455
456        // Assert
457        assert_eq!(
458            schema.get("title").and_then(Value::as_str),
459            Some("AgentResponse")
460        );
461        assert_eq!(
462            schema.get("description").and_then(Value::as_str),
463            Some(
464                "Wire-format protocol payload used for schema-driven provider output. Return this \
465                 object as the entire assistant response payload. Providers that support output \
466                 schemas (for example, Codex app-server) are asked to emit this object directly."
467            )
468        );
469    }
470
471    #[test]
472    /// Keeps nested response-schema models self-descriptive for inline docs.
473    fn test_agent_response_json_schema_preserves_nested_metadata() {
474        // Arrange / Act
475        let schema = agent_response_json_schema();
476        let question_definition = schema
477            .get("$defs")
478            .and_then(|value| value.get("QuestionItem"))
479            .and_then(Value::as_object)
480            .expect("question definition should exist");
481        let review_comment_outcome_definition = schema
482            .get("$defs")
483            .and_then(|value| value.get("ReviewCommentOutcome"))
484            .and_then(Value::as_object)
485            .expect("review comment outcome definition should exist");
486        let review_comment_resolution_definition = schema
487            .get("$defs")
488            .and_then(|value| value.get("ReviewCommentResolution"))
489            .and_then(Value::as_object)
490            .expect("review comment resolution definition should exist");
491        let summary_definition = schema
492            .get("$defs")
493            .and_then(|value| value.get("AgentResponseSummary"))
494            .and_then(Value::as_object)
495            .expect("summary definition should exist");
496
497        // Assert
498        assert_eq!(
499            question_definition.get("title").and_then(Value::as_str),
500            Some("QuestionItem")
501        );
502        assert_eq!(
503            review_comment_outcome_definition
504                .get("title")
505                .and_then(Value::as_str),
506            Some("ReviewCommentOutcome")
507        );
508        assert_eq!(
509            review_comment_resolution_definition
510                .get("title")
511                .and_then(Value::as_str),
512            Some("ReviewCommentResolution")
513        );
514        assert_eq!(
515            summary_definition.get("title").and_then(Value::as_str),
516            Some("AgentResponseSummary")
517        );
518    }
519
520    #[test]
521    /// Keeps response-schema fields self-descriptive for inline schema docs.
522    fn test_agent_response_json_schema_preserves_field_metadata() {
523        // Arrange / Act
524        let schema = agent_response_json_schema();
525        let response_properties = schema
526            .get("properties")
527            .and_then(Value::as_object)
528            .expect("response properties should exist");
529        let question_properties = schema_definition_properties(&schema, "QuestionItem");
530        let review_comment_outcome_properties =
531            schema_definition_properties(&schema, "ReviewCommentOutcome");
532        let summary_properties = schema_definition_properties(&schema, "AgentResponseSummary");
533        let expected_questions_description = questions_field_description();
534
535        // Assert
536        assert_schema_property_title_and_description(
537            response_properties,
538            "answer",
539            "answer",
540            "Markdown answer text for delivered work, status updates, or concise completion \
541             notes. Keep clarification requests out of this field and emit them through \
542             `questions` instead.",
543        );
544        assert_eq!(
545            response_properties
546                .get("questions")
547                .and_then(|value| value.get("description"))
548                .and_then(Value::as_str),
549            Some(expected_questions_description.as_str())
550        );
551        assert_schema_property_title_and_description(
552            response_properties,
553            "review_comment_outcomes",
554            "review_comment_outcomes",
555            "Per-thread outcomes for an agent-driven forge comment-resolution turn. Emit an empty \
556             array unless the prompt explicitly supplies forge thread IDs. Copy each reported \
557             `thread_id` exactly from the prompt.",
558        );
559        assert_schema_property_title_and_description(
560            response_properties,
561            "summary",
562            "summary",
563            "Structured summary for session-discussion turns, kept outside `answer` markdown. Use \
564             `null` for one-shot prompts and legacy payloads.",
565        );
566        assert_schema_property_title_and_description(
567            question_properties,
568            "text",
569            "text",
570            "Human-readable markdown text for this question. Ask one specific actionable question \
571             instead of bundling multiple decisions into one item.",
572        );
573        assert_schema_property_title(question_properties, "options", "options");
574        assert_schema_property_title_and_description(
575            review_comment_outcome_properties,
576            "reply",
577            "reply",
578            "Concise reply suitable for posting to the forge review thread.",
579        );
580        assert_schema_property_title_and_description(
581            review_comment_outcome_properties,
582            "resolution",
583            "resolution",
584            "Whether the targeted thread was fixed or required no change.",
585        );
586        assert_schema_property_title_and_description(
587            review_comment_outcome_properties,
588            "thread_id",
589            "thread_id",
590            "Opaque forge thread identifier copied exactly from the turn prompt.",
591        );
592        assert_schema_property_title_and_description(
593            summary_properties,
594            "turn",
595            "turn",
596            "Concise summary of only the work completed in the current turn.",
597        );
598        assert_schema_property_title_and_description(
599            summary_properties,
600            "session",
601            "session",
602            "Cumulative summary of active changes on the current session branch.",
603        );
604    }
605
606    #[test]
607    /// Preserves optional prompt fields in the raw schema instead of forcing
608    /// transport-only requirements into prompt docs.
609    fn test_agent_response_json_schema_keeps_optional_summary_field() {
610        // Arrange / Act
611        let schema = agent_response_json_schema();
612        let response_required_fields = schema
613            .get("required")
614            .and_then(Value::as_array)
615            .cloned()
616            .unwrap_or_default();
617        let question_definition = schema
618            .get("$defs")
619            .and_then(|value| value.get("QuestionItem"))
620            .and_then(Value::as_object)
621            .expect("question definition should exist");
622        let question_required_fields = question_definition
623            .get("required")
624            .and_then(Value::as_array)
625            .cloned()
626            .unwrap_or_default();
627
628        // Assert
629        assert!(
630            response_required_fields
631                .iter()
632                .all(|field| field.as_str() != Some("summary")),
633            "raw prompt schema should keep optional summary fields optional"
634        );
635        assert!(
636            question_required_fields
637                .iter()
638                .all(|field| field.as_str() != Some("options")),
639            "question schema should keep `options` optional for omitted empty lists"
640        );
641    }
642
643    #[test]
644    /// Exposes a parseable pretty JSON schema string for transport-level
645    /// schema enforcement.
646    fn test_agent_response_output_schema_json_is_parseable_value() {
647        // Arrange / Act
648        let schema_json =
649            agent_response_output_schema_json(SchemaRequiredPolicy::MinimumProtocolKeys);
650        let parsed_schema: Value =
651            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
652        let schema_value = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
653
654        // Assert
655        assert_eq!(parsed_schema, schema_value);
656    }
657
658    /// Recursively checks whether one JSON value tree contains a schema key.
659    fn contains_schema_key(value: &Value, key: &str) -> bool {
660        match value {
661            Value::Object(object) => {
662                if object.contains_key(key) {
663                    return true;
664                }
665
666                object
667                    .values()
668                    .any(|nested_value| contains_schema_key(nested_value, key))
669            }
670            Value::Array(array) => array
671                .iter()
672                .any(|nested_value| contains_schema_key(nested_value, key)),
673            _ => false,
674        }
675    }
676
677    /// Recursively checks whether any `$ref` object has extra sibling keys.
678    fn contains_ref_with_sibling_keywords(value: &Value) -> bool {
679        match value {
680            Value::Object(object) => {
681                if object.contains_key("$ref") && object.len() > 1 {
682                    return true;
683                }
684
685                object.values().any(contains_ref_with_sibling_keywords)
686            }
687            Value::Array(array) => array.iter().any(contains_ref_with_sibling_keywords),
688            _ => false,
689        }
690    }
691
692    /// Recursively checks that every object with `properties` lists all
693    /// property keys in `required`.
694    fn all_properties_in_required(value: &Value) -> bool {
695        match value {
696            Value::Object(object) => {
697                if let Some(properties) = object.get("properties").and_then(Value::as_object) {
698                    let required_keys: Vec<&str> = object
699                        .get("required")
700                        .and_then(Value::as_array)
701                        .map(|array| array.iter().filter_map(Value::as_str).collect())
702                        .unwrap_or_default();
703
704                    for key in properties.keys() {
705                        if !required_keys.contains(&key.as_str()) {
706                            return false;
707                        }
708                    }
709                }
710
711                object.values().all(all_properties_in_required)
712            }
713            Value::Array(array) => array.iter().all(all_properties_in_required),
714            _ => true,
715        }
716    }
717
718    /// Recursively checks that every object with `properties` sets
719    /// `additionalProperties: false`.
720    fn all_properties_objects_deny_additional(value: &Value) -> bool {
721        match value {
722            Value::Object(object) => {
723                if object.contains_key("properties")
724                    && object.get("additionalProperties") != Some(&Value::Bool(false))
725                {
726                    return false;
727                }
728
729                object.values().all(all_properties_objects_deny_additional)
730            }
731            Value::Array(array) => array.iter().all(all_properties_objects_deny_additional),
732            _ => true,
733        }
734    }
735
736    /// Returns the properties object for one named schema definition.
737    fn schema_definition_properties<'a>(
738        schema: &'a Value,
739        definition_name: &str,
740    ) -> &'a serde_json::Map<String, Value> {
741        schema
742            .get("$defs")
743            .and_then(|value| value.get(definition_name))
744            .and_then(|value| value.get("properties"))
745            .and_then(Value::as_object)
746            .expect("schema definition properties should exist")
747    }
748
749    /// Asserts one property schema has the expected `title`.
750    fn assert_schema_property_title(
751        properties: &serde_json::Map<String, Value>,
752        property_name: &str,
753        expected_title: &str,
754    ) {
755        assert_eq!(
756            properties
757                .get(property_name)
758                .and_then(|value| value.get("title"))
759                .and_then(Value::as_str),
760            Some(expected_title)
761        );
762    }
763
764    /// Asserts one property schema has the expected `title` and
765    /// `description`.
766    fn assert_schema_property_title_and_description(
767        properties: &serde_json::Map<String, Value>,
768        property_name: &str,
769        expected_title: &str,
770        expected_description: &str,
771    ) {
772        assert_schema_property_title(properties, property_name, expected_title);
773        assert_eq!(
774            properties
775                .get(property_name)
776                .and_then(|value| value.get("description"))
777                .and_then(Value::as_str),
778            Some(expected_description)
779        );
780    }
781}