POST /api/v1/graphql queries and mutations
GET /api/v1/graphql the schema, as SDLSame bearer tokens, same workspace bucket, same scopes, same visibility rules as
/api/v1/content. It is a protocol adapter over the same services, not a
second implementation — a resolver translates a GraphQL field and its arguments
into the DTO the REST read already takes, calls the same service, and hands the
result back.
A token minted before GraphQL existed works against it unchanged.
A query
query Homepage($locale: String) {
articles(
pageSize: 6
sort: "-publishedAt"
filter: { and: [{ field: "featured", op: eq, value: true }] }
locale: $locale
) {
total
items {
id
title
slug
author {
name
}
coverImage {
url
alt
}
}
}
} curl -X POST https://cms.example.com/api/v1/graphql \
-H 'Authorization: Bearer apograph_…' \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles(pageSize: 3) { items { id title } } }"}' const query = 'query Homepage($locale: String) { articles(pageSize: 6, sort: "-publishedAt", locale: $locale) { total items { id title slug } } }';
const response = await fetch('https://cms.example.com/api/v1/graphql', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.APOGRAPH_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query, variables: { locale: 'de' } })
});
const { data, errors } = await response.json(); // always HTTP 200 Naming
A content type called blog_post becomes:
| Thing | Name |
|---|---|
| The object type | BlogPost |
| The single-entry field | blogPost(id: …) |
| The list field | blogPosts(…) |
| Mutations | createBlogPost, updateBlogPost, publishBlogPost, unpublishBlogPost, deleteBlogPost |
The selection set becomes a projection
Selecting { id title } narrows the SQL projection exactly as
?fields=id,title does — an unselected rich-text column is never read from the
database. That is not an optimisation layered on top; the selection is
translated into the same query parameters the REST path takes, so it comes for
free.
Fragments are followed, since that is how real clients select.
Introspection differs per token
The schema is built per workspace grant set, not once at boot.
REST prunes the type list and 404s an ungranted type so a token cannot enumerate the content model beyond what its workspace exposes. A single global schema would hand every token the entire model through introspection — a strictly worse leak than the one REST goes out of its way to avoid.
Two tokens can get different SDL, and that is correct
If you compare the schema fetched with two different tokens and find they disagree, that is the design rather than a bug. An ungranted type is not in your schema at all, so naming it is a validation error rather than a 404 — the field does not exist and the request never reaches a resolver.
A relation whose target is ungranted is omitted from the object type and from the write input — exposing a settable field pointing into an invisible type would advertise that type’s existence in the SDL.
The schema is cached per grant set with a TTL, but that cache is a freshness knob and not a security boundary: every resolver re-checks the live grant set, so a stale schema can describe a just-revoked type but can never read it.
Where the shapes differ from REST
| REST | GraphQL | Why |
|---|---|---|
A single type is served by the list route; clients take items[0] | landing(locale:) returns the record | items[0] is storage leaking into a published API |
A relation is not in values; it arrives under relations when expanded | author { name } — the target itself | A many-to-one holds at most one target |
{ items, total, page, pageSize } | { items, total } | The caller passed page and pageSize; echoing them is noise |
"published" | PUBLISHED | GraphQL enum convention; the value maps back |
DELETE returns 204 | deleteArticle returns true | Inventing a payload would be the protocols disagreeing |
| Errors carry an HTTP status | HTTP 200, with errors[].extensions.status | How GraphQL works |
Everything else is byte-identical, and the end-to-end suite asserts it directly: the same fixture read through both protocols, compared field by field.
That last row is the one thing a consumer porting from REST must adjust to. A
GraphQL error is a 200 with an errors array; the HTTP status it would have
had is in extensions.status.
Mutations
Mutations mirror the REST write routes one for one, and need a full token.
mutation {
createArticle(input: {
values: { title: "Hello", slug: "hello" }
relations: { tags: { link: ["9c4b…"] } }
}) {
id
status
}
}Merge semantics are preserved exactly: a field you omit is left alone, and an
explicit null clears. Input fields therefore declare no default values — a
default would materialise the key and turn every omitted field into an
overwrite.
A mutation result can be read back immediately, including its relations, even though a create always produces a draft and the public default is published-only. Holding a draft already implies the right to see one.
Cost limits
A GraphQL document is not structurally bounded the way one REST route is, so a budget is enforced after parsing and before execution.
| Limit | Default | Catches |
|---|---|---|
maxQueryLength | 16384 | An enormous document, before it is parsed |
maxDepth | 8 | Deep nesting |
maxFields | 500 | Aliasing one expensive field many times |
maxComplexity | 1000 | Shallow but wide — page sizes multiplied down the nesting path |
| Operations per request | 1 | Multiplying every other budget |
Each is configurable on the plugin:
ContentGraphqlPlugin({ limits: { maxDepth: 6, maxComplexity: 500 } });maxFields counts every selection, aliases and plain fields alike — there
is no way to tell them apart and no reason to.
The complexity estimator assumes every list comes back full, because the point
is to refuse shapes that can be enormous. A field carrying id: or
localeGroupId: counts as one record, since it addresses a single row.
Introspection is enabled and exempt from the budget: the endpoint is
authenticated, the schema is already pruned to the caller’s grants, and
__schema is answered from an object already in memory.
Nested reads batch per level
GraphQL callers nest, and the naive implementation of that is an N+1. Every request made in one execution tick is collected and issued as one query per level, running through the same visibility rules as any other public read.
Page two of a relation is not batched
page > 1 on a relation field falls through to the same call
/relations/:field serves, which on a wide list is one query per row. It is a
targeted request and the complexity budget bounds it, and the SDL says so on the
argument — but it is worth knowing before you page relations inside a list.
GraphiQL
GET /api/v1/graphql/playground serves GraphiQL when the API reference is
enabled, so the schema can be explored interactively with a real token.
What to read next
- Front-end frameworks — generating a typed client from the SDL your token sees.
- Filtering — the same filter grammar, which the
filter:argument takes verbatim. - The GraphQL plugin — registering the adapter and the environment variables that bound a query.