Skip to content

Schema-aware validation and schema drift detection (0.2.0) - #1

Merged
MobyNL merged 4 commits into
mainfrom
feat/schema-validation
Aug 17, 2026
Merged

Schema-aware validation and schema drift detection (0.2.0)#1
MobyNL merged 4 commits into
mainfrom
feat/schema-validation

Conversation

@MobyNL

@MobyNL MobyNL commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Adds the half of GraphQL validation the library was missing, plus drift detection against a committed snapshot.

Why

Validate Query only ever parsed. It passed { user(id: 1) { nickname } } against a schema with no nickname field, because catching that needs the schema. Validation against a schema is GraphQL specification §5 rather than an extra, and graphql-core — already a dependency — implements all 27 rules. The library simply never called them.

What

  • Queries are checked against the endpoint's own schema before they are sent, reporting each problem with its line and column. On by default; validate_against_schema=False turns it off.
  • Query Should Be Valid Against Schema for an explicit check. Validate Query is unchanged and now documents that it is syntax-only.
  • Save Schema Snapshot, Get Schema Breaking Changes, Get Schema Dangerous Changes, Schema Should Have No Breaking Changes.
  • Refresh Graphql Schema.
  • The schema is introspected once per endpoint and held. Measured at 595 KB against a real endpoint, so caching is what makes this affordable at all.

Two things found only by testing against a real server

Neither reproduced against the acceptance server:

  1. A schema behind authentication was never picked up. The first query on a session is often the login itself, so introspection 401s. Latching on that first failure left validation off for the entire suite — exactly where it was wanted. It is now retried once after an operation succeeds, with a bounded attempt count so a server that genuinely refuses is not asked before every query.

  2. Drift detection reported a false positive. A snapshot compared against itself claimed DIRECTIVE_DEFINITION was removed from deprecated, because graphql-js describes @deprecated with four locations while graphql-core's built-in adds a fifth. Every comparison against a real JS server would have hit it. Both sides are now built the same way.

Verification

  • 162 unit tests, 97% coverage
  • 61 acceptance tests, including atest/schema_tests.robot unchanged — the regression test proving the rewritten schema keywords kept their contract
  • ruff, mypy, robocop clean; twine check passes
  • Verified against a live endpoint (85 queries / 149 mutations / 436 types): a typo'd field caught locally in 0.37s, cached re-check in 0.003s, and a 120 KB SDL snapshot round-tripping with no spurious changes

A server with introspection disabled degrades to a warning rather than failing, since that is a normal thing to test against.

🤖 Generated with Claude Code

MobyNL and others added 4 commits August 17, 2026 21:36
The groundwork for checking queries against a schema. A schema is read with
graphql-core's own full introspection query, because the minimal one the schema
keywords used cannot build a schema object at all: build_client_schema needs each
type's kind, the ofType chain, field types, arguments, interfaces and possible
types, and rejects anything partial.

That query is expensive - 595 KB against the endpoint this was measured on, where
the minimal one was 4.6 KB - so the result is cached. The cache is keyed on the
same connection parameters as the client cache, so aliases against one endpoint
share a schema exactly as they already share a connection pool, and a closed
client takes its schema with it rather than leaving a stale one behind for the
next session on that endpoint.

fetch_schema lives in its own module rather than on either keyword class: both
the executing keywords and the schema keywords need it, and neither owns the
other.

SchemaUnavailable is deliberately not GraphQLResponseError. Callers have to be
able to tell 'the server would not describe itself' apart from 'the server
reported errors', because the two deserve opposite responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parsing catches a query that is not GraphQL. It cannot catch one that is valid
GraphQL but wrong for this server: a misspelled field, an argument that does not
exist, a selection missing its subfields. That check needs the schema, is part of
the specification rather than an extra, and graphql-core already implements all 27
rules - so this is mostly a matter of calling it.

On by default, since a query that cannot succeed is worth catching whether or not
a suite remembered to ask. validate_against_schema=False turns it off, and
validate_queries=False turns it off with the other local checks.

Two behaviours make 'on by default' safe rather than obstructive:

A server that will not describe itself is not a failure. Introspection is commonly
disabled outside development, so failing there would break every suite pointed at
staging. The check is skipped with a warning instead.

It is retried once after an operation succeeds. Found against a real endpoint: the
first query on a session is very often the login, and a schema behind
authentication answers 401 until that has run. Latching on the first failure left
validation off for the whole suite, which is precisely where it was wanted.
Attempts are capped, so a server that genuinely refuses is asked twice rather than
before every query.

Failures raise ValueError, matching the convention for a bad call caught before
anything is sent. GraphQLResponseError would be wrong: nothing was sent, so there
is no response.

Execute Raw Request still bypasses all of this, as documented.

The shared unit-test fixture switches validation off, because it puts a request in
front of the one each of those tests reads back. The default path has its own
tests rather than being left untested behind a disabled fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he schema

Save Schema Snapshot writes the schema as SDL; Get Schema Breaking Changes, Get
Schema Dangerous Changes and Schema Should Have No Breaking Changes compare a live
endpoint against one. SDL rather than the introspection JSON, because a snapshot is
only worth committing if the diff is readable - a removed field then shows up in
review rather than as a puzzling failure months later. Breaking and dangerous are
reported apart because they answer different questions: a removed field breaks a
query outright, a value added to an enum only breaks a suite that switches on it.

The comparison builds both sides the same way. Comparing snapshot SDL against a
live introspected schema reported a change that was not one: a server built on
graphql-js describes @deprecated with four locations, while graphql-core's built-in
adds DIRECTIVE_DEFINITION, so every comparison against a real JS server claimed a
directive location had been removed. Printing the live schema and reading it back
leaves only real differences. Found by running this against a real endpoint; the
acceptance server never showed it.

The existing keywords now read the cached schema object instead of raw
introspection JSON, which is what removed the hand-written introspection query.
Names, arguments, return values and error messages are unchanged, so
atest/schema_tests.robot passes untouched - that suite is the regression test for
this part.

The unit tests build their fake introspection by running graphql-core's
introspection query against a schema built from SDL. The hand-rolled fixture they
used before cannot build a client schema, which is the same limitation that made
the old introspection query unusable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also corrects the note on the graphql-core dependency, which claimed the library
called `validate` directly when it only ever called `parse`. That is finally true,
and the note now lists what is actually imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MobyNL
MobyNL merged commit 1ae68fc into main Aug 17, 2026
11 checks passed
@MobyNL
MobyNL deleted the feat/schema-validation branch August 17, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant