From 4ff7ee09034e4fed4506e99a908bfba414d3f930 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Thu, 3 Sep 2026 07:01:18 +0000 Subject: [PATCH] Retire the schema-check CLI subcommand The API-schema snapshot verification (#298) runs graph-node in CI and diffs crates/metaboard/src/schema/metaboard.graphql directly, so the schema-check subcommand no longer has a job. No caller exists in any org repo. graphql-parser had no use outside it and goes with it. Closes #149 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyWWorieTJV9FmMm2JM6Ua --- Cargo.lock | 1 - crates/cli/Cargo.toml | 2 - crates/cli/src/cli/mod.rs | 3 - crates/cli/src/cli/schema_check.rs | 1034 ---------------------------- crates/cli/tests/cli.rs | 36 - crates/cli/tests/cli_dispatch.rs | 27 - 6 files changed, 1103 deletions(-) delete mode 100644 crates/cli/src/cli/schema_check.rs diff --git a/Cargo.lock b/Cargo.lock index 3bfdd653..d058232e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3560,7 +3560,6 @@ dependencies = [ "clap", "deflate", "futures", - "graphql-parser", "graphql_client", "httpmock", "inflate", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ae750183..ec2c4043 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,7 +14,6 @@ cli = [ "dep:tracing-subscriber", "dep:clap", "dep:tokio", - "dep:graphql-parser", ] json-schema = ["dep:schemars"] tokio-full = ["cli", "tokio/full"] @@ -36,7 +35,6 @@ validator = { version = "0.16", features = ["derive"] } reqwest = { version = "0.11.22", features = ["json"] } alloy = { version = "1.0.9", features = ["rand", "json", "json-abi"] } graphql_client = "0.13.0" -graphql-parser = { version = "0.4", optional = true } rain-metaboard-subgraph = { path = "../metaboard", version = "0.1.52" } rain-metadata-bindings = { path = "../bindings", version = "0.1.54" } thiserror = "1.0.56" diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index d1c7b0f6..58137cd5 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -8,7 +8,6 @@ pub mod solc; pub mod build; pub mod magic; pub mod schema; -pub mod schema_check; pub mod output; pub mod validate; pub mod generate; @@ -27,7 +26,6 @@ pub enum Meta { #[command(subcommand)] Schema(schema::Schema), Validate(validate::Validate), - SchemaCheck(schema_check::SchemaCheck), #[command(subcommand)] Magic(magic::Magic), Build(build::Build), @@ -43,7 +41,6 @@ pub async fn dispatch(meta: Meta) -> anyhow::Result<()> { Meta::Magic(magic) => magic::dispatch(magic), Meta::Schema(schema) => schema::dispatch(schema), Meta::Validate(validate) => validate::validate(validate), - Meta::SchemaCheck(c) => schema_check::schema_check(c).await, Meta::Generate(generate) => generate::generate(generate), } } diff --git a/crates/cli/src/cli/schema_check.rs b/crates/cli/src/cli/schema_check.rs deleted file mode 100644 index d6071a33..00000000 --- a/crates/cli/src/cli/schema_check.rs +++ /dev/null @@ -1,1034 +0,0 @@ -use clap::Parser; -use graphql_parser::schema::{ - parse_schema, Definition, Document, Field, ObjectType, Type, TypeDefinition, -}; -use once_cell::sync::Lazy; -use std::collections::BTreeMap; -use std::path::PathBuf; - -/// Type-ref levels the introspection query expands. GraphQL puts no bound on -/// wrapper chains, so every depth truncates something; 8 is the depth the -/// reference introspection query settled on. -const TYPE_REF_DEPTH: usize = 8; - -static INTROSPECTION_QUERY: Lazy = Lazy::new(|| { - let mut type_ref = "kind name".to_string(); - for _ in 1..TYPE_REF_DEPTH { - type_ref = format!("kind name ofType {{ {type_ref} }}"); - } - format!( - "{{ __schema {{ types {{ kind name fields(includeDeprecated: true) \ - {{ name type {{ {type_ref} }} }} }} }} }}" - ) -}); - -/// Compare entity types in a subgraph schema against a consumer's snapshot -/// of the deployed introspection schema. Used in deploy CI to fail early -/// when the consumer's snapshot has drifted from what is about to be -/// deployed. -#[derive(Parser)] -pub struct SchemaCheck { - /// Path to the source subgraph schema (with `@entity` directives). - /// Mutually exclusive with --live-url. - #[arg(short, long, conflicts_with = "live_url")] - pub source: Option, - /// URL of a live deployed subgraph endpoint. Introspection is fetched - /// and used as the source of truth in place of --source. The full - /// introspection-derived SDL of the entity types is printed on - /// failure so the consumer snapshot can be regenerated. - #[arg(short, long, conflicts_with = "source")] - pub live_url: Option, - /// Path to the consumer's snapshot of the deployed introspection schema. - #[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)) - } - } -} - -/// POST a GraphQL introspection query to `url` and reduce the response to -/// a synthetic SDL document containing only entity Object types and their -/// fields. The synthetic SDL re-tags each type with `@entity` so the -/// existing `entities` filter picks them up. -async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result { - // Bound the request so a slow or hung Goldsky endpoint can't wedge - // the deploy job indefinitely. reqwest's wasm impl uses the browser - // fetch API and doesn't expose ClientBuilder timing methods, so the - // bound is native-only. The CLI binary never runs under wasm. - #[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.as_str() }); - 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)) - .map_err(|e| anyhow::anyhow!("field `{name}.{fname}`: {e}"))?; - sdl.push_str(&format!(" {fname}: {ftype}\n")); - } - } - sdl.push_str("}\n\n"); - } - Ok(sdl) -} - -/// Filter for "entity-shaped" Object types in a Graph Protocol introspection -/// response: skip auto-generated derivative types (filter, orderBy, Query, -/// Subscription, _Meta_, _Block_, etc.) and any name with a leading underscore. -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") -} - -/// Render an introspection type-ref into SDL syntax (`Bytes!`, `[Foo!]!`). -/// A ref the response does not carry in full is an error, not a placeholder: -/// a placeholder is compared against the consumer snapshot as if it were the -/// deployed type. -fn render_type(t: &serde_json::Value) -> anyhow::Result { - let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or(""); - match kind { - "NON_NULL" => Ok(format!("{}!", render_type(of_type(t, kind)?)?)), - "LIST" => Ok(format!("[{}]", render_type(of_type(t, kind)?)?)), - _ => t - .get("name") - .and_then(|v| v.as_str()) - .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("type-ref of kind `{kind}` has no name")), - } -} - -fn of_type<'a>(t: &'a serde_json::Value, kind: &str) -> anyhow::Result<&'a serde_json::Value> { - t.get("ofType").filter(|v| !v.is_null()).ok_or_else(|| { - anyhow::anyhow!( - "`{kind}` wrapper has no `ofType`: the type nests deeper than the \ - {TYPE_REF_DEPTH} levels the introspection query resolves" - ) - }) -} - -fn check(source_sdl: &str, consumer_sdl: &str) -> Result> { - let source_doc: Document = - parse_schema(source_sdl).map_err(|e| vec![format!("parse source: {e}")])?; - let consumer_doc: Document = - 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() -} - -/// Build a name → (field-name → field) lookup over every Object type in -/// the document, so the per-entity field map isn't reconstructed inside -/// the comparison loop. -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() { - // Mirrors the actual divergence between subgraph/schema.graphql - // (source) and crates/metaboard/src/schema/metaboard.graphql - // (consumer) at the time this subcommand was added: missing - // `Transaction` entity and `MetaV1.transaction`, plus `subject` - // type mismatch (Bytes! vs BigInt!). - 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() { - // Silently passing with 0 entities verified would mask a - // misconfigured --source path or a non-subgraph SDL in CI. - 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() { - // Enums, scalars, and Object types without `@entity` must not be - // treated as entities to check. - 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() { - // `@entity(immutable: true)` must still be picked up. - 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() { - // The consumer schema is the introspected GraphQL service surface - // and contains derivative types (filters, orderBy) plus extra - // fields with arguments. Those must not cause errors. - 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() { - // Introspected derived fields look like `metas(skip: Int = 0, ...): [MetaV1!]`. - // We compare only the return type, not the args, so this must pass. - 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() { - // `Bytes` (nullable) vs `Bytes!` (non-null) are distinct types - // and must be flagged. - 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() { - // `[Bytes!]!` must match `[Bytes!]!` exactly and differ from `[Bytes!]`. - 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() { - // One run should surface every problem, not stop at the first. - 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(); - // MetaBoard.address mismatch + MetaBoard.nextMetaId missing - // + MetaV1.subject missing + Transaction entity missing = 4 - 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() { - // Use a valid-but-Object-free schema (graphql-parser rejects fully - // empty input as a parse error, which is its own test case). - let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err(); - // SOURCE_OK has 2 entities (MetaBoard, MetaV1). - 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"))); - } - - // ---------- helper-function unit tests ---------- - - 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!["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() { - // NON_NULL[LIST[NON_NULL[Bytes]]] → "[Bytes!]!" - 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).unwrap(), "[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).unwrap(), "BigInt"); - } - - #[test] - fn render_type_errors_on_missing_name() { - let bad = serde_json::json!({ "kind": "SCALAR", "name": null, "ofType": null }); - let err = render_type(&bad).unwrap_err().to_string(); - assert!(err.contains("kind `SCALAR` has no name"), "{err}"); - } - - #[test] - fn render_type_errors_on_a_truncated_wrapper_chain() { - let truncated = serde_json::json!({ - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "LIST", "name": null, "ofType": null } - }); - let err = render_type(&truncated).unwrap_err().to_string(); - assert!(err.contains("`LIST` wrapper has no `ofType`"), "{err}"); - assert!(err.contains(&TYPE_REF_DEPTH.to_string()), "{err}"); - assert!(!err.contains("Unknown"), "{err}"); - } - - #[test] - fn render_type_round_trips_a_chain_at_the_query_depth() { - let mut t = serde_json::json!({ "kind": "SCALAR", "name": "Bytes", "ofType": null }); - let mut expected = "Bytes".to_string(); - for level in 1..TYPE_REF_DEPTH { - let kind = if level % 2 == 1 { "NON_NULL" } else { "LIST" }; - t = serde_json::json!({ "kind": kind, "name": null, "ofType": t }); - expected = if kind == "NON_NULL" { - format!("{expected}!") - } else { - format!("[{expected}]") - }; - } - assert_eq!(render_type(&t).unwrap(), expected); - } - - #[test] - fn introspection_query_is_valid_graphql_nested_to_the_declared_depth() { - graphql_parser::query::parse_query::(&INTROSPECTION_QUERY).unwrap(); - assert_eq!( - INTROSPECTION_QUERY.matches("ofType").count(), - TYPE_REF_DEPTH - 1 - ); - assert!(INTROSPECTION_QUERY.contains("fields(includeDeprecated: true)")); - } - - // ---------- live-URL HTTP path ---------- - - #[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_errors_on_a_truncated_type_ref() { - 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": "metas", "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "LIST", "name": null, "ofType": null } } } - ] } - ] } } - })); - }) - .await; - - let err = fetch_live_entities_as_sdl(&server.url("/")) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("field `MetaBoard.metas`"), "{err}"); - assert!(err.contains("has no `ofType`"), "{err}"); - assert!(!err.contains("Unknown"), "{err}"); - } - - #[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(); - // reqwest's error_for_status produces "500 Internal Server Error" text. - assert!(err.to_string().contains("500")); - } - - // ---------- end-to-end CLI handler ---------- - - #[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; - - // Live introspection returns a single MetaBoard entity; consumer - // file omits it, so the error should include the live-derived SDL. - 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")); - } -} diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs index ec06d10e..fa103732 100644 --- a/crates/cli/tests/cli.rs +++ b/crates/cli/tests/cli.rs @@ -110,42 +110,6 @@ fn schema_show_refuses_op_v1() { assert!(!out.status.success()); } -/// `schema-check` reports the number of verified entities and the source -/// label on success. -#[test] -fn schema_check_prints_verified_entity_count() { - let dir = tempfile::tempdir().unwrap(); - let source = dir.path().join("schema.graphql"); - std::fs::write( - &source, - "type MetaBoard @entity { id: Bytes! }\ntype MetaV1 @entity { id: ID! }\n", - ) - .unwrap(); - let consumer = dir.path().join("consumer.graphql"); - std::fs::write( - &consumer, - "type MetaBoard { id: Bytes! }\ntype MetaV1 { id: ID! }\n", - ) - .unwrap(); - - let out = bin() - .args([ - "schema-check", - "--source", - source.to_str().unwrap(), - "--consumer", - consumer.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!(out.status.success()); - let stdout = String::from_utf8(out.stdout).unwrap(); - assert_eq!( - stdout, - "schema check ok: 2 entities verified against source\n" - ); -} - /// `generate source` reads the dotrain content from stdin when no input /// path is given, and writes the emit data JSON to stdout when no output /// path is given. diff --git a/crates/cli/tests/cli_dispatch.rs b/crates/cli/tests/cli_dispatch.rs index fdf34216..01e5a1c8 100644 --- a/crates/cli/tests/cli_dispatch.rs +++ b/crates/cli/tests/cli_dispatch.rs @@ -87,33 +87,6 @@ fn test_dispatch_validate() { assert!(!out.status.success()); } -/// `schema-check` routes to `schema_check::schema_check`: an unreadable -/// consumer snapshot is a non-zero exit before any network access. A no-op -/// arm would exit 0. -#[test] -fn test_dispatch_schema_check() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("does-not-exist.graphql"); - let out = run(&["schema-check", "--consumer", missing.to_str().unwrap()]); - assert!(!out.status.success()); - - // Happy path: identical source and consumer entity SDL verifies. - let sdl = "type Foo @entity {\n id: Bytes!\n}\n"; - let source = dir.path().join("source.graphql"); - let consumer = dir.path().join("consumer.graphql"); - std::fs::write(&source, sdl).unwrap(); - std::fs::write(&consumer, sdl).unwrap(); - let out = run(&[ - "schema-check", - "--source", - source.to_str().unwrap(), - "--consumer", - consumer.to_str().unwrap(), - ]); - assert!(out.status.success()); - assert!(stdout_utf8(&out).contains("schema check ok: 1 entities")); -} - /// `magic ls` routes to `magic::dispatch` and prints every `KnownMagic` as /// `0x `, pinning the rain meta document magic to its /// published literal.