Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graphql-schema-metrics-persisted-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': minor
---

Export GraphQL SDL schema artifact, enforce zero drift check in CI, add bounded Prometheus metrics, implement persisted query support with configurable arbitrary query toggle, and add comprehensive query execution test suite (#372, #374, #375, #378).
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ PRICE_CACHE_TTL=10
# Default timeout for outbound HTTP requests in milliseconds (default: 10000)
HTTP_TIMEOUT_MS=10000

# --- GraphQL ---
# Whether arbitrary (unpersisted) GraphQL documents are accepted.
# Defaults to true in development/test, and false in production (enforcing persisted queries only).
GRAPHQL_ALLOW_ARBITRARY_QUERIES=true

# --- Outbound request policy (SSRF) ---
# Comma-separated hostnames the app may reach when a caller supplies the target
# URL — e.g. the Bazaar MCP server fetching a node's /discovery endpoint. Leave
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ curl "https://api.example.com/price/XLM/USDC?network=mainnet"
Available at `/graphql` with GraphiQL IDE at `/graphiql`. Real-time price
streaming is available via the `priceUpdated` [subscription](#graphql-subscriptions-live-prices).

- **Schema Artifact**: Committed SDL artifact at [`schema.graphql`](schema.graphql).
- Refresh schema: `npm run schema:export`
- CI drift check: `npm run schema:check`
- **Persisted Queries**: Apollo APQ and hash execution supported with configurable arbitrary-document toggle (`GRAPHQL_ALLOW_ARBITRARY_QUERIES`, default `false` in production). Register queries via `npm run query:register`.
- **Metrics**: GraphQL operations (`graphql_operations_total`), database resolver durations (`graphql_resolver_duration_seconds`), and categorized errors (`graphql_errors_total`) with bounded cardinality on `/metrics`.

For complete GraphQL schema documentation, all queries with runnable `curl` examples, WebSocket `graphql-transport-ws` handshake specifications, and an explicit comparison against the REST surface, see [`docs/graphql.md`](docs/graphql.md).

```graphql
Expand Down
97 changes: 97 additions & 0 deletions docs/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Lens exposes a high-performance GraphQL API alongside its REST interface, powere
- [Runnable Client Example (Raw WebSocket & Handshake)](#runnable-client-example-raw-websocket--handshake)
- [Runnable Client Example (`graphql-ws` Library)](#runnable-client-example-graphql-ws-library)
5. [Capabilities & Gaps Against REST](#capabilities--gaps-against-rest)
6. [Schema Artifact & CI Drift Guard](#schema-artifact--ci-drift-guard)
7. [Persisted Queries & Security](#persisted-queries--security)
8. [Prometheus Metrics & Observability](#prometheus-metrics--observability)

---

Expand Down Expand Up @@ -551,3 +554,97 @@ The following matrix documents the differences explicitly:
| **Indexer Synchronization & Health** | `GET /status` | *No equivalent* | **REST Only**: Checking ledger heights, node lag seconds, and indexer operational status is REST-only. |
| **x402 Micropayment Facilitator** | `/verify`, `/settle`, `/supported`, `/discovery/resources`, `/usage/me` | *No equivalent* | **REST Only**: All x402 payment validation, settlement, and bazaar resource discovery routes are REST-only. |
| **Webhook Management** | `POST /webhooks`, `DELETE /webhooks/:id` | *No equivalent* | **REST Only**: Subscribing to price-threshold alert webhooks is REST-only. |

---

## Schema Artifact & CI Drift Guard

Lens provides a committed GraphQL Schema Definition Language (SDL) artifact at [`schema.graphql`](../schema.graphql). This allows client code generators (Apollo, GraphQL Code Generator, Relay) and IDE tooling to inspect the full schema statically without booting a live server.

### Commands

- **Export/Regenerate Schema:**
```bash
npm run schema:export
```
Exports the canonical, formatted SDL from `src/api/schema.ts` directly into `schema.graphql`.

- **Verify Zero Schema Drift (CI Check):**
```bash
npm run schema:check
```
Fails with exit code `1` if `schema.graphql` does not match the schema defined in TypeScript.

---

## Persisted Queries & Security

To prevent Denial of Service (DoS) via expensive, unbounded adversarial GraphQL documents, Lens supports **persisted queries**. Clients send a cryptographic SHA-256 hash instead of a full query string.

### Arbitrary Query Toggle (`GRAPHQL_ALLOW_ARBITRARY_QUERIES`)

- **Development / Test (`true`)**: Arbitrary raw GraphQL query strings are accepted alongside registered hashes.
- **Production (`false` by default)**: Only registered persisted queries are accepted. Any arbitrary query string is rejected with `400 Bad Request`:
```json
{
"data": null,
"errors": [
{
"message": "Arbitrary GraphQL queries are disabled. Only registered persisted queries are allowed."
}
]
}
```

### Executing a Persisted Query

Clients can execute persisted queries using the standard **Apollo Automatic Persisted Queries (APQ)** format:

```bash
curl -X POST http://localhost:3002/graphql \
-H "Content-Type: application/json" \
-d '{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "99d1aac6bd493749bd5db715d4ccb398a3fd85796ee1fbaccfc39740a9548a67"
}
},
"variables": {
"assetA": "XLM",
"assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
}
}'
```

Or via the Mercurius persisted format:
```bash
curl -X POST http://localhost:3002/graphql \
-H "Content-Type: application/json" \
-d '{
"persisted": true,
"query": "99d1aac6bd493749bd5db715d4ccb398a3fd85796ee1fbaccfc39740a9548a67",
"variables": { "assetA": "XLM", "assetB": "USDC" }
}'
```

### Registering Queries

The query manifest lives in [`queries/manifest.json`](../queries/manifest.json). To register a new query file or string:
```bash
npm run query:register <path-to-query.graphql> [OperationName]
```
The script validates the document against the schema, computes its SHA-256 hash, and appends it to the manifest.

---

## Prometheus Metrics & Observability

Lens exports dedicated GraphQL metrics on `GET /metrics` with strictly bounded label cardinality:

| Metric Name | Type | Labels | Description |
|---|---|---|---|
| `graphql_operations_total` | Counter | `operation_name`, `operation_type` | Counts GraphQL operations by operation name (`getPrice`, `getBestRoute`, `getPriceHistory`, `listPairs`, `priceUpdated`, `other`) and operation type (`query`, `subscription`). Unknown client-provided names are folded into `"other"` to prevent time-series cardinality explosion. |
| `graphql_resolver_duration_seconds` | Histogram | `field` | Execution duration in seconds for expensive database-backed resolvers (`getPrice`, `getBestRoute`, `getPriceHistory`). |
| `graphql_errors_total` | Counter | `category` | GraphQL errors classified into bounded categories: `"validation"`, `"not_found"`, `"unauthorized"`, `"persisted_query"`, `"internal"`, or `"other"`. |

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"smoke": "node scripts/deployed-smoke.mjs",
"test:smoke": "node --test scripts/test-deployed-smoke.mjs",
"openapi:gen": "tsx scripts/generate-openapi.ts",
"schema:export": "tsx scripts/export-schema.ts",
"schema:check": "tsx scripts/export-schema.ts --check",
"query:register": "tsx scripts/register-query.ts",
"graphql:schema:gen": "tsx scripts/generate-graphql-schema.ts",
"changeset": "changeset",
"version-packages": "changeset version",
Expand Down
27 changes: 27 additions & 0 deletions queries/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"99d1aac6bd493749bd5db715d4ccb398a3fd85796ee1fbaccfc39740a9548a67": {
"name": "GetPrice",
"hash": "99d1aac6bd493749bd5db715d4ccb398a3fd85796ee1fbaccfc39740a9548a67",
"query": "query GetPrice($assetA: String!, $assetB: String!) {\n getPrice(assetA: $assetA, assetB: $assetB) {\n assetA\n assetB\n pairKey\n price\n sdexPrice\n ammPrice\n bestRoute\n volume24h\n sdexVolume24h\n ammVolume24h\n vwap1m\n vwap5m\n vwap1h\n vwap24h\n priceChange24h\n lastUpdated\n sources\n confidence\n lastTradeAgeSeconds\n }\n}"
},
"741b1ab3f437d3f016304fe4102d5df6d00858b8f4cb65c87c4251c24d47f566": {
"name": "GetBestRoute",
"hash": "741b1ab3f437d3f016304fe4102d5df6d00858b8f4cb65c87c4251c24d47f566",
"query": "query GetBestRoute($assetA: String!, $assetB: String!, $amount: Float!) {\n getBestRoute(assetA: $assetA, assetB: $assetB, amount: $amount) {\n route\n sdexPrice\n ammPrice\n estimatedOutput\n slippagePct\n recommendation\n }\n}"
},
"47c189a4cea77c48994a7d9c8b0ce810733f5e0312a71a00743e8331ba64d1f1": {
"name": "GetPriceHistory",
"hash": "47c189a4cea77c48994a7d9c8b0ce810733f5e0312a71a00743e8331ba64d1f1",
"query": "query GetPriceHistory($assetA: String!, $assetB: String!, $window: String!, $limit: Int) {\n getPriceHistory(assetA: $assetA, assetB: $assetB, window: $window, limit: $limit) {\n bucket\n window\n vwap\n sdexVwap\n ammVwap\n volume\n tradeCount\n open\n close\n high\n low\n }\n}"
},
"c655423a31d157afc1f5b492f50189bff13ad7c781a2147bd82721a3eeee0bab": {
"name": "ListPairs",
"hash": "c655423a31d157afc1f5b492f50189bff13ad7c781a2147bd82721a3eeee0bab",
"query": "query ListPairs {\n listPairs\n}"
},
"c991c45a10395c6d0a0c5d9b93f0ef5c4ef23033638d9b9716c371ab7de5f7bd": {
"name": "PriceUpdated",
"hash": "c991c45a10395c6d0a0c5d9b93f0ef5c4ef23033638d9b9716c371ab7de5f7bd",
"query": "subscription PriceUpdated($pair: String!, $network: String) {\n priceUpdated(pair: $pair, network: $network) {\n pair\n price\n ts\n network\n }\n}"
}
}
34 changes: 34 additions & 0 deletions scripts/export-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env tsx
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { getCanonicalSchemaSDL } from '../src/api/schema'

const rootDir = resolve(__dirname, '..')
const targetFile = resolve(rootDir, 'schema.graphql')

const isCheck = process.argv.includes('--check')

const generatedSDL = getCanonicalSchemaSDL()

if (isCheck) {
if (!existsSync(targetFile)) {
console.error(`❌ Error: schema.graphql not found at ${targetFile}`)
console.error(`Run 'npm run schema:export' to generate it.`)
process.exit(1)
}

const existingSDL = readFileSync(targetFile, 'utf8').replace(/\r\n/g, '\n')
const normalizedGenerated = generatedSDL.replace(/\r\n/g, '\n')

if (existingSDL.trim() !== normalizedGenerated.trim()) {
console.error(`❌ Error: schema.graphql is out of sync with src/api/schema.ts`)
console.error(`Run 'npm run schema:export' to regenerate the GraphQL SDL artifact.`)
process.exit(1)
}

console.log(`✅ schema.graphql is in sync with src/api/schema.ts`)
process.exit(0)
} else {
writeFileSync(targetFile, generatedSDL, 'utf8')
console.log(`✅ Exported GraphQL schema SDL to ${targetFile}`)
}
75 changes: 75 additions & 0 deletions scripts/register-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env tsx
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { parse, validate, buildSchema } from 'graphql'
import { schema } from '../src/api/schema'
import { hashQuery, registerPersistedQuery } from '../src/api/persistedQueries'

const rootDir = resolve(__dirname, '..')
const manifestDir = resolve(rootDir, 'queries')
const manifestFile = resolve(manifestDir, 'manifest.json')

function loadManifest(): Record<string, { name?: string; hash: string; query: string }> {
if (existsSync(manifestFile)) {
try {
return JSON.parse(readFileSync(manifestFile, 'utf8'))
} catch {
return {}
}
}
return {}
}

function saveManifest(data: Record<string, { name?: string; hash: string; query: string }>) {
if (!existsSync(manifestDir)) {
mkdirSync(manifestDir, { recursive: true })
}
writeFileSync(manifestFile, JSON.stringify(data, null, 2) + '\n', 'utf8')
}

export function registerQueryFromFileOrString(input: string, name?: string): { hash: string; query: string } {
let queryText = input
if (existsSync(input)) {
queryText = readFileSync(input, 'utf8')
}

// Validate the query against the schema
const compiledSchema = buildSchema(schema)
const document = parse(queryText)
const errors = validate(compiledSchema, document)
if (errors.length > 0) {
const messages = errors.map(e => e.message).join('; ')
throw new Error(`Invalid GraphQL query: ${messages}`)
}

const hash = registerPersistedQuery(queryText)
const manifest = loadManifest()
manifest[hash] = {
name: name || undefined,
hash,
query: queryText.trim(),
}
saveManifest(manifest)
return { hash, query: queryText.trim() }
}

if (require.main === module || process.argv[1]?.endsWith('register-query.ts')) {
const args = process.argv.slice(2)
if (args.length === 0) {
console.log('Usage: npx tsx scripts/register-query.ts <query-file-or-string> [operationName]')
process.exit(0)
}

try {
const input = args[0]
const opName = args[1]
const result = registerQueryFromFileOrString(input, opName)
console.log(`✅ Successfully registered persisted query:`)
console.log(` Hash: ${result.hash}`)
if (opName) console.log(` Name: ${opName}`)
console.log(` Manifest updated at: ${manifestFile}`)
} catch (err: any) {
console.error(`❌ Failed to register query: ${err.message}`)
process.exit(1)
}
}
Loading