Features Apograph CMS on GitHub

GraphQL

GraphQL plugin @apograph/content-graphql

The same API in a second protocol, with a schema built per workspace grant set.

Documents 0.5.2 Updated Edit this page Report a problem

On this page
POST /api/v1/graphql     queries and mutations
GET  /api/v1/graphql     the schema, as SDL

Same 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

Example language
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:

ThingName
The object typeBlogPost
The single-entry fieldblogPost(id: …)
The list fieldblogPosts(…)
MutationscreateBlogPost, 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

RESTGraphQLWhy
A single type is served by the list route; clients take items[0]landing(locale:) returns the recorditems[0] is storage leaking into a published API
A relation is not in values; it arrives under relations when expandedauthor { name } — the target itselfA 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"PUBLISHEDGraphQL enum convention; the value maps back
DELETE returns 204deleteArticle returns trueInventing a payload would be the protocols disagreeing
Errors carry an HTTP statusHTTP 200, with errors[].extensions.statusHow 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.

LimitDefaultCatches
maxQueryLength16384An enormous document, before it is parsed
maxDepth8Deep nesting
maxFields500Aliasing one expensive field many times
maxComplexity1000Shallow but wide — page sizes multiplied down the nesting path
Operations per request1Multiplying 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.

  • 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.