1pub mod diff;
23mod sdl;
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use serde_json::{Value, json};
28
29use crate::dialect::{Document, Info, Operation, unknown_method};
30
31pub use sdl::to_sdl;
32
33const ROOTS: &[(&str, &str)] = &[
36 ("QUERY", "Query"),
37 ("MUTATION", "Mutation"),
38 ("SUBSCRIPTION", "Subscription"),
39];
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Position {
46 Input,
47 Output,
48}
49
50pub fn document(info: &Info, ops: &[Operation]) -> Document {
52 let mut doc = Document::default();
53 let components = merge_components(ops, &mut doc);
54 let mut builder = Builder {
55 components,
56 types: BTreeMap::new(),
57 notes: Vec::new(),
58 stack: BTreeSet::new(),
59 };
60
61 let mut roots: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
62 for op in ops {
63 let Some((_, root)) = ROOTS
64 .iter()
65 .find(|(m, _)| m.eq_ignore_ascii_case(&op.method))
66 else {
67 doc.conflicts.push(unknown_method(
68 op,
69 "GraphQL",
70 "QUERY, MUTATION, SUBSCRIPTION",
71 ));
72 continue;
73 };
74 let fields = roots.entry((*root).to_string()).or_default();
75 if fields.contains_key(&op.operation_id) {
76 doc.conflicts.push(format!(
77 "field `{}` is declared by more than one {root} operation",
78 op.operation_id
79 ));
80 continue;
81 }
82 let field = builder.field(op);
83 fields.insert(op.operation_id.clone(), field);
84 }
85
86 if !roots.is_empty() && !roots.contains_key("Query") {
88 builder.notes.push(
89 "no QUERY operations, but a GraphQL schema must declare a query \
90 root — emitted with a placeholder field"
91 .into(),
92 );
93 roots.insert(
94 "Query".into(),
95 [(
96 "_empty".to_string(),
97 json!({ "type": "Boolean",
98 "description": "Placeholder: this schema declares no queries." }),
99 )]
100 .into(),
101 );
102 }
103
104 doc.notes.extend(builder.notes);
105 doc.value = json!({
106 "info": { "title": info.title, "version": info.version },
107 "types": Value::Object(builder.types.into_iter().collect()),
108 "roots": Value::Object(
109 roots
110 .into_iter()
111 .map(|(k, v)| (k, Value::Object(v.into_iter().collect())))
112 .collect(),
113 ),
114 });
115 doc
116}
117
118fn merge_components(ops: &[Operation], doc: &mut Document) -> BTreeMap<String, Value> {
121 let mut merged: BTreeMap<String, Value> = BTreeMap::new();
122 for op in ops {
123 for (name, schema) in &op.shape.components {
124 match merged.get(name) {
125 Some(existing) if existing != schema => doc.conflicts.push(format!(
126 "type `{name}` has two different definitions (second seen \
127 via operation `{}`)",
128 op.operation_id
129 )),
130 _ => {
131 merged.insert(name.clone(), schema.clone());
132 }
133 }
134 }
135 }
136 merged
137}
138
139struct Builder {
141 components: BTreeMap<String, Value>,
142 types: BTreeMap<String, Value>,
143 notes: Vec<String>,
144 stack: BTreeSet<String>,
146}
147
148impl Builder {
149 fn field(&mut self, op: &Operation) -> Value {
151 let mut args: BTreeMap<String, Value> = BTreeMap::new();
152 for p in &op.shape.parameters {
153 let Some(name) = self.valid_name(&p.name, &op.operation_id) else {
154 continue;
155 };
156 let ty = self.type_ref(
157 &p.schema,
158 Position::Input,
159 &format!("{}{}", capitalize(&op.operation_id), capitalize(&p.name)),
160 p.required,
161 );
162 args.insert(name, json!({ "type": ty }));
163 }
164 if let Some(body) = &op.shape.request {
165 let ty = self.type_ref(
169 &body.schema,
170 Position::Input,
171 &format!("{}Input", capitalize(&op.operation_id)),
172 body.required,
173 );
174 args.insert("input".into(), json!({ "type": ty }));
175 }
176
177 let ty = match success_schema(op) {
178 Some(schema) => self.type_ref(
179 &schema,
180 Position::Output,
181 &format!("{}Result", capitalize(&op.operation_id)),
182 true,
183 ),
184 None => "Boolean!".into(),
187 };
188
189 let mut field = serde_json::Map::new();
190 field.insert("type".into(), json!(ty));
191 if let Some(s) = &op.summary {
192 field.insert("description".into(), json!(s));
193 }
194 if !args.is_empty() {
195 field.insert("args".into(), Value::Object(args.into_iter().collect()));
196 }
197 Value::Object(field)
198 }
199
200 fn type_ref(&mut self, schema: &Value, pos: Position, hint: &str, required: bool) -> String {
205 let base = self.base_type(schema, pos, hint);
206 if required { format!("{base}!") } else { base }
207 }
208
209 fn base_type(&mut self, schema: &Value, pos: Position, hint: &str) -> String {
210 if let Some(name) = component_name(schema) {
211 return self.declare_component(&name, pos);
212 }
213
214 if schema.get("oneOf").is_some() || schema.get("enum").is_some() {
217 return match self.enum_values(schema) {
218 Some(values) => self.declare_enum(hint, values, schema),
219 None => self.opaque(
220 hint,
221 "a union of shapes, which GraphQL can only express when \
222 every branch is a distinct object type and never in an \
223 input position",
224 ),
225 };
226 }
227 if schema.get("allOf").is_some() {
228 return self.opaque(
229 hint,
230 "a composed (flattened or internally tagged) object, whose \
231 field set is not statically known here",
232 );
233 }
234
235 match schema["type"].as_str() {
236 Some("string") => "String".into(),
237 Some("boolean") => "Boolean".into(),
238 Some("number") => "Float".into(),
239 Some("integer") => {
240 if schema["format"].as_str() == Some("int64") {
243 self.scalar("Int64", "64-bit integer, serialized as a JSON number.");
244 "Int64".into()
245 } else {
246 "Int".into()
247 }
248 }
249 Some("array") => {
250 let inner = self.base_type(&schema["items"], pos, &format!("{hint}Item"));
251 format!("[{inner}]")
255 }
256 Some("object") => {
257 if schema.get("properties").is_some() {
258 self.declare_object(hint, schema, pos)
259 } else {
260 self.opaque(hint, "a map with dynamic keys")
262 }
263 }
264 Some("null") => self.opaque(hint, "a unit value, which SDL cannot name"),
265 _ => self.opaque(hint, "an open schema (the extractor reported a gap here)"),
266 }
267 }
268
269 fn declare_component(&mut self, name: &str, pos: Position) -> String {
271 let Some(schema) = self.components.get(name).cloned() else {
272 return self.opaque(name, "a type with no definition in this crate");
273 };
274 if let Some(values) = self.enum_values(&schema) {
276 return self.declare_enum(name, values, &schema);
277 }
278 if schema["type"].as_str() == Some("object") && schema.get("properties").is_some() {
279 return self.declare_object(name, &schema, pos);
280 }
281 self.base_type(&schema.clone(), pos, name)
283 }
284
285 fn declare_object(&mut self, hint: &str, schema: &Value, pos: Position) -> String {
287 let name = match pos {
288 Position::Output => hint.to_string(),
289 Position::Input if hint.ends_with("Input") => hint.to_string(),
293 Position::Input => format!("{hint}Input"),
294 };
295 if self.types.contains_key(&name) || self.stack.contains(&name) {
296 return name;
297 }
298 self.stack.insert(name.clone());
299
300 let required = crate::compat::required_set(schema);
301 let mut fields: BTreeMap<String, Value> = BTreeMap::new();
302 for (prop, prop_schema) in schema["properties"].as_object().into_iter().flatten() {
303 let Some(field_name) = self.valid_name(prop, &name) else {
304 continue;
305 };
306 let ty = self.type_ref(
307 prop_schema,
308 pos,
309 &format!("{name}{}", capitalize(prop)),
310 required.contains(prop.as_str()),
311 );
312 let mut field = serde_json::Map::new();
313 field.insert("type".into(), json!(ty));
314 if let Some(d) = prop_schema["description"].as_str() {
315 field.insert("description".into(), json!(d));
316 }
317 fields.insert(field_name, Value::Object(field));
318 }
319
320 self.stack.remove(&name);
321 let mut decl = serde_json::Map::new();
322 decl.insert(
323 "kind".into(),
324 json!(match pos {
325 Position::Input => "input",
326 Position::Output => "type",
327 }),
328 );
329 if let Some(d) = schema["description"].as_str() {
330 decl.insert("description".into(), json!(d));
331 }
332 if fields.is_empty() {
333 return self.opaque(hint, "an object with no field GraphQL can name");
336 }
337 decl.insert("fields".into(), Value::Object(fields.into_iter().collect()));
338 self.types.insert(name.clone(), Value::Object(decl));
339 name
340 }
341
342 fn enum_values(&self, schema: &Value) -> Option<Vec<String>> {
348 if let Some(list) = schema["enum"].as_array() {
349 return list
350 .iter()
351 .map(|v| v.as_str().map(String::from))
352 .collect::<Option<Vec<_>>>()
353 .filter(|v| !v.is_empty());
354 }
355 let branches = schema["oneOf"].as_array()?;
356 if branches.is_empty() {
357 return None;
358 }
359 branches
360 .iter()
361 .map(|b| b.get("const").and_then(|c| c.as_str()).map(String::from))
362 .collect::<Option<Vec<_>>>()
363 }
364
365 fn declare_enum(&mut self, hint: &str, values: Vec<String>, schema: &Value) -> String {
366 let unusable: Vec<&String> = values.iter().filter(|v| !is_valid_name(v)).collect();
369 if !unusable.is_empty() {
370 let listed: Vec<&str> = unusable.iter().map(|s| s.as_str()).collect();
371 self.notes.push(format!(
372 "`{hint}`: enum value(s) {} are not valid GraphQL names, so the \
373 enumeration is emitted as `String` — renaming them in serde \
374 would let it cross",
375 listed.join(", ")
376 ));
377 return "String".into();
378 }
379 if !self.types.contains_key(hint) {
380 let mut decl = serde_json::Map::new();
381 decl.insert("kind".into(), json!("enum"));
382 if let Some(d) = schema["description"].as_str() {
383 decl.insert("description".into(), json!(d));
384 }
385 decl.insert("values".into(), json!(values));
386 self.types.insert(hint.to_string(), Value::Object(decl));
387 }
388 hint.to_string()
389 }
390
391 fn opaque(&mut self, at: &str, why: &str) -> String {
393 self.scalar(
394 "JSON",
395 "Arbitrary JSON, for shapes the GraphQL type system cannot name.",
396 );
397 self.notes
398 .push(format!("`{at}` is {why}; emitted as the `JSON` scalar"));
399 "JSON".into()
400 }
401
402 fn scalar(&mut self, name: &str, description: &str) {
403 self.types
404 .entry(name.to_string())
405 .or_insert_with(|| json!({ "kind": "scalar", "description": description }));
406 }
407
408 fn valid_name(&mut self, name: &str, at: &str) -> Option<String> {
410 if is_valid_name(name) {
411 return Some(name.to_string());
412 }
413 self.notes.push(format!(
414 "`{at}`: field `{name}` is not a valid GraphQL name and was \
415 dropped — a `#[serde(rename)]` would let it cross"
416 ));
417 None
418 }
419}
420
421fn success_schema(op: &Operation) -> Option<Value> {
423 op.shape
424 .responses
425 .iter()
426 .filter(|(code, _)| code.starts_with('2'))
427 .min_by_key(|(code, _)| code.parse::<u16>().unwrap_or(u16::MAX))
428 .map(|(_, r)| r.schema.clone())
429 .filter(|s| !s.is_null() && s.as_object().is_some_and(|o| !o.is_empty()))
430}
431
432fn component_name(schema: &Value) -> Option<String> {
433 schema
434 .get("$ref")?
435 .as_str()?
436 .strip_prefix("#/components/schemas/")
437 .map(String::from)
438}
439
440fn is_valid_name(name: &str) -> bool {
443 if name.starts_with("__") {
444 return false;
445 }
446 let mut chars = name.chars();
447 match chars.next() {
448 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
449 _ => return false,
450 }
451 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
452}
453
454fn capitalize(word: &str) -> String {
455 let mut chars = word.chars();
456 match chars.next() {
457 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
458 None => String::new(),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::schema::route_sig::{Parameter, RequestBody, Response, RouteShape};
466
467 fn info() -> Info {
468 Info {
469 title: "Items".into(),
470 version: "1.0".into(),
471 description: None,
472 servers: Vec::new(),
473 }
474 }
475
476 fn op(id: &str, method: &str, shape: RouteShape) -> Operation {
477 Operation {
478 operation_id: id.into(),
479 method: method.into(),
480 path: id.into(),
481 summary: None,
482 shape,
483 }
484 }
485
486 fn returns(schema: Value) -> RouteShape {
487 let mut shape = RouteShape::default();
488 shape.responses.insert("200".into(), Response::json(schema));
489 shape
490 }
491
492 fn object(props: Value, required: &[&str]) -> Value {
493 json!({ "type": "object", "properties": props, "required": required })
494 }
495
496 fn item_shape() -> RouteShape {
497 let mut shape = returns(json!({ "$ref": "#/components/schemas/Item" }));
498 shape.components.insert(
499 "Item".into(),
500 object(
501 json!({ "id": { "type": "string" }, "note": { "type": "string" } }),
502 &["id"],
503 ),
504 );
505 shape
506 }
507
508 #[test]
509 fn a_query_field_carries_its_return_type() {
510 let d = document(&info(), &[op("item", "QUERY", item_shape())]);
511 assert_eq!(d.value["roots"]["Query"]["item"]["type"], "Item!");
512 assert_eq!(d.value["types"]["Item"]["kind"], "type");
513 assert!(d.conflicts.is_empty(), "got {:?}", d.conflicts);
514 }
515
516 #[test]
517 fn requiredness_becomes_field_nullability() {
518 let d = document(&info(), &[op("item", "QUERY", item_shape())]);
519 let fields = &d.value["types"]["Item"]["fields"];
520 assert_eq!(fields["id"]["type"], "String!", "required is non-null");
521 assert_eq!(fields["note"]["type"], "String", "optional is nullable");
522 }
523
524 #[test]
525 fn parameters_become_field_arguments() {
526 let mut shape = item_shape();
527 shape.parameters.push(Parameter {
528 name: "id".into(),
529 location: "path".into(),
530 required: true,
531 schema: json!({ "type": "integer" }),
532 });
533 shape.parameters.push(Parameter {
534 name: "verbose".into(),
535 location: "query".into(),
536 required: false,
537 schema: json!({ "type": "boolean" }),
538 });
539 let d = document(&info(), &[op("item", "QUERY", shape)]);
540 let args = &d.value["roots"]["Query"]["item"]["args"];
541 assert_eq!(args["id"]["type"], "Int!");
542 assert_eq!(args["verbose"]["type"], "Boolean");
543 }
544
545 #[test]
546 fn a_request_body_becomes_one_input_argument() {
547 let mut shape = returns(json!({ "$ref": "#/components/schemas/Item" }));
548 shape.components.insert(
549 "Item".into(),
550 object(json!({ "id": { "type": "string" } }), &["id"]),
551 );
552 shape.components.insert(
553 "NewItem".into(),
554 object(json!({ "name": { "type": "string" } }), &["name"]),
555 );
556 shape.request = Some(RequestBody {
557 content_type: "application/json".into(),
558 schema: json!({ "$ref": "#/components/schemas/NewItem" }),
559 required: true,
560 });
561 let d = document(&info(), &[op("createItem", "MUTATION", shape)]);
562 let args = &d.value["roots"]["Mutation"]["createItem"]["args"];
563 assert_eq!(args["input"]["type"], "NewItemInput!");
564 assert_eq!(d.value["types"]["NewItemInput"]["kind"], "input");
565 }
566
567 #[test]
568 fn one_struct_used_both_ways_becomes_two_declarations() {
569 let mut shape = returns(json!({ "$ref": "#/components/schemas/Item" }));
570 shape.components.insert(
571 "Item".into(),
572 object(json!({ "id": { "type": "string" } }), &["id"]),
573 );
574 shape.request = Some(RequestBody {
575 content_type: "application/json".into(),
576 schema: json!({ "$ref": "#/components/schemas/Item" }),
577 required: true,
578 });
579 let d = document(&info(), &[op("echoItem", "MUTATION", shape)]);
580 assert_eq!(d.value["types"]["Item"]["kind"], "type");
581 assert_eq!(d.value["types"]["ItemInput"]["kind"], "input");
582 }
583
584 #[test]
585 fn a_unit_variant_enum_crosses_as_a_graphql_enum() {
586 let mut shape = returns(json!({ "$ref": "#/components/schemas/Status" }));
587 shape.components.insert(
588 "Status".into(),
589 json!({ "oneOf": [ { "const": "ACTIVE" }, { "const": "ARCHIVED" } ] }),
590 );
591 let d = document(&info(), &[op("status", "QUERY", shape)]);
592 assert_eq!(d.value["types"]["Status"]["kind"], "enum");
593 assert_eq!(
594 d.value["types"]["Status"]["values"],
595 json!(["ACTIVE", "ARCHIVED"])
596 );
597 assert_eq!(d.value["roots"]["Query"]["status"]["type"], "Status!");
598 }
599
600 #[test]
601 fn an_enum_whose_values_are_not_identifiers_falls_back_and_says_so() {
602 let mut shape = returns(json!({ "$ref": "#/components/schemas/Status" }));
603 shape.components.insert(
604 "Status".into(),
605 json!({ "oneOf": [ { "const": "in-progress" }, { "const": "done" } ] }),
606 );
607 let d = document(&info(), &[op("status", "QUERY", shape)]);
608 assert_eq!(d.value["roots"]["Query"]["status"]["type"], "String!");
609 assert!(
610 d.notes.iter().any(|n| n.contains("in-progress")),
611 "got {:?}",
612 d.notes
613 );
614 }
615
616 #[test]
617 fn a_sixty_four_bit_integer_gets_its_own_scalar_rather_than_truncating() {
618 let shape = returns(json!({ "type": "integer", "format": "int64" }));
619 let d = document(&info(), &[op("count", "QUERY", shape)]);
620 assert_eq!(d.value["roots"]["Query"]["count"]["type"], "Int64!");
621 assert_eq!(d.value["types"]["Int64"]["kind"], "scalar");
622 }
623
624 #[test]
625 fn a_map_becomes_the_json_scalar_with_a_note() {
626 let shape =
627 returns(json!({ "type": "object", "additionalProperties": { "type": "string" } }));
628 let d = document(&info(), &[op("labels", "QUERY", shape)]);
629 assert_eq!(d.value["roots"]["Query"]["labels"]["type"], "JSON!");
630 assert!(
631 d.notes.iter().any(|n| n.contains("dynamic keys")),
632 "{:?}",
633 d.notes
634 );
635 }
636
637 #[test]
638 fn a_union_of_objects_is_reported_rather_than_guessed() {
639 let mut shape = returns(json!({ "$ref": "#/components/schemas/Event" }));
640 shape.components.insert(
641 "Event".into(),
642 json!({ "oneOf": [
643 { "type": "object", "properties": { "Created": { "type": "string" } } },
644 { "type": "object", "properties": { "Deleted": { "type": "string" } } },
645 ]}),
646 );
647 let d = document(&info(), &[op("event", "QUERY", shape)]);
648 assert_eq!(d.value["roots"]["Query"]["event"]["type"], "JSON!");
649 assert!(d.notes.iter().any(|n| n.contains("union")), "{:?}", d.notes);
650 }
651
652 #[test]
653 fn an_array_leaves_its_items_nullable() {
654 let shape = returns(json!({ "type": "array", "items": { "type": "string" } }));
657 let d = document(&info(), &[op("tags", "QUERY", shape)]);
658 assert_eq!(d.value["roots"]["Query"]["tags"]["type"], "[String]!");
659 }
660
661 #[test]
662 fn a_handler_that_returns_nothing_still_returns_something() {
663 let mut shape = RouteShape::default();
664 shape.responses.insert("204".into(), Response::empty());
665 let d = document(&info(), &[op("deleteItem", "MUTATION", shape)]);
666 assert_eq!(
667 d.value["roots"]["Mutation"]["deleteItem"]["type"],
668 "Boolean!"
669 );
670 }
671
672 #[test]
673 fn a_schema_with_only_mutations_still_declares_a_query_root() {
674 let d = document(&info(), &[op("createItem", "MUTATION", item_shape())]);
675 assert!(d.value["roots"]["Query"]["_empty"].is_object());
676 assert!(
677 d.notes.iter().any(|n| n.contains("query root")),
678 "{:?}",
679 d.notes
680 );
681 }
682
683 #[test]
684 fn a_self_referential_type_terminates() {
685 let mut shape = returns(json!({ "$ref": "#/components/schemas/Node" }));
686 shape.components.insert(
687 "Node".into(),
688 object(
689 json!({ "next": { "$ref": "#/components/schemas/Node" } }),
690 &[],
691 ),
692 );
693 let d = document(&info(), &[op("node", "QUERY", shape)]);
694 assert_eq!(d.value["types"]["Node"]["fields"]["next"]["type"], "Node");
695 }
696
697 #[test]
698 fn a_field_name_sdl_cannot_spell_is_dropped_with_a_note() {
699 let mut shape = returns(json!({ "$ref": "#/components/schemas/Item" }));
700 shape.components.insert(
701 "Item".into(),
702 object(
703 json!({ "id": { "type": "string" }, "content-type": { "type": "string" } }),
704 &["id"],
705 ),
706 );
707 let d = document(&info(), &[op("item", "QUERY", shape)]);
708 let fields = d.value["types"]["Item"]["fields"]
709 .as_object()
710 .expect("fields");
711 assert!(fields.contains_key("id"));
712 assert!(!fields.contains_key("content-type"));
713 assert!(
714 d.notes.iter().any(|n| n.contains("content-type")),
715 "{:?}",
716 d.notes
717 );
718 }
719
720 #[test]
721 fn an_http_verb_is_a_conflict_not_a_guessed_root() {
722 let d = document(&info(), &[op("getItem", "GET", item_shape())]);
723 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
724 assert!(d.value["roots"].as_object().is_some_and(|r| r.is_empty()));
725 }
726
727 #[test]
728 fn two_fields_with_one_name_on_the_same_root_are_reported() {
729 let d = document(
730 &info(),
731 &[
732 op("item", "QUERY", item_shape()),
733 op("item", "QUERY", item_shape()),
734 ],
735 );
736 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
737 }
738
739 #[test]
740 fn subscriptions_land_on_their_own_root() {
741 let d = document(&info(), &[op("itemAdded", "SUBSCRIPTION", item_shape())]);
742 assert_eq!(
743 d.value["roots"]["Subscription"]["itemAdded"]["type"],
744 "Item!"
745 );
746 }
747}