Skip to main content

endpoint_libs/model/
api_document.rs

1//! Shared plumbing for emitting API description documents (OpenAPI 3.1,
2//! AsyncAPI 3.0) from the endpoint model.
3//!
4//! This module deliberately contains no OpenAPI- or AsyncAPI-specific shapes.
5//! It provides the three things both emitters need and neither should
6//! reimplement:
7//!
8//! 1. [`SchemaComponents`] — every referenced struct/enum collected once, at
9//!    document scope rather than per-endpoint.
10//! 2. [`relocate_refs`] — moving `#/$defs/X` to wherever a given document format
11//!    wants its shared definitions.
12//! 3. [`apply_meta`] — the `meta` passthrough, which is the sanctioned way to
13//!    get per-field and per-endpoint annotations into a document without
14//!    growing the model.
15//!
16//! It lives in endpoint-libs rather than endpointgen because a server needs the
17//! same registry walk at startup to answer MCP requests, and a future OpenRPC
18//! emitter would need it a third time.
19//!
20//! **The RON definitions are the source of truth.** Nothing here parses a
21//! document; these are outputs only.
22
23use std::collections::BTreeMap;
24
25use eyre::{Result, bail};
26use serde_json::{Map, Value};
27
28use crate::model::endpoint::EndpointSchema;
29use crate::model::json_schema::{TypeRegistry, fields_to_object_schema};
30use crate::model::types::MetaMap;
31
32/// Where a document format keeps its shared schema definitions.
33///
34/// JSON Schema puts them in `#/$defs`; both OpenAPI 3.1 and AsyncAPI 3.0 want
35/// `#/components/schemas`.
36pub const COMPONENTS_SCHEMAS_PREFIX: &str = "#/components/schemas/";
37
38/// The `$defs` prefix that [`crate::model::Type::to_json_schema`] emits.
39const DEFS_PREFIX: &str = "#/$defs/";
40
41/// Every struct and enum referenced by a set of endpoints, emitted once.
42///
43/// [`crate::model::Type::to_json_schema`] already produces JSON Schema 2020-12,
44/// and OpenAPI 3.1 is a superset of it, so these values drop into a document
45/// essentially verbatim. The only work is hoisting them to document scope:
46/// `to_mcp_input_schema`/`to_mcp_output_schema` deliberately build a *fresh*
47/// `defs` map per endpoint so each MCP tool schema is self-contained, which is
48/// exactly wrong for a document where operations should share `$ref`s.
49///
50/// Refs in [`SchemaComponents::schemas`] and in the per-endpoint schemas
51/// returned by [`SchemaComponents::request_schema`] /
52/// [`SchemaComponents::response_schema`] are already relocated to
53/// [`COMPONENTS_SCHEMAS_PREFIX`] — callers do not need to call
54/// [`relocate_refs`] themselves.
55#[derive(Debug, Default, Clone, PartialEq, Eq)]
56pub struct SchemaComponents {
57    /// Definition name → schema, e.g. `"User"` → `{ "type": "object", ... }`.
58    pub schemas: BTreeMap<String, Value>,
59}
60
61impl SchemaComponents {
62    /// Walks `endpoints`, collecting every referenced definition exactly once.
63    ///
64    /// Fails if any `StructRef`/`EnumRef`/`StructTable` is missing from
65    /// `registry` — a document with dangling `$ref`s is worse than no document,
66    /// because validators accept it and generators emit broken clients.
67    pub fn collect(endpoints: &[EndpointSchema], registry: &TypeRegistry) -> Result<Self> {
68        let mut defs = BTreeMap::new();
69
70        for endpoint in endpoints {
71            // Build and discard the object schemas; the point is the side
72            // effect on the shared `defs` map.
73            fields_to_object_schema(&endpoint.parameters, registry, &mut defs)
74                .map_err(|e| eyre::eyre!("endpoint {}: parameters: {e}", endpoint.name))?;
75            fields_to_object_schema(&endpoint.returns, registry, &mut defs)
76                .map_err(|e| eyre::eyre!("endpoint {}: returns: {e}", endpoint.name))?;
77
78            if let Some(stream) = &endpoint.stream_response {
79                stream
80                    .to_json_schema(registry, &mut defs)
81                    .map_err(|e| eyre::eyre!("endpoint {}: stream_response: {e}", endpoint.name))?;
82            }
83
84            for error in &endpoint.errors {
85                fields_to_object_schema(&error.fields, registry, &mut defs).map_err(|e| {
86                    eyre::eyre!("endpoint {}: error {}: {e}", endpoint.name, error.name)
87                })?;
88            }
89        }
90
91        for schema in defs.values_mut() {
92            relocate_refs(schema, COMPONENTS_SCHEMAS_PREFIX);
93        }
94
95        Ok(Self { schemas: defs })
96    }
97
98    /// Object schema over an endpoint's `parameters`, with refs pointing at
99    /// [`Self::schemas`]. Non-`Optional` parameters land in `required`.
100    pub fn request_schema(
101        &self,
102        endpoint: &EndpointSchema,
103        registry: &TypeRegistry,
104    ) -> Result<Value> {
105        self.object_schema(
106            &endpoint.parameters,
107            registry,
108            &format!("endpoint {} parameters", endpoint.name),
109        )
110    }
111
112    /// Object schema over an endpoint's `returns`. See [`Self::request_schema`].
113    pub fn response_schema(
114        &self,
115        endpoint: &EndpointSchema,
116        registry: &TypeRegistry,
117    ) -> Result<Value> {
118        self.object_schema(
119            &endpoint.returns,
120            registry,
121            &format!("endpoint {} returns", endpoint.name),
122        )
123    }
124
125    /// Builds an object schema whose `$ref`s point into the shared components.
126    ///
127    /// The throwaway `defs` map here is not a leak: every definition it would
128    /// collect is already in [`Self::schemas`], because `collect` walked the
129    /// same fields. Only the returned root object is used.
130    fn object_schema(
131        &self,
132        fields: &[crate::model::types::Field],
133        registry: &TypeRegistry,
134        context: &str,
135    ) -> Result<Value> {
136        let mut throwaway = BTreeMap::new();
137        let mut schema = fields_to_object_schema(fields, registry, &mut throwaway)?;
138        relocate_refs(&mut schema, COMPONENTS_SCHEMAS_PREFIX);
139        apply_field_meta(&mut schema, fields, context)?;
140        Ok(schema)
141    }
142}
143
144/// Applies each field's `meta` to its property in an object schema.
145///
146/// Deliberately done here rather than inside `fields_to_object_schema`: that
147/// helper also builds MCP tool schemas, and those must stay byte-identical.
148/// Field annotations are a document concern.
149///
150/// A field whose schema is a bare `$ref` is a hard error rather than a silent
151/// no-op: sibling keys next to a `$ref` are ignored by most JSON Schema 2020-12
152/// tooling, so emitting them would produce a document that looks annotated and
153/// is not. Annotate the referenced definition instead.
154fn apply_field_meta(
155    schema: &mut Value,
156    fields: &[crate::model::types::Field],
157    context: &str,
158) -> Result<()> {
159    use convert_case::{Case, Casing};
160
161    for field in fields {
162        if field.meta.is_empty() {
163            continue;
164        }
165        let key = field.name.to_case(Case::Camel);
166        let Some(property) = schema.get_mut("properties").and_then(|p| p.get_mut(&key)) else {
167            continue;
168        };
169        if property.get("$ref").is_some() {
170            bail!(
171                "{context}: field `{}` carries meta but its schema is a bare $ref; \
172                 annotate the referenced definition instead — sibling keys next to \
173                 $ref are ignored by JSON Schema 2020-12 tooling",
174                field.name
175            );
176        }
177        apply_meta(
178            property,
179            &field.meta,
180            &format!("{context}: field `{}`", field.name),
181        )?;
182    }
183    Ok(())
184}
185
186/// Rewrites `#/$defs/X` to `{prefix}X` throughout `value`, in place.
187///
188/// Recurses through objects and arrays, and only touches string values under a
189/// `$ref` key — a `description` that happens to mention `#/$defs/` is left
190/// alone. Refs that do not start with `#/$defs/` (absolute URLs, refs already
191/// relocated) are untouched, which makes this idempotent.
192///
193/// Termination: this walks the finished `Value` tree, not the type graph.
194/// `to_json_schema` reserves a `$defs` slot before recursing into struct
195/// fields, so recursive types produce a finite tree containing a `$ref` back to
196/// themselves, not an infinite one.
197pub fn relocate_refs(value: &mut Value, prefix: &str) {
198    match value {
199        Value::Object(map) => {
200            if let Some(Value::String(target)) = map.get_mut("$ref")
201                && let Some(name) = target.strip_prefix(DEFS_PREFIX)
202            {
203                *target = format!("{prefix}{name}");
204            }
205            for child in map.values_mut() {
206                relocate_refs(child, prefix);
207            }
208        }
209        Value::Array(items) => {
210            for item in items {
211                relocate_refs(item, prefix);
212            }
213        }
214        _ => {}
215    }
216}
217
218/// Non-`x-` `meta` keys that map onto a schema or operation object verbatim.
219///
220/// Everything else without an `x-` prefix is rejected: a typo'd `exmaple` that
221/// silently vanishes is how these documents rot, and the failure is invisible
222/// until someone reads the generated spec and believes it.
223const RECOGNISED_META_KEYS: &[&str] = &[
224    // Annotation keywords
225    "example",
226    "examples",
227    "deprecated",
228    "tags",
229    // JSON Schema constraint keywords
230    "minimum",
231    "maximum",
232    "minLength",
233    "maxLength",
234    "pattern",
235    "enum",
236];
237
238/// Copies `meta` onto `target`, which must be a JSON object.
239///
240/// - Keys starting with `x-` are copied verbatim (both formats allow arbitrary
241///   specification extensions).
242/// - Keys in [`RECOGNISED_META_KEYS`] are copied verbatim.
243/// - Anything else is a hard error naming `context`, so a mistake surfaces at
244///   generation time rather than as a quietly missing field.
245///
246/// Existing keys on `target` are overwritten: `meta` is an explicit author
247/// annotation and should win over an inferred default.
248pub fn apply_meta(target: &mut Value, meta: &MetaMap, context: &str) -> Result<()> {
249    if meta.is_empty() {
250        return Ok(());
251    }
252
253    let Value::Object(map) = target else {
254        bail!("{context}: cannot apply meta to a non-object JSON value");
255    };
256
257    for (key, value) in &meta.0 {
258        if key.starts_with("x-") || RECOGNISED_META_KEYS.contains(&key.as_str()) {
259            map.insert(key.clone(), value.clone());
260            continue;
261        }
262        bail!(
263            "{context}: unrecognised meta key `{key}`. Prefix it with `x-` to emit it as a \
264             specification extension, or use one of the mapped keys: {}",
265            RECOGNISED_META_KEYS.join(", ")
266        );
267    }
268
269    Ok(())
270}
271
272/// Collects every `$ref` target string in `value`.
273///
274/// Used by emitter tests to prove that a finished document has no dangling
275/// references and no surviving `$defs`.
276pub fn collect_refs(value: &Value, out: &mut Vec<String>) {
277    match value {
278        Value::Object(map) => {
279            if let Some(Value::String(target)) = map.get("$ref") {
280                out.push(target.clone());
281            }
282            for child in map.values() {
283                collect_refs(child, out);
284            }
285        }
286        Value::Array(items) => {
287            for item in items {
288                collect_refs(item, out);
289            }
290        }
291        _ => {}
292    }
293}
294
295/// Object schema over a `Vec<Field>` with refs already relocated.
296///
297/// Convenience for emitters building envelope payloads (error objects, message
298/// wrappers) that are not an endpoint's own parameters or returns.
299pub fn object_schema_for_fields(
300    fields: &[crate::model::types::Field],
301    registry: &TypeRegistry,
302) -> Result<Value> {
303    let mut defs = BTreeMap::new();
304    let mut schema = fields_to_object_schema(fields, registry, &mut defs)?;
305    relocate_refs(&mut schema, COMPONENTS_SCHEMAS_PREFIX);
306    Ok(schema)
307}
308
309/// Builds a JSON object from `(key, value)` pairs, skipping `None` values.
310///
311/// Emitters assemble a lot of optional fields; this keeps that readable without
312/// a builder type.
313pub fn json_object(entries: impl IntoIterator<Item = (String, Option<Value>)>) -> Value {
314    let mut map = Map::new();
315    for (key, value) in entries {
316        if let Some(value) = value {
317            map.insert(key, value);
318        }
319    }
320    Value::Object(map)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::model::types::{EnumVariant, Field, Type};
327    use serde_json::json;
328
329    fn registry_with(types: &[Type]) -> TypeRegistry {
330        let mut registry = TypeRegistry::new();
331        registry.add_all(types.iter());
332        registry
333    }
334
335    fn user_struct() -> Type {
336        Type::struct_(
337            "User",
338            vec![
339                Field::new("id", Type::Int64),
340                Field::new("name", Type::String),
341            ],
342        )
343    }
344
345    /// A struct that contains itself, to prove the rewrite terminates.
346    fn recursive_node() -> Type {
347        Type::struct_(
348            "Node",
349            vec![
350                Field::new("value", Type::String),
351                Field::new(
352                    "children",
353                    Type::Vec(Box::new(Type::StructRef("Node".into()))),
354                ),
355            ],
356        )
357    }
358
359    #[test]
360    fn relocate_refs_rewrites_nested_and_array_positions() {
361        let mut value = json!({
362            "type": "object",
363            "properties": {
364                "items": {
365                    "type": "array",
366                    "items": { "anyOf": [ { "$ref": "#/$defs/User" }, { "type": "null" } ] }
367                }
368            }
369        });
370
371        relocate_refs(&mut value, COMPONENTS_SCHEMAS_PREFIX);
372
373        let target = &value["properties"]["items"]["items"]["anyOf"][0]["$ref"];
374        assert_eq!(target, "#/components/schemas/User");
375    }
376
377    #[test]
378    fn relocate_refs_leaves_non_defs_refs_and_is_idempotent() {
379        let mut value = json!({
380            "a": { "$ref": "#/components/schemas/Already" },
381            "b": { "$ref": "https://example.com/schema.json" },
382            "c": { "$ref": "#/$defs/Moves" },
383        });
384
385        relocate_refs(&mut value, COMPONENTS_SCHEMAS_PREFIX);
386        let once = value.clone();
387        relocate_refs(&mut value, COMPONENTS_SCHEMAS_PREFIX);
388
389        assert_eq!(value, once, "relocation must be idempotent");
390        assert_eq!(value["a"]["$ref"], "#/components/schemas/Already");
391        assert_eq!(value["b"]["$ref"], "https://example.com/schema.json");
392        assert_eq!(value["c"]["$ref"], "#/components/schemas/Moves");
393    }
394
395    #[test]
396    fn relocate_refs_ignores_ref_like_strings_outside_ref_keys() {
397        let mut value = json!({ "description": "see #/$defs/User for details" });
398        relocate_refs(&mut value, COMPONENTS_SCHEMAS_PREFIX);
399        assert_eq!(value["description"], "see #/$defs/User for details");
400    }
401
402    #[test]
403    fn collect_shares_definitions_across_endpoints() {
404        let registry = registry_with(&[user_struct()]);
405        let endpoints = vec![
406            EndpointSchema::new(
407                "GetUser",
408                1,
409                vec![Field::new("id", Type::Int64)],
410                vec![Field::new("user", Type::StructRef("User".into()))],
411            ),
412            EndpointSchema::new(
413                "ListUsers",
414                2,
415                vec![],
416                vec![Field::new(
417                    "users",
418                    Type::Vec(Box::new(Type::StructRef("User".into()))),
419                )],
420            ),
421        ];
422
423        let components = SchemaComponents::collect(&endpoints, &registry).unwrap();
424
425        // One shared definition, not one per endpoint.
426        assert_eq!(components.schemas.keys().collect::<Vec<_>>(), vec!["User"]);
427    }
428
429    #[test]
430    fn collect_relocates_refs_inside_components() {
431        let registry = registry_with(&[recursive_node()]);
432        let endpoints = vec![EndpointSchema::new(
433            "GetTree",
434            1,
435            vec![],
436            vec![Field::new("root", Type::StructRef("Node".into()))],
437        )];
438
439        let components = SchemaComponents::collect(&endpoints, &registry).unwrap();
440
441        let mut refs = vec![];
442        for schema in components.schemas.values() {
443            collect_refs(schema, &mut refs);
444        }
445        assert!(!refs.is_empty(), "recursive struct should self-reference");
446        for target in &refs {
447            assert!(
448                target.starts_with(COMPONENTS_SCHEMAS_PREFIX),
449                "ref {target} was not relocated"
450            );
451        }
452    }
453
454    #[test]
455    fn collect_terminates_on_recursive_structs() {
456        let registry = registry_with(&[recursive_node()]);
457        let endpoints = vec![EndpointSchema::new(
458            "GetTree",
459            1,
460            vec![],
461            vec![Field::new("root", Type::StructRef("Node".into()))],
462        )];
463
464        // The assertion is that this returns at all.
465        let components = SchemaComponents::collect(&endpoints, &registry).unwrap();
466        assert!(components.schemas.contains_key("Node"));
467    }
468
469    #[test]
470    fn collect_resolves_enum_refs() {
471        let role = Type::Enum {
472            name: "UserRole".into(),
473            variants: vec![
474                EnumVariant::new_with_description("Admin", "Platform admin".to_string(), 0),
475                EnumVariant::new_with_description("User", "Regular user".to_string(), 1),
476            ],
477        };
478        let registry = registry_with(&[role]);
479        let endpoints = vec![EndpointSchema::new(
480            "GetRole",
481            1,
482            vec![],
483            vec![Field::new(
484                "role",
485                Type::EnumRef {
486                    name: "UserRole".into(),
487                    prefixed_name: false,
488                },
489            )],
490        )];
491
492        let components = SchemaComponents::collect(&endpoints, &registry).unwrap();
493        assert!(
494            components.schemas.contains_key("UserRole"),
495            "got {:?}",
496            components.schemas.keys().collect::<Vec<_>>()
497        );
498    }
499
500    #[test]
501    fn collect_reports_the_endpoint_for_a_dangling_ref() {
502        let registry = TypeRegistry::new();
503        let endpoints = vec![EndpointSchema::new(
504            "GetGhost",
505            1,
506            vec![],
507            vec![Field::new("ghost", Type::StructRef("Missing".into()))],
508        )];
509
510        let err = SchemaComponents::collect(&endpoints, &registry)
511            .unwrap_err()
512            .to_string();
513        assert!(err.contains("GetGhost"), "{err}");
514        assert!(err.contains("Missing"), "{err}");
515    }
516
517    #[test]
518    fn request_and_response_schemas_reference_shared_components() {
519        let registry = registry_with(&[user_struct()]);
520        let endpoint = EndpointSchema::new(
521            "GetUser",
522            1,
523            vec![Field::new("id", Type::Int64)],
524            vec![Field::new("user", Type::StructRef("User".into()))],
525        );
526        let components =
527            SchemaComponents::collect(std::slice::from_ref(&endpoint), &registry).unwrap();
528
529        let response = components.response_schema(&endpoint, &registry).unwrap();
530
531        let mut refs = vec![];
532        collect_refs(&response, &mut refs);
533        assert_eq!(refs, vec!["#/components/schemas/User"]);
534
535        let request = components.request_schema(&endpoint, &registry).unwrap();
536        assert_eq!(request["required"], json!(["id"]));
537    }
538
539    #[test]
540    fn optional_fields_are_not_required() {
541        let registry = TypeRegistry::new();
542        let endpoint = EndpointSchema::new(
543            "Search",
544            1,
545            vec![
546                Field::new("query", Type::String),
547                Field::new("cursor", Type::Optional(Box::new(Type::String))),
548            ],
549            vec![],
550        );
551        let components =
552            SchemaComponents::collect(std::slice::from_ref(&endpoint), &registry).unwrap();
553
554        let request = components.request_schema(&endpoint, &registry).unwrap();
555        assert_eq!(request["required"], json!(["query"]));
556        assert!(request["properties"]["cursor"].is_object());
557    }
558
559    #[test]
560    fn apply_meta_copies_extensions_and_recognised_keys() {
561        let mut target = json!({ "type": "string" });
562        let mut meta = MetaMap::default();
563        meta.insert("x-internal-id", json!(42));
564        meta.insert("example", json!("hello"));
565        meta.insert("deprecated", json!(true));
566
567        apply_meta(&mut target, &meta, "Endpoint Foo").unwrap();
568
569        assert_eq!(target["x-internal-id"], 42);
570        assert_eq!(target["example"], "hello");
571        assert_eq!(target["deprecated"], true);
572        assert_eq!(target["type"], "string");
573    }
574
575    #[test]
576    fn apply_meta_rejects_unrecognised_keys_with_context() {
577        let mut target = json!({ "type": "string" });
578        let mut meta = MetaMap::default();
579        // A plausible typo of `example`.
580        meta.insert("exmaple", json!("hello"));
581
582        let err = apply_meta(&mut target, &meta, "endpoint GetUser field id")
583            .unwrap_err()
584            .to_string();
585
586        assert!(err.contains("endpoint GetUser field id"), "{err}");
587        assert!(err.contains("exmaple"), "{err}");
588        // The message should tell the author how to fix it.
589        assert!(err.contains("x-"), "{err}");
590    }
591
592    #[test]
593    fn field_meta_lands_on_the_property() {
594        let registry = TypeRegistry::new();
595        let mut field = Field::new("cursor", Type::String);
596        field.meta.insert("example", json!("abc123"));
597        field.meta.insert("x-opaque", json!(true));
598
599        let endpoint = EndpointSchema::new("Search", 1, vec![field], vec![]);
600        let components =
601            SchemaComponents::collect(std::slice::from_ref(&endpoint), &registry).unwrap();
602
603        let request = components.request_schema(&endpoint, &registry).unwrap();
604        assert_eq!(request["properties"]["cursor"]["example"], "abc123");
605        assert_eq!(request["properties"]["cursor"]["x-opaque"], true);
606    }
607
608    #[test]
609    fn field_meta_on_a_bare_ref_is_rejected() {
610        // Sibling keys next to $ref are ignored by tooling; failing loudly beats
611        // emitting a document that looks annotated but isn't.
612        let registry = registry_with(&[user_struct()]);
613        let mut field = Field::new("user", Type::StructRef("User".into()));
614        field.meta.insert("example", json!({ "id": 1 }));
615
616        let endpoint = EndpointSchema::new("GetUser", 1, vec![], vec![field]);
617        let components =
618            SchemaComponents::collect(std::slice::from_ref(&endpoint), &registry).unwrap();
619
620        let err = components
621            .response_schema(&endpoint, &registry)
622            .unwrap_err()
623            .to_string();
624        assert!(err.contains("GetUser"), "{err}");
625        assert!(err.contains("user"), "{err}");
626        assert!(err.contains("$ref"), "{err}");
627    }
628
629    #[test]
630    fn apply_meta_is_a_noop_when_empty() {
631        let mut target = json!({ "type": "string" });
632        let before = target.clone();
633        apply_meta(&mut target, &MetaMap::default(), "ctx").unwrap();
634        assert_eq!(target, before);
635    }
636
637    #[test]
638    fn mcp_schemas_stay_self_contained() {
639        // The document emitters share definitions; MCP tool schemas must not.
640        // Consumers rely on each tool schema standing alone, so this guards
641        // against a future refactor "unifying" the two paths.
642        let registry = registry_with(&[user_struct()]);
643        let endpoint = EndpointSchema::new(
644            "GetUser",
645            1,
646            vec![],
647            vec![Field::new("user", Type::StructRef("User".into()))],
648        );
649
650        let mcp = endpoint.to_mcp_output_schema(&registry).unwrap();
651
652        assert!(
653            mcp.get("$defs").is_some(),
654            "MCP output schema must carry its own $defs"
655        );
656        let mut refs = vec![];
657        collect_refs(&mcp, &mut refs);
658        assert!(
659            refs.iter().all(|r| r.starts_with("#/$defs/")),
660            "MCP schemas must keep $defs refs, got {refs:?}"
661        );
662    }
663}