Skip to content

GraphQL

zeython.graphql serves a GraphQL endpoint built on graphql-core, the pure-Python reference GraphQL implementation. Zeython owns the transport — the endpoint, request parsing, error formatting, and an optional interactive GraphiQL UI. You own the schema.

Not registered by default, and graphql-core is an optional dependency — install it with:

pip install zeython[graphql]

What this is, and what it deliberately isn't

This is not Strawberry- or Ariadne-style automatic schema generation from type-hinted Python classes — the same reasoning zeython.openapi gives for not auto-generating request models from type hints applies here too. You build a graphql.GraphQLSchema however graphql-core lets you: programmatically with GraphQLObjectType/ GraphQLField, or from an SDL string via graphql.build_schema() with resolvers attached afterward. Zeython just executes queries against whichever one you hand it.

Setup

# app/graphql/schema.py
from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLString

async def resolve_hello(root, info, name="World"):
    return f"Hello, {name}!"

query_type = GraphQLObjectType(
    name="Query",
    fields={"hello": GraphQLField(GraphQLString, args={"name": GraphQLString}, resolve=resolve_hello)},
)

schema = GraphQLSchema(query=query_type)
# main.py
from zeython import Application, GraphQLServiceProvider
from app.graphql.schema import schema

app = Application()
app.register(GraphQLServiceProvider(app, schema=schema))
curl -X POST http://localhost:8000/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ hello(name: \"Ada\") }"}'
# {"data": {"hello": "Hello, Ada!"}}

A single route (default /graphql) handles both verbs: POST executes a query/mutation from a JSON body (query, optional variables/ operationName); GET serves the interactive GraphiQL UI, when enabled.

Reaching your app's services from a resolver

Every resolver's info.context is a dict with request (the current starlette.requests.Request) and container (the app's Container) — the same things a plain HTTP handler already has, just handed to resolvers instead of read off the request directly:

async def resolve_me(root, info):
    request = info.context["request"]
    return current_user(request)

async def resolve_posts(root, info):
    return await Post.all()  # a Model query works the same as anywhere else --
                              # the request-scoped session is already open

The GraphiQL UI

GraphQLServiceProvider(app, schema=schema, graphiql=True)

graphiql defaults to app.config.debug — visible locally, off in production, the same default Swagger UI and the HTML debug error page use, since it exposes your whole schema and lets anyone run arbitrary queries against it. Pass graphiql=True/False explicitly to override that default in either direction. Loads React and GraphiQL from a CDN, same zero-setup approach as Swagger UI — a static asset bundle, not something recompiled per request.

With graphiql off (the production default), a GET request to the endpoint gets a 405 instead of the UI.

Query complexity limits

Every query is checked before execution against two limits, since graphql-core itself enforces neither and a single request costs the server whatever it takes to run — a deeply nested or highly-aliased query can make that exponentially more than the request itself cost to send:

  • max_depth (default 15) rejects a query nested deeper than that — the standard shape of a GraphQL denial-of-service query (the same field aliased and re-nested inside itself hundreds of times).
  • max_tokens (default 10,000) bounds how much the parser reads before giving up, so a pathologically large query body can't make parsing itself the expensive step.

Either rejection comes back as an ordinary GraphQL error response (data is null, errors explains why) — never a raised exception or a 5xx. Pass max_depth=None/max_tokens=None to disable a check, e.g. for an internal API you trust the callers of.

Configuration

GraphQLServiceProvider(
    app,
    schema=schema,
    path="/graphql",     # default
    graphiql=None,       # default: falls back to app.config.debug
    max_depth=15,        # default
    max_tokens=10_000,   # default
)

Errors

execute_graphql() — the function the service provider calls, also usable directly (e.g. from a console command, or a test) — always returns 200 from the HTTP endpoint, with the GraphQL-over-HTTP-standard body shape:

{"data": {"hello": null}, "errors": [{"message": "...", "path": ["hello"], "locations": [...]}]}

errors is only present when at least one occurred — a query syntax error, a resolver that raised, or a value that failed the schema's own validation. This is GraphQL convention, not a Zeython choice: a GraphQL request can partially succeed (some fields resolved, one didn't), which a single HTTP status code can't represent — the client always reads errors, the same way it always reads data.

A request with a body that isn't valid JSON, or is JSON but has no query field, gets a 400 — that's malformed at the transport level, before GraphQL execution ever starts.

Scope limits

Single-schema, single-endpoint, query/mutation execution — no schema stitching or federation across services, no subscriptions (GraphQL's WebSocket-based push model; see WebSockets for Zeython's own real-time primitives if you need push today), and no field-level query cost accounting (a per-field weight multiplied across list arguments, the way some GraphQL gateways meter it) — only the depth/token limits above. For an API with those requirements, or a schema large enough to want to split across teams/services, a dedicated GraphQL gateway is a better fit than growing this module to match it.

API reference

See zeython.graphql for the full API.