Skip to main content

soothfast_spec/graphql/
mod.rs

1//! GraphQL SDL generated from inferred route shapes.
2//!
3//! Unlike the other dialects, this one cannot pass JSON Schema through: SDL
4//! is a different type system, not a different serialization. Three
5//! mismatches shape the whole module:
6//!
7//! - **Inputs and outputs are separate declarations.** A struct used as both
8//!   a request body and a response becomes `Item` *and* `ItemInput`; GraphQL
9//!   forbids one declaration serving both positions.
10//! - **Nullability lives on the reference, not the object.** JSON Schema says
11//!   `required: [id]` on the parent; SDL says `id: ID!` on the field.
12//! - **Not everything crosses.** Untagged unions, flattened structs, maps and
13//!   64-bit integers have no faithful SDL spelling. Each becomes a note and a
14//!   `JSON` (or `Int64`) custom scalar rather than a quietly wrong type — the
15//!   same "imprecise, never wrong" rule the extractor follows for gaps.
16//!
17//! The document this module builds is a type graph, not SDL text; [`to_sdl`]
18//! renders it and [`diff`](diff::diff) compares two of them. Diffing the
19//! graph rather than the text is what lets the gate say "field removed"
20//! instead of "line 42 changed".
21
22pub 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
33/// The three root operation types, in the order SDL conventionally declares
34/// them.
35const ROOTS: &[(&str, &str)] = &[
36    ("QUERY", "Query"),
37    ("MUTATION", "Mutation"),
38    ("SUBSCRIPTION", "Subscription"),
39];
40
41/// Whether a type is being reached as something a caller writes or as
42/// something the server returns. GraphQL needs separate declarations for the
43/// two, so the same component can arrive here twice.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Position {
46    Input,
47    Output,
48}
49
50/// Assemble a GraphQL type graph.
51pub 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    // Every GraphQL schema needs a query root, even one that only mutates.
87    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
118/// One component map for the whole document, reporting definitions that
119/// disagree rather than letting the last operation win.
120fn 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
139/// Walks JSON Schema, emitting GraphQL type declarations.
140struct Builder {
141    components: BTreeMap<String, Value>,
142    types: BTreeMap<String, Value>,
143    notes: Vec<String>,
144    /// Declarations currently being built, so a self-referential type stops.
145    stack: BTreeSet<String>,
146}
147
148impl Builder {
149    /// One root field: its arguments, its type, and its description.
150    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            // A body is one argument, not a splat of its fields: GraphQL
166            // convention is `createItem(input: NewItemInput!)`, and expanding
167            // it would collide with same-named parameters.
168            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            // A handler that returns nothing still has to return something in
185            // GraphQL; `Boolean!` is the conventional "it happened".
186            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    /// A type reference, e.g. `Item`, `[String]` or `ID!`.
201    ///
202    /// `hint` names the declaration if this schema is an anonymous shape that
203    /// has to become one; `required` decides the trailing `!`.
204    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        // An enumeration of string constants is the one union GraphQL can
215        // express faithfully.
216        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                // GraphQL's Int is 32-bit; calling a u64 an Int would silently
241                // truncate every value above 2^31.
242                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                // Items are left nullable: `Vec<Option<T>>` and `Vec<T>` reach
252                // here identically, so `[T!]` would be a guarantee we cannot
253                // make.
254                format!("[{inner}]")
255            }
256            Some("object") => {
257                if schema.get("properties").is_some() {
258                    self.declare_object(hint, schema, pos)
259                } else {
260                    // A map: dynamic keys, which SDL has no way to declare.
261                    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    /// Declare a named component under `pos`, returning the GraphQL type name.
270    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        // An enumeration is legal in both positions, so it keeps one name.
275        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        // A newtype over a scalar has no declaration of its own in SDL.
282        self.base_type(&schema.clone(), pos, name)
283    }
284
285    /// Declare an object or input type, recursing into its fields.
286    fn declare_object(&mut self, hint: &str, schema: &Value, pos: Position) -> String {
287        let name = match pos {
288            Position::Output => hint.to_string(),
289            // Inputs are always suffixed, even when no output type shares the
290            // name: a predictable name beats one that depends on what else
291            // the schema happens to contain.
292            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            // SDL has no empty type body, and an object whose every field was
334            // unrepresentable is not an object worth declaring.
335            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    /// The string constants an enumeration accepts, if that is what it is.
343    ///
344    /// Both spellings the extractor produces are read: a plain `enum` list,
345    /// and the `oneOf` of `const` branches that serde's externally tagged
346    /// unit variants become.
347    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        // GraphQL enum values are identifiers; a serde rename rule can produce
367        // wire values (`kebab-case`, `with space`) that simply are not.
368        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    /// Fall back to a custom scalar, saying what could not be expressed.
392    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    /// Accept a name SDL can spell, or report the one it cannot.
409    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
421/// The schema of the lowest 2xx response, if it carries a body.
422fn 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
440/// `/[_A-Za-z][_0-9A-Za-z]*/`, minus the `__` prefix GraphQL reserves for
441/// introspection.
442fn 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        // `Vec<T>` and `Vec<Option<T>>` reach the emitter identically, so
655        // `[T!]` would be a guarantee the code does not make.
656        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}