GraphQL Client
Category: Backend & APIs
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
Query a GraphQL API with variables and store the result — with the error handling GraphQL actually requires.
The trap: a failed GraphQL query returns HTTP 200
REST tells you it failed with a status code. GraphQL doesn't. A query that fails —
unauthorised, bad field, missing record — comes back as HTTP 200 with an errors array
next to a (usually null) data:
{ "data": null, "errors": [{ "message": "Could not resolve to a User with the login of 'ghost'." }] }
So an HTTP-status check alone treats that as success and happily writes nulls into your database. This pack asserts on the errors array explicitly, before anything is stored:
- name: CheckGraphqlErrors
input: a|Query|
assert:
http_code_on_error: 502
error_message: "GraphQL query returned errors"
tests:
- value: body.errors
is_null: true
Endpoints
| Method | Route | Purpose |
|---|---|---|
| POST | /graphql/sync | Run a parameterised query, check errors, upsert the result. |
| GET | /graphql/users | List synced records (?limit=, default 25). |
| POST | /api/seed | Create the graphql_users table (from seed.yml). |
Setup
- Set the managed variables:
DATABASE_URL— Postgres connection stringGRAPHQL_ENDPOINT— e.g.https://api.github.com/graphqlGRAPHQL_TOKEN— bearer token for that endpoint
- Seed the table:
POST /api/seed.
Try it
curl -s -X POST https://<your-host>/graphql/sync \
-H 'content-type: application/json' \
-d '{"login":"octocat"}'
{ "login": "octocat", "name": "The Octocat", "repos": 8, "synced": true }
A login that doesn't exist returns 502 and writes nothing — rather than a 200 with a row
full of nulls.
Variables, not string concatenation
The query and its variables are separate fields:
body: |-
{
"query": "query($login: String!) { user(login: $login) { login name } }",
"variables": { "login": "a|ValidateBody::login->json_escape|" }
}
Interpolating a value into the query text is how you get both breakage on quotes and a
GraphQL injection. Keep values in variables, and keep ->json_escape on any string going
into a JSON body.
Adapting it
Change the query string, the variables, and the a|Query::body.data...| paths in the
insert to match your schema. The error check needs no changes — errors is standard across
every GraphQL server.
Configuration
config.yml
name: GraphqlClient
docs: true
# Query a GraphQL API and store the result — with the error handling GraphQL
# actually needs.
#
# The trap: a GraphQL server returns HTTP 200 even when the query FAILED. Errors
# arrive in an `errors` array alongside a (possibly null) `data`. An HTTP-status
# check alone therefore treats a failed query as success and stores nulls. This
# pack asserts on `body.errors` explicitly, before anything is written.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string
# GRAPHQL_ENDPOINT — e.g. https://api.github.com/graphql
# GRAPHQL_TOKEN — bearer token for the endpoint
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /graphql/sync
# Body: { "login": "octocat" }
# Runs a parameterised GraphQL query and upserts the result.
graphql/sync:
output: http
method: POST
summary: Run a GraphQL query with variables and store the result
description: >
POSTs a query + variables to a GraphQL endpoint, fails loudly on a GraphQL
`errors` array (which arrives with HTTP 200), and upserts the result.
tags: [graphql, integration, sync, postgres]
request_example: |-
{ "login": "octocat" }
response_example: |-
{ "login": "octocat", "name": "The Octocat", "repos": 8, "synced": true }
actions:
- name: ValidateBody
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "login is required"
tests:
- value: login
is_not_null: true
is_not_empty: true
# Query and variables are separate fields — never string-concatenate a value
# into the query text.
- name: Query
run_when_succeeded: [ValidateBody]
http:
url: a|ap_var::GRAPHQL_ENDPOINT|
method: POST
bearer_auth: a|ap_var::GRAPHQL_TOKEN|
headers:
content-type: application/json
body: |-
{
"query": "query($login: String!) { user(login: $login) { login name repositories { totalCount } } }",
"variables": { "login": "a|ValidateBody::login->json_escape|" }
}
# THE GRAPHQL-SPECIFIC STEP. A failed query still returns HTTP 200, so the
# only way to notice is to assert the errors array is absent.
- name: CheckGraphqlErrors
run_when_succeeded: [Query]
input: a|Query|
hide_data_on_success: true
assert:
http_code_on_error: 502
error_message: "GraphQL query returned errors"
tests:
- value: body.errors
is_null: true
- name: Save
run_when_succeeded: [CheckGraphqlErrors]
database: main
query: |-
INSERT INTO graphql_users (login, name, repo_count, synced_at)
VALUES (
a|Query::body.data.user.login->single_quote|,
a|Query::body.data.user.name->default('')->single_quote|,
a|Query::body.data.user.repositories.totalCount->default(0)|,
NOW()
)
ON CONFLICT (login) DO UPDATE SET
name = EXCLUDED.name,
repo_count = EXCLUDED.repo_count,
synced_at = NOW()
RETURNING login, name, repo_count
post_transforms:
- extract_value: "[0]"
- name: Respond
run_when_succeeded: [Save]
json_output: |-
{
"login": "a|Save::login|",
"name": "a|Save::name|",
"repos": a|Save::repo_count|,
"synced": true
}
# GET /graphql/users?limit=25
graphql/users:
output: http
method: GET
summary: List synced records
tags: [graphql, postgres]
actions:
- name: ReadParams
input: a|params|
hide_data_on_success: true
- name: ListUsers
run_when_succeeded: [ReadParams]
database: main
query: |-
SELECT login, name, repo_count, synced_at
FROM graphql_users
ORDER BY synced_at DESC
LIMIT a|ReadParams::limit->default(25)|
seed.yml
name: GraphqlClientSeed
description: Creates the graphql_users table (idempotent). Safe to re-run — resets to empty.
docs: true
# Required managed variables:
# DATABASE_URL — Postgres connection string
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /api/seed
api/seed:
output: http
method: POST
actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS graphql_users (
login TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
repo_count INTEGER NOT NULL DEFAULT 0,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- name: Reset
database: main
run_when_succeeded: [CreateTable]
hide_data_on_success: true
query: "TRUNCATE TABLE graphql_users;"
- name: Respond
run_when_succeeded: [Reset]
json_output: |-
{ "status": "seeded" }
schema.sql
-- Records synced from a GraphQL API.
CREATE TABLE IF NOT EXISTS graphql_users (
login TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
repo_count INTEGER NOT NULL DEFAULT 0,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graphql_users_synced_at ON graphql_users (synced_at DESC);