1use 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
32pub const COMPONENTS_SCHEMAS_PREFIX: &str = "#/components/schemas/";
37
38const DEFS_PREFIX: &str = "#/$defs/";
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
56pub struct SchemaComponents {
57 pub schemas: BTreeMap<String, Value>,
59}
60
61impl SchemaComponents {
62 pub fn collect(endpoints: &[EndpointSchema], registry: &TypeRegistry) -> Result<Self> {
68 let mut defs = BTreeMap::new();
69
70 for endpoint in endpoints {
71 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 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 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 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
144fn 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
186pub 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
218const RECOGNISED_META_KEYS: &[&str] = &[
224 "example",
226 "examples",
227 "deprecated",
228 "tags",
229 "minimum",
231 "maximum",
232 "minLength",
233 "maxLength",
234 "pattern",
235 "enum",
236];
237
238pub 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
272pub 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
295pub 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
309pub 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 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, ®istry).unwrap();
424
425 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, ®istry).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 let components = SchemaComponents::collect(&endpoints, ®istry).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, ®istry).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, ®istry)
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), ®istry).unwrap();
528
529 let response = components.response_schema(&endpoint, ®istry).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, ®istry).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), ®istry).unwrap();
553
554 let request = components.request_schema(&endpoint, ®istry).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 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 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), ®istry).unwrap();
602
603 let request = components.request_schema(&endpoint, ®istry).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 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), ®istry).unwrap();
619
620 let err = components
621 .response_schema(&endpoint, ®istry)
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 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(®istry).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}