Skip to main content

GraphQL Client

Category: Backend & APIs

Get this pack →

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

MethodRoutePurpose
POST/graphql/syncRun a parameterised query, check errors, upsert the result.
GET/graphql/usersList synced records (?limit=, default 25).
POST/api/seedCreate the graphql_users table (from seed.yml).

Setup

  1. Set the managed variables:
    • DATABASE_URL — Postgres connection string
    • GRAPHQL_ENDPOINT — e.g. https://api.github.com/graphql
    • GRAPHQL_TOKEN — bearer token for that endpoint
  2. 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);