use clap::Parser;
use graphql_parser::schema::{
parse_schema, Definition, Document, Field, ObjectType, Type, TypeDefinition,
};
use std::collections::BTreeMap;
use std::path::PathBuf;
const INTROSPECTION_QUERY: &str = r#"
{ __schema { types {
kind name
fields(includeDeprecated: true) {
name type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
}
} } }
"#;
#[derive(Parser)]
pub struct SchemaCheck {
#[arg(short, long, conflicts_with = "live_url")]
pub source: Option<PathBuf>,
#[arg(short, long, conflicts_with = "source")]
pub live_url: Option<String>,
#[arg(short, long)]
pub consumer: PathBuf,
}
pub async fn schema_check(cmd: SchemaCheck) -> anyhow::Result<()> {
let consumer_sdl = std::fs::read_to_string(&cmd.consumer)?;
let (source_sdl, source_label) = match (&cmd.source, &cmd.live_url) {
(Some(path), None) => (std::fs::read_to_string(path)?, "source".to_string()),
(None, Some(url)) => {
let sdl = fetch_live_entities_as_sdl(url).await?;
(sdl, format!("live ({url})"))
}
_ => {
return Err(anyhow::anyhow!(
"exactly one of --source or --live-url must be provided"
));
}
};
match check(&source_sdl, &consumer_sdl) {
Ok(count) => {
println!("schema check ok: {count} entities verified against {source_label}");
Ok(())
}
Err(errors) => {
let mut msg = format!("schema check failed with {} mismatches:", errors.len());
for e in &errors {
msg.push_str("\n - ");
msg.push_str(e);
}
if cmd.live_url.is_some() {
msg.push_str(
"\n\nLive introspection-derived entity SDL (copy into consumer file):\n",
);
msg.push_str(&source_sdl);
}
Err(anyhow::anyhow!(msg))
}
}
}
async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> {
#[cfg(not(target_family = "wasm"))]
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()?;
#[cfg(target_family = "wasm")]
let client = reqwest::Client::new();
let body = serde_json::json!({ "query": INTROSPECTION_QUERY });
let resp: serde_json::Value = client
.post(url)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
if let Some(errors) = resp.get("errors") {
return Err(anyhow::anyhow!("introspection errors: {errors}"));
}
let types = resp
.pointer("/data/__schema/types")
.and_then(|t| t.as_array())
.ok_or_else(|| anyhow::anyhow!("introspection response missing /data/__schema/types"))?;
let mut sdl = String::new();
for t in types {
let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
if kind != "OBJECT" || !is_entity_object(name) {
continue;
}
sdl.push_str(&format!("type {name} @entity {{\n"));
if let Some(fields) = t.get("fields").and_then(|f| f.as_array()) {
for f in fields {
let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("");
let ftype = render_type(f.get("type").unwrap_or(&serde_json::Value::Null));
sdl.push_str(&format!(" {fname}: {ftype}\n"));
}
}
sdl.push_str("}\n\n");
}
Ok(sdl)
}
fn is_entity_object(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('_')
&& name != "Query"
&& name != "Subscription"
&& !name.ends_with("_filter")
&& !name.ends_with("_orderBy")
}
fn render_type(t: &serde_json::Value) -> String {
let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let name = t.get("name").and_then(|v| v.as_str());
let of_type = t.get("ofType");
match kind {
"NON_NULL" => format!(
"{}!",
render_type(of_type.unwrap_or(&serde_json::Value::Null))
),
"LIST" => format!(
"[{}]",
render_type(of_type.unwrap_or(&serde_json::Value::Null))
),
_ => name.unwrap_or("Unknown").to_string(),
}
}
fn check(source_sdl: &str, consumer_sdl: &str) -> Result<usize, Vec<String>> {
let source_doc: Document<String> =
parse_schema(source_sdl).map_err(|e| vec![format!("parse source: {e}")])?;
let consumer_doc: Document<String> =
parse_schema(consumer_sdl).map_err(|e| vec![format!("parse consumer: {e}")])?;
let source_entities = entities(&source_doc);
let consumer_field_index = build_field_index(&consumer_doc);
if source_entities.is_empty() {
return Err(vec![
"source schema has no `@entity` types; check that --source/--live-url \
points at a subgraph SDL or live introspection endpoint"
.to_string(),
]);
}
let mut errors = Vec::new();
for entity in &source_entities {
match consumer_field_index.get(entity.name.as_str()) {
None => errors.push(format!(
"entity `{}` is missing from consumer schema",
entity.name
)),
Some(consumer_fields) => {
for field in &entity.fields {
match consumer_fields.get(field.name.as_str()) {
None => errors.push(format!(
"field `{}.{}` is missing from consumer schema",
entity.name, field.name
)),
Some(consumer_field) => {
if !type_equal(&field.field_type, &consumer_field.field_type) {
errors.push(format!(
"field `{}.{}` type mismatch: source `{}` vs consumer `{}`",
entity.name,
field.name,
type_to_string(&field.field_type),
type_to_string(&consumer_field.field_type),
));
}
}
}
}
}
}
}
if errors.is_empty() {
Ok(source_entities.len())
} else {
Err(errors)
}
}
fn entities<'a>(doc: &'a Document<'a, String>) -> Vec<&'a ObjectType<'a, String>> {
doc.definitions
.iter()
.filter_map(|def| {
if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
if obj.directives.iter().any(|d| d.name == "entity") {
return Some(obj);
}
}
None
})
.collect()
}
fn build_field_index<'a>(
doc: &'a Document<'a, String>,
) -> BTreeMap<&'a str, BTreeMap<&'a str, &'a Field<'a, String>>> {
doc.definitions
.iter()
.filter_map(|def| {
if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
let fields: BTreeMap<&str, &Field<'_, String>> =
obj.fields.iter().map(|f| (f.name.as_str(), f)).collect();
Some((obj.name.as_str(), fields))
} else {
None
}
})
.collect()
}
fn type_equal(a: &Type<'_, String>, b: &Type<'_, String>) -> bool {
match (a, b) {
(Type::NamedType(an), Type::NamedType(bn)) => an == bn,
(Type::ListType(ai), Type::ListType(bi)) => type_equal(ai, bi),
(Type::NonNullType(ai), Type::NonNullType(bi)) => type_equal(ai, bi),
_ => false,
}
}
fn type_to_string(t: &Type<'_, String>) -> String {
match t {
Type::NamedType(n) => n.clone(),
Type::ListType(inner) => format!("[{}]", type_to_string(inner)),
Type::NonNullType(inner) => format!("{}!", type_to_string(inner)),
}
}
#[cfg(all(test, not(target_family = "wasm")))]
mod tests {
use super::*;
const SOURCE_OK: &str = r#"
type MetaBoard @entity {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 @entity {
id: ID!
sender: Bytes!
subject: Bytes!
}
"#;
const CONSUMER_OK: &str = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 {
id: ID!
sender: Bytes!
subject: Bytes!
}
"#;
#[test]
fn matching_schemas_pass() {
let n = check(SOURCE_OK, CONSUMER_OK).unwrap();
assert_eq!(n, 2);
}
#[test]
fn missing_entity_is_reported() {
let consumer = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
"#;
let errs = check(SOURCE_OK, consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("entity `MetaV1` is missing"));
}
#[test]
fn missing_field_is_reported() {
let consumer = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 {
id: ID!
sender: Bytes!
}
"#;
let errs = check(SOURCE_OK, consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("field `MetaV1.subject` is missing"));
}
#[test]
fn type_mismatch_is_reported() {
let consumer = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 {
id: ID!
sender: Bytes!
subject: BigInt!
}
"#;
let errs = check(SOURCE_OK, consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("`MetaV1.subject` type mismatch"));
assert!(errs[0].contains("source `Bytes!`"));
assert!(errs[0].contains("consumer `BigInt!`"));
}
#[test]
fn deployed_subgraph_drift_is_caught() {
let source = r#"
type MetaBoard @entity {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 @entity {
id: ID!
transaction: Transaction!
metaBoard: MetaBoard!
sender: Bytes!
subject: Bytes!
metaHash: Bytes!
meta: Bytes!
}
type Transaction @entity(immutable: true) {
id: Bytes!
timestamp: BigInt!
blockNumber: BigInt!
from: Bytes!
}
"#;
let consumer = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 {
id: ID!
metaBoard: MetaBoard!
sender: Bytes!
subject: BigInt!
metaHash: Bytes!
meta: Bytes!
}
"#;
let errs = check(source, consumer).unwrap_err();
assert!(errs
.iter()
.any(|e| e.contains("entity `Transaction` is missing")));
assert!(errs
.iter()
.any(|e| e.contains("field `MetaV1.transaction` is missing")));
assert!(errs
.iter()
.any(|e| e.contains("`MetaV1.subject` type mismatch")));
}
#[test]
fn source_with_no_entities_is_an_error() {
let source = "scalar Bytes";
let consumer = "type Whatever { x: Int }";
let errs = check(source, consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("no `@entity` types"));
}
#[test]
fn non_object_definitions_in_source_are_ignored() {
let source = r#"
scalar Bytes
enum Direction { ASC DESC }
type NotAnEntity {
noisefield: Int
}
type MetaBoard @entity {
id: Bytes!
}
"#;
let consumer = r#"
type MetaBoard {
id: Bytes!
}
"#;
let n = check(source, consumer).unwrap();
assert_eq!(n, 1, "only the @entity-tagged type should be verified");
}
#[test]
fn entity_directive_with_arguments_is_detected() {
let source = r#"
type Transaction @entity(immutable: true) {
id: Bytes!
}
"#;
let consumer = r#"
type Transaction {
id: Bytes!
}
"#;
let n = check(source, consumer).unwrap();
assert_eq!(n, 1);
}
#[test]
fn consumer_extras_are_ignored() {
let consumer = r#"
type MetaBoard {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
extraField: String
}
type MetaV1 {
id: ID!
sender: Bytes!
subject: Bytes!
}
input MetaBoard_filter {
id: Bytes
}
enum MetaBoard_orderBy { id address }
"#;
let n = check(SOURCE_OK, consumer).unwrap();
assert_eq!(n, 2);
}
#[test]
fn consumer_field_with_arguments_matches_when_return_type_matches() {
let source = r#"
type MetaBoard @entity {
id: Bytes!
metas: [MetaV1!]
}
type MetaV1 @entity {
id: ID!
}
"#;
let consumer = r#"
type MetaBoard {
id: Bytes!
metas(skip: Int = 0, first: Int = 100): [MetaV1!]
}
type MetaV1 {
id: ID!
}
"#;
let n = check(source, consumer).unwrap();
assert_eq!(n, 2);
}
#[test]
fn nullability_mismatch_is_reported() {
let source = r#"
type MetaBoard @entity {
id: Bytes!
}
"#;
let consumer = r#"
type MetaBoard {
id: Bytes
}
"#;
let errs = check(source, consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("source `Bytes!`"));
assert!(errs[0].contains("consumer `Bytes`"));
}
#[test]
fn list_vs_scalar_mismatch_is_reported() {
let source = r#"
type MetaBoard @entity {
metas: [MetaV1!]
}
type MetaV1 @entity {
id: ID!
}
"#;
let consumer = r#"
type MetaBoard {
metas: MetaV1
}
type MetaV1 {
id: ID!
}
"#;
let errs = check(source, consumer).unwrap_err();
assert!(errs
.iter()
.any(|e| e.contains("`MetaBoard.metas` type mismatch")));
assert!(errs.iter().any(|e| e.contains("source `[MetaV1!]`")));
}
#[test]
fn nested_wrapper_types_compare_recursively() {
let source = r#"
type MetaBoard @entity {
tags: [Bytes!]!
}
"#;
let ok_consumer = r#"
type MetaBoard {
tags: [Bytes!]!
}
"#;
let n = check(source, ok_consumer).unwrap();
assert_eq!(n, 1);
let bad_consumer = r#"
type MetaBoard {
tags: [Bytes!]
}
"#;
let errs = check(source, bad_consumer).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("source `[Bytes!]!`"));
assert!(errs[0].contains("consumer `[Bytes!]`"));
}
#[test]
fn multiple_errors_are_all_reported() {
let source = r#"
type MetaBoard @entity {
id: Bytes!
address: Bytes!
nextMetaId: BigInt!
}
type MetaV1 @entity {
id: ID!
sender: Bytes!
subject: Bytes!
}
type Transaction @entity {
id: Bytes!
}
"#;
let consumer = r#"
type MetaBoard {
id: Bytes!
address: BigInt!
}
type MetaV1 {
id: ID!
sender: Bytes!
}
"#;
let errs = check(source, consumer).unwrap_err();
assert_eq!(errs.len(), 4, "errors were: {:?}", errs);
}
#[test]
fn unparseable_source_is_reported() {
let errs = check("type Broken @entity {", CONSUMER_OK).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].starts_with("parse source:"));
}
#[test]
fn unparseable_consumer_is_reported() {
let errs = check(SOURCE_OK, "type Broken {").unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].starts_with("parse consumer:"));
}
#[test]
fn consumer_with_no_objects_reports_every_source_entity_missing() {
let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err();
assert_eq!(errs.len(), 2);
assert!(errs
.iter()
.any(|e| e.contains("entity `MetaBoard` is missing")));
assert!(errs
.iter()
.any(|e| e.contains("entity `MetaV1` is missing")));
}
fn parse(sdl: &str) -> Document<'_, String> {
parse_schema(sdl).unwrap()
}
#[test]
fn entities_returns_only_entity_directive_objects() {
let doc = parse(
r#"
type WithEntity @entity { id: ID! }
type WithEntityArgs @entity(immutable: true) { id: ID! }
type Plain { id: ID! }
type WithOtherDirective @other { id: ID! }
scalar S
enum E { A B }
"#,
);
let names: Vec<&str> = entities(&doc).iter().map(|o| o.name.as_str()).collect();
assert_eq!(names, vec!["WithEntity", "WithEntityArgs"]);
}
#[test]
fn build_field_index_returns_field_maps_keyed_by_object_name() {
let doc = parse(
r#"
type A { x: Int y: String }
type B @entity { y: Int }
scalar S
enum E { X }
input I { z: Int }
"#,
);
let m = build_field_index(&doc);
let mut names: Vec<&str> = m.keys().copied().collect();
names.sort();
assert_eq!(names, vec!["A", "B"]);
let mut a_fields: Vec<&str> = m["A"].keys().copied().collect();
a_fields.sort();
assert_eq!(a_fields, vec!["x", "y"]);
assert_eq!(m["B"].keys().copied().collect::<Vec<_>>(), vec!["y"]);
}
fn named(s: &str) -> Type<'static, String> {
Type::NamedType(s.to_string())
}
fn nn(t: Type<'static, String>) -> Type<'static, String> {
Type::NonNullType(Box::new(t))
}
fn list(t: Type<'static, String>) -> Type<'static, String> {
Type::ListType(Box::new(t))
}
#[test]
fn type_equal_named_named() {
assert!(type_equal(&named("Bytes"), &named("Bytes")));
assert!(!type_equal(&named("Bytes"), &named("BigInt")));
}
#[test]
fn type_equal_distinguishes_wrappers() {
assert!(!type_equal(&named("Bytes"), &nn(named("Bytes"))));
assert!(!type_equal(&named("Bytes"), &list(named("Bytes"))));
assert!(!type_equal(&nn(named("Bytes")), &list(named("Bytes"))));
}
#[test]
fn type_equal_recurses_through_nested_wrappers() {
let a = nn(list(nn(named("Bytes"))));
let b = nn(list(nn(named("Bytes"))));
assert!(type_equal(&a, &b));
let c = nn(list(named("Bytes")));
assert!(!type_equal(&a, &c));
}
#[test]
fn type_to_string_renders_sdl_syntax() {
assert_eq!(type_to_string(&named("Bytes")), "Bytes");
assert_eq!(type_to_string(&nn(named("Bytes"))), "Bytes!");
assert_eq!(type_to_string(&list(named("X"))), "[X]");
assert_eq!(type_to_string(&nn(list(nn(named("X"))))), "[X!]!");
}
#[test]
fn is_entity_object_skips_derivative_and_internal_types() {
assert!(is_entity_object("MetaBoard"));
assert!(is_entity_object("Transaction"));
assert!(!is_entity_object(""));
assert!(!is_entity_object("_Meta_"));
assert!(!is_entity_object("Query"));
assert!(!is_entity_object("Subscription"));
assert!(!is_entity_object("MetaV1_filter"));
assert!(!is_entity_object("MetaV1_orderBy"));
}
#[test]
fn render_type_unwraps_introspection_typeref_recursively() {
let nested = serde_json::json!({
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null }
}
}
});
assert_eq!(render_type(&nested), "[Bytes!]!");
}
#[test]
fn render_type_handles_plain_named_type() {
let scalar = serde_json::json!({ "kind": "SCALAR", "name": "BigInt", "ofType": null });
assert_eq!(render_type(&scalar), "BigInt");
}
#[test]
fn render_type_falls_back_to_unknown_for_missing_name() {
let bad = serde_json::json!({ "kind": "SCALAR", "name": null, "ofType": null });
assert_eq!(render_type(&bad), "Unknown");
}
#[tokio::test]
async fn fetch_live_entities_filters_to_entity_object_types() {
use httpmock::Method::POST;
use httpmock::MockServer;
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(POST).path("/");
then.status(200).json_body(serde_json::json!({
"data": { "__schema": { "types": [
{ "kind": "OBJECT", "name": "MetaBoard", "fields": [
{ "name": "id", "type": { "kind": "NON_NULL", "name": null,
"ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
] },
{ "kind": "OBJECT", "name": "MetaV1_filter", "fields": [
{ "name": "id", "type": { "kind": "SCALAR", "name": "ID", "ofType": null } }
] },
{ "kind": "OBJECT", "name": "Query", "fields": [] },
{ "kind": "OBJECT", "name": "_Meta_", "fields": [] },
{ "kind": "SCALAR", "name": "Bytes", "fields": null }
] } }
}));
})
.await;
let sdl = fetch_live_entities_as_sdl(&server.url("/")).await.unwrap();
assert!(sdl.contains("type MetaBoard @entity"));
assert!(sdl.contains("id: Bytes!"));
assert!(!sdl.contains("MetaV1_filter"));
assert!(!sdl.contains("Query"));
assert!(!sdl.contains("_Meta_"));
}
#[tokio::test]
async fn fetch_live_entities_propagates_graphql_errors() {
use httpmock::Method::POST;
use httpmock::MockServer;
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(POST).path("/");
then.status(200).json_body(serde_json::json!({
"errors": [{ "message": "introspection disabled" }]
}));
})
.await;
let err = fetch_live_entities_as_sdl(&server.url("/"))
.await
.unwrap_err();
assert!(err.to_string().contains("introspection errors"));
assert!(err.to_string().contains("introspection disabled"));
}
#[tokio::test]
async fn fetch_live_entities_errors_on_malformed_response() {
use httpmock::Method::POST;
use httpmock::MockServer;
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(POST).path("/");
then.status(200)
.json_body(serde_json::json!({ "data": {} }));
})
.await;
let err = fetch_live_entities_as_sdl(&server.url("/"))
.await
.unwrap_err();
assert!(err.to_string().contains("missing /data/__schema/types"));
}
#[tokio::test]
async fn fetch_live_entities_errors_on_http_failure() {
use httpmock::Method::POST;
use httpmock::MockServer;
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(POST).path("/");
then.status(500);
})
.await;
let err = fetch_live_entities_as_sdl(&server.url("/"))
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn schema_check_reads_files_and_succeeds_on_match() {
use std::io::Write;
let mut src = tempfile::NamedTempFile::new().unwrap();
src.write_all(SOURCE_OK.as_bytes()).unwrap();
let mut con = tempfile::NamedTempFile::new().unwrap();
con.write_all(CONSUMER_OK.as_bytes()).unwrap();
schema_check(SchemaCheck {
source: Some(src.path().into()),
live_url: None,
consumer: con.path().into(),
})
.await
.unwrap();
}
#[tokio::test]
async fn schema_check_rejects_neither_source_nor_live_url() {
let mut con = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut con, CONSUMER_OK.as_bytes()).unwrap();
let err = schema_check(SchemaCheck {
source: None,
live_url: None,
consumer: con.path().into(),
})
.await
.unwrap_err();
assert!(err
.to_string()
.contains("exactly one of --source or --live-url"));
}
#[tokio::test]
async fn schema_check_failure_includes_live_sdl_in_error() {
use httpmock::Method::POST;
use httpmock::MockServer;
use std::io::Write;
let server = MockServer::start_async().await;
let _mock = server
.mock_async(|when, then| {
when.method(POST).path("/");
then.status(200).json_body(serde_json::json!({
"data": { "__schema": { "types": [
{ "kind": "OBJECT", "name": "MetaBoard", "fields": [
{ "name": "id", "type": { "kind": "NON_NULL", "name": null,
"ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
] }
] } }
}));
})
.await;
let mut con = tempfile::NamedTempFile::new().unwrap();
con.write_all(b"scalar X").unwrap();
let err = schema_check(SchemaCheck {
source: None,
live_url: Some(server.url("/")),
consumer: con.path().into(),
})
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("entity `MetaBoard` is missing"));
assert!(msg.contains("Live introspection-derived entity SDL"));
assert!(msg.contains("type MetaBoard @entity"));
}
}