GraphQL
Ingest a GraphQL API by putting the query in the manifest — a request shape on the REST engine (
connector_typestaysrest-generic), with Relay pagination required.
| Type | rest-generic (shape graphql) |
| Protocol | GraphQL over POST |
| Target | ext.* hybrid |
| Schedulable | cron |
| Edition | Open |
Auth, retry, rate limiting and the hybrid JSONB + typed storage are all shared with
REST. This page covers what is different: the query, Relay pagination, and what
happens when the server answers 200 and still says no.
Quickstart
The screens below read the demo API that ships with the repo — the same one
the REST page uses, under its POST /graphql shape. Start it, and put its key in the registry:
python3 docs/connectors/rest/demo/acme-api.py
lm secret set ACME_API_KEY # demo-key
# 70-graphql-relay.yaml — GraphQL is a request shape of the REST connector.
#
# `connector_type` stays rest-generic. The request is a POST carrying {"query", "variables"} to
# one URL; the cursor rides in `variables`.
#
# A graphql endpoint must declare `kind: relay` with `cursor_binding: variable`. Both are refused
# at apply otherwise.
#
# Relay puts the rows under edges[].node and the paging state under pageInfo:
# cursor_path → $.data.partners.pageInfo.endCursor
# has_more_path → $.data.partners.pageInfo.hasNextPage
# response_path → $.data.partners.edges[*].node
# cursor_var → after — the name of $after in the query, without the $
#
# Page size lives in the query (`first: 500`).
#
# Expect: 2 400 rows in ext.acme_graphql_partners.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: acme-graphql
connector_type: rest-generic
scopes: [billing]
spec:
base_url: http://host.docker.internal:8099
graphql_path: /graphql
auth:
kind: api-key
in: header
name: X-API-Key
value_env: ACME_API_KEY
endpoints:
- id: partners
kind: graphql
query: |
query($after: String) {
partners(first: 500, after: $after) {
edges { cursor node { id name city credit_limit } }
pageInfo { hasNextPage endCursor }
}
}
pagination:
kind: relay
cursor_binding: variable
cursor_var: after
cursor_path: "$.data.partners.pageInfo.endCursor"
has_more_path: "$.data.partners.pageInfo.hasNextPage"
response_path: "$.data.partners.edges[*].node"
target:
kind: hybrid
table: ext.acme_graphql_partners
unique_key: "$.id"
promote:
- { name: name, path: "$.name", type: text }
- { name: city, path: "$.city", type: text }
- { name: credit_limit, path: "$.credit_limit", type: bigint }
$ lm apply -f docs/connectors/rest/demo/70-graphql-relay.yaml
Applied connector "acme-graphql" — 1 endpoint
$ lm source rest test acme-graphql
ENDPOINT METHOD STATUS ITEMS VERDICT
partners POST 200 500 ok
The pre-flight sends the request a run sends (POST): one page, 500 nodes, nothing written.
$ lm connector run acme-graphql
run scheduled: f26868f0-641e-471a-8719-e6c4c2957fee
$ lm run list --connector acme-graphql
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
f26868f0 acme-graphql partners Completed 2400 2400 0 0 2026-10-19 01:45 2026-10-19 01:45
The manifest
| Field | Where | Required | Notes |
|---|---|---|---|
graphql_path |
spec |
no | The single POST endpoint; requests go to base_url + graphql_path (default /graphql) |
kind: graphql |
endpoint | no | An endpoint carrying a query is treated as GraphQL either way — the examples declare it for readability |
query |
endpoint | yes | The query document; declare the cursor variable it expects (e.g. $after) |
variables |
endpoint | no | Static variables sent with every request; the relay cursor is injected on top |
pagination |
endpoint | yes | Hard rule: kind: relay with cursor_binding: variable — anything else is refused at apply |
cursor_var |
pagination | yes | The variable that receives endCursor (e.g. after) |
cursor_path, has_more_path, response_path |
pagination | yes | JSONPaths to endCursor, hasNextPage and the node list |
target |
endpoint | yes | The same hybrid target as REST: table, unique_key, promote |
incremental |
endpoint | no | mode: watermark sends the mark as the variable named by param — see Incremental |
path and method are implicit: the request is always a POST to graphql_path.
- In the query the variable carries its sigil (
$after).cursor_var: afternames it without. - There is no page-size field: page size is in the query itself (
first: 500above). response_pathpoints at the nodes (edges[*].node): each node is one row, without theedgeswrapper.- Any
paginationother thankind: relaywithcursor_binding: variableis refused bylm apply, which shows the block to write.
The two ways a GraphQL server says no on HTTP 200
A GraphQL server may answer 200 with an errors array, or with "data": null. Both are
refused. lm source rest test says so before any run:
$ lm source rest test acme-graphql-errors
ENDPOINT METHOD STATUS ITEMS VERDICT
errors-array POST 200 0 GraphQL response carried 1 error(s); first: Cannot query field 'boom' on type 'Query'
null-data POST 200 0 GraphQL response has no errors and null/absent data (malformed envelope): {"data": null}
Error: 2 of 2 enabled endpoint(s) did not answer with readable data
A run fails with the same message:
# 80-graphql-errors.yaml — the two ways a GraphQL server says no on HTTP 200.
#
# GraphQL does not use status codes to report a failed query. A server that cannot resolve a
# field answers 200 with an `errors` array; one whose resolver blew up answers 200 with
# `"data": null`. Read as transport, both are successes — and a run that reads them as data
# ingests nothing and completes green.
#
# Both endpoints below must FAIL, and `lm source rest test acme-graphql-errors` must refuse
# them before any run is started.
#
# The demo API triggers them from the query text: a query mentioning `boom` gets the errors
# array, one mentioning `nullData` gets the null envelope.
#
# Expect: both endpoints Failed, each naming what came back.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: acme-graphql-errors
connector_type: rest-generic
scopes: [billing]
spec:
base_url: http://host.docker.internal:8099
graphql_path: /graphql
auth:
kind: api-key
in: header
name: X-API-Key
value_env: ACME_API_KEY
endpoints:
- id: errors-array
kind: graphql
query: |
query($after: String) {
boom(first: 500, after: $after) {
edges { cursor node { id } }
pageInfo { hasNextPage endCursor }
}
}
pagination:
kind: relay
cursor_binding: variable
cursor_var: after
cursor_path: "$.data.boom.pageInfo.endCursor"
has_more_path: "$.data.boom.pageInfo.hasNextPage"
response_path: "$.data.boom.edges[*].node"
target:
kind: hybrid
table: ext.acme_gql_errors
unique_key: "$.id"
promote:
- { name: name, path: "$.name", type: text }
- id: null-data
kind: graphql
query: |
query($after: String) {
nullData(first: 500, after: $after) {
edges { cursor node { id } }
pageInfo { hasNextPage endCursor }
}
}
pagination:
kind: relay
cursor_binding: variable
cursor_var: after
cursor_path: "$.data.nullData.pageInfo.endCursor"
has_more_path: "$.data.nullData.pageInfo.hasNextPage"
response_path: "$.data.nullData.edges[*].node"
target:
kind: hybrid
table: ext.acme_gql_nulldata
unique_key: "$.id"
promote:
- { name: name, path: "$.name", type: text }
$ lm run list --connector acme-graphql-errors
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
2ca12c44 acme-graphql-errors null-data Failed 0 0 0 0 2026-10-19 01:45 2026-10-19 01:45
84f519e9 acme-graphql-errors errors-array Failed 0 0 0 0 2026-10-19 01:45 2026-10-19 01:45
$ lm run get 84f519e9
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
84f519e9 acme-graphql-errors errors-array Failed 0 0 0 0 2026-10-19 01:45 2026-10-19 01:45
✖ run failed: GraphQL response carried 1 error(s); first: Cannot query field 'boom' on type 'Query'
Nothing is ingested and nothing is dead-lettered. Fix the query, or the permission it needs, and run again.
Headers
spec.headers[] applies to every request, as for REST request headers.
Incremental: the mark rides in a variable
On a graphql endpoint, incremental.param names a GraphQL variable. The stored mark is
sent in the request's variables, on every page of the run:
endpoints:
- id: partners
kind: graphql
query: |
query($after: String, $updated_since: String) {
partners(first: 500, after: $after, updatedSince: $updated_since) {
edges { cursor node { id name updated_at } }
pageInfo { hasNextPage endCursor }
}
}
incremental:
mode: watermark
param: updated_since # the variable, without the $
watermark_path: "$.updated_at"
- The query must declare the variable.
lm applyrefuses aparamthe query does not declare. - Declare it nullable (
String, notString!). The first run has no mark yet and sends none. - The server filters on it. The demo API reads
$updated_sinceand returns the partners changed at or after it.
The demo's acme-graphql with this incremental block (and updated_at added to the node),
run twice:
$ lm run get 0e15e816
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED EVALUATED
0e15e816 acme-graphql-inc partners Completed 2400 2400 0 0 2026-10-25 11:16 2026-10-25 11:16 yes
$ lm run get beb5aab8
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED EVALUATED
beb5aab8 acme-graphql-inc partners Completed 2 2 0 0 2026-10-25 11:16 2026-10-25 11:16 yes
The second run asks only from the mark. It reads a little before it, by the length of the previous run plus one minute: a change during a run.
Not yet supported
Mutations, subscriptions, schema-introspection discovery, persisted queries, query batching, per-endpoint header overrides.
How to…
Send static variables with every request
endpoints:
- id: partners
kind: graphql
variables: { locale: fr-FR } # merged into every request
Result: every request carries locale: fr-FR in its GraphQL variables, beside the relay
cursor.
Read a second collection from the same API
Add a second endpoint with its own query, its own pagination paths (they name the collection:
$.data.<collection>.pageInfo.endCursor) and its own table. One connector, one API, one
credential. lm run list shows one run per endpoint.
Troubleshooting
| Symptom | Cause | Gesture |
|---|---|---|
| Run fails though the HTTP status was 200 | an errors[] array, or data: null with no errors |
read the message in lm run get — it carries the server's first error verbatim |
Run failed on pagination.response_path reads nothing on the first page |
response_path does not match the envelope |
lm source rest test <connector> shows the same message as its verdict; check response_path against the answer |
cursor_binding: variable refused at apply |
that binding is only valid on relay on a graphql endpoint |
use query or header binding on a plain REST endpoint |
Run failed on the source sent back the cursor it was given while saying more pages |
cursor_path reads a value that does not change, or cursor_var is not the query's variable |
point cursor_path at pageInfo.endCursor, the opaque cursor the server hands back; check cursor_var against the query |
See also
- REST — the engine underneath (auth, retry, rate limit, storage layout)
- Pagination —
relay, and the binding rule above - The demo API — the source these screens read
- Overview
- Transformers
- Diagnosing a failed run