1use clap::Parser;
2use graphql_parser::schema::{
3 parse_schema, Definition, Document, Field, ObjectType, Type, TypeDefinition,
4};
5use once_cell::sync::Lazy;
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9const TYPE_REF_DEPTH: usize = 8;
13
14static INTROSPECTION_QUERY: Lazy<String> = Lazy::new(|| {
15 let mut type_ref = "kind name".to_string();
16 for _ in 1..TYPE_REF_DEPTH {
17 type_ref = format!("kind name ofType {{ {type_ref} }}");
18 }
19 format!(
20 "{{ __schema {{ types {{ kind name fields(includeDeprecated: true) \
21 {{ name type {{ {type_ref} }} }} }} }} }}"
22 )
23});
24
25#[derive(Parser)]
30pub struct SchemaCheck {
31 #[arg(short, long, conflicts_with = "live_url")]
34 pub source: Option<PathBuf>,
35 #[arg(short, long, conflicts_with = "source")]
40 pub live_url: Option<String>,
41 #[arg(short, long)]
43 pub consumer: PathBuf,
44}
45
46pub async fn schema_check(cmd: SchemaCheck) -> anyhow::Result<()> {
47 let consumer_sdl = std::fs::read_to_string(&cmd.consumer)?;
48
49 let (source_sdl, source_label) = match (&cmd.source, &cmd.live_url) {
50 (Some(path), None) => (std::fs::read_to_string(path)?, "source".to_string()),
51 (None, Some(url)) => {
52 let sdl = fetch_live_entities_as_sdl(url).await?;
53 (sdl, format!("live ({url})"))
54 }
55 _ => {
56 return Err(anyhow::anyhow!(
57 "exactly one of --source or --live-url must be provided"
58 ));
59 }
60 };
61
62 match check(&source_sdl, &consumer_sdl) {
63 Ok(count) => {
64 println!("schema check ok: {count} entities verified against {source_label}");
65 Ok(())
66 }
67 Err(errors) => {
68 let mut msg = format!("schema check failed with {} mismatches:", errors.len());
69 for e in &errors {
70 msg.push_str("\n - ");
71 msg.push_str(e);
72 }
73 if cmd.live_url.is_some() {
74 msg.push_str(
75 "\n\nLive introspection-derived entity SDL (copy into consumer file):\n",
76 );
77 msg.push_str(&source_sdl);
78 }
79 Err(anyhow::anyhow!(msg))
80 }
81 }
82}
83
84async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> {
89 #[cfg(not(target_family = "wasm"))]
94 let client = reqwest::Client::builder()
95 .connect_timeout(std::time::Duration::from_secs(10))
96 .timeout(std::time::Duration::from_secs(30))
97 .build()?;
98 #[cfg(target_family = "wasm")]
99 let client = reqwest::Client::new();
100
101 let body = serde_json::json!({ "query": INTROSPECTION_QUERY.as_str() });
102 let resp: serde_json::Value = client
103 .post(url)
104 .json(&body)
105 .send()
106 .await?
107 .error_for_status()?
108 .json()
109 .await?;
110 if let Some(errors) = resp.get("errors") {
111 return Err(anyhow::anyhow!("introspection errors: {errors}"));
112 }
113 let types = resp
114 .pointer("/data/__schema/types")
115 .and_then(|t| t.as_array())
116 .ok_or_else(|| anyhow::anyhow!("introspection response missing /data/__schema/types"))?;
117
118 let mut sdl = String::new();
119 for t in types {
120 let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
121 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
122 if kind != "OBJECT" || !is_entity_object(name) {
123 continue;
124 }
125 sdl.push_str(&format!("type {name} @entity {{\n"));
126 if let Some(fields) = t.get("fields").and_then(|f| f.as_array()) {
127 for f in fields {
128 let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("");
129 let ftype = render_type(f.get("type").unwrap_or(&serde_json::Value::Null))
130 .map_err(|e| anyhow::anyhow!("field `{name}.{fname}`: {e}"))?;
131 sdl.push_str(&format!(" {fname}: {ftype}\n"));
132 }
133 }
134 sdl.push_str("}\n\n");
135 }
136 Ok(sdl)
137}
138
139fn is_entity_object(name: &str) -> bool {
143 !name.is_empty()
144 && !name.starts_with('_')
145 && name != "Query"
146 && name != "Subscription"
147 && !name.ends_with("_filter")
148 && !name.ends_with("_orderBy")
149}
150
151fn render_type(t: &serde_json::Value) -> anyhow::Result<String> {
156 let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
157 match kind {
158 "NON_NULL" => Ok(format!("{}!", render_type(of_type(t, kind)?)?)),
159 "LIST" => Ok(format!("[{}]", render_type(of_type(t, kind)?)?)),
160 _ => t
161 .get("name")
162 .and_then(|v| v.as_str())
163 .map(str::to_string)
164 .ok_or_else(|| anyhow::anyhow!("type-ref of kind `{kind}` has no name")),
165 }
166}
167
168fn of_type<'a>(t: &'a serde_json::Value, kind: &str) -> anyhow::Result<&'a serde_json::Value> {
169 t.get("ofType").filter(|v| !v.is_null()).ok_or_else(|| {
170 anyhow::anyhow!(
171 "`{kind}` wrapper has no `ofType`: the type nests deeper than the \
172 {TYPE_REF_DEPTH} levels the introspection query resolves"
173 )
174 })
175}
176
177fn check(source_sdl: &str, consumer_sdl: &str) -> Result<usize, Vec<String>> {
178 let source_doc: Document<String> =
179 parse_schema(source_sdl).map_err(|e| vec![format!("parse source: {e}")])?;
180 let consumer_doc: Document<String> =
181 parse_schema(consumer_sdl).map_err(|e| vec![format!("parse consumer: {e}")])?;
182
183 let source_entities = entities(&source_doc);
184 let consumer_field_index = build_field_index(&consumer_doc);
185
186 if source_entities.is_empty() {
187 return Err(vec![
188 "source schema has no `@entity` types; check that --source/--live-url \
189 points at a subgraph SDL or live introspection endpoint"
190 .to_string(),
191 ]);
192 }
193
194 let mut errors = Vec::new();
195
196 for entity in &source_entities {
197 match consumer_field_index.get(entity.name.as_str()) {
198 None => errors.push(format!(
199 "entity `{}` is missing from consumer schema",
200 entity.name
201 )),
202 Some(consumer_fields) => {
203 for field in &entity.fields {
204 match consumer_fields.get(field.name.as_str()) {
205 None => errors.push(format!(
206 "field `{}.{}` is missing from consumer schema",
207 entity.name, field.name
208 )),
209 Some(consumer_field) => {
210 if !type_equal(&field.field_type, &consumer_field.field_type) {
211 errors.push(format!(
212 "field `{}.{}` type mismatch: source `{}` vs consumer `{}`",
213 entity.name,
214 field.name,
215 type_to_string(&field.field_type),
216 type_to_string(&consumer_field.field_type),
217 ));
218 }
219 }
220 }
221 }
222 }
223 }
224 }
225
226 if errors.is_empty() {
227 Ok(source_entities.len())
228 } else {
229 Err(errors)
230 }
231}
232
233fn entities<'a>(doc: &'a Document<'a, String>) -> Vec<&'a ObjectType<'a, String>> {
234 doc.definitions
235 .iter()
236 .filter_map(|def| {
237 if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
238 if obj.directives.iter().any(|d| d.name == "entity") {
239 return Some(obj);
240 }
241 }
242 None
243 })
244 .collect()
245}
246
247fn build_field_index<'a>(
251 doc: &'a Document<'a, String>,
252) -> BTreeMap<&'a str, BTreeMap<&'a str, &'a Field<'a, String>>> {
253 doc.definitions
254 .iter()
255 .filter_map(|def| {
256 if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
257 let fields: BTreeMap<&str, &Field<'_, String>> =
258 obj.fields.iter().map(|f| (f.name.as_str(), f)).collect();
259 Some((obj.name.as_str(), fields))
260 } else {
261 None
262 }
263 })
264 .collect()
265}
266
267fn type_equal(a: &Type<'_, String>, b: &Type<'_, String>) -> bool {
268 match (a, b) {
269 (Type::NamedType(an), Type::NamedType(bn)) => an == bn,
270 (Type::ListType(ai), Type::ListType(bi)) => type_equal(ai, bi),
271 (Type::NonNullType(ai), Type::NonNullType(bi)) => type_equal(ai, bi),
272 _ => false,
273 }
274}
275
276fn type_to_string(t: &Type<'_, String>) -> String {
277 match t {
278 Type::NamedType(n) => n.clone(),
279 Type::ListType(inner) => format!("[{}]", type_to_string(inner)),
280 Type::NonNullType(inner) => format!("{}!", type_to_string(inner)),
281 }
282}
283
284#[cfg(all(test, not(target_family = "wasm")))]
285mod tests {
286 use super::*;
287
288 const SOURCE_OK: &str = r#"
289 type MetaBoard @entity {
290 id: Bytes!
291 address: Bytes!
292 nextMetaId: BigInt!
293 }
294 type MetaV1 @entity {
295 id: ID!
296 sender: Bytes!
297 subject: Bytes!
298 }
299 "#;
300
301 const CONSUMER_OK: &str = r#"
302 type MetaBoard {
303 id: Bytes!
304 address: Bytes!
305 nextMetaId: BigInt!
306 }
307 type MetaV1 {
308 id: ID!
309 sender: Bytes!
310 subject: Bytes!
311 }
312 "#;
313
314 #[test]
315 fn matching_schemas_pass() {
316 let n = check(SOURCE_OK, CONSUMER_OK).unwrap();
317 assert_eq!(n, 2);
318 }
319
320 #[test]
321 fn missing_entity_is_reported() {
322 let consumer = r#"
323 type MetaBoard {
324 id: Bytes!
325 address: Bytes!
326 nextMetaId: BigInt!
327 }
328 "#;
329 let errs = check(SOURCE_OK, consumer).unwrap_err();
330 assert_eq!(errs.len(), 1);
331 assert!(errs[0].contains("entity `MetaV1` is missing"));
332 }
333
334 #[test]
335 fn missing_field_is_reported() {
336 let consumer = r#"
337 type MetaBoard {
338 id: Bytes!
339 address: Bytes!
340 nextMetaId: BigInt!
341 }
342 type MetaV1 {
343 id: ID!
344 sender: Bytes!
345 }
346 "#;
347 let errs = check(SOURCE_OK, consumer).unwrap_err();
348 assert_eq!(errs.len(), 1);
349 assert!(errs[0].contains("field `MetaV1.subject` is missing"));
350 }
351
352 #[test]
353 fn type_mismatch_is_reported() {
354 let consumer = r#"
355 type MetaBoard {
356 id: Bytes!
357 address: Bytes!
358 nextMetaId: BigInt!
359 }
360 type MetaV1 {
361 id: ID!
362 sender: Bytes!
363 subject: BigInt!
364 }
365 "#;
366 let errs = check(SOURCE_OK, consumer).unwrap_err();
367 assert_eq!(errs.len(), 1);
368 assert!(errs[0].contains("`MetaV1.subject` type mismatch"));
369 assert!(errs[0].contains("source `Bytes!`"));
370 assert!(errs[0].contains("consumer `BigInt!`"));
371 }
372
373 #[test]
374 fn deployed_subgraph_drift_is_caught() {
375 let source = r#"
381 type MetaBoard @entity {
382 id: Bytes!
383 address: Bytes!
384 nextMetaId: BigInt!
385 }
386 type MetaV1 @entity {
387 id: ID!
388 transaction: Transaction!
389 metaBoard: MetaBoard!
390 sender: Bytes!
391 subject: Bytes!
392 metaHash: Bytes!
393 meta: Bytes!
394 }
395 type Transaction @entity(immutable: true) {
396 id: Bytes!
397 timestamp: BigInt!
398 blockNumber: BigInt!
399 from: Bytes!
400 }
401 "#;
402 let consumer = r#"
403 type MetaBoard {
404 id: Bytes!
405 address: Bytes!
406 nextMetaId: BigInt!
407 }
408 type MetaV1 {
409 id: ID!
410 metaBoard: MetaBoard!
411 sender: Bytes!
412 subject: BigInt!
413 metaHash: Bytes!
414 meta: Bytes!
415 }
416 "#;
417 let errs = check(source, consumer).unwrap_err();
418 assert!(errs
419 .iter()
420 .any(|e| e.contains("entity `Transaction` is missing")));
421 assert!(errs
422 .iter()
423 .any(|e| e.contains("field `MetaV1.transaction` is missing")));
424 assert!(errs
425 .iter()
426 .any(|e| e.contains("`MetaV1.subject` type mismatch")));
427 }
428
429 #[test]
430 fn source_with_no_entities_is_an_error() {
431 let source = "scalar Bytes";
434 let consumer = "type Whatever { x: Int }";
435 let errs = check(source, consumer).unwrap_err();
436 assert_eq!(errs.len(), 1);
437 assert!(errs[0].contains("no `@entity` types"));
438 }
439
440 #[test]
441 fn non_object_definitions_in_source_are_ignored() {
442 let source = r#"
445 scalar Bytes
446 enum Direction { ASC DESC }
447 type NotAnEntity {
448 noisefield: Int
449 }
450 type MetaBoard @entity {
451 id: Bytes!
452 }
453 "#;
454 let consumer = r#"
455 type MetaBoard {
456 id: Bytes!
457 }
458 "#;
459 let n = check(source, consumer).unwrap();
460 assert_eq!(n, 1, "only the @entity-tagged type should be verified");
461 }
462
463 #[test]
464 fn entity_directive_with_arguments_is_detected() {
465 let source = r#"
467 type Transaction @entity(immutable: true) {
468 id: Bytes!
469 }
470 "#;
471 let consumer = r#"
472 type Transaction {
473 id: Bytes!
474 }
475 "#;
476 let n = check(source, consumer).unwrap();
477 assert_eq!(n, 1);
478 }
479
480 #[test]
481 fn consumer_extras_are_ignored() {
482 let consumer = r#"
486 type MetaBoard {
487 id: Bytes!
488 address: Bytes!
489 nextMetaId: BigInt!
490 extraField: String
491 }
492 type MetaV1 {
493 id: ID!
494 sender: Bytes!
495 subject: Bytes!
496 }
497 input MetaBoard_filter {
498 id: Bytes
499 }
500 enum MetaBoard_orderBy { id address }
501 "#;
502 let n = check(SOURCE_OK, consumer).unwrap();
503 assert_eq!(n, 2);
504 }
505
506 #[test]
507 fn consumer_field_with_arguments_matches_when_return_type_matches() {
508 let source = r#"
511 type MetaBoard @entity {
512 id: Bytes!
513 metas: [MetaV1!]
514 }
515 type MetaV1 @entity {
516 id: ID!
517 }
518 "#;
519 let consumer = r#"
520 type MetaBoard {
521 id: Bytes!
522 metas(skip: Int = 0, first: Int = 100): [MetaV1!]
523 }
524 type MetaV1 {
525 id: ID!
526 }
527 "#;
528 let n = check(source, consumer).unwrap();
529 assert_eq!(n, 2);
530 }
531
532 #[test]
533 fn nullability_mismatch_is_reported() {
534 let source = r#"
537 type MetaBoard @entity {
538 id: Bytes!
539 }
540 "#;
541 let consumer = r#"
542 type MetaBoard {
543 id: Bytes
544 }
545 "#;
546 let errs = check(source, consumer).unwrap_err();
547 assert_eq!(errs.len(), 1);
548 assert!(errs[0].contains("source `Bytes!`"));
549 assert!(errs[0].contains("consumer `Bytes`"));
550 }
551
552 #[test]
553 fn list_vs_scalar_mismatch_is_reported() {
554 let source = r#"
555 type MetaBoard @entity {
556 metas: [MetaV1!]
557 }
558 type MetaV1 @entity {
559 id: ID!
560 }
561 "#;
562 let consumer = r#"
563 type MetaBoard {
564 metas: MetaV1
565 }
566 type MetaV1 {
567 id: ID!
568 }
569 "#;
570 let errs = check(source, consumer).unwrap_err();
571 assert!(errs
572 .iter()
573 .any(|e| e.contains("`MetaBoard.metas` type mismatch")));
574 assert!(errs.iter().any(|e| e.contains("source `[MetaV1!]`")));
575 }
576
577 #[test]
578 fn nested_wrapper_types_compare_recursively() {
579 let source = r#"
581 type MetaBoard @entity {
582 tags: [Bytes!]!
583 }
584 "#;
585 let ok_consumer = r#"
586 type MetaBoard {
587 tags: [Bytes!]!
588 }
589 "#;
590 let n = check(source, ok_consumer).unwrap();
591 assert_eq!(n, 1);
592
593 let bad_consumer = r#"
594 type MetaBoard {
595 tags: [Bytes!]
596 }
597 "#;
598 let errs = check(source, bad_consumer).unwrap_err();
599 assert_eq!(errs.len(), 1);
600 assert!(errs[0].contains("source `[Bytes!]!`"));
601 assert!(errs[0].contains("consumer `[Bytes!]`"));
602 }
603
604 #[test]
605 fn multiple_errors_are_all_reported() {
606 let source = r#"
608 type MetaBoard @entity {
609 id: Bytes!
610 address: Bytes!
611 nextMetaId: BigInt!
612 }
613 type MetaV1 @entity {
614 id: ID!
615 sender: Bytes!
616 subject: Bytes!
617 }
618 type Transaction @entity {
619 id: Bytes!
620 }
621 "#;
622 let consumer = r#"
623 type MetaBoard {
624 id: Bytes!
625 address: BigInt!
626 }
627 type MetaV1 {
628 id: ID!
629 sender: Bytes!
630 }
631 "#;
632 let errs = check(source, consumer).unwrap_err();
633 assert_eq!(errs.len(), 4, "errors were: {:?}", errs);
636 }
637
638 #[test]
639 fn unparseable_source_is_reported() {
640 let errs = check("type Broken @entity {", CONSUMER_OK).unwrap_err();
641 assert_eq!(errs.len(), 1);
642 assert!(errs[0].starts_with("parse source:"));
643 }
644
645 #[test]
646 fn unparseable_consumer_is_reported() {
647 let errs = check(SOURCE_OK, "type Broken {").unwrap_err();
648 assert_eq!(errs.len(), 1);
649 assert!(errs[0].starts_with("parse consumer:"));
650 }
651
652 #[test]
653 fn consumer_with_no_objects_reports_every_source_entity_missing() {
654 let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err();
657 assert_eq!(errs.len(), 2);
659 assert!(errs
660 .iter()
661 .any(|e| e.contains("entity `MetaBoard` is missing")));
662 assert!(errs
663 .iter()
664 .any(|e| e.contains("entity `MetaV1` is missing")));
665 }
666
667 fn parse(sdl: &str) -> Document<'_, String> {
670 parse_schema(sdl).unwrap()
671 }
672
673 #[test]
674 fn entities_returns_only_entity_directive_objects() {
675 let doc = parse(
676 r#"
677 type WithEntity @entity { id: ID! }
678 type WithEntityArgs @entity(immutable: true) { id: ID! }
679 type Plain { id: ID! }
680 type WithOtherDirective @other { id: ID! }
681 scalar S
682 enum E { A B }
683 "#,
684 );
685 let names: Vec<&str> = entities(&doc).iter().map(|o| o.name.as_str()).collect();
686 assert_eq!(names, vec!["WithEntity", "WithEntityArgs"]);
687 }
688
689 #[test]
690 fn build_field_index_returns_field_maps_keyed_by_object_name() {
691 let doc = parse(
692 r#"
693 type A { x: Int y: String }
694 type B @entity { y: Int }
695 scalar S
696 enum E { X }
697 input I { z: Int }
698 "#,
699 );
700 let m = build_field_index(&doc);
701 let mut names: Vec<&str> = m.keys().copied().collect();
702 names.sort();
703 assert_eq!(names, vec!["A", "B"]);
704 let mut a_fields: Vec<&str> = m["A"].keys().copied().collect();
705 a_fields.sort();
706 assert_eq!(a_fields, vec!["x", "y"]);
707 assert_eq!(m["B"].keys().copied().collect::<Vec<_>>(), vec!["y"]);
708 }
709
710 fn named(s: &str) -> Type<'static, String> {
711 Type::NamedType(s.to_string())
712 }
713 fn nn(t: Type<'static, String>) -> Type<'static, String> {
714 Type::NonNullType(Box::new(t))
715 }
716 fn list(t: Type<'static, String>) -> Type<'static, String> {
717 Type::ListType(Box::new(t))
718 }
719
720 #[test]
721 fn type_equal_named_named() {
722 assert!(type_equal(&named("Bytes"), &named("Bytes")));
723 assert!(!type_equal(&named("Bytes"), &named("BigInt")));
724 }
725
726 #[test]
727 fn type_equal_distinguishes_wrappers() {
728 assert!(!type_equal(&named("Bytes"), &nn(named("Bytes"))));
729 assert!(!type_equal(&named("Bytes"), &list(named("Bytes"))));
730 assert!(!type_equal(&nn(named("Bytes")), &list(named("Bytes"))));
731 }
732
733 #[test]
734 fn type_equal_recurses_through_nested_wrappers() {
735 let a = nn(list(nn(named("Bytes"))));
736 let b = nn(list(nn(named("Bytes"))));
737 assert!(type_equal(&a, &b));
738 let c = nn(list(named("Bytes")));
739 assert!(!type_equal(&a, &c));
740 }
741
742 #[test]
743 fn type_to_string_renders_sdl_syntax() {
744 assert_eq!(type_to_string(&named("Bytes")), "Bytes");
745 assert_eq!(type_to_string(&nn(named("Bytes"))), "Bytes!");
746 assert_eq!(type_to_string(&list(named("X"))), "[X]");
747 assert_eq!(type_to_string(&nn(list(nn(named("X"))))), "[X!]!");
748 }
749
750 #[test]
751 fn is_entity_object_skips_derivative_and_internal_types() {
752 assert!(is_entity_object("MetaBoard"));
753 assert!(is_entity_object("Transaction"));
754 assert!(!is_entity_object(""));
755 assert!(!is_entity_object("_Meta_"));
756 assert!(!is_entity_object("Query"));
757 assert!(!is_entity_object("Subscription"));
758 assert!(!is_entity_object("MetaV1_filter"));
759 assert!(!is_entity_object("MetaV1_orderBy"));
760 }
761
762 #[test]
763 fn render_type_unwraps_introspection_typeref_recursively() {
764 let nested = serde_json::json!({
766 "kind": "NON_NULL",
767 "name": null,
768 "ofType": {
769 "kind": "LIST",
770 "name": null,
771 "ofType": {
772 "kind": "NON_NULL",
773 "name": null,
774 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null }
775 }
776 }
777 });
778 assert_eq!(render_type(&nested).unwrap(), "[Bytes!]!");
779 }
780
781 #[test]
782 fn render_type_handles_plain_named_type() {
783 let scalar = serde_json::json!({ "kind": "SCALAR", "name": "BigInt", "ofType": null });
784 assert_eq!(render_type(&scalar).unwrap(), "BigInt");
785 }
786
787 #[test]
788 fn render_type_errors_on_missing_name() {
789 let bad = serde_json::json!({ "kind": "SCALAR", "name": null, "ofType": null });
790 let err = render_type(&bad).unwrap_err().to_string();
791 assert!(err.contains("kind `SCALAR` has no name"), "{err}");
792 }
793
794 #[test]
795 fn render_type_errors_on_a_truncated_wrapper_chain() {
796 let truncated = serde_json::json!({
797 "kind": "NON_NULL",
798 "name": null,
799 "ofType": { "kind": "LIST", "name": null, "ofType": null }
800 });
801 let err = render_type(&truncated).unwrap_err().to_string();
802 assert!(err.contains("`LIST` wrapper has no `ofType`"), "{err}");
803 assert!(err.contains(&TYPE_REF_DEPTH.to_string()), "{err}");
804 assert!(!err.contains("Unknown"), "{err}");
805 }
806
807 #[test]
808 fn render_type_round_trips_a_chain_at_the_query_depth() {
809 let mut t = serde_json::json!({ "kind": "SCALAR", "name": "Bytes", "ofType": null });
810 let mut expected = "Bytes".to_string();
811 for level in 1..TYPE_REF_DEPTH {
812 let kind = if level % 2 == 1 { "NON_NULL" } else { "LIST" };
813 t = serde_json::json!({ "kind": kind, "name": null, "ofType": t });
814 expected = if kind == "NON_NULL" {
815 format!("{expected}!")
816 } else {
817 format!("[{expected}]")
818 };
819 }
820 assert_eq!(render_type(&t).unwrap(), expected);
821 }
822
823 #[test]
824 fn introspection_query_is_valid_graphql_nested_to_the_declared_depth() {
825 graphql_parser::query::parse_query::<String>(&INTROSPECTION_QUERY).unwrap();
826 assert_eq!(
827 INTROSPECTION_QUERY.matches("ofType").count(),
828 TYPE_REF_DEPTH - 1
829 );
830 assert!(INTROSPECTION_QUERY.contains("fields(includeDeprecated: true)"));
831 }
832
833 #[tokio::test]
836 async fn fetch_live_entities_filters_to_entity_object_types() {
837 use httpmock::Method::POST;
838 use httpmock::MockServer;
839
840 let server = MockServer::start_async().await;
841 let _mock = server
842 .mock_async(|when, then| {
843 when.method(POST).path("/");
844 then.status(200).json_body(serde_json::json!({
845 "data": { "__schema": { "types": [
846 { "kind": "OBJECT", "name": "MetaBoard", "fields": [
847 { "name": "id", "type": { "kind": "NON_NULL", "name": null,
848 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
849 ] },
850 { "kind": "OBJECT", "name": "MetaV1_filter", "fields": [
851 { "name": "id", "type": { "kind": "SCALAR", "name": "ID", "ofType": null } }
852 ] },
853 { "kind": "OBJECT", "name": "Query", "fields": [] },
854 { "kind": "OBJECT", "name": "_Meta_", "fields": [] },
855 { "kind": "SCALAR", "name": "Bytes", "fields": null }
856 ] } }
857 }));
858 })
859 .await;
860
861 let sdl = fetch_live_entities_as_sdl(&server.url("/")).await.unwrap();
862 assert!(sdl.contains("type MetaBoard @entity"));
863 assert!(sdl.contains("id: Bytes!"));
864 assert!(!sdl.contains("MetaV1_filter"));
865 assert!(!sdl.contains("Query"));
866 assert!(!sdl.contains("_Meta_"));
867 }
868
869 #[tokio::test]
870 async fn fetch_live_entities_errors_on_a_truncated_type_ref() {
871 use httpmock::Method::POST;
872 use httpmock::MockServer;
873
874 let server = MockServer::start_async().await;
875 let _mock = server
876 .mock_async(|when, then| {
877 when.method(POST).path("/");
878 then.status(200).json_body(serde_json::json!({
879 "data": { "__schema": { "types": [
880 { "kind": "OBJECT", "name": "MetaBoard", "fields": [
881 { "name": "metas", "type": { "kind": "NON_NULL", "name": null,
882 "ofType": { "kind": "LIST", "name": null, "ofType": null } } }
883 ] }
884 ] } }
885 }));
886 })
887 .await;
888
889 let err = fetch_live_entities_as_sdl(&server.url("/"))
890 .await
891 .unwrap_err()
892 .to_string();
893 assert!(err.contains("field `MetaBoard.metas`"), "{err}");
894 assert!(err.contains("has no `ofType`"), "{err}");
895 assert!(!err.contains("Unknown"), "{err}");
896 }
897
898 #[tokio::test]
899 async fn fetch_live_entities_propagates_graphql_errors() {
900 use httpmock::Method::POST;
901 use httpmock::MockServer;
902
903 let server = MockServer::start_async().await;
904 let _mock = server
905 .mock_async(|when, then| {
906 when.method(POST).path("/");
907 then.status(200).json_body(serde_json::json!({
908 "errors": [{ "message": "introspection disabled" }]
909 }));
910 })
911 .await;
912
913 let err = fetch_live_entities_as_sdl(&server.url("/"))
914 .await
915 .unwrap_err();
916 assert!(err.to_string().contains("introspection errors"));
917 assert!(err.to_string().contains("introspection disabled"));
918 }
919
920 #[tokio::test]
921 async fn fetch_live_entities_errors_on_malformed_response() {
922 use httpmock::Method::POST;
923 use httpmock::MockServer;
924
925 let server = MockServer::start_async().await;
926 let _mock = server
927 .mock_async(|when, then| {
928 when.method(POST).path("/");
929 then.status(200)
930 .json_body(serde_json::json!({ "data": {} }));
931 })
932 .await;
933
934 let err = fetch_live_entities_as_sdl(&server.url("/"))
935 .await
936 .unwrap_err();
937 assert!(err.to_string().contains("missing /data/__schema/types"));
938 }
939
940 #[tokio::test]
941 async fn fetch_live_entities_errors_on_http_failure() {
942 use httpmock::Method::POST;
943 use httpmock::MockServer;
944
945 let server = MockServer::start_async().await;
946 let _mock = server
947 .mock_async(|when, then| {
948 when.method(POST).path("/");
949 then.status(500);
950 })
951 .await;
952
953 let err = fetch_live_entities_as_sdl(&server.url("/"))
954 .await
955 .unwrap_err();
956 assert!(err.to_string().contains("500"));
958 }
959
960 #[tokio::test]
963 async fn schema_check_reads_files_and_succeeds_on_match() {
964 use std::io::Write;
965 let mut src = tempfile::NamedTempFile::new().unwrap();
966 src.write_all(SOURCE_OK.as_bytes()).unwrap();
967 let mut con = tempfile::NamedTempFile::new().unwrap();
968 con.write_all(CONSUMER_OK.as_bytes()).unwrap();
969
970 schema_check(SchemaCheck {
971 source: Some(src.path().into()),
972 live_url: None,
973 consumer: con.path().into(),
974 })
975 .await
976 .unwrap();
977 }
978
979 #[tokio::test]
980 async fn schema_check_rejects_neither_source_nor_live_url() {
981 let mut con = tempfile::NamedTempFile::new().unwrap();
982 std::io::Write::write_all(&mut con, CONSUMER_OK.as_bytes()).unwrap();
983
984 let err = schema_check(SchemaCheck {
985 source: None,
986 live_url: None,
987 consumer: con.path().into(),
988 })
989 .await
990 .unwrap_err();
991 assert!(err
992 .to_string()
993 .contains("exactly one of --source or --live-url"));
994 }
995
996 #[tokio::test]
997 async fn schema_check_failure_includes_live_sdl_in_error() {
998 use httpmock::Method::POST;
999 use httpmock::MockServer;
1000 use std::io::Write;
1001
1002 let server = MockServer::start_async().await;
1005 let _mock = server
1006 .mock_async(|when, then| {
1007 when.method(POST).path("/");
1008 then.status(200).json_body(serde_json::json!({
1009 "data": { "__schema": { "types": [
1010 { "kind": "OBJECT", "name": "MetaBoard", "fields": [
1011 { "name": "id", "type": { "kind": "NON_NULL", "name": null,
1012 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
1013 ] }
1014 ] } }
1015 }));
1016 })
1017 .await;
1018
1019 let mut con = tempfile::NamedTempFile::new().unwrap();
1020 con.write_all(b"scalar X").unwrap();
1021
1022 let err = schema_check(SchemaCheck {
1023 source: None,
1024 live_url: Some(server.url("/")),
1025 consumer: con.path().into(),
1026 })
1027 .await
1028 .unwrap_err();
1029 let msg = err.to_string();
1030 assert!(msg.contains("entity `MetaBoard` is missing"));
1031 assert!(msg.contains("Live introspection-derived entity SDL"));
1032 assert!(msg.contains("type MetaBoard @entity"));
1033 }
1034}