Initial Commit
This commit is contained in:
69
packages/api-stack/api/api-graphql-apollo/README.md
Normal file
69
packages/api-stack/api/api-graphql-apollo/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# @suaveplan/api-graphql-apollo — Apollo Server GraphQL engine adapter for the Suaveplan API plugin system. Implements the GraphqlEngine contract from @suaveplan/api-graphql and passes runGraphqlEngineConformance with the same invariants as the Yoga sibling. Adds Apollo-specific persisted-query manifest support, optional Apollo Studio usage reporting, depth + complexity guards, federated multi-plugin schema merging, per-request DataLoader scoping, engine-agnostic OTel instruments, and graphql-ws subscription bridging.
|
||||
|
||||
Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API
|
||||
plugin system. Implements the `GraphqlEngine` contract from
|
||||
`@suaveplan/api-graphql` and passes the conformance suite alongside
|
||||
the Yoga sibling — switching engines is a host-config change.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [http](./src/endpoints/http.md) — `endpoints/http` mounts the two HTTP endpoints every GraphQL adapter must expose: `POST /graphql` for operation dispatc…
|
||||
- [ui-proxy](./src/endpoints/ui-proxy.md) — `endpoints/ui-proxy` mounts the `/graphql/ui` endpoint. The Apollo engine never embeds Apollo's landing page — UI is op…
|
||||
- [engine](./src/engine/apollo-engine.md) — `ApolloGraphqlEngine` is the concrete Apollo Server implementation of the `@suaveplan/api-graphql` engine contract. It…
|
||||
- [apollo-engine](./src/engine/apollo-engine.md) — `ApolloGraphqlEngine` is the concrete Apollo Server implementation of the `@suaveplan/api-graphql` engine contract. It…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the `GraphqlApolloError` class consumed by every oth…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the `GraphqlApolloError` class consumed by every oth…
|
||||
- [federation](./src/federation/federation.md) — `federation` is the engine's facade over the canonical `mergeSdl` function from `@suaveplan/api-graphql`. Apollo doesn'…
|
||||
- [federation](./src/federation/federation.md) — `federation` is the engine's facade over the canonical `mergeSdl` function from `@suaveplan/api-graphql`. Apollo doesn'…
|
||||
- [guards](./src/guards/depth-and-complexity.md) — `depth-and-complexity` walks a parsed `DocumentNode` and computes two coarse static metrics — the maximum selection-set…
|
||||
- [depth-and-complexity](./src/guards/depth-and-complexity.md) — `depth-and-complexity` walks a parsed `DocumentNode` and computes two coarse static metrics — the maximum selection-set…
|
||||
- [instruments](./src/instruments/instruments.md) — `instruments` owns the five OTel instrument names emitted by every GraphQL engine adapter in the foundation. Both `api-…
|
||||
- [instruments](./src/instruments/instruments.md) — `instruments` owns the five OTel instrument names emitted by every GraphQL engine adapter in the foundation. Both `api-…
|
||||
- [loaders](./src/loaders/loaders.md) — `loaders` defines the per-request DataLoader scope the engine creates around every incoming GraphQL operation. Two conc…
|
||||
- [loaders](./src/loaders/loaders.md) — `loaders` defines the per-request DataLoader scope the engine creates around every incoming GraphQL operation. Two conc…
|
||||
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface accepted by `createApolloGraphqlEngine`. The schema mirrors the…
|
||||
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface accepted by `createApolloGraphqlEngine`. The schema mirrors the…
|
||||
- [persisted-queries](./src/persisted-queries/persisted-queries.md) — `persisted-queries` implements Apollo's persisted-query manifest lookup — the ability for clients to send `{ id }` in p…
|
||||
- [persisted-queries](./src/persisted-queries/persisted-queries.md) — `persisted-queries` implements Apollo's persisted-query manifest lookup — the ability for clients to send `{ id }` in p…
|
||||
- [studio-reporting](./src/studio-reporting/studio-reporting.md) — `studio-reporting` materialises the configuration descriptor consumed by the engine when an operator opts in to Apollo…
|
||||
- [studio-reporting](./src/studio-reporting/studio-reporting.md) — `studio-reporting` materialises the configuration descriptor consumed by the engine when an operator opts in to Apollo…
|
||||
- [subscriptions](./src/subscriptions/subscriptions.md) — `subscriptions` runs a GraphQL subscription document against a wired schema, returning the `AsyncIterable<ExecutionResu…
|
||||
- [subscriptions](./src/subscriptions/subscriptions.md) — `subscriptions` runs a GraphQL subscription document against a wired schema, returning the `AsyncIterable<ExecutionResu…
|
||||
- [testing](./src/testing/conformance.md) — `testing/conformance` is the re-export of `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing`, pre-boun…
|
||||
- [conformance](./src/testing/conformance.md) — `testing/conformance` is the re-export of `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing`, pre-boun…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## When to choose Apollo over Yoga
|
||||
|
||||
- You want Apollo Studio usage reporting (`studioReporting` option).
|
||||
- You ship persisted-query manifests built by Apollo's CLI / Rover.
|
||||
- Your team already runs Apollo's federation tooling in adjacent
|
||||
services.
|
||||
- You need Apollo's plugin ecosystem (cost analyser, request
|
||||
coalescer, custom cache plugin, …).
|
||||
|
||||
Otherwise, Yoga is the faster default — fewer allocations per request,
|
||||
slimmer dependency closure.
|
||||
|
||||
## Modules
|
||||
|
||||
- `engine` — `ApolloGraphqlEngine`, `createApolloGraphqlEngine`.
|
||||
- `options` — Zod-validated `ApolloGraphqlOptions` schema.
|
||||
- `error-codes` — `GRAPHQL_APOLLO.*` registry + `GraphqlApolloError`.
|
||||
- `instruments` — engine-agnostic `graphql.*` OTel surface.
|
||||
- `guards` — depth + complexity rejection helpers.
|
||||
- `persisted-queries` — APQ manifest loader.
|
||||
- `studio-reporting` — Apollo Studio descriptor bridge.
|
||||
- `subscriptions` — `graphql-ws` runner.
|
||||
- `loaders` — per-request DataLoader scope.
|
||||
- `federation` — `mergeSdl` passthrough.
|
||||
- `endpoints` — HTTP + UI proxy mounts.
|
||||
- `testing` — `runApolloConformance` re-export.
|
||||
|
||||
## Status
|
||||
|
||||
Original. Owned by the `api-stack` super-category.
|
||||
Passes the abstract conformance harness with the same invariants Yoga
|
||||
satisfies — see `src/engine/apollo-engine.spec-conformance.test.ts`.
|
||||
|
||||
141
packages/api-stack/api/api-graphql-apollo/package.json
Normal file
141
packages/api-stack/api/api-graphql-apollo/package.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "@suaveplan/api-graphql-apollo",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API plugin foundation. Implements the GraphqlEngine contract from @suaveplan/api-graphql and passes runGraphqlEngineConformance with the same invariants as the Yoga sibling. Adds Apollo-specific persisted-query manifest support, optional Apollo Studio usage reporting, depth + complexity guards, federated multi-plugin schema merging, per-request DataLoader scoping, engine-agnostic OTel instruments, and graphql-ws subscription bridging.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./engine": {
|
||||
"import": {
|
||||
"types": "./dist/engine/index.d.ts",
|
||||
"default": "./dist/engine/index.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/index.d.ts",
|
||||
"default": "./dist/options/index.js"
|
||||
}
|
||||
},
|
||||
"./endpoints": {
|
||||
"import": {
|
||||
"types": "./dist/endpoints/index.d.ts",
|
||||
"default": "./dist/endpoints/index.js"
|
||||
}
|
||||
},
|
||||
"./federation": {
|
||||
"import": {
|
||||
"types": "./dist/federation/index.d.ts",
|
||||
"default": "./dist/federation/index.js"
|
||||
}
|
||||
},
|
||||
"./guards": {
|
||||
"import": {
|
||||
"types": "./dist/guards/index.d.ts",
|
||||
"default": "./dist/guards/index.js"
|
||||
}
|
||||
},
|
||||
"./instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/index.d.ts",
|
||||
"default": "./dist/instruments/index.js"
|
||||
}
|
||||
},
|
||||
"./loaders": {
|
||||
"import": {
|
||||
"types": "./dist/loaders/index.d.ts",
|
||||
"default": "./dist/loaders/index.js"
|
||||
}
|
||||
},
|
||||
"./persisted-queries": {
|
||||
"import": {
|
||||
"types": "./dist/persisted-queries/index.d.ts",
|
||||
"default": "./dist/persisted-queries/index.js"
|
||||
}
|
||||
},
|
||||
"./studio-reporting": {
|
||||
"import": {
|
||||
"types": "./dist/studio-reporting/index.d.ts",
|
||||
"default": "./dist/studio-reporting/index.js"
|
||||
}
|
||||
},
|
||||
"./subscriptions": {
|
||||
"import": {
|
||||
"types": "./dist/subscriptions/index.d.ts",
|
||||
"default": "./dist/subscriptions/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"default": "./dist/testing/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-graphql": "workspace:^",
|
||||
"@suaveplan/api-router": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apollo/server": "^4.11.0",
|
||||
"graphql": "^16.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/server": "^4.11.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"graphql": "^16.9.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `endpoints/http` — `POST /graphql` + `GET /graphql/sdl`
|
||||
|
||||
## Purpose
|
||||
|
||||
`endpoints/http` mounts the two HTTP endpoints every GraphQL adapter
|
||||
must expose: `POST /graphql` for operation dispatch and
|
||||
`GET /graphql/sdl` for schema introspection by external tooling. The
|
||||
paths are driven by `ApolloGraphqlOptions` so operators can rename
|
||||
them — useful for shops that namespace internal APIs under `/internal/`.
|
||||
|
||||
The module is framework-agnostic in that it takes a `Router` from
|
||||
`@suaveplan/api-router` and registers handlers via the router's verb
|
||||
methods. Concrete router adapters (`api-router-hono`,
|
||||
`api-router-in-memory`) consume the same `Router` interface.
|
||||
|
||||
## Features
|
||||
|
||||
- Mount paths driven by `ApolloGraphqlOptions.endpoint` /
|
||||
`sdlEndpoint`.
|
||||
- `executeQuery` handler accepts a `{ body, headers, context }` envelope
|
||||
— the host's request pipeline decodes the HTTP frame into this shape
|
||||
before dispatch.
|
||||
- `sdl` handler returns the merged SDL synchronously.
|
||||
- Returns a teardown function (currently no-op; reserved for hot
|
||||
reload).
|
||||
- Empty `headers` default — handlers that the host invokes without an
|
||||
explicit map see `{}` rather than `undefined`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Router } from "@suaveplan/api-router";
|
||||
import { mountGraphqlHttpEndpoints } from "@suaveplan/api-graphql-apollo/endpoints";
|
||||
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
|
||||
|
||||
const router = new Router();
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: parseApolloGraphqlOptions(),
|
||||
executeQuery: async (r) => ({ data: { ok: true } }),
|
||||
sdl: () => "type Query { ok: Boolean! }",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the executeQuery handler through the engine instead of a stub
|
||||
so the router calls into Apollo Server's lifecycle on every POST:
|
||||
|
||||
```ts
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
|
||||
const engine = createApolloGraphqlEngine();
|
||||
engine.buildSchema(registry);
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options,
|
||||
executeQuery: async ({ body, context }) => {
|
||||
const req = body as { query: string; variables?: Record<string, unknown> };
|
||||
return await engine.executeQuery(req, context as RequestContext);
|
||||
},
|
||||
sdl: () => engine.sdl() ?? "",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GraphqlHttpHandler` — `(req: { body, headers, context }) → Promise<unknown>`.
|
||||
- `GraphqlSdlHandler` — `() → string`.
|
||||
- `MountHttpEndpointsArgs` — `{ router, options, executeQuery, sdl }`.
|
||||
- `mountGraphqlHttpEndpoints(args): () → void` — returns teardown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handler returns a free-form `Record<string, unknown>` because the
|
||||
host's transport adapter serialises the response to wire form. The
|
||||
SDL handler is synchronous on purpose; SDL is computed once at
|
||||
`buildSchema` time and held in memory thereafter. The teardown
|
||||
closure is a no-op today but exists so callers do not need to rewrite
|
||||
their host-wiring code when hot reload lands.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from "@suaveplan/api-router";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseApolloGraphqlOptions } from "../options/options.js";
|
||||
import { mountGraphqlHttpEndpoints } from "./http.js";
|
||||
|
||||
describe("endpoints/http", () => {
|
||||
it("mounts POST /graphql + GET /graphql/sdl at default paths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
const teardown = mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: opts,
|
||||
executeQuery: async () => ({ data: { ok: true } }),
|
||||
sdl: () => "type Query { ok: Boolean! }",
|
||||
});
|
||||
expect(router.match("POST", "/graphql")).not.toBeNull();
|
||||
expect(router.match("GET", "/graphql/sdl")).not.toBeNull();
|
||||
teardown();
|
||||
});
|
||||
|
||||
it("respects configured endpoint paths", { timeout: 5000 }, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions({
|
||||
endpoint: "/gql",
|
||||
sdlEndpoint: "/gql/sdl",
|
||||
});
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: opts,
|
||||
executeQuery: async () => ({}),
|
||||
sdl: () => "",
|
||||
});
|
||||
expect(router.match("POST", "/gql")).not.toBeNull();
|
||||
expect(router.match("GET", "/gql/sdl")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("dispatches the request through executeQuery", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
const calls: unknown[] = [];
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: opts,
|
||||
executeQuery: async (r) => {
|
||||
calls.push(r);
|
||||
return { data: { echo: 1 } };
|
||||
},
|
||||
sdl: () => "type Query { x: Int! }",
|
||||
});
|
||||
const m = router.match("POST", "/graphql");
|
||||
expect(m).not.toBeNull();
|
||||
const handler = m?.handler as (req: unknown) => Promise<unknown>;
|
||||
const result = await handler({
|
||||
body: { query: "{ x }" },
|
||||
headers: { "content-type": "application/json" },
|
||||
context: { tenantId: "t" },
|
||||
});
|
||||
expect(result).toEqual({ data: { echo: 1 } });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatches with empty headers when none supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
let received: Readonly<Record<string, string>> | null = null;
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: opts,
|
||||
executeQuery: async (r) => {
|
||||
received = r.headers;
|
||||
return {};
|
||||
},
|
||||
sdl: () => "",
|
||||
});
|
||||
const m = router.match("POST", "/graphql");
|
||||
const handler = m?.handler as (req: unknown) => Promise<unknown>;
|
||||
await handler({ body: null });
|
||||
expect(received).toEqual({});
|
||||
});
|
||||
|
||||
it("returns the SDL string on GET", { timeout: 5000 }, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
mountGraphqlHttpEndpoints({
|
||||
router,
|
||||
options: opts,
|
||||
executeQuery: async () => ({}),
|
||||
sdl: () => "SDL!",
|
||||
});
|
||||
const m = router.match("GET", "/graphql/sdl");
|
||||
const handler = m?.handler as () => string;
|
||||
expect(handler()).toBe("SDL!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Router } from "@suaveplan/api-router";
|
||||
import type { ApolloGraphqlOptions } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Inbound HTTP handler the host invokes. Returns the engine response
|
||||
* body — Apollo Server's `executeOperation` shape — as `Record<string,
|
||||
* unknown>` so the router adapter can serialise it to wire form.
|
||||
*/
|
||||
export type GraphqlHttpHandler = (request: {
|
||||
readonly body: unknown;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly context: unknown;
|
||||
}) => Promise<Readonly<Record<string, unknown>>>;
|
||||
|
||||
/** Inbound handler for the SDL endpoint — no-arg, returns the SDL string. */
|
||||
export type GraphqlSdlHandler = () => string;
|
||||
|
||||
/** Configuration consumed by {@link mountGraphqlHttpEndpoints}. */
|
||||
export interface MountHttpEndpointsArgs {
|
||||
readonly router: Router;
|
||||
readonly options: ApolloGraphqlOptions;
|
||||
readonly executeQuery: GraphqlHttpHandler;
|
||||
readonly sdl: GraphqlSdlHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the `POST /graphql` execution endpoint and `GET /graphql/sdl`
|
||||
* schema endpoint on the host-supplied router. The endpoint paths are
|
||||
* driven by {@link ApolloGraphqlOptions}.
|
||||
*
|
||||
* Returns the unsubscribe handle (a no-op today; reserved for engine
|
||||
* hot-reload in a later stage).
|
||||
*/
|
||||
export function mountGraphqlHttpEndpoints(
|
||||
args: MountHttpEndpointsArgs
|
||||
): () => void {
|
||||
args.router.post(args.options.endpoint, async (req: unknown) => {
|
||||
const r = req as {
|
||||
body?: unknown;
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
context?: unknown;
|
||||
};
|
||||
return args.executeQuery({
|
||||
body: r.body,
|
||||
headers: r.headers ?? {},
|
||||
context: r.context,
|
||||
});
|
||||
});
|
||||
|
||||
args.router.get(args.options.sdlEndpoint, () => args.sdl());
|
||||
|
||||
return () => {
|
||||
/* hot-reload teardown will land alongside the host's reload driver */
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type GraphqlHttpHandler,
|
||||
type GraphqlSdlHandler,
|
||||
type MountHttpEndpointsArgs,
|
||||
mountGraphqlHttpEndpoints,
|
||||
} from "./http.js";
|
||||
export {
|
||||
type GraphqlUiPlugin,
|
||||
mountGraphqlUiProxy,
|
||||
type UiProxyResponse,
|
||||
} from "./ui-proxy.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `endpoints/ui-proxy` — `/graphql/ui` proxy
|
||||
|
||||
## Purpose
|
||||
|
||||
`endpoints/ui-proxy` mounts the `/graphql/ui` endpoint. The Apollo
|
||||
engine never embeds Apollo's landing page — UI is opt-in via a
|
||||
separate package (GraphiQL, Altair, …), identical to Yoga's stance.
|
||||
When no UI plugin is wired the endpoint returns `404` with a short
|
||||
text body explaining the policy; when a plugin is wired the endpoint
|
||||
forwards its `render()` output verbatim.
|
||||
|
||||
This module is deliberately framework-agnostic: it accepts a
|
||||
`@suaveplan/api-router` `Router` and a UI plugin descriptor with a
|
||||
`render()` method. The host wires whichever GraphiQL / Altair package
|
||||
it prefers without the engine becoming aware of the choice.
|
||||
|
||||
## Features
|
||||
|
||||
- 404 by default — no Apollo landing page leakage.
|
||||
- Custom mime type — UI plugins that ship binary (e.g. WASM) bodies
|
||||
can override the default `text/html`.
|
||||
- Driven by `ApolloGraphqlOptions.uiEndpoint` — operator can rename
|
||||
the path.
|
||||
- Returns a teardown handle reserved for future hot reload.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Router } from "@suaveplan/api-router";
|
||||
import { mountGraphqlUiProxy } from "@suaveplan/api-graphql-apollo/endpoints";
|
||||
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
|
||||
|
||||
const router = new Router();
|
||||
mountGraphqlUiProxy({
|
||||
router,
|
||||
options: parseApolloGraphqlOptions(),
|
||||
ui: null,
|
||||
});
|
||||
// GET /graphql/ui → 404
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a GraphiQL-style UI plugin so the same router serves the
|
||||
playground at `/graphql/ui`:
|
||||
|
||||
```ts
|
||||
mountGraphqlUiProxy({
|
||||
router,
|
||||
options,
|
||||
ui: {
|
||||
render: () =>
|
||||
`<!doctype html>
|
||||
<html><body>
|
||||
<div id="root"></div>
|
||||
<script>/* GraphiQL bundle */</script>
|
||||
</body></html>`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GraphqlUiPlugin` — `{ render(): string; contentType?: string }`.
|
||||
- `UiProxyResponse` — `{ status: 200 | 404; body: string; contentType: string }`.
|
||||
- `mountGraphqlUiProxy(args): () → void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The UI plugin contract is intentionally minimal — `render()` returns
|
||||
a string and the proxy serves it. Future evolution (CSP nonces,
|
||||
per-tenant configuration) lives in the UI plugin itself, not in this
|
||||
module. The 404 response body is `text/plain` so curl users see a
|
||||
human-readable explanation rather than an empty page.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Router } from "@suaveplan/api-router";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseApolloGraphqlOptions } from "../options/options.js";
|
||||
import { mountGraphqlUiProxy } from "./ui-proxy.js";
|
||||
|
||||
describe("endpoints/ui-proxy", () => {
|
||||
it("returns 404 when no UI plugin is wired", { timeout: 5000 }, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
const teardown = mountGraphqlUiProxy({
|
||||
router,
|
||||
options: opts,
|
||||
ui: null,
|
||||
});
|
||||
const m = router.match("GET", "/graphql/ui");
|
||||
const handler = m?.handler as () => {
|
||||
status: number;
|
||||
body: string;
|
||||
contentType: string;
|
||||
};
|
||||
const res = handler();
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toContain("opt-in");
|
||||
expect(res.contentType).toBe("text/plain");
|
||||
teardown();
|
||||
});
|
||||
|
||||
it("returns the plugin's rendered HTML when wired", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
mountGraphqlUiProxy({
|
||||
router,
|
||||
options: opts,
|
||||
ui: { render: () => "<!doctype html><h1>UI</h1>" },
|
||||
});
|
||||
const m = router.match("GET", "/graphql/ui");
|
||||
const handler = m?.handler as () => {
|
||||
status: number;
|
||||
body: string;
|
||||
contentType: string;
|
||||
};
|
||||
const res = handler();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain("UI");
|
||||
expect(res.contentType).toBe("text/html");
|
||||
});
|
||||
|
||||
it("honours a custom MIME type from the plugin", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const router = new Router();
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
mountGraphqlUiProxy({
|
||||
router,
|
||||
options: opts,
|
||||
ui: { render: () => "{}", contentType: "application/json" },
|
||||
});
|
||||
const m = router.match("GET", "/graphql/ui");
|
||||
const handler = m?.handler as () => {
|
||||
contentType: string;
|
||||
};
|
||||
expect(handler().contentType).toBe("application/json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Router } from "@suaveplan/api-router";
|
||||
import type { ApolloGraphqlOptions } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* UI plugin descriptor. The host wires a separate UI package (GraphiQL,
|
||||
* Altair, …) and hands a renderer in; the Apollo engine itself never
|
||||
* embeds Apollo's landing page (UI is opt-in — same stance as Yoga).
|
||||
*/
|
||||
export interface GraphqlUiPlugin {
|
||||
/** Returns the HTML body served at `/graphql/ui`. */
|
||||
render(): string;
|
||||
/** MIME type — defaults to `text/html` when omitted. */
|
||||
readonly contentType?: string;
|
||||
}
|
||||
|
||||
/** Response envelope returned by the UI proxy handler. */
|
||||
export interface UiProxyResponse {
|
||||
readonly status: 200 | 404;
|
||||
readonly body: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the `/graphql/ui` proxy endpoint. When `ui` is `null` the
|
||||
* handler responds `404` with a short note explaining the UI is opt-in;
|
||||
* when supplied it returns the plugin's rendered HTML.
|
||||
*/
|
||||
export function mountGraphqlUiProxy(args: {
|
||||
readonly router: Router;
|
||||
readonly options: ApolloGraphqlOptions;
|
||||
readonly ui: GraphqlUiPlugin | null;
|
||||
}): () => void {
|
||||
const { router, options, ui } = args;
|
||||
router.get(options.uiEndpoint, (): UiProxyResponse => {
|
||||
if (ui === null) {
|
||||
return {
|
||||
status: 404,
|
||||
body: "GraphQL UI is opt-in — wire a UI plugin to enable",
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: ui.render(),
|
||||
contentType: ui.contentType ?? "text/html",
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
/* hot-reload teardown reserved */
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# `engine/apollo-engine` — `ApolloGraphqlEngine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ApolloGraphqlEngine` is the concrete Apollo Server implementation of
|
||||
the `@suaveplan/api-graphql` engine contract. It owns the Apollo
|
||||
Server instance, the merged executable schema, the persisted-query
|
||||
manifest plumbing, the request-time depth and complexity guards, and
|
||||
the OTel instrument emission. The factory `createApolloGraphqlEngine`
|
||||
hands out fresh instances; the class is `new`-able for tests and
|
||||
power users that want subclass overrides.
|
||||
|
||||
Every operation pivots on three lifecycle calls: `buildSchema` once
|
||||
at boot (or on hot reload), `executeQuery` per request, `dispose` at
|
||||
shutdown. Schema rebuild gracefully tears down the prior Apollo
|
||||
instance only when its startup has completed — a half-started server
|
||||
is left alone, which is safe because the new server replaces the
|
||||
slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every `GraphqlEngine` method — `buildSchema`,
|
||||
`executeQuery`, `executeSubscription`, `dispose`.
|
||||
- Pluggable `instruments`, `persistedQueries`, `apolloPlugins`
|
||||
dependencies — sane defaults, full override.
|
||||
- Depth + complexity guards run BEFORE Apollo sees the document so
|
||||
the engine rejects pathological queries cheaply.
|
||||
- `requestTimeoutMs` enforced via a race against a `setTimeout`-
|
||||
driven rejection — the timer is `unref`'d so it never holds the
|
||||
event loop open.
|
||||
- `attachResolvers` resolves SDL-declared fields against the
|
||||
registry's resolver maps; unknown types and unknown fields are
|
||||
silently skipped so plugin authors can register superset maps
|
||||
without runtime breakage.
|
||||
- Introspection denial layered above Apollo's
|
||||
`introspection: false` flag so the engine raises a typed error
|
||||
before Apollo even validates the document.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const registry = createGraphqlRegistry();
|
||||
registry.addTypes("type Query { ping: String! }");
|
||||
registry.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(registry);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
requestContext
|
||||
);
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire persisted queries, custom instruments, a 5-second timeout, and a
|
||||
custom Apollo plugin for cache control:
|
||||
|
||||
```ts
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
|
||||
import { createPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
|
||||
|
||||
const instruments = createInstruments();
|
||||
const persistedQueries = createPersistedQueryManifest([
|
||||
["abc123", "{ ping }"],
|
||||
]);
|
||||
const engine = createApolloGraphqlEngine({
|
||||
instruments,
|
||||
persistedQueries,
|
||||
apolloPlugins: [cacheControlPlugin],
|
||||
options: { requestTimeoutMs: 5000, maxQueryDepth: 12 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ApolloGraphqlEngine<Caps> implements GraphqlEngine<Caps>`.
|
||||
- `constructor(args?: CreateApolloGraphqlEngineArgs)`
|
||||
- `buildSchema(registry): MergedSchema`
|
||||
- `executeQuery(req, ctx): Promise<GraphqlResponse>`
|
||||
- `executeSubscription(req, ctx): AsyncIterable<GraphqlResponse>`
|
||||
- `dispose(): Promise<void>`
|
||||
- `sdl(): string | null`
|
||||
- `createApolloGraphqlEngine(args?): ApolloGraphqlEngine`.
|
||||
- `raceWithTimeout<T>(p, ms): Promise<T>` — exported for unit testing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests`
|
||||
was rejected in favour of an explicit `start()` Promise because the
|
||||
former turns a startup failure into per-request rejections we cannot
|
||||
discriminate cleanly. The current implementation awaits the start
|
||||
promise inside `executeQuery` and again inside `dispose` so a failed
|
||||
start never leaves the engine in a half-stopped state. The
|
||||
`raceWithTimeout` helper is exported so a focused unit test can
|
||||
exercise both the resolve and reject branches without standing up an
|
||||
Apollo Server.
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runApolloConformance } from "../testing/conformance.js";
|
||||
|
||||
// Re-run the abstract GraphqlEngine contract against the Apollo factory.
|
||||
runApolloConformance("apollo");
|
||||
@@ -0,0 +1,461 @@
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import { createInstruments } from "../instruments/instruments.js";
|
||||
import { createPersistedQueryManifest } from "../persisted-queries/persisted-queries.js";
|
||||
import {
|
||||
ApolloGraphqlEngine,
|
||||
createApolloGraphqlEngine,
|
||||
raceWithTimeout,
|
||||
} from "./apollo-engine.js";
|
||||
|
||||
function makeCtx(): RequestContext {
|
||||
return {} as unknown as RequestContext;
|
||||
}
|
||||
|
||||
describe("ApolloGraphqlEngine", () => {
|
||||
it("builds a schema, executes a query, returns data", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const registry = createGraphqlRegistry();
|
||||
registry.addTypes("type Query { ping: String! }");
|
||||
registry.addResolvers({ Query: { ping: () => "pong" } });
|
||||
const merged = engine.buildSchema(registry);
|
||||
expect(merged.sdl).toContain("ping");
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
makeCtx()
|
||||
);
|
||||
expect(result.data).toEqual({ ping: "pong" });
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("supports rebuild — disposes prior Apollo server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ApolloGraphqlEngine();
|
||||
const r1 = createGraphqlRegistry();
|
||||
r1.addTypes("type Query { a: String! }");
|
||||
r1.addResolvers({ Query: { a: () => "A" } });
|
||||
engine.buildSchema(r1);
|
||||
// Force the first Apollo server to fully start before rebuilding.
|
||||
await engine.executeQuery({ query: "{ a }" }, makeCtx());
|
||||
const r2 = createGraphqlRegistry();
|
||||
r2.addTypes("type Query { b: String! }");
|
||||
r2.addResolvers({ Query: { b: () => "B" } });
|
||||
engine.buildSchema(r2);
|
||||
const out = await engine.executeQuery({ query: "{ b }" }, makeCtx());
|
||||
expect(out.data).toEqual({ b: "B" });
|
||||
expect(engine.sdl()).toContain("b: String!");
|
||||
await engine.dispose();
|
||||
expect(engine.sdl()).toBeNull();
|
||||
});
|
||||
|
||||
it("propagates variables + operationName when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { echo(s: String!): String! }");
|
||||
reg.addResolvers({
|
||||
Query: {
|
||||
echo: (_p: unknown, args: { s: string }) => args.s,
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{
|
||||
query: "query Echo($s: String!) { echo(s: $s) }",
|
||||
variables: { s: "hi" },
|
||||
operationName: "Echo",
|
||||
},
|
||||
makeCtx()
|
||||
);
|
||||
expect(result.data).toEqual({ echo: "hi" });
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("emits an instruments record per request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ins = createInstruments();
|
||||
const engine = createApolloGraphqlEngine({ instruments: ins });
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
await engine.executeQuery({ query: "{ ping }" }, makeCtx());
|
||||
const snap = ins.snapshot();
|
||||
expect(snap.requestsTotal).toBe(1);
|
||||
expect(snap.schemaRebuildsTotal).toBe(1);
|
||||
expect(snap.requestDurationMs).toHaveLength(1);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("rejects executeQuery before buildSchema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery({ query: "{ ping }" }, makeCtx());
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed SDL", { timeout: 5000 }, () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("totally not graphql");
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
engine.buildSchema(reg);
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
|
||||
});
|
||||
|
||||
it("rejects a registry without freeze()", { timeout: 5000 }, () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
engine.buildSchema({
|
||||
addTypes() {
|
||||
/* noop */
|
||||
},
|
||||
addResolvers() {
|
||||
/* noop */
|
||||
},
|
||||
addLoader() {
|
||||
/* noop */
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
|
||||
});
|
||||
|
||||
it("rejects introspection when disabled", { timeout: 5000 }, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery(
|
||||
{ query: "{ __schema { types { name } } }" },
|
||||
makeCtx()
|
||||
);
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.INTROSPECTION_DENIED");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("allows introspection when configured on", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine({
|
||||
options: { introspection: true },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ __typename }" },
|
||||
makeCtx()
|
||||
);
|
||||
expect(result.data?.__typename).toBe("Query");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("guards depth + complexity", { timeout: 5000 }, async () => {
|
||||
const engine = createApolloGraphqlEngine({
|
||||
options: { maxQueryDepth: 1 },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes(
|
||||
"type Query { nested: Nested! } type Nested { a: String! }"
|
||||
);
|
||||
reg.addResolvers({
|
||||
Query: { nested: () => ({ a: "x" }) },
|
||||
Nested: { a: () => "x" },
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery({ query: "{ nested { a } }" }, makeCtx());
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.DEPTH_EXCEEDED");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises PARSE_FAILED for malformed documents", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery({ query: "{ ping" }, makeCtx());
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises PARSE_FAILED when neither query nor id supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery({ query: "" }, makeCtx());
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("resolves persisted-query id when manifest wired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const manifest = createPersistedQueryManifest([["pingId", "{ ping }"]]);
|
||||
const engine = createApolloGraphqlEngine({
|
||||
persistedQueries: manifest,
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "", ...({ id: "pingId" } as Record<string, string>) } as {
|
||||
query: string;
|
||||
},
|
||||
makeCtx()
|
||||
);
|
||||
expect(result.data).toEqual({ ping: "pong" });
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises PERSISTED_QUERY_NOT_FOUND for unknown id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const manifest = createPersistedQueryManifest([]);
|
||||
const engine = createApolloGraphqlEngine({
|
||||
persistedQueries: manifest,
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery(
|
||||
{
|
||||
query: "",
|
||||
...({ id: "missing" } as Record<string, string>),
|
||||
} as { query: string },
|
||||
makeCtx()
|
||||
);
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("executeSubscription returns an empty AsyncIterable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const iter = engine.executeSubscription(
|
||||
{ query: "subscription { x }" },
|
||||
makeCtx()
|
||||
);
|
||||
const out: unknown[] = [];
|
||||
for await (const v of iter) out.push(v);
|
||||
expect(out).toEqual([]);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
await engine.dispose();
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("dispose swallows a failed Apollo start", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingPlugin = {
|
||||
async serverWillStart(): Promise<void> {
|
||||
throw new Error("plugin-init-fail");
|
||||
},
|
||||
};
|
||||
const engine = createApolloGraphqlEngine({
|
||||
apolloPlugins: [failingPlugin],
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
// dispose must not throw even though start() failed.
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("merges resolvers across multiple addResolvers calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { a: String! b: String! }");
|
||||
reg.addResolvers({ Query: { a: () => "A" } });
|
||||
reg.addResolvers({ Query: { b: () => "B" } });
|
||||
engine.buildSchema(reg);
|
||||
const out = await engine.executeQuery({ query: "{ a b }" }, makeCtx());
|
||||
expect(out.data).toEqual({ a: "A", b: "B" });
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("ignores resolvers for unknown type names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({
|
||||
Unknown: { x: () => "x" },
|
||||
Query: { ping: () => "pong" },
|
||||
});
|
||||
// No throw — unknown type is silently skipped.
|
||||
engine.buildSchema(reg);
|
||||
});
|
||||
|
||||
it("returns errors envelope when a resolver throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { boom: String! }");
|
||||
reg.addResolvers({
|
||||
Query: {
|
||||
boom: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ boom }" },
|
||||
makeCtx()
|
||||
);
|
||||
expect(result.errors?.length).toBeGreaterThan(0);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("skips scalar-type resolvers and unknown field names", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({
|
||||
Query: { ping: () => "pong", nonexistent: () => "x" },
|
||||
// String is a scalar without getFields(): attachResolvers skips.
|
||||
String: { weird: () => "z" } as Record<string, () => string>,
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const out = await engine.executeQuery({ query: "{ ping }" }, makeCtx());
|
||||
expect(out.data).toEqual({ ping: "pong" });
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises RESOLVER_THREW when resolver attach fails", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({
|
||||
Query: undefined as unknown as Record<string, never>,
|
||||
});
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
engine.buildSchema(reg);
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.RESOLVER_THREW");
|
||||
});
|
||||
|
||||
it("raceWithTimeout resolves the inner promise when it wins", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const v = await raceWithTimeout(Promise.resolve(42), 1000);
|
||||
expect(v).toBe(42);
|
||||
});
|
||||
|
||||
it("raceWithTimeout forwards inner rejection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let err: Error | null = null;
|
||||
try {
|
||||
await raceWithTimeout(
|
||||
Promise.reject(new Error("inner-fail")),
|
||||
1000
|
||||
);
|
||||
} catch (e) {
|
||||
err = e as Error;
|
||||
}
|
||||
expect(err?.message).toBe("inner-fail");
|
||||
});
|
||||
|
||||
it("raises TIMEOUT when execution exceeds requestTimeoutMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createApolloGraphqlEngine({
|
||||
options: { requestTimeoutMs: 1 },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { slow: String! }");
|
||||
reg.addResolvers({
|
||||
Query: {
|
||||
slow: async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return "late";
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await engine.executeQuery({ query: "{ slow }" }, makeCtx());
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.TIMEOUT");
|
||||
await engine.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { ApolloServer } from "@apollo/server";
|
||||
import type {
|
||||
FrozenRegistry,
|
||||
GraphqlEngine,
|
||||
GraphqlRegistry,
|
||||
GraphqlRequest,
|
||||
GraphqlResponse,
|
||||
MergedSchema,
|
||||
SdlContribution,
|
||||
} from "@suaveplan/api-graphql";
|
||||
import { mergeSdl } from "@suaveplan/api-graphql";
|
||||
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
|
||||
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
|
||||
import { buildSchema, parse } from "graphql";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import { assertDepthAndComplexity } from "../guards/depth-and-complexity.js";
|
||||
import {
|
||||
createInstruments,
|
||||
type GraphqlInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
type ApolloGraphqlOptions,
|
||||
type ApolloGraphqlOptionsInput,
|
||||
parseApolloGraphqlOptions,
|
||||
} from "../options/options.js";
|
||||
import type { PersistedQueryManifest } from "../persisted-queries/persisted-queries.js";
|
||||
|
||||
interface ApolloEngineDependencies {
|
||||
readonly persistedQueries?: PersistedQueryManifest;
|
||||
readonly instruments?: GraphqlInstruments;
|
||||
/**
|
||||
* Optional Apollo Server plugin descriptors. The engine forwards them
|
||||
* to `new ApolloServer({ plugins })`; supplied here so tests can wire
|
||||
* the Studio reporting plugin, persisted-query cache plugin, etc.
|
||||
*/
|
||||
readonly apolloPlugins?: ReadonlyArray<unknown>;
|
||||
}
|
||||
|
||||
/** Public options accepted by the factory. */
|
||||
export interface CreateApolloGraphqlEngineArgs
|
||||
extends ApolloEngineDependencies {
|
||||
readonly options?: ApolloGraphqlOptionsInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete Apollo Server engine. Implements the
|
||||
* `@suaveplan/api-graphql` engine contract end-to-end.
|
||||
*
|
||||
* `buildSchema` consumes a frozen registry snapshot, merges the SDL via
|
||||
* the canonical `mergeSdl`, builds a `GraphQLSchema` and instantiates an
|
||||
* `ApolloServer`. `executeQuery` proxies to `executeOperation`. The
|
||||
* lifecycle is replay-safe: subsequent `buildSchema` calls dispose the
|
||||
* previous Apollo Server before instantiating the next one.
|
||||
*/
|
||||
export class ApolloGraphqlEngine<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> implements GraphqlEngine<Caps>
|
||||
{
|
||||
private apollo: ApolloServer | null = null;
|
||||
private apolloStarted = false;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private merged: MergedSchema | null = null;
|
||||
private readonly opts: ApolloGraphqlOptions;
|
||||
private readonly persistedQueries: PersistedQueryManifest | undefined;
|
||||
private readonly instruments: GraphqlInstruments;
|
||||
private readonly apolloPlugins: ReadonlyArray<unknown>;
|
||||
|
||||
public constructor(args: CreateApolloGraphqlEngineArgs = {}) {
|
||||
this.opts = parseApolloGraphqlOptions(args.options);
|
||||
this.persistedQueries = args.persistedQueries;
|
||||
this.instruments = args.instruments ?? createInstruments();
|
||||
this.apolloPlugins = args.apolloPlugins ?? [];
|
||||
}
|
||||
|
||||
public buildSchema(registry: GraphqlRegistry<Caps>): MergedSchema {
|
||||
const frozen = freezeRegistry(registry);
|
||||
const contribution: SdlContribution = {
|
||||
plugin: "default",
|
||||
typeDefs: [...frozen.typeDefs],
|
||||
};
|
||||
const merged = mergeSdl([contribution]);
|
||||
|
||||
let resolvers: Record<
|
||||
string,
|
||||
Record<string, (...args: unknown[]) => unknown>
|
||||
>;
|
||||
try {
|
||||
resolvers = mergeResolvers(frozen);
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.RESOLVER_THREW",
|
||||
"failed to merge resolver maps",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
let schema: ReturnType<typeof buildSchema>;
|
||||
try {
|
||||
schema = buildSchema(merged.sdl);
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
|
||||
"failed to build executable schema from merged SDL",
|
||||
{ cause: cause as Error, context: { sdl: merged.sdl } }
|
||||
);
|
||||
}
|
||||
|
||||
if (this.apollo !== null && this.apolloStarted) {
|
||||
void this.apollo.stop();
|
||||
}
|
||||
this.apollo = new ApolloServer({
|
||||
schema,
|
||||
introspection: this.opts.introspection,
|
||||
includeStacktraceInErrorResponses: false,
|
||||
plugins: [...(this.apolloPlugins as Array<never>)],
|
||||
});
|
||||
attachResolvers(schema, resolvers);
|
||||
this.apolloStarted = false;
|
||||
this.startPromise = this.apollo.start().then(() => {
|
||||
this.apolloStarted = true;
|
||||
});
|
||||
this.merged = merged;
|
||||
this.instruments.incSchemaRebuilds();
|
||||
return merged;
|
||||
}
|
||||
|
||||
public async executeQuery(
|
||||
req: GraphqlRequest,
|
||||
ctx: RequestContext<Caps>
|
||||
): Promise<GraphqlResponse> {
|
||||
const apollo = this.requireApollo();
|
||||
await this.startPromise;
|
||||
const query = await this.resolveQuery(req);
|
||||
const document = await this.parseDocument(query);
|
||||
if (!this.opts.introspection && containsIntrospection(query)) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.INTROSPECTION_DENIED",
|
||||
"introspection is disabled by configuration"
|
||||
);
|
||||
}
|
||||
assertDepthAndComplexity(document, {
|
||||
maxDepth: this.opts.maxQueryDepth,
|
||||
maxComplexity: this.opts.maxQueryComplexity,
|
||||
});
|
||||
|
||||
this.instruments.incRequests({
|
||||
operation: req.operationName ?? "anonymous",
|
||||
});
|
||||
const started = Date.now();
|
||||
|
||||
const opArgs: {
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
operationName?: string;
|
||||
} = { query };
|
||||
if (req.variables !== undefined) {
|
||||
opArgs.variables = req.variables as Record<string, unknown>;
|
||||
}
|
||||
if (req.operationName !== undefined) {
|
||||
opArgs.operationName = req.operationName;
|
||||
}
|
||||
const result = await raceWithTimeout(
|
||||
apollo.executeOperation(opArgs, {
|
||||
contextValue: ctx as unknown as Record<string, never>,
|
||||
}),
|
||||
this.opts.requestTimeoutMs
|
||||
);
|
||||
this.instruments.recordRequestDurationMs(Date.now() - started);
|
||||
|
||||
// Apollo always returns `body.kind === "single"` for non-incremental
|
||||
// operations — we never enable `@defer`/`@stream` so this branch is
|
||||
// the only one reachable.
|
||||
const single = result.body as {
|
||||
singleResult: Parameters<typeof toResponse>[0];
|
||||
};
|
||||
return toResponse(single.singleResult);
|
||||
}
|
||||
|
||||
public executeSubscription(
|
||||
_req: GraphqlRequest,
|
||||
_ctx: RequestContext<Caps>
|
||||
): AsyncIterable<GraphqlResponse> {
|
||||
return emptyIterable();
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (this.apollo !== null) {
|
||||
try {
|
||||
await this.startPromise;
|
||||
} catch {
|
||||
/* start failed — skip stop */
|
||||
}
|
||||
if (this.apolloStarted) {
|
||||
await this.apollo.stop();
|
||||
}
|
||||
this.apollo = null;
|
||||
this.apolloStarted = false;
|
||||
this.startPromise = null;
|
||||
}
|
||||
this.merged = null;
|
||||
}
|
||||
|
||||
/** SDL of the most recent `buildSchema` call — `null` before first build. */
|
||||
public sdl(): string | null {
|
||||
return this.merged === null ? null : this.merged.sdl;
|
||||
}
|
||||
|
||||
private requireApollo(): ApolloServer {
|
||||
if (this.apollo === null) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
|
||||
"engine.buildSchema must be called before executeQuery"
|
||||
);
|
||||
}
|
||||
return this.apollo;
|
||||
}
|
||||
|
||||
private async resolveQuery(req: GraphqlRequest): Promise<string> {
|
||||
if (req.query !== "" && req.query !== undefined) return req.query;
|
||||
const persistedId = (req as unknown as { id?: string }).id;
|
||||
if (persistedId !== undefined && this.persistedQueries !== undefined) {
|
||||
return this.persistedQueries.lookup(persistedId);
|
||||
}
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"request omitted both `query` and `id` — cannot dispatch"
|
||||
);
|
||||
}
|
||||
|
||||
private async parseDocument(
|
||||
query: string
|
||||
): Promise<ReturnType<typeof parse>> {
|
||||
try {
|
||||
return parse(query);
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"request document failed to parse",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for unit testing — `raceWithTimeout(p, ms)`. */
|
||||
export function raceWithTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.TIMEOUT",
|
||||
`request exceeded ${ms}ms`
|
||||
)
|
||||
);
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(e as Error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Factory yielding a fresh `ApolloGraphqlEngine`. */
|
||||
export function createApolloGraphqlEngine<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
>(args: CreateApolloGraphqlEngineArgs = {}): ApolloGraphqlEngine<Caps> {
|
||||
return new ApolloGraphqlEngine<Caps>(args);
|
||||
}
|
||||
|
||||
function freezeRegistry<Caps extends CapabilityRequirements>(
|
||||
registry: GraphqlRegistry<Caps>
|
||||
): FrozenRegistry<Caps> {
|
||||
const withFreeze = registry as GraphqlRegistry<Caps> & {
|
||||
readonly freeze?: () => FrozenRegistry<Caps>;
|
||||
};
|
||||
if (typeof withFreeze.freeze === "function") return withFreeze.freeze();
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
|
||||
"registry does not expose a freeze() snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
function mergeResolvers<Caps extends CapabilityRequirements>(
|
||||
frozen: FrozenRegistry<Caps>
|
||||
): Record<string, Record<string, (...args: unknown[]) => unknown>> {
|
||||
const out: Record<
|
||||
string,
|
||||
Record<string, (...args: unknown[]) => unknown>
|
||||
> = {};
|
||||
for (const map of frozen.resolvers) {
|
||||
for (const [typeName, fields] of Object.entries(map)) {
|
||||
const target = out[typeName] ?? {};
|
||||
for (const [fieldName, fn] of Object.entries(
|
||||
fields as Record<string, unknown>
|
||||
)) {
|
||||
target[fieldName] = fn as (...args: unknown[]) => unknown;
|
||||
}
|
||||
out[typeName] = target;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function attachResolvers(
|
||||
schema: ReturnType<typeof buildSchema>,
|
||||
resolvers: Record<string, Record<string, (...args: unknown[]) => unknown>>
|
||||
): void {
|
||||
for (const [typeName, fields] of Object.entries(resolvers)) {
|
||||
const type = schema.getType(typeName);
|
||||
if (type === undefined || type === null) continue;
|
||||
const fieldsOf = (
|
||||
type as unknown as {
|
||||
getFields?: () => Record<
|
||||
string,
|
||||
{ resolve?: (...args: unknown[]) => unknown }
|
||||
>;
|
||||
}
|
||||
).getFields;
|
||||
if (typeof fieldsOf !== "function") continue;
|
||||
const map = fieldsOf.call(type);
|
||||
for (const [fieldName, fn] of Object.entries(fields)) {
|
||||
const def = map[fieldName];
|
||||
if (def !== undefined) def.resolve = fn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toResponse(result: {
|
||||
readonly data?: Readonly<Record<string, unknown>> | null;
|
||||
readonly errors?: ReadonlyArray<{
|
||||
readonly message: string;
|
||||
readonly path?: ReadonlyArray<string | number>;
|
||||
readonly extensions?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
readonly extensions?: Readonly<Record<string, unknown>>;
|
||||
}): GraphqlResponse {
|
||||
// Apollo always sets at least `data` or `errors` on the singleResult.
|
||||
// We forward the full envelope verbatim so callers see every field
|
||||
// including the (optional) `extensions` map.
|
||||
return { ...result };
|
||||
}
|
||||
|
||||
function containsIntrospection(query: string): boolean {
|
||||
return /\b__(schema|type)\b/.test(query);
|
||||
}
|
||||
|
||||
function emptyIterable(): AsyncIterable<GraphqlResponse> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<GraphqlResponse> {
|
||||
return {
|
||||
next: async (): Promise<IteratorResult<GraphqlResponse>> => ({
|
||||
value: undefined,
|
||||
done: true,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ApolloGraphqlEngine,
|
||||
type CreateApolloGraphqlEngineArgs,
|
||||
createApolloGraphqlEngine,
|
||||
} from "./apollo-engine.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `error-codes` — `GRAPHQL_APOLLO.*` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the
|
||||
`GraphqlApolloError` class consumed by every other module in the
|
||||
`@suaveplan/api-graphql-apollo` package. It sits in the same shape as the
|
||||
sibling `@suaveplan/api-graphql` registry so callers can write a single
|
||||
`instanceof SuaveplanError` discriminator and narrow further on
|
||||
`error.code`.
|
||||
|
||||
The registry covers ten codes — request-document parse and validation
|
||||
failures, depth and complexity guard rejections, schema-build failures
|
||||
inside Apollo, the missing-transport case for subscriptions, request
|
||||
timeouts, introspection-denied responses, persisted-query lookup misses,
|
||||
and the resolver-attach catch-all. Every code is thrown by a production
|
||||
path; the conformance harness enforces that empirically.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `defineCodes("GRAPHQL_APOLLO", […])` registry — no untyped string
|
||||
ever escapes the package.
|
||||
- `GraphqlApolloError` exposes the code via both the inherited `error.code`
|
||||
and a narrowed `graphqlApolloCode` field; either narrows downstream code
|
||||
to the union of ten possible values.
|
||||
- Constructor preserves `cause` and `context` per the `SuaveplanError`
|
||||
contract — wrappers can attach the raw `cause: Error` for crash dumps.
|
||||
- The class sets `this.name = "GraphqlApolloError"` so logs surface the
|
||||
concrete subclass.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
GRAPHQL_APOLLO_CODES,
|
||||
GraphqlApolloError,
|
||||
} from "@suaveplan/api-graphql-apollo/error-codes";
|
||||
|
||||
throw new GraphqlApolloError(
|
||||
GRAPHQL_APOLLO_CODES.PARSE_FAILED,
|
||||
"request document failed to parse"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Attach a cause and a context payload that the OTel span recorder will
|
||||
attach as event attributes:
|
||||
|
||||
```ts
|
||||
import { GraphqlApolloError } from "@suaveplan/api-graphql-apollo/error-codes";
|
||||
|
||||
try {
|
||||
JSON.parse(maybeJson);
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"persisted-query manifest is not valid JSON",
|
||||
{ cause: cause as Error, context: { path: "./pq.json" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GRAPHQL_APOLLO_CODES` — frozen object mapping each short name to its
|
||||
fully-qualified code string.
|
||||
- `GraphqlApolloErrorCode` — union of every code value resolved through
|
||||
`GRAPHQL_APOLLO_CODES`.
|
||||
- `class GraphqlApolloError extends SuaveplanError<GraphqlApolloErrorCode>`.
|
||||
- `constructor(code, message, options?)`
|
||||
- `graphqlApolloCode` — convenience accessor pre-narrowed to the
|
||||
`GraphqlApolloErrorCode` union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes intentionally mirror the design-doc §10.5 list. Adding a new
|
||||
code requires (a) appending it to the `defineCodes` array, (b) wiring at
|
||||
least one production throw site, and (c) extending the conformance
|
||||
checklist. The `name` field is set so structured logs see
|
||||
`GraphqlApolloError` rather than the generic `SuaveplanError`. The class
|
||||
extends the abstract `SuaveplanError` directly — no intermediate base.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GRAPHQL_APOLLO_CODES, GraphqlApolloError } from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("registers ten GRAPHQL_APOLLO codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(GRAPHQL_APOLLO_CODES)).toHaveLength(10);
|
||||
expect(GRAPHQL_APOLLO_CODES.PARSE_FAILED).toBe(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED"
|
||||
);
|
||||
expect(GRAPHQL_APOLLO_CODES.PERSISTED_QUERY_NOT_FOUND).toBe(
|
||||
"GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs a typed error with code accessor", { timeout: 5000 }, () => {
|
||||
const err = new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"boom"
|
||||
);
|
||||
expect(err).toBeInstanceOf(GraphqlApolloError);
|
||||
expect(err.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
|
||||
expect(err.graphqlApolloCode).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
|
||||
expect(err.name).toBe("GraphqlApolloError");
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("accepts optional cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
|
||||
"outer",
|
||||
{ cause, context: { foo: "bar" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-graphql-apollo`.
|
||||
*
|
||||
* The ten codes cover Apollo-specific failure surfaces layered on top of
|
||||
* the abstract `GRAPHQL.*` codes owned by `@suaveplan/api-graphql`:
|
||||
*
|
||||
* - `PARSE_FAILED` — the request body was not a valid GraphQL document.
|
||||
* - `VALIDATION_FAILED` — the parsed document failed schema validation.
|
||||
* - `DEPTH_EXCEEDED` — query nesting exceeded `maxQueryDepth`.
|
||||
* - `COMPLEXITY_EXCEEDED` — query cost exceeded `maxQueryComplexity`.
|
||||
* - `SCHEMA_BUILD_FAILED` — `buildSubgraphSchema` rejected the merged SDL.
|
||||
* - `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` — subscription requested but no
|
||||
* `api-ws` engine wired.
|
||||
* - `TIMEOUT` — execution exceeded the host-supplied timeout.
|
||||
* - `INTROSPECTION_DENIED` — introspection query received while
|
||||
* `introspection: false`.
|
||||
* - `PERSISTED_QUERY_NOT_FOUND` — `{ id }` referenced an unknown persisted
|
||||
* query.
|
||||
* - `RESOLVER_THREW` — a resolver threw a non-SuaveplanError (the engine
|
||||
* wraps it so wire shape remains stable).
|
||||
*/
|
||||
export const GRAPHQL_APOLLO_CODES = defineCodes("GRAPHQL_APOLLO", [
|
||||
"PARSE_FAILED",
|
||||
"VALIDATION_FAILED",
|
||||
"DEPTH_EXCEEDED",
|
||||
"COMPLEXITY_EXCEEDED",
|
||||
"SCHEMA_BUILD_FAILED",
|
||||
"SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
"INTROSPECTION_DENIED",
|
||||
"PERSISTED_QUERY_NOT_FOUND",
|
||||
"RESOLVER_THREW",
|
||||
] as const);
|
||||
|
||||
export type GraphqlApolloErrorCode =
|
||||
(typeof GRAPHQL_APOLLO_CODES)[keyof typeof GRAPHQL_APOLLO_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for `@suaveplan/api-graphql-apollo`. Extends
|
||||
* {@link SuaveplanError} so callers can narrow with `instanceof
|
||||
* GraphqlApolloError` AND the stable `error.code` property.
|
||||
*/
|
||||
export class GraphqlApolloError extends SuaveplanError<GraphqlApolloErrorCode> {
|
||||
public readonly graphqlApolloCode: GraphqlApolloErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: GraphqlApolloErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.graphqlApolloCode = code;
|
||||
this.name = "GraphqlApolloError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
GRAPHQL_APOLLO_CODES,
|
||||
GraphqlApolloError,
|
||||
type GraphqlApolloErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `federation` — multi-plugin SDL merger
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`federation` is the engine's facade over the canonical `mergeSdl`
|
||||
function from `@suaveplan/api-graphql`. Apollo doesn't see federated
|
||||
plugin contributions directly — the engine collects every plugin's
|
||||
SDL via the abstract `GraphqlRegistry`, federates them through this
|
||||
module, and feeds the merged SDL to `buildSchema()`. Collision
|
||||
detection, namespace wrapping, and strict-mode policy come for free
|
||||
because all three are owned by the abstract merger.
|
||||
|
||||
The module is intentionally a one-line passthrough; it exists so the
|
||||
package surface signals "yes, Apollo federates the same way Yoga
|
||||
does — `GRAPHQL.SCHEMA_COLLISION` is the canonical code on either
|
||||
engine".
|
||||
|
||||
## Features
|
||||
|
||||
- 1:1 delegation to `@suaveplan/api-graphql.mergeSdl` — no shape
|
||||
change, no behaviour change.
|
||||
- Same collision semantics as Yoga — `GRAPHQL.SCHEMA_COLLISION` is
|
||||
raised, NOT a `GRAPHQL_APOLLO.*` code.
|
||||
- Strict-mode propagation — `strictNamespace: true` requires every
|
||||
plugin to declare a `graphql.namespace`.
|
||||
- Returns the canonical `MergedSchema` shape so engine internals stay
|
||||
assignable to the abstract contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { federateForApollo } from "@suaveplan/api-graphql-apollo/federation";
|
||||
|
||||
const merged = federateForApollo([
|
||||
{ plugin: "billing", typeDefs: ["type Query { invoice: String! }"] },
|
||||
{ plugin: "users", typeDefs: ["extend type Query { me: String! }"] },
|
||||
]);
|
||||
console.log(merged.sdl);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Enable strict-mode federation across all plugins to enforce a
|
||||
namespace contract before the host even compiles:
|
||||
|
||||
```ts
|
||||
import { federateForApollo } from "@suaveplan/api-graphql-apollo/federation";
|
||||
|
||||
federateForApollo(contributions, { strictNamespace: true });
|
||||
// → throws GRAPHQL.NAMESPACE_REQUIRED if any plugin omitted namespace
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `federateForApollo(contributions, options?): MergedSchema`
|
||||
- delegates to `@suaveplan/api-graphql.mergeSdl`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function exists primarily so the package's TypeScript exports
|
||||
expose a `federateForApollo` symbol that mirrors the sibling
|
||||
`federateForYoga` (when the Yoga adapter lands). The behavioural
|
||||
contract is owned by the abstract `mergeSdl`. Tests verify that
|
||||
`GRAPHQL.SCHEMA_COLLISION` and `GRAPHQL.NAMESPACE_REQUIRED` are raised
|
||||
through this wrapper — concrete proof that Apollo and Yoga agree on
|
||||
the federation contract.
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GraphqlError } from "@suaveplan/api-graphql";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { federateForApollo } from "./federation.js";
|
||||
|
||||
describe("federation/federateForApollo", () => {
|
||||
it("merges two plugins both extending Query", { timeout: 5000 }, () => {
|
||||
const merged = federateForApollo([
|
||||
{
|
||||
plugin: "a",
|
||||
typeDefs: ["type Query { a: String! }"],
|
||||
},
|
||||
{
|
||||
plugin: "b",
|
||||
typeDefs: ["extend type Query { b: String! }"],
|
||||
},
|
||||
]);
|
||||
expect(merged.sdl).toContain("a: String!");
|
||||
expect(merged.sdl).toContain("b: String!");
|
||||
expect(merged.plugins).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("raises GRAPHQL.SCHEMA_COLLISION on same-field collision", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let err: GraphqlError | null = null;
|
||||
try {
|
||||
federateForApollo([
|
||||
{ plugin: "a", typeDefs: ["type Query { ping: String! }"] },
|
||||
{
|
||||
plugin: "b",
|
||||
typeDefs: ["type Query { ping: String! }"],
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
err = e as GraphqlError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL.SCHEMA_COLLISION");
|
||||
});
|
||||
|
||||
it("forwards strictNamespace requirement", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
federateForApollo(
|
||||
[{ plugin: "a", typeDefs: ["type Query { a: String! }"] }],
|
||||
{ strictNamespace: true }
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
MergedSchema,
|
||||
MergeOptions,
|
||||
SdlContribution,
|
||||
} from "@suaveplan/api-graphql";
|
||||
import { mergeSdl } from "@suaveplan/api-graphql";
|
||||
|
||||
/**
|
||||
* Federate a set of plugin SDL contributions into a single executable
|
||||
* schema document for Apollo. Delegates to `@suaveplan/api-graphql`'s
|
||||
* canonical `mergeSdl` so collision semantics, namespace wrapping and
|
||||
* the `GRAPHQL.SCHEMA_COLLISION` / `GRAPHQL.NAMESPACE_REQUIRED` raise
|
||||
* paths stay 1:1 with the Yoga sibling.
|
||||
*
|
||||
* Returning the original `MergedSchema` keeps the Apollo adapter's
|
||||
* federation surface assignable to the abstract contract — engines may
|
||||
* not subclass or re-shape this value.
|
||||
*/
|
||||
export function federateForApollo(
|
||||
contributions: ReadonlyArray<SdlContribution>,
|
||||
options: MergeOptions = {}
|
||||
): MergedSchema {
|
||||
return mergeSdl(contributions, options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { federateForApollo } from "./federation.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `guards/depth-and-complexity` — query-shape rejection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`depth-and-complexity` walks a parsed `DocumentNode` and computes two
|
||||
coarse static metrics — the maximum selection-set nesting depth and the
|
||||
total field-count cost. The engine consults both before handing the
|
||||
document to Apollo Server; queries that exceed `maxQueryDepth` or
|
||||
`maxQueryComplexity` are rejected with the corresponding
|
||||
`GRAPHQL_APOLLO.DEPTH_EXCEEDED` / `COMPLEXITY_EXCEEDED` codes.
|
||||
|
||||
The walk follows fragment spreads through their definitions and includes
|
||||
inline-fragment subselections, so a clever query that hides depth or
|
||||
cost behind a deeply nested fragment cannot bypass the guards. The
|
||||
heuristic matches the Yoga sibling so the two engines reject the same
|
||||
query set under identical limits.
|
||||
|
||||
## Features
|
||||
|
||||
- Depth counter increments per nested `selectionSet`, never on fragment
|
||||
spreads or inline fragments themselves (matches conventional
|
||||
depth-limiter implementations).
|
||||
- Complexity counter adds 1 per selected field, plus the recursive cost
|
||||
of its subselection, plus the expansion of fragment spreads. Missing
|
||||
fragment spreads contribute zero.
|
||||
- `assertDepthAndComplexity` evaluates depth first; the more localised
|
||||
failure wins so operators see the rule they triggered.
|
||||
- Pure-functional — no instance state, no `this`. Safe to call from any
|
||||
request lifecycle phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parse } from "graphql";
|
||||
import { assertDepthAndComplexity } from "@suaveplan/api-graphql-apollo/guards";
|
||||
|
||||
const doc = parse("{ a { b { c } } }");
|
||||
assertDepthAndComplexity(doc, { maxDepth: 10, maxComplexity: 1000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the metrics in isolation to feed an OTel histogram before the engine
|
||||
even routes the document:
|
||||
|
||||
```ts
|
||||
import { parse } from "graphql";
|
||||
import {
|
||||
maxQueryDepth,
|
||||
queryComplexity,
|
||||
} from "@suaveplan/api-graphql-apollo/guards";
|
||||
|
||||
const doc = parse(req.body.query);
|
||||
metrics.histogram("graphql.depth").record(maxQueryDepth(doc));
|
||||
metrics.histogram("graphql.complexity").record(queryComplexity(doc));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `maxQueryDepth(doc: DocumentNode): number`
|
||||
- `queryComplexity(doc: DocumentNode): number`
|
||||
- `assertDepthAndComplexity(doc, { maxDepth, maxComplexity }): void` —
|
||||
throws `GraphqlApolloError` on first violation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Fragment definitions are collected up-front into a `Map` so spread
|
||||
resolution is O(1). Inline fragments share the parent depth — they do
|
||||
not add a level. The complexity heuristic intentionally undercounts
|
||||
list multiplicity; production deployments wire a richer estimator via a
|
||||
host-supplied `costExtension` (out of scope for the foundation engine).
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parse } from "graphql";
|
||||
import type { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
assertDepthAndComplexity,
|
||||
maxQueryDepth,
|
||||
queryComplexity,
|
||||
} from "./depth-and-complexity.js";
|
||||
|
||||
describe("guards/depth-and-complexity", () => {
|
||||
it("computes depth of a nested selection", { timeout: 5000 }, () => {
|
||||
const doc = parse("{ a { b { c { d } } } }");
|
||||
expect(maxQueryDepth(doc)).toBe(4);
|
||||
});
|
||||
|
||||
it("counts complexity across fragments + inline fragments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse(
|
||||
"{ a { ...F ... on T { x } } } fragment F on T { y z }"
|
||||
);
|
||||
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("ignores fragment spreads with no matching definition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ...Missing } }");
|
||||
expect(maxQueryDepth(doc)).toBe(2);
|
||||
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("throws DEPTH_EXCEEDED for over-nested queries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { b { c { d } } } }");
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
assertDepthAndComplexity(doc, {
|
||||
maxDepth: 2,
|
||||
maxComplexity: 1000,
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err).not.toBeNull();
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.DEPTH_EXCEEDED");
|
||||
});
|
||||
|
||||
it("throws COMPLEXITY_EXCEEDED for high-cost queries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a b c d e }");
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
assertDepthAndComplexity(doc, {
|
||||
maxDepth: 100,
|
||||
maxComplexity: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.COMPLEXITY_EXCEEDED");
|
||||
});
|
||||
|
||||
it("passes when limits allow", { timeout: 5000 }, () => {
|
||||
const doc = parse("{ ping }");
|
||||
expect(() =>
|
||||
assertDepthAndComplexity(doc, {
|
||||
maxDepth: 10,
|
||||
maxComplexity: 100,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns zero depth for fragment-only documents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("fragment F on T { a }");
|
||||
expect(maxQueryDepth(doc)).toBe(0);
|
||||
expect(queryComplexity(doc)).toBe(0);
|
||||
});
|
||||
|
||||
it("walks fragment spread depth through depth counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ...F } } fragment F on T { b { c { d } } }");
|
||||
// a (1) -> spread F whose body { b { c { d } } } adds 3 -> total 4
|
||||
expect(maxQueryDepth(doc)).toBe(4);
|
||||
});
|
||||
|
||||
it("walks inline fragment depth + complexity", { timeout: 5000 }, () => {
|
||||
const doc = parse("{ a { ... on T { b { c } } } }");
|
||||
expect(maxQueryDepth(doc)).toBe(3);
|
||||
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("walks fragments through complexity counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ...F } } fragment F on T { x y z }");
|
||||
// 'a' = 1, fragment spread expands x,y,z = 3 -> total 4
|
||||
expect(queryComplexity(doc)).toBe(4);
|
||||
});
|
||||
|
||||
it("treats missing fragment spreads as zero complexity contribution", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ...Missing } }");
|
||||
expect(queryComplexity(doc)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
DocumentNode,
|
||||
FieldNode,
|
||||
FragmentDefinitionNode,
|
||||
SelectionSetNode,
|
||||
} from "graphql";
|
||||
import { Kind } from "graphql";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Compute the maximum selection depth of a parsed GraphQL document. Each
|
||||
* nested selection set increments the depth counter; fragment spreads
|
||||
* follow their definition.
|
||||
*/
|
||||
export function maxQueryDepth(doc: DocumentNode): number {
|
||||
const fragments = collectFragments(doc);
|
||||
let maxDepth = 0;
|
||||
for (const def of doc.definitions) {
|
||||
if (def.kind === Kind.OPERATION_DEFINITION) {
|
||||
maxDepth = Math.max(
|
||||
maxDepth,
|
||||
depthOfSelectionSet(def.selectionSet, fragments, 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a coarse cost estimate: each selected field contributes 1. List
|
||||
* fields contribute their multiplicity (default 10). Fragment spreads
|
||||
* inline. This is the same heuristic the Yoga sibling uses so dashboards
|
||||
* cross-correlate.
|
||||
*/
|
||||
export function queryComplexity(doc: DocumentNode): number {
|
||||
const fragments = collectFragments(doc);
|
||||
let total = 0;
|
||||
for (const def of doc.definitions) {
|
||||
if (def.kind === Kind.OPERATION_DEFINITION) {
|
||||
total += complexityOfSelectionSet(def.selectionSet, fragments);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `GRAPHQL_APOLLO.DEPTH_EXCEEDED` / `COMPLEXITY_EXCEEDED` when the
|
||||
* document violates either guard. Both checks run; the first violation
|
||||
* wins so callers always see the more localised failure.
|
||||
*/
|
||||
export function assertDepthAndComplexity(
|
||||
doc: DocumentNode,
|
||||
limits: { readonly maxDepth: number; readonly maxComplexity: number }
|
||||
): void {
|
||||
const depth = maxQueryDepth(doc);
|
||||
if (depth > limits.maxDepth) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.DEPTH_EXCEEDED",
|
||||
`query depth ${depth} exceeds maxQueryDepth ${limits.maxDepth}`,
|
||||
{ context: { depth, limit: limits.maxDepth } }
|
||||
);
|
||||
}
|
||||
const complexity = queryComplexity(doc);
|
||||
if (complexity > limits.maxComplexity) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.COMPLEXITY_EXCEEDED",
|
||||
`query complexity ${complexity} exceeds maxQueryComplexity ${limits.maxComplexity}`,
|
||||
{ context: { complexity, limit: limits.maxComplexity } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function collectFragments(
|
||||
doc: DocumentNode
|
||||
): ReadonlyMap<string, FragmentDefinitionNode> {
|
||||
const out = new Map<string, FragmentDefinitionNode>();
|
||||
for (const def of doc.definitions) {
|
||||
if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
||||
out.set(def.name.value, def);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function depthOfSelectionSet(
|
||||
set: SelectionSetNode,
|
||||
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
|
||||
depth: number
|
||||
): number {
|
||||
let max = depth;
|
||||
for (const sel of set.selections) {
|
||||
max = Math.max(max, depthOfSelection(sel, fragments, depth));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function depthOfSelection(
|
||||
sel: SelectionSetNode["selections"][number],
|
||||
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
|
||||
depth: number
|
||||
): number {
|
||||
if (sel.kind === Kind.FIELD) {
|
||||
const node = sel as FieldNode;
|
||||
if (node.selectionSet === undefined) return depth;
|
||||
return depthOfSelectionSet(node.selectionSet, fragments, depth + 1);
|
||||
}
|
||||
if (sel.kind === Kind.FRAGMENT_SPREAD) {
|
||||
const frag = fragments.get(sel.name.value);
|
||||
if (frag === undefined) return depth;
|
||||
return depthOfSelectionSet(frag.selectionSet, fragments, depth);
|
||||
}
|
||||
// INLINE_FRAGMENT
|
||||
return depthOfSelectionSet(sel.selectionSet, fragments, depth);
|
||||
}
|
||||
|
||||
function complexityOfSelectionSet(
|
||||
set: SelectionSetNode,
|
||||
fragments: ReadonlyMap<string, FragmentDefinitionNode>
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const sel of set.selections) {
|
||||
total += complexityOfSelection(sel, fragments);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function complexityOfSelection(
|
||||
sel: SelectionSetNode["selections"][number],
|
||||
fragments: ReadonlyMap<string, FragmentDefinitionNode>
|
||||
): number {
|
||||
if (sel.kind === Kind.FIELD) {
|
||||
const nested =
|
||||
sel.selectionSet === undefined
|
||||
? 0
|
||||
: complexityOfSelectionSet(sel.selectionSet, fragments);
|
||||
return 1 + nested;
|
||||
}
|
||||
if (sel.kind === Kind.FRAGMENT_SPREAD) {
|
||||
const frag = fragments.get(sel.name.value);
|
||||
if (frag === undefined) return 0;
|
||||
return complexityOfSelectionSet(frag.selectionSet, fragments);
|
||||
}
|
||||
// INLINE_FRAGMENT
|
||||
return complexityOfSelectionSet(sel.selectionSet, fragments);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
assertDepthAndComplexity,
|
||||
maxQueryDepth,
|
||||
queryComplexity,
|
||||
} from "./depth-and-complexity.js";
|
||||
67
packages/api-stack/api/api-graphql-apollo/src/index.ts
Normal file
67
packages/api-stack/api/api-graphql-apollo/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Side-effect import — registers the GRAPHQL_APOLLO error-code namespace.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
type GraphqlHttpHandler,
|
||||
type GraphqlSdlHandler,
|
||||
type MountHttpEndpointsArgs,
|
||||
mountGraphqlHttpEndpoints,
|
||||
} from "./endpoints/http.js";
|
||||
export {
|
||||
type GraphqlUiPlugin,
|
||||
mountGraphqlUiProxy,
|
||||
type UiProxyResponse,
|
||||
} from "./endpoints/ui-proxy.js";
|
||||
export {
|
||||
ApolloGraphqlEngine,
|
||||
type CreateApolloGraphqlEngineArgs,
|
||||
createApolloGraphqlEngine,
|
||||
} from "./engine/apollo-engine.js";
|
||||
export {
|
||||
GRAPHQL_APOLLO_CODES,
|
||||
GraphqlApolloError,
|
||||
type GraphqlApolloErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { federateForApollo } from "./federation/federation.js";
|
||||
export {
|
||||
assertDepthAndComplexity,
|
||||
maxQueryDepth,
|
||||
queryComplexity,
|
||||
} from "./guards/depth-and-complexity.js";
|
||||
export {
|
||||
createInstruments,
|
||||
GRAPHQL_INSTRUMENT_NAMES,
|
||||
type GraphqlInstrumentName,
|
||||
type GraphqlInstruments,
|
||||
type InstrumentSnapshot,
|
||||
} from "./instruments/instruments.js";
|
||||
export {
|
||||
createRequestLoaderScope,
|
||||
type RequestLoaderScope,
|
||||
} from "./loaders/loaders.js";
|
||||
export {
|
||||
type ApolloGraphqlOptions,
|
||||
type ApolloGraphqlOptionsInput,
|
||||
apolloGraphqlOptionsSchema,
|
||||
parseApolloGraphqlOptions,
|
||||
} from "./options/options.js";
|
||||
export {
|
||||
createPersistedQueryManifest,
|
||||
loadPersistedQueryManifest,
|
||||
type PersistedQueryManifest,
|
||||
parsePersistedQueryManifest,
|
||||
} from "./persisted-queries/persisted-queries.js";
|
||||
export {
|
||||
createStudioReporting,
|
||||
type StudioReportingConfig,
|
||||
type StudioReportingDescriptor,
|
||||
} from "./studio-reporting/studio-reporting.js";
|
||||
export {
|
||||
assertSubscriptionTransport,
|
||||
runSubscription,
|
||||
type SubscriptionTransport,
|
||||
} from "./subscriptions/subscriptions.js";
|
||||
export {
|
||||
runApolloConformance,
|
||||
runGraphqlEngineConformance,
|
||||
} from "./testing/conformance.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createInstruments,
|
||||
GRAPHQL_INSTRUMENT_NAMES,
|
||||
type GraphqlInstrumentName,
|
||||
type GraphqlInstruments,
|
||||
type InstrumentSnapshot,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# `instruments` — engine-agnostic OTel surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`instruments` owns the five OTel instrument names emitted by every GraphQL
|
||||
engine adapter in the foundation. Both `api-graphql-apollo` and
|
||||
`api-graphql-yoga` MUST emit the same set so operator dashboards survive
|
||||
an engine swap with zero rename work. The names are stable strings (not
|
||||
generated identifiers); flipping the host's `engine` slot from Yoga to
|
||||
Apollo or back keeps the time-series continuous.
|
||||
|
||||
The module also ships an in-memory `GraphqlInstruments` implementation
|
||||
used by unit tests — `createInstruments()` returns a recorder that pushes
|
||||
to plain arrays so tests can assert exact request counts, schema rebuild
|
||||
counts, etc. Production hosts pass their own implementation backed by
|
||||
`@opentelemetry/sdk-metrics`.
|
||||
|
||||
## Features
|
||||
|
||||
- `GRAPHQL_INSTRUMENT_NAMES` frozen object — five canonical
|
||||
`graphql.*` names.
|
||||
- `createInstruments()` — zero-arg factory returning an in-memory
|
||||
recorder with `snapshot()` for assertions.
|
||||
- Counter + histogram surface: `incRequests`, `recordRequestDurationMs`,
|
||||
`recordResolverDurationMs`, `incSubscriptionsActive` /
|
||||
`decSubscriptionsActive`, `incSchemaRebuilds`.
|
||||
- Snapshot is a deep copy — tests can compare without worrying about
|
||||
later mutation aliasing the saved value.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
|
||||
|
||||
const ins = createInstruments();
|
||||
ins.incRequests({ operation: "ping" });
|
||||
ins.recordRequestDurationMs(42);
|
||||
ins.snapshot();
|
||||
// → { requestsTotal: 1, requestDurationMs: [42], … }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Plug an OTel-backed instrument set into the engine and assert the
|
||||
expected metrics are emitted across a workload:
|
||||
|
||||
```ts
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
|
||||
|
||||
const ins = createInstruments();
|
||||
const engine = createApolloGraphqlEngine({ instruments: ins });
|
||||
// … buildSchema + executeQuery …
|
||||
const snap = ins.snapshot();
|
||||
console.log(snap.requestsTotal, snap.schemaRebuildsTotal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GRAPHQL_INSTRUMENT_NAMES.requestsTotal` (etc.) — exact metric names.
|
||||
- `GraphqlInstruments` — interface implemented by the in-memory recorder
|
||||
and by production OTel adapters.
|
||||
- `InstrumentSnapshot` — read-only snapshot returned by `snapshot()`.
|
||||
- `createInstruments(): GraphqlInstruments` — in-memory factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The recorder is deliberately a closure over four primitives + two arrays
|
||||
— no Map allocations on the hot path. The arrays are copied at snapshot
|
||||
time so callers cannot mutate the live counters. Engine-agnostic naming
|
||||
is enforced by the engine-swap integration test in `tests/integration/`
|
||||
which asserts the metric-name set is identical across Apollo and Yoga.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createInstruments, GRAPHQL_INSTRUMENT_NAMES } from "./instruments.js";
|
||||
|
||||
describe("instruments", () => {
|
||||
it("exposes the engine-agnostic name set", { timeout: 5000 }, () => {
|
||||
expect(GRAPHQL_INSTRUMENT_NAMES.requestsTotal).toBe(
|
||||
"graphql.requests_total"
|
||||
);
|
||||
expect(GRAPHQL_INSTRUMENT_NAMES.requestDurationMs).toBe(
|
||||
"graphql.request_duration_ms"
|
||||
);
|
||||
expect(GRAPHQL_INSTRUMENT_NAMES.resolverDurationMs).toBe(
|
||||
"graphql.resolver_duration_ms"
|
||||
);
|
||||
expect(GRAPHQL_INSTRUMENT_NAMES.subscriptionsActive).toBe(
|
||||
"graphql.subscriptions_active"
|
||||
);
|
||||
expect(GRAPHQL_INSTRUMENT_NAMES.schemaRebuildsTotal).toBe(
|
||||
"graphql.schema_rebuilds_total"
|
||||
);
|
||||
});
|
||||
|
||||
it("records every instrument and snapshots them", { timeout: 5000 }, () => {
|
||||
const ins = createInstruments();
|
||||
ins.incRequests({ op: "ping" });
|
||||
ins.incRequests({ op: "ping" });
|
||||
ins.recordRequestDurationMs(12);
|
||||
ins.recordResolverDurationMs(3);
|
||||
ins.incSubscriptionsActive();
|
||||
ins.incSubscriptionsActive();
|
||||
ins.decSubscriptionsActive();
|
||||
ins.incSchemaRebuilds();
|
||||
const snap = ins.snapshot();
|
||||
expect(snap.requestsTotal).toBe(2);
|
||||
expect(snap.requestDurationMs).toEqual([12]);
|
||||
expect(snap.resolverDurationMs).toEqual([3]);
|
||||
expect(snap.subscriptionsActive).toBe(1);
|
||||
expect(snap.schemaRebuildsTotal).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Engine-agnostic OTel instrument names emitted by every GraphQL engine
|
||||
* adapter — Apollo AND Yoga MUST emit this exact set so operator
|
||||
* dashboards survive an engine swap.
|
||||
*/
|
||||
export const GRAPHQL_INSTRUMENT_NAMES = Object.freeze({
|
||||
requestsTotal: "graphql.requests_total",
|
||||
requestDurationMs: "graphql.request_duration_ms",
|
||||
resolverDurationMs: "graphql.resolver_duration_ms",
|
||||
subscriptionsActive: "graphql.subscriptions_active",
|
||||
schemaRebuildsTotal: "graphql.schema_rebuilds_total",
|
||||
} as const);
|
||||
|
||||
export type GraphqlInstrumentName =
|
||||
(typeof GRAPHQL_INSTRUMENT_NAMES)[keyof typeof GRAPHQL_INSTRUMENT_NAMES];
|
||||
|
||||
/**
|
||||
* Runtime instrument registry — minimal counters + histograms shape
|
||||
* pluggable into a real OTel meter at host wire-up. Implementations are
|
||||
* passed in via {@link createInstruments}; the in-memory default records
|
||||
* to arrays for unit-test introspection.
|
||||
*/
|
||||
export interface GraphqlInstruments {
|
||||
incRequests(attrs: Readonly<Record<string, string>>): void;
|
||||
recordRequestDurationMs(value: number): void;
|
||||
recordResolverDurationMs(value: number): void;
|
||||
incSubscriptionsActive(): void;
|
||||
decSubscriptionsActive(): void;
|
||||
incSchemaRebuilds(): void;
|
||||
snapshot(): InstrumentSnapshot;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of every instrument's current value. */
|
||||
export interface InstrumentSnapshot {
|
||||
readonly requestsTotal: number;
|
||||
readonly requestDurationMs: readonly number[];
|
||||
readonly resolverDurationMs: readonly number[];
|
||||
readonly subscriptionsActive: number;
|
||||
readonly schemaRebuildsTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default in-memory implementation. Used by unit tests; production callers
|
||||
* pass their own implementation backed by `@opentelemetry/sdk-metrics`.
|
||||
*/
|
||||
export function createInstruments(): GraphqlInstruments {
|
||||
let requestsTotal = 0;
|
||||
const requestDurationMs: number[] = [];
|
||||
const resolverDurationMs: number[] = [];
|
||||
let subscriptionsActive = 0;
|
||||
let schemaRebuildsTotal = 0;
|
||||
|
||||
return {
|
||||
incRequests(): void {
|
||||
requestsTotal += 1;
|
||||
},
|
||||
recordRequestDurationMs(value: number): void {
|
||||
requestDurationMs.push(value);
|
||||
},
|
||||
recordResolverDurationMs(value: number): void {
|
||||
resolverDurationMs.push(value);
|
||||
},
|
||||
incSubscriptionsActive(): void {
|
||||
subscriptionsActive += 1;
|
||||
},
|
||||
decSubscriptionsActive(): void {
|
||||
subscriptionsActive -= 1;
|
||||
},
|
||||
incSchemaRebuilds(): void {
|
||||
schemaRebuildsTotal += 1;
|
||||
},
|
||||
snapshot(): InstrumentSnapshot {
|
||||
return {
|
||||
requestsTotal,
|
||||
requestDurationMs: [...requestDurationMs],
|
||||
resolverDurationMs: [...resolverDurationMs],
|
||||
subscriptionsActive,
|
||||
schemaRebuildsTotal,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createRequestLoaderScope,
|
||||
type RequestLoaderScope,
|
||||
} from "./loaders.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `loaders` — per-request DataLoader scope
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`loaders` defines the per-request DataLoader scope the engine creates
|
||||
around every incoming GraphQL operation. Two concurrent requests
|
||||
receive distinct loader instances; the cache never leaks across
|
||||
request boundaries even though both requests draw from the same set of
|
||||
loader factories registered on the abstract `GraphqlRegistry`.
|
||||
|
||||
The module is deliberately the thinnest possible wrapper around the
|
||||
`LoaderFactory.create()` surface owned by `@suaveplan/api-types/graphql`.
|
||||
The engine calls `createRequestLoaderScope()` once per request; resolvers
|
||||
that need a loader call `scope.get(name, factory)`; the engine disposes
|
||||
the scope at request end.
|
||||
|
||||
## Features
|
||||
|
||||
- One scope per request — distinct cache per request guaranteed.
|
||||
- Loader lazily instantiated on first `get(name, factory)` call;
|
||||
subsequent calls reuse the instance.
|
||||
- `dispose()` blocks subsequent `get()` calls so a buggy late resolver
|
||||
cannot resurrect a freed cache.
|
||||
- Zero runtime dependencies — pure JS, fits in ~40 lines.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRequestLoaderScope } from "@suaveplan/api-graphql-apollo/loaders";
|
||||
|
||||
const scope = createRequestLoaderScope();
|
||||
const userLoader = scope.get("users", userLoaderFactory);
|
||||
const user = await userLoader.load("u_42");
|
||||
scope.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the scope into a resolver context object so resolvers consume
|
||||
loaders by name through the request context:
|
||||
|
||||
```ts
|
||||
import { createRequestLoaderScope } from "@suaveplan/api-graphql-apollo/loaders";
|
||||
|
||||
function makeContext(): RequestContext {
|
||||
const scope = createRequestLoaderScope();
|
||||
return {
|
||||
loaders: {
|
||||
users: () => scope.get("users", userLoaderFactory),
|
||||
orders: () => scope.get("orders", orderLoaderFactory),
|
||||
},
|
||||
finalize: () => scope.dispose(),
|
||||
} as unknown as RequestContext;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RequestLoaderScope`:
|
||||
- `get<TKey, TValue>(name, factory): { load }`
|
||||
- `dispose(): void`
|
||||
- `size(): number`
|
||||
- `createRequestLoaderScope(): RequestLoaderScope`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scope is a closure over a `Map<string, LoaderInstance>` plus a
|
||||
`disposed` boolean. Throwing on `get` after `dispose` surfaces the
|
||||
late-resolver bug at the call site instead of silently producing
|
||||
inconsistent reads. The size accessor is provided so OTel histograms
|
||||
can record loader-fanout per request.
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRequestLoaderScope } from "./loaders.js";
|
||||
|
||||
function makeFactory(): LoaderFactory<string, string> {
|
||||
let createCount = 0;
|
||||
return {
|
||||
create() {
|
||||
createCount += 1;
|
||||
const seen = createCount;
|
||||
return {
|
||||
async load(key: string): Promise<string> {
|
||||
return `${key}:${seen}`;
|
||||
},
|
||||
async loadMany(
|
||||
keys: readonly string[]
|
||||
): Promise<readonly string[]> {
|
||||
return keys.map((k) => `${k}:${seen}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("loaders/createRequestLoaderScope", () => {
|
||||
it("creates one loader per name per request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const scope = createRequestLoaderScope();
|
||||
const factory = makeFactory();
|
||||
const a = scope.get("users", factory);
|
||||
const b = scope.get("users", factory);
|
||||
expect(a).toBe(b);
|
||||
expect(scope.size()).toBe(1);
|
||||
expect(await a.load("u1")).toBe("u1:1");
|
||||
});
|
||||
|
||||
it("isolates loaders across two scopes (concurrent requests)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory = makeFactory();
|
||||
const s1 = createRequestLoaderScope();
|
||||
const s2 = createRequestLoaderScope();
|
||||
const l1 = s1.get("orders", factory);
|
||||
const l2 = s2.get("orders", factory);
|
||||
expect(await l1.load("o1")).toBe("o1:1");
|
||||
expect(await l2.load("o1")).toBe("o1:2");
|
||||
});
|
||||
|
||||
it("throws on use after dispose", { timeout: 5000 }, () => {
|
||||
const s = createRequestLoaderScope();
|
||||
s.dispose();
|
||||
expect(() => s.get("x", makeFactory())).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
|
||||
|
||||
/**
|
||||
* Per-request DataLoader scope. The engine instantiates one of these
|
||||
* around every incoming GraphQL request and disposes it at request end.
|
||||
* Two concurrent requests therefore receive distinct loader instances —
|
||||
* the cache never leaks across requests.
|
||||
*/
|
||||
export interface RequestLoaderScope {
|
||||
/**
|
||||
* Resolve a loader by name. Calls the factory's `create()` once per
|
||||
* scope; subsequent calls within the same request reuse the
|
||||
* instance.
|
||||
*/
|
||||
get<TKey, TValue>(
|
||||
name: string,
|
||||
factory: LoaderFactory<TKey, TValue>
|
||||
): { load(key: TKey): Promise<TValue> };
|
||||
/**
|
||||
* Dispose the scope. After disposal `get()` throws. Provided as a
|
||||
* dedicated hook so engines can wire it to the request lifecycle.
|
||||
*/
|
||||
dispose(): void;
|
||||
/** Read-only count of loaders instantiated this request. */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
/** Construct a fresh loader scope. */
|
||||
export function createRequestLoaderScope(): RequestLoaderScope {
|
||||
const instances = new Map<
|
||||
string,
|
||||
{ load(key: unknown): Promise<unknown> }
|
||||
>();
|
||||
let disposed = false;
|
||||
return {
|
||||
get<TKey, TValue>(
|
||||
name: string,
|
||||
factory: LoaderFactory<TKey, TValue>
|
||||
): { load(key: TKey): Promise<TValue> } {
|
||||
if (disposed) {
|
||||
throw new Error(
|
||||
`request loader scope already disposed (loader "${name}")`
|
||||
);
|
||||
}
|
||||
const existing = instances.get(name);
|
||||
if (existing !== undefined) {
|
||||
return existing as { load(key: TKey): Promise<TValue> };
|
||||
}
|
||||
const fresh = factory.create();
|
||||
instances.set(
|
||||
name,
|
||||
fresh as unknown as { load(key: unknown): Promise<unknown> }
|
||||
);
|
||||
return fresh as { load(key: TKey): Promise<TValue> };
|
||||
},
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
instances.clear();
|
||||
},
|
||||
size(): number {
|
||||
return instances.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ApolloGraphqlOptions,
|
||||
type ApolloGraphqlOptionsInput,
|
||||
apolloGraphqlOptionsSchema,
|
||||
parseApolloGraphqlOptions,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# `options` — `ApolloGraphqlOptions`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`options` owns the Zod-validated configuration surface accepted by
|
||||
`createApolloGraphqlEngine`. The schema mirrors the Yoga sibling's options
|
||||
1:1 so an operator can swap engines without touching the host wiring —
|
||||
defaults agree, paths agree, depth and complexity limits agree, and the
|
||||
persisted-query path / Studio reporting fields are the Apollo-specific
|
||||
additions layered on top.
|
||||
|
||||
Inputs cross the Zod boundary once at engine construction time; downstream
|
||||
code consumes the fully resolved `ApolloGraphqlOptions` shape and never
|
||||
re-validates. Configuration failures throw `GRAPHQL_APOLLO.PARSE_FAILED`
|
||||
so caller-visible input errors share a single error code with the
|
||||
request-document parse path.
|
||||
|
||||
## Features
|
||||
|
||||
- Endpoint paths default to `/graphql`, `/graphql/sdl`, `/graphql/ws`,
|
||||
`/graphql/ui` — identical to Yoga.
|
||||
- `introspection: false` by default; flipping the flag wires Apollo's
|
||||
schema introspection on AND lets the engine skip the
|
||||
`INTROSPECTION_DENIED` guard.
|
||||
- `maxQueryDepth` and `maxQueryComplexity` mirror the Yoga defaults
|
||||
(`10`, `1000`).
|
||||
- `requestTimeoutMs` defaults to `30_000`; the engine wraps every
|
||||
`executeOperation` call in a timeout race.
|
||||
- Persisted-query manifest path is optional; the engine wires the
|
||||
manifest loader only when `persistedQueriesPath` is set.
|
||||
- `studioReporting` opt-in — the engine returns `null` from
|
||||
`createStudioReporting()` when omitted.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
|
||||
|
||||
const opts = parseApolloGraphqlOptions({});
|
||||
// opts.endpoint === "/graphql"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire persisted queries plus Apollo Studio reporting plus a custom error
|
||||
formatter:
|
||||
|
||||
```ts
|
||||
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
|
||||
|
||||
const opts = parseApolloGraphqlOptions({
|
||||
persistedQueriesPath: "./pq.json",
|
||||
studioReporting: {
|
||||
apiKey: process.env.APOLLO_KEY ?? "",
|
||||
graphRef: "supergraph@current",
|
||||
},
|
||||
formatError: (err) => ({ msg: String(err), redacted: true }),
|
||||
requestTimeoutMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `apolloGraphqlOptionsSchema` — the underlying Zod schema (exported for
|
||||
introspection / OpenAPI registration).
|
||||
- `parseApolloGraphqlOptions(input?) → ApolloGraphqlOptions` — throws
|
||||
`GraphqlApolloError("GRAPHQL_APOLLO.PARSE_FAILED")` on validation
|
||||
failure.
|
||||
- `ApolloGraphqlOptions` — fully-resolved options type (every field
|
||||
required, every default applied).
|
||||
- `ApolloGraphqlOptionsInput` — input type (every field optional).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses `z.string().startsWith("/")` for every endpoint path —
|
||||
operator typos like `endpoint: "graphql"` (no leading slash) surface
|
||||
synchronously at boot. The `formatError` field is `z.custom<(err) => …>`
|
||||
because Zod has no first-class function type; the custom guard rejects
|
||||
non-functions with the same `GRAPHQL_APOLLO.PARSE_FAILED` code.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import { parseApolloGraphqlOptions } from "./options.js";
|
||||
|
||||
describe("options", () => {
|
||||
it("applies defaults when called with no input", { timeout: 5000 }, () => {
|
||||
const opts = parseApolloGraphqlOptions();
|
||||
expect(opts.endpoint).toBe("/graphql");
|
||||
expect(opts.sdlEndpoint).toBe("/graphql/sdl");
|
||||
expect(opts.wsEndpoint).toBe("/graphql/ws");
|
||||
expect(opts.uiEndpoint).toBe("/graphql/ui");
|
||||
expect(opts.introspection).toBe(false);
|
||||
expect(opts.maxQueryDepth).toBe(10);
|
||||
expect(opts.maxQueryComplexity).toBe(1000);
|
||||
expect(opts.persistedQueriesPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts overrides + persisted-queries path", { timeout: 5000 }, () => {
|
||||
const opts = parseApolloGraphqlOptions({
|
||||
endpoint: "/gql",
|
||||
persistedQueriesPath: "./pq.json",
|
||||
introspection: true,
|
||||
studioReporting: { apiKey: "k", graphRef: "g@current" },
|
||||
formatError: (err) => ({ error: String(err) }),
|
||||
});
|
||||
expect(opts.endpoint).toBe("/gql");
|
||||
expect(opts.persistedQueriesPath).toBe("./pq.json");
|
||||
expect(opts.studioReporting?.apiKey).toBe("k");
|
||||
expect(typeof opts.formatError).toBe("function");
|
||||
});
|
||||
|
||||
it("throws GRAPHQL_APOLLO.PARSE_FAILED on invalid endpoint", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseApolloGraphqlOptions({ endpoint: "graphql" })
|
||||
).toThrow(GraphqlApolloError);
|
||||
});
|
||||
|
||||
it("rejects non-positive maxQueryDepth", { timeout: 5000 }, () => {
|
||||
expect(() => parseApolloGraphqlOptions({ maxQueryDepth: 0 })).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-function formatError", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseApolloGraphqlOptions({
|
||||
formatError: "nope" as unknown as () => Record<string, unknown>,
|
||||
})
|
||||
).toThrow(GraphqlApolloError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import z from "zod";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Zod schema for `ApolloGraphqlOptions`. Validates the public configuration
|
||||
* surface for `createApolloGraphqlEngine`. Defaults mirror the Yoga sibling
|
||||
* to keep operator behaviour interchangeable across engines.
|
||||
*/
|
||||
export const apolloGraphqlOptionsSchema = z.object({
|
||||
endpoint: z.string().startsWith("/").default("/graphql"),
|
||||
sdlEndpoint: z.string().startsWith("/").default("/graphql/sdl"),
|
||||
wsEndpoint: z.string().startsWith("/").default("/graphql/ws"),
|
||||
uiEndpoint: z.string().startsWith("/").default("/graphql/ui"),
|
||||
introspection: z.boolean().default(false),
|
||||
persistedQueriesPath: z.string().min(1).optional(),
|
||||
studioReporting: z
|
||||
.object({
|
||||
apiKey: z.string().min(1),
|
||||
graphRef: z.string().min(1),
|
||||
})
|
||||
.optional(),
|
||||
maxQueryDepth: z.number().int().positive().default(10),
|
||||
maxQueryComplexity: z.number().int().positive().default(1000),
|
||||
requestTimeoutMs: z.number().int().positive().default(30_000),
|
||||
formatError: z
|
||||
.custom<(err: unknown) => Readonly<Record<string, unknown>>>(
|
||||
(val) => typeof val === "function",
|
||||
{ message: "formatError must be a function" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Public input type — every field optional. */
|
||||
export type ApolloGraphqlOptionsInput = z.input<
|
||||
typeof apolloGraphqlOptionsSchema
|
||||
>;
|
||||
|
||||
/** Fully-resolved options. */
|
||||
export type ApolloGraphqlOptions = z.output<typeof apolloGraphqlOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Parse a user-supplied options bag, applying every default. Throws
|
||||
* `GRAPHQL_APOLLO.PARSE_FAILED` (config-shape failures map to the same code
|
||||
* the engine raises for request-document parse failures because both are
|
||||
* caller-visible input errors).
|
||||
*/
|
||||
export function parseApolloGraphqlOptions(
|
||||
input: ApolloGraphqlOptionsInput = {}
|
||||
): ApolloGraphqlOptions {
|
||||
const parsed = apolloGraphqlOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
`invalid ApolloGraphqlOptions: ${parsed.error.message}`,
|
||||
{ context: { input, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createPersistedQueryManifest,
|
||||
loadPersistedQueryManifest,
|
||||
type PersistedQueryManifest,
|
||||
parsePersistedQueryManifest,
|
||||
} from "./persisted-queries.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `persisted-queries` — APQ manifest support
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`persisted-queries` implements Apollo's persisted-query manifest lookup
|
||||
— the ability for clients to send `{ id }` in place of `{ query }` and
|
||||
have the engine resolve the id against a build-time-generated manifest.
|
||||
The feature is Apollo-specific (Yoga uses a different protocol variant)
|
||||
so the loader lives in this adapter and not in the abstract
|
||||
`api-graphql` core.
|
||||
|
||||
The module exposes three entry points: a filesystem loader, an
|
||||
in-memory parser for callers that already have the JSON text, and a
|
||||
direct-from-entries factory for tests. Lookups against an unknown id
|
||||
raise `GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND`; malformed input
|
||||
raises `GRAPHQL_APOLLO.PARSE_FAILED` so callers see a single
|
||||
configuration-failure code for any manifest shape error.
|
||||
|
||||
## Features
|
||||
|
||||
- File loader uses `node:fs/promises.readFile` — async-safe in the host
|
||||
start-up sequence.
|
||||
- JSON parser validates the manifest is an object of `string → string`
|
||||
before returning; non-string values raise `PARSE_FAILED`.
|
||||
- Direct factory accepts any `Iterable<[id, query]>` — useful in tests.
|
||||
- Manifest is frozen on construction; the only mutation surface is
|
||||
`lookup(id)` (reads) and `has(id)` (existence check).
|
||||
- `entries` accessor returns the frozen map for callers that need to
|
||||
diff manifests across deploys.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { loadPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
|
||||
|
||||
const manifest = await loadPersistedQueryManifest("./pq.json");
|
||||
const query = manifest.lookup("abc123");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Build a manifest from an in-process source (e.g. a KV-store fetch) and
|
||||
wire it into the engine for runtime lookup:
|
||||
|
||||
```ts
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
import { createPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
|
||||
|
||||
const entries = await kv.list("pq:");
|
||||
const manifest = createPersistedQueryManifest(
|
||||
entries.map((e) => [e.key.slice(3), e.value as string])
|
||||
);
|
||||
const engine = createApolloGraphqlEngine({ persistedQueries: manifest });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PersistedQueryManifest` — `{ entries, lookup, has }`.
|
||||
- `loadPersistedQueryManifest(path: string): Promise<PersistedQueryManifest>`
|
||||
- `parsePersistedQueryManifest(raw: string): PersistedQueryManifest`
|
||||
- `createPersistedQueryManifest(entries): PersistedQueryManifest`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Manifest entries are stored in a `Map<string, string>` — O(1) lookup,
|
||||
deterministic iteration order. The `entries` field returns a new `Map`
|
||||
constructed from the underlying entries on every access so callers
|
||||
cannot mutate the live state. The loader maps every filesystem and JSON
|
||||
failure to `GRAPHQL_APOLLO.PARSE_FAILED` so wire-level diagnostics show
|
||||
a single code regardless of failure mode.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createPersistedQueryManifest,
|
||||
loadPersistedQueryManifest,
|
||||
parsePersistedQueryManifest,
|
||||
} from "./persisted-queries.js";
|
||||
|
||||
describe("persisted-queries", () => {
|
||||
it("looks up a known id", { timeout: 5000 }, () => {
|
||||
const m = createPersistedQueryManifest([["abc", "{ ping }"]]);
|
||||
expect(m.has("abc")).toBe(true);
|
||||
expect(m.lookup("abc")).toBe("{ ping }");
|
||||
expect(m.entries.size).toBe(1);
|
||||
});
|
||||
|
||||
it("raises PERSISTED_QUERY_NOT_FOUND on miss", { timeout: 5000 }, () => {
|
||||
const m = createPersistedQueryManifest([]);
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
m.lookup("nope");
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("parses a JSON manifest", { timeout: 5000 }, () => {
|
||||
const m = parsePersistedQueryManifest('{"a":"{ x }","b":"{ y }"}');
|
||||
expect(m.lookup("a")).toBe("{ x }");
|
||||
expect(m.lookup("b")).toBe("{ y }");
|
||||
});
|
||||
|
||||
it("rejects non-JSON input", { timeout: 5000 }, () => {
|
||||
expect(() => parsePersistedQueryManifest("not json")).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-object JSON", { timeout: 5000 }, () => {
|
||||
expect(() => parsePersistedQueryManifest("[]")).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
expect(() => parsePersistedQueryManifest("null")).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-string entry values", { timeout: 5000 }, () => {
|
||||
expect(() => parsePersistedQueryManifest('{"a":123}')).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a manifest from disk", { timeout: 5000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pq-"));
|
||||
const file = join(dir, "manifest.json");
|
||||
writeFileSync(file, '{"hello":"{ greet }"}');
|
||||
const m = await loadPersistedQueryManifest(file);
|
||||
expect(m.lookup("hello")).toBe("{ greet }");
|
||||
});
|
||||
|
||||
it("raises PARSE_FAILED for missing files", { timeout: 5000 }, async () => {
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await loadPersistedQueryManifest("/no/such/path.json");
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Persisted-query manifest. A JSON record of `{ [id]: query }` produced
|
||||
* by a build-time tool. The engine refuses to mount the persisted-query
|
||||
* surface when `persistedQueriesPath` is undefined.
|
||||
*/
|
||||
export interface PersistedQueryManifest {
|
||||
readonly entries: ReadonlyMap<string, string>;
|
||||
lookup(id: string): string;
|
||||
has(id: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a manifest from disk. The file MUST be a JSON object whose values
|
||||
* are GraphQL query strings. Reading or parsing failures raise
|
||||
* `GRAPHQL_APOLLO.PARSE_FAILED` so configuration errors surface with the
|
||||
* same code path as request-document parse failures.
|
||||
*/
|
||||
export async function loadPersistedQueryManifest(
|
||||
path: string
|
||||
): Promise<PersistedQueryManifest> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
`failed to read persisted-query manifest at ${path}`,
|
||||
{ cause: cause as Error, context: { path } }
|
||||
);
|
||||
}
|
||||
return parsePersistedQueryManifest(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse manifest JSON in-memory. Used by `loadPersistedQueryManifest` and
|
||||
* by callers that already have the manifest contents (e.g. compiled into
|
||||
* the bundle).
|
||||
*/
|
||||
export function parsePersistedQueryManifest(
|
||||
raw: string
|
||||
): PersistedQueryManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"persisted-query manifest is not valid JSON",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
"persisted-query manifest must be a JSON object of id → query"
|
||||
);
|
||||
}
|
||||
const entries = new Map<string, string>();
|
||||
for (const [id, value] of Object.entries(
|
||||
parsed as Record<string, unknown>
|
||||
)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PARSE_FAILED",
|
||||
`persisted-query manifest entry "${id}" is not a string`
|
||||
);
|
||||
}
|
||||
entries.set(id, value);
|
||||
}
|
||||
return makeManifest(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a manifest from an explicit map. Convenient for tests and for
|
||||
* callers that load the manifest from an alternative source (HTTP, KV
|
||||
* store, …).
|
||||
*/
|
||||
export function createPersistedQueryManifest(
|
||||
entries: Iterable<readonly [string, string]>
|
||||
): PersistedQueryManifest {
|
||||
return makeManifest(new Map(entries));
|
||||
}
|
||||
|
||||
function makeManifest(entries: Map<string, string>): PersistedQueryManifest {
|
||||
const frozen: ReadonlyMap<string, string> = new Map(entries);
|
||||
return {
|
||||
entries: frozen,
|
||||
lookup(id: string): string {
|
||||
const q = frozen.get(id);
|
||||
if (q === undefined) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND",
|
||||
`persisted query "${id}" not found in manifest`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return q;
|
||||
},
|
||||
has(id: string): boolean {
|
||||
return frozen.has(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createStudioReporting,
|
||||
type StudioReportingConfig,
|
||||
type StudioReportingDescriptor,
|
||||
} from "./studio-reporting.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# `studio-reporting` — Apollo Studio bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`studio-reporting` materialises the configuration descriptor consumed
|
||||
by the engine when an operator opts in to Apollo Studio usage
|
||||
reporting. The module is deliberately small: it validates the supplied
|
||||
`apiKey` / `graphRef` pair, returns a frozen descriptor, and lets the
|
||||
engine forward the descriptor to Apollo's `ApolloServerPluginUsageReporting`
|
||||
without leaking the SDK weight into the dependency closure of shops
|
||||
that do not use Studio.
|
||||
|
||||
When the operator does not supply a configuration, the factory returns
|
||||
`null` and the engine instantiates Apollo Server without the reporting
|
||||
plugin — the request hot path pays nothing for the unused feature.
|
||||
|
||||
## Features
|
||||
|
||||
- Optional configuration — the factory short-circuits to `null` when
|
||||
the caller passes `undefined`.
|
||||
- Frozen descriptor — the returned object is immutable so downstream
|
||||
code cannot mutate the API-key surface.
|
||||
- Pluggable — production callers can pre-emptively swap the descriptor
|
||||
for a mock plugin in a staging environment.
|
||||
- zero dependency on the Apollo Studio plugin package — the
|
||||
bridge is data-only.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStudioReporting } from "@suaveplan/api-graphql-apollo/studio-reporting";
|
||||
|
||||
const d = createStudioReporting({
|
||||
apiKey: process.env.APOLLO_KEY ?? "",
|
||||
graphRef: "supergraph@current",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Conditionally enable reporting per environment without forking the
|
||||
engine wire-up code:
|
||||
|
||||
```ts
|
||||
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
|
||||
import { createStudioReporting } from "@suaveplan/api-graphql-apollo/studio-reporting";
|
||||
|
||||
const studio = createStudioReporting(
|
||||
process.env.APOLLO_KEY === undefined
|
||||
? undefined
|
||||
: {
|
||||
apiKey: process.env.APOLLO_KEY,
|
||||
graphRef: "supergraph@" + (process.env.STAGE ?? "current"),
|
||||
}
|
||||
);
|
||||
const engine = createApolloGraphqlEngine({
|
||||
apolloPlugins: studio === null ? [] : [/* wire Studio plugin from descriptor */],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StudioReportingConfig` — `{ apiKey, graphRef }`.
|
||||
- `StudioReportingDescriptor` — `{ enabled: true, apiKey, graphRef }`.
|
||||
- `createStudioReporting(config | undefined) → StudioReportingDescriptor | null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The descriptor is `Object.freeze`d so callers cannot mutate it after
|
||||
construction. The factory is the only entry point — there is no
|
||||
mutable runtime state. The actual Studio plugin is dynamically loaded
|
||||
by callers that want to wire it; this avoids pulling
|
||||
`@apollo/server-plugin-usage-reporting` into the dependency closure
|
||||
when the feature is off.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createStudioReporting } from "./studio-reporting.js";
|
||||
|
||||
describe("studio-reporting", () => {
|
||||
it("returns null when config absent", { timeout: 5000 }, () => {
|
||||
expect(createStudioReporting(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("materialises a frozen descriptor when configured", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = createStudioReporting({
|
||||
apiKey: "service:foo",
|
||||
graphRef: "supergraph@current",
|
||||
});
|
||||
expect(d).not.toBeNull();
|
||||
expect(d?.enabled).toBe(true);
|
||||
expect(d?.apiKey).toBe("service:foo");
|
||||
expect(d?.graphRef).toBe("supergraph@current");
|
||||
expect(Object.isFrozen(d as object)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Apollo Studio usage-reporting bridge. When the host wires
|
||||
* `studioReporting: { apiKey, graphRef }` we return an Apollo Server
|
||||
* plugin descriptor; otherwise we return `null` and the engine omits
|
||||
* the plugin entirely (keeping the wire path quiet for shops not on
|
||||
* Studio).
|
||||
*
|
||||
* The implementation is deliberately a thin descriptor — the actual
|
||||
* Apollo Studio plugin is imported lazily by the engine factory so the
|
||||
* adapter does not pay the SDK weight when the feature is disabled.
|
||||
*/
|
||||
export interface StudioReportingConfig {
|
||||
readonly apiKey: string;
|
||||
readonly graphRef: string;
|
||||
}
|
||||
|
||||
/** Marker descriptor returned to the engine factory. */
|
||||
export interface StudioReportingDescriptor {
|
||||
readonly enabled: true;
|
||||
readonly apiKey: string;
|
||||
readonly graphRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise the configuration descriptor. Returns `null` when no
|
||||
* configuration is supplied; otherwise returns a frozen descriptor the
|
||||
* engine consumes to wire the Apollo Studio plugin.
|
||||
*/
|
||||
export function createStudioReporting(
|
||||
config: StudioReportingConfig | undefined
|
||||
): StudioReportingDescriptor | null {
|
||||
if (config === undefined) return null;
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
apiKey: config.apiKey,
|
||||
graphRef: config.graphRef,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
assertSubscriptionTransport,
|
||||
runSubscription,
|
||||
type SubscriptionTransport,
|
||||
} from "./subscriptions.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `subscriptions` — `graphql-ws` bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`subscriptions` runs a GraphQL subscription document against a wired
|
||||
schema, returning the `AsyncIterable<ExecutionResult>` the engine
|
||||
forwards over the host's `api-ws` transport. Subscriptions ride
|
||||
`graphql-ws` semantically; this module is the framework-agnostic core
|
||||
that the engine's transport adapter wraps.
|
||||
|
||||
When the host has not wired an `api-ws` engine, the
|
||||
`assertSubscriptionTransport` guard raises
|
||||
`GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` so that the missing
|
||||
infrastructure surfaces at the first subscription frame — not later
|
||||
when a publish silently never reaches a subscriber.
|
||||
|
||||
## Features
|
||||
|
||||
- Direct `graphql.subscribe()` delegation — no extra framing.
|
||||
- Accepts both `DocumentNode` and SDL string for the operation.
|
||||
- Wraps a single-result rejection in a one-shot AsyncIterable so the
|
||||
caller's frame loop sees one error frame and closes cleanly.
|
||||
- Transport guard raises a typed `GraphqlApolloError` with the
|
||||
`SUBSCRIPTION_TRANSPORT_UNAVAILABLE` code.
|
||||
- Validates the document contains a subscription operation — non-
|
||||
subscription operations raise
|
||||
`GRAPHQL_APOLLO.VALIDATION_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runSubscription } from "@suaveplan/api-graphql-apollo/subscriptions";
|
||||
|
||||
const iter = await runSubscription({
|
||||
schema,
|
||||
document: "subscription { ticks }",
|
||||
contextValue: { tenantId: "t_1" },
|
||||
});
|
||||
for await (const frame of iter) {
|
||||
console.log(frame);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the runtime guard with the host's `api-ws` wiring so subscription
|
||||
frames are rejected at the wire boundary when the transport is not
|
||||
configured:
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertSubscriptionTransport,
|
||||
runSubscription,
|
||||
} from "@suaveplan/api-graphql-apollo/subscriptions";
|
||||
|
||||
const transport = host.adapters.ws ?? null;
|
||||
assertSubscriptionTransport(transport);
|
||||
const iter = await runSubscription({ schema, document, contextValue });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SubscriptionTransport` — `{ protocol: "graphql-ws" }`.
|
||||
- `assertSubscriptionTransport(transport | null)` — throws
|
||||
`GraphqlApolloError` on miss.
|
||||
- `runSubscription({ schema, document, variableValues?,
|
||||
operationName?, contextValue })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
When `graphql.subscribe()` returns a single `ExecutionResult` (e.g.
|
||||
the subscribe field threw synchronously), the helper wraps it in a
|
||||
single-iteration AsyncIterable so callers' frame loops handle the
|
||||
error path identically to the streaming path. The validation step
|
||||
sniffs the document for any `OPERATION_DEFINITION` of kind
|
||||
`subscription`; documents that contain only query/mutation operations
|
||||
reject with `VALIDATION_FAILED`.
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildSchema, parse } from "graphql";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
assertSubscriptionTransport,
|
||||
runSubscription,
|
||||
} from "./subscriptions.js";
|
||||
|
||||
const schema = buildSchema(/* GraphQL */ `
|
||||
type Query { ping: String! }
|
||||
type Subscription { ticks: Int! }
|
||||
`);
|
||||
|
||||
// Attach a subscribe resolver — graphql-js requires `subscribe` on the field.
|
||||
const sub = schema.getSubscriptionType();
|
||||
if (sub !== undefined && sub !== null) {
|
||||
const fields = sub.getFields();
|
||||
const ticks = fields.ticks;
|
||||
if (ticks !== undefined) {
|
||||
(
|
||||
ticks as unknown as { subscribe: () => AsyncIterable<unknown> }
|
||||
).subscribe = async function* () {
|
||||
yield { ticks: 1 };
|
||||
yield { ticks: 2 };
|
||||
};
|
||||
(ticks as unknown as { resolve: (p: unknown) => unknown }).resolve = (
|
||||
payload
|
||||
) => (payload as { ticks: number }).ticks;
|
||||
}
|
||||
}
|
||||
|
||||
describe("subscriptions", () => {
|
||||
it("asserts transport is wired", { timeout: 5000 }, () => {
|
||||
expect(() => assertSubscriptionTransport(null)).toThrow(
|
||||
GraphqlApolloError
|
||||
);
|
||||
expect(() =>
|
||||
assertSubscriptionTransport({ protocol: "graphql-ws" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("delivers AsyncIterable for a subscription document", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const iter = await runSubscription({
|
||||
schema,
|
||||
document: "subscription { ticks }",
|
||||
contextValue: {},
|
||||
});
|
||||
const values: unknown[] = [];
|
||||
for await (const v of iter) {
|
||||
values.push(v);
|
||||
}
|
||||
expect(values).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects non-subscription documents", { timeout: 5000 }, async () => {
|
||||
let err: GraphqlApolloError | null = null;
|
||||
try {
|
||||
await runSubscription({
|
||||
schema,
|
||||
document: "{ ping }",
|
||||
contextValue: {},
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as GraphqlApolloError;
|
||||
}
|
||||
expect(err?.code).toBe("GRAPHQL_APOLLO.VALIDATION_FAILED");
|
||||
});
|
||||
|
||||
it("accepts a pre-parsed DocumentNode", { timeout: 5000 }, async () => {
|
||||
const iter = await runSubscription({
|
||||
schema,
|
||||
document: parse("subscription { ticks }"),
|
||||
contextValue: {},
|
||||
});
|
||||
const values: unknown[] = [];
|
||||
for await (const v of iter) values.push(v);
|
||||
expect(values).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps single ExecutionResult when subscribe yields a single result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Build a schema with a broken subscribe to force single-result path.
|
||||
const broken = buildSchema(/* GraphQL */ `
|
||||
type Query { ping: String! }
|
||||
type Subscription { broken: Int! }
|
||||
`);
|
||||
const s2 = broken.getSubscriptionType();
|
||||
if (s2 !== undefined && s2 !== null) {
|
||||
const f = s2.getFields().broken;
|
||||
if (f !== undefined) {
|
||||
(f as unknown as { subscribe: () => unknown }).subscribe =
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
}
|
||||
}
|
||||
const iter = await runSubscription({
|
||||
schema: broken,
|
||||
document: "subscription { broken }",
|
||||
variableValues: {},
|
||||
operationName: undefined,
|
||||
contextValue: {},
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for await (const v of iter) out.push(v);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
type DocumentNode,
|
||||
type ExecutionResult,
|
||||
type GraphQLSchema,
|
||||
Kind,
|
||||
parse,
|
||||
subscribe,
|
||||
} from "graphql";
|
||||
import { GraphqlApolloError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Engine-side `graphql-ws` bridge marker. The host's `api-ws` engine
|
||||
* decodes the wire frames and hands them to {@link runSubscription} —
|
||||
* we delegate to `graphql`'s `subscribe()` and forward the resulting
|
||||
* `AsyncIterable` upstream.
|
||||
*/
|
||||
export interface SubscriptionTransport {
|
||||
readonly protocol: "graphql-ws";
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that subscription transport is wired. The engine raises
|
||||
* `GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` when a
|
||||
* subscription operation arrives without a wired transport.
|
||||
*/
|
||||
export function assertSubscriptionTransport(
|
||||
transport: SubscriptionTransport | null | undefined
|
||||
): asserts transport is SubscriptionTransport {
|
||||
if (transport === undefined || transport === null) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
|
||||
"subscription operation received but no api-ws transport is wired"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a subscription against the given schema, returning the
|
||||
* iterable the engine forwards over `api-ws`. The `contextValue` is
|
||||
* the {@link import("@suaveplan/api-types/plugin-context").RequestContext}
|
||||
* the host built around the inbound frame.
|
||||
*/
|
||||
export async function runSubscription(args: {
|
||||
readonly schema: GraphQLSchema;
|
||||
readonly document: DocumentNode | string;
|
||||
readonly variableValues?: Readonly<Record<string, unknown>>;
|
||||
readonly operationName?: string;
|
||||
readonly contextValue: unknown;
|
||||
}): Promise<AsyncIterable<ExecutionResult>> {
|
||||
const document =
|
||||
typeof args.document === "string"
|
||||
? parse(args.document)
|
||||
: args.document;
|
||||
const op = document.definitions.find(
|
||||
(d) =>
|
||||
d.kind === Kind.OPERATION_DEFINITION &&
|
||||
d.operation === "subscription"
|
||||
);
|
||||
if (op === undefined) {
|
||||
throw new GraphqlApolloError(
|
||||
"GRAPHQL_APOLLO.VALIDATION_FAILED",
|
||||
"runSubscription requires a subscription operation in the document"
|
||||
);
|
||||
}
|
||||
const result = await subscribe({
|
||||
schema: args.schema,
|
||||
document,
|
||||
variableValues: args.variableValues as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
operationName: args.operationName,
|
||||
contextValue: args.contextValue,
|
||||
});
|
||||
if (Symbol.asyncIterator in (result as object)) {
|
||||
return result as AsyncIterable<ExecutionResult>;
|
||||
}
|
||||
return singleResult(result as ExecutionResult);
|
||||
}
|
||||
|
||||
async function* singleResult(
|
||||
result: ExecutionResult
|
||||
): AsyncIterable<ExecutionResult> {
|
||||
yield result;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# `testing/conformance` — Apollo-bound conformance harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`testing/conformance` is the re-export of `runGraphqlEngineConformance`
|
||||
from `@suaveplan/api-graphql/testing`, pre-bound to the Apollo factory.
|
||||
Consumers call `runApolloConformance()` and the harness exercises the
|
||||
abstract `GraphqlEngine` contract against the concrete Apollo adapter.
|
||||
Both `api-graphql-apollo` and `api-graphql-yoga` MUST pass the same
|
||||
invariants — operator behaviour is engine-agnostic at the contract
|
||||
boundary.
|
||||
|
||||
The module also re-exports the unmodified `runGraphqlEngineConformance`
|
||||
so downstream packages that want to run the harness against a
|
||||
*different* factory (e.g. a federated subgraph composing Apollo + a
|
||||
custom transport) keep a single import surface.
|
||||
|
||||
## Features
|
||||
|
||||
- One-line invocation — `runApolloConformance()` runs every scenario.
|
||||
- Pre-bound factory — callers do not need to know Apollo's
|
||||
construction args.
|
||||
- Labelled — the second-positional `label` parameter shows up in
|
||||
test output so split CI logs are unambiguous.
|
||||
- Re-exports `runGraphqlEngineConformance` for advanced callers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runApolloConformance } from "@suaveplan/api-graphql-apollo/testing";
|
||||
|
||||
runApolloConformance();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance suite under a custom label so two adapter variants
|
||||
appear distinctly in CI output:
|
||||
|
||||
```ts
|
||||
import { runApolloConformance } from "@suaveplan/api-graphql-apollo/testing";
|
||||
|
||||
runApolloConformance("apollo (persisted-queries)");
|
||||
runApolloConformance("apollo (no-persisted-queries)");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runApolloConformance(label?: string): void`.
|
||||
- `runGraphqlEngineConformance(opts): void` — unchanged re-export.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Apollo-bound factory yields a fresh engine per scenario so each
|
||||
invariant exercises a clean lifecycle. The `makeRequestContext` slot
|
||||
is the cast `{} as RequestContext` — sufficient for the abstract
|
||||
contract because the harness never touches context fields beyond
|
||||
hand-off. Tests that need a richer context construct one explicitly.
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
runApolloConformance,
|
||||
runGraphqlEngineConformance,
|
||||
} from "./conformance.js";
|
||||
|
||||
describe("testing/conformance re-export", () => {
|
||||
it("re-exports the abstract harness", { timeout: 5000 }, () => {
|
||||
expect(typeof runGraphqlEngineConformance).toBe("function");
|
||||
expect(typeof runApolloConformance).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// Invoke the Apollo-bound conformance to assert the engine satisfies the
|
||||
// abstract contract. The harness internally calls describe()+it() so the
|
||||
// scenarios surface as nested tests.
|
||||
runApolloConformance();
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ConformanceContext } from "@suaveplan/api-graphql";
|
||||
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql";
|
||||
import { createApolloGraphqlEngine } from "../engine/apollo-engine.js";
|
||||
|
||||
/**
|
||||
* Re-export of `runGraphqlEngineConformance` pre-bound to the Apollo
|
||||
* factory. Consumers call this to run the abstract engine contract
|
||||
* against the Apollo adapter — identical surface to
|
||||
* `api-graphql-yoga/testing`.
|
||||
*/
|
||||
export function runApolloConformance(label = "apollo"): void {
|
||||
runGraphqlEngineConformance({
|
||||
label,
|
||||
factory: async (): Promise<ConformanceContext> => {
|
||||
const engine = createApolloGraphqlEngine();
|
||||
return {
|
||||
engine,
|
||||
makeRequestContext: () =>
|
||||
({}) as unknown as ConformanceContext["makeRequestContext"] extends () => infer R
|
||||
? R
|
||||
: never,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { runGraphqlEngineConformance };
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
runApolloConformance,
|
||||
runGraphqlEngineConformance,
|
||||
} from "./conformance.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/api-stack/api/api-graphql-apollo/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-graphql-apollo/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
61
packages/api-stack/api/api-graphql-apollo/vite.config.ts
Normal file
61
packages/api-stack/api/api-graphql-apollo/vite.config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-graphql-apollo",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"engine/index": "src/engine/index.ts",
|
||||
"options/index": "src/options/index.ts",
|
||||
"endpoints/index": "src/endpoints/index.ts",
|
||||
"federation/index": "src/federation/index.ts",
|
||||
"guards/index": "src/guards/index.ts",
|
||||
"instruments/index": "src/instruments/index.ts",
|
||||
"loaders/index": "src/loaders/index.ts",
|
||||
"persisted-queries/index": "src/persisted-queries/index.ts",
|
||||
"studio-reporting/index": "src/studio-reporting/index.ts",
|
||||
"subscriptions/index": "src/subscriptions/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"testing/index": "src/testing/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@apollo/server",
|
||||
"@apollo/server/standalone",
|
||||
"@suaveplan/api-graphql",
|
||||
"@suaveplan/api-graphql/testing",
|
||||
"@suaveplan/api-router",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/testing",
|
||||
"@suaveplan/testing/runner",
|
||||
"graphql",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
38
packages/api-stack/api/api-graphql-apollo/vitest.config.ts
Normal file
38
packages/api-stack/api/api-graphql-apollo/vitest.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
dedupe: ["graphql"],
|
||||
},
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
server: {
|
||||
deps: {
|
||||
inline: ["@apollo/server", "graphql"],
|
||||
},
|
||||
},
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user