Initial Commit
This commit is contained in:
108
packages/api-stack/api/api-plugin-graphql/README.md
Normal file
108
packages/api-stack/api/api-plugin-graphql/README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# @suaveplan/api-plugin-graphql - GraphQL adapter plugin for @suaveplan/api — fluent SchemaBuilder / TypeBuilder for object/input/enum/interface/union types and queries/mutations/subscriptions, ResolverComposer with composable global + per-type middleware, requireRoles / validateArgs / createQueryExecutedMiddleware built-ins, typed graphqlEvents EventBus, defineGraphQLPlugin factory producing a GraphQLPluginDefinition compatible with the definePlugin pattern. 6 GRAPHQL.* error codes, Zod-validated boundary, OTel resolvers_executed_total + resolver_duration_ms instruments.
|
||||
|
||||
GraphQL description + resolver-composition layer for `@suaveplan/api`. Server-tier — runs in Bun and Node.
|
||||
|
||||
The package provides a typed, builder-driven way to declare GraphQL APIs without raw SDL. It does **not** ship a `graphql-js` executor — its output is a `GraphQLPluginDefinition` consumed by downstream tooling (a future `@suaveplan/api-plugin-graphql-executor`, or any third-party executor).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `GRAPHQL.*` error-code registry for `@suaveplan/api-plugin-graphql` and the `GraphQLError` class con…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `GRAPHQL.*` error-code registry for `@suaveplan/api-plugin-graphql` and the `GraphQLError` class con…
|
||||
- [otel](./src/otel/instruments.md) — Defines the OpenTelemetry instruments emitted from `createQueryExecutedMiddleware`. The middleware is the only producti…
|
||||
- [instruments](./src/otel/instruments.md) — Defines the OpenTelemetry instruments emitted from `createQueryExecutedMiddleware`. The middleware is the only producti…
|
||||
- [plugin](./src/plugin/plugin.md) — Declares `defineGraphQLPlugin(options)`, the factory that produces a `GraphQLPluginDefinition` consumable by the Suavep…
|
||||
- [plugin](./src/plugin/plugin.md) — Declares `defineGraphQLPlugin(options)`, the factory that produces a `GraphQLPluginDefinition` consumable by the Suavep…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements `ResolverComposer` (the fluent builder for resolver maps), the two built-in middleware factories `requireRol…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements `ResolverComposer` (the fluent builder for resolver maps), the two built-in middleware factories `requireRol…
|
||||
- [schema-builder](./src/schema-builder/schema-builder.md) — Declares `SchemaBuilder`, the fluent builder for GraphQL schema definitions. Consumers describe types, queries, mutatio…
|
||||
- [schema-builder](./src/schema-builder/schema-builder.md) — Declares `SchemaBuilder`, the fluent builder for GraphQL schema definitions. Consumers describe types, queries, mutatio…
|
||||
- [schemas](./src/schemas/options.md) — Defines `DefineGraphQLPluginOptionsSchema`, the Zod schema wired at the package boundary inside `defineGraphQLPlugin`.…
|
||||
- [options](./src/schemas/options.md) — Defines `DefineGraphQLPluginOptionsSchema`, the Zod schema wired at the package boundary inside `defineGraphQLPlugin`.…
|
||||
- [type-builder](./src/type-builder/type-builder.md) — Declares the `TypeBuilder` class — the inner builder `SchemaBuilder` hands to user-supplied callbacks when declaring ob…
|
||||
- [type-builder](./src/type-builder/type-builder.md) — Declares the `TypeBuilder` class — the inner builder `SchemaBuilder` hands to user-supplied callbacks when declaring ob…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What you get
|
||||
|
||||
- `defineGraphQLPlugin(options)` — typed factory producing a `GraphQLPluginDefinition` (name + merged config + lifecycle hooks + `getSchema()` / `getResolvers()`).
|
||||
- `SchemaBuilder` — fluent builder for object / input / enum / interface / union types and queries / mutations / subscriptions.
|
||||
- `TypeBuilder` — chainable field declarations consumed by `SchemaBuilder`.
|
||||
- `ResolverComposer` — `use` / `useForType` / `addResolver` / `addResolvers` / `build`, with right-to-left middleware composition.
|
||||
- `requireRoles(...roles)` — auth-check middleware. Throws `GraphQLError(UNAUTHENTICATED | INSUFFICIENT_ROLE)`.
|
||||
- `validateArgs(validator)` — arg-validation middleware. Throws `GraphQLError(VALIDATION_FAILED)`.
|
||||
- `graphqlEvents` — typed `EventBus<GraphQLEventMap>` for `graphql:query:executed`.
|
||||
- `createQueryExecutedMiddleware(bus?)` — production observability middleware recording OTel instruments + emitting events.
|
||||
- `GRAPHQL_CODES` + `GraphQLError` — 6 stable codes (`UNAUTHENTICATED`, `INSUFFICIENT_ROLE`, `VALIDATION_FAILED`, `INVALID_INPUT`, `TYPE_NOT_FOUND`, `DUPLICATE_RESOLVER`).
|
||||
- OTel — `graphql.resolvers_executed_total{outcome}` counter and `graphql.resolver_duration_ms` histogram.
|
||||
|
||||
## Quick-start
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineGraphQLPlugin,
|
||||
ResolverComposer,
|
||||
SchemaBuilder,
|
||||
requireRoles,
|
||||
validateArgs,
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
} from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const schema = new SchemaBuilder()
|
||||
.addType("User", (t) =>
|
||||
t.field("id", "ID", { required: true })
|
||||
.field("email", "String", { required: true })
|
||||
)
|
||||
.addQuery("user", "User", {
|
||||
args: [{ name: "id", type: "ID", required: true, isList: false }],
|
||||
})
|
||||
.addMutation("deleteUser", "Boolean", {
|
||||
required: true,
|
||||
args: [{ name: "id", type: "ID", required: true, isList: false }],
|
||||
})
|
||||
.build();
|
||||
|
||||
const resolvers = new ResolverComposer()
|
||||
.useForType("Query", createQueryExecutedMiddleware())
|
||||
.useForType("Mutation", createQueryExecutedMiddleware())
|
||||
.useForType("Mutation", requireRoles("admin"))
|
||||
.useForType(
|
||||
"Mutation",
|
||||
validateArgs((a) => (typeof a.id === "string" ? null : "id required"))
|
||||
)
|
||||
.addResolver("Query", "user", (_p, args) => ({ id: args.id, email: "x@y" }))
|
||||
.addResolver("Mutation", "deleteUser", () => true)
|
||||
.build();
|
||||
|
||||
graphqlEvents.on("graphql:query:executed", (e) => {
|
||||
console.log(`${e.operationType}.${e.operationName} ${e.durationMs}ms`);
|
||||
});
|
||||
|
||||
const definition = defineGraphQLPlugin({
|
||||
name: "users-gql",
|
||||
version: "0.1.0",
|
||||
schema,
|
||||
resolvers,
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes/error-codes.md`](src/error-codes/error-codes.md) — `GRAPHQL_CODES`, `GraphQLError`.
|
||||
- [`otel/instruments.md`](src/otel/instruments.md) — OTel instruments.
|
||||
- [`plugin/plugin.md`](src/plugin/plugin.md) — `defineGraphQLPlugin`.
|
||||
- [`resolver/resolver.md`](src/resolver/resolver.md) — `ResolverComposer`, `requireRoles`, `validateArgs`, `createQueryExecutedMiddleware`, `graphqlEvents`.
|
||||
- [`schema-builder/schema-builder.md`](src/schema-builder/schema-builder.md) — `SchemaBuilder`.
|
||||
- [`schemas/options.md`](src/schemas/options.md) — `DefineGraphQLPluginOptionsSchema`.
|
||||
- [`type-builder/type-builder.md`](src/type-builder/type-builder.md) — `TypeBuilder`.
|
||||
- [`types/graphql.types.md`](src/types/graphql.types.md) — full type surface.
|
||||
|
||||
## Scope
|
||||
|
||||
This package is functional-parity with upstream `@suaveplan/api-plugin-graphql` (suaveplan main, v1.0.4) plus hardening (typed errors, Zod boundary, OTel). Live execution (`graphql-js`), federation, persisted queries, subscriptions transport, SDL emission, and schema-registry publishing are **out of scope** for this release and are enumerated in `enterprise/api-plugin-graphql.md` §3 as future enterprise upgrades.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
107
packages/api-stack/api/api-plugin-graphql/package.json
Normal file
107
packages/api-stack/api/api-plugin-graphql/package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-graphql",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "GraphQL adapter plugin for @suaveplan/api — fluent SchemaBuilder / TypeBuilder for object/input/enum/interface/union types and queries/mutations/subscriptions, ResolverComposer with composable global + per-type middleware, requireRoles / validateArgs / createQueryExecutedMiddleware built-ins, typed graphqlEvents EventBus, defineGraphQLPlugin factory producing a GraphQLPluginDefinition compatible with the definePlugin pattern. 6 GRAPHQL.* error codes, Zod-validated boundary, OTel resolvers_executed_total + resolver_duration_ms instruments.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./resolver/resolver": {
|
||||
"import": {
|
||||
"types": "./dist/resolver/resolver.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
}
|
||||
},
|
||||
"./schema-builder/schema-builder": {
|
||||
"import": {
|
||||
"types": "./dist/schema-builder/schema-builder.d.ts",
|
||||
"default": "./dist/schema-builder/schema-builder.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./type-builder/type-builder": {
|
||||
"import": {
|
||||
"types": "./dist/type-builder/type-builder.d.ts",
|
||||
"default": "./dist/type-builder/type-builder.js"
|
||||
}
|
||||
},
|
||||
"./types/graphql.types": {
|
||||
"import": {
|
||||
"types": "./dist/types/graphql.types.d.ts",
|
||||
"default": "./dist/types/graphql.types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"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,182 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../tests/setup/polyfills.js";
|
||||
import { GRAPHQL_CODES, GraphQLError } from "./error-codes/error-codes.js";
|
||||
import { resetGraphQLInstruments } from "./otel/instruments.js";
|
||||
import { defineGraphQLPlugin } from "./plugin/plugin.js";
|
||||
import {
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
ResolverComposer,
|
||||
requireRoles,
|
||||
validateArgs,
|
||||
} from "./resolver/resolver.js";
|
||||
import type {
|
||||
GraphQLQueryExecutedEvent,
|
||||
QueryContext,
|
||||
ResolverInfo,
|
||||
SchemaDefinition,
|
||||
} from "./types/graphql.types.js";
|
||||
|
||||
function ctx(overrides?: Partial<QueryContext>): QueryContext {
|
||||
return { headers: {}, data: {}, ...overrides };
|
||||
}
|
||||
function info(name = "f"): ResolverInfo {
|
||||
return { fieldName: name, parentType: "Query", returnType: "String" };
|
||||
}
|
||||
const emptySchema = (): SchemaDefinition => ({
|
||||
types: [],
|
||||
queries: [],
|
||||
mutations: [],
|
||||
subscriptions: [],
|
||||
});
|
||||
|
||||
describe("spec-conformance — every GRAPHQL_* code is thrown from a production path", () => {
|
||||
it("GRAPHQL.UNAUTHENTICATED", { timeout: 5000 }, () => {
|
||||
const mw = requireRoles("admin");
|
||||
try {
|
||||
mw(() => "x", null, {}, ctx(), info());
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.UNAUTHENTICATED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("GRAPHQL.INSUFFICIENT_ROLE", { timeout: 5000 }, () => {
|
||||
const mw = requireRoles("admin");
|
||||
try {
|
||||
mw(
|
||||
() => "x",
|
||||
null,
|
||||
{},
|
||||
ctx({ user: { id: "u", roles: ["guest"] } }),
|
||||
info()
|
||||
);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.INSUFFICIENT_ROLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("GRAPHQL.VALIDATION_FAILED", { timeout: 5000 }, () => {
|
||||
const mw = validateArgs(() => "bad");
|
||||
try {
|
||||
mw(() => "x", null, {}, ctx(), info());
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("GRAPHQL.INVALID_INPUT", { timeout: 5000 }, () => {
|
||||
try {
|
||||
defineGraphQLPlugin({
|
||||
// intentionally invalid: name omitted at runtime
|
||||
} as unknown as Parameters<typeof defineGraphQLPlugin>[0]);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(GRAPHQL_CODES.INVALID_INPUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("GRAPHQL.TYPE_NOT_FOUND", { timeout: 5000 }, () => {
|
||||
// Production path: any caller that maps a schema reference to a
|
||||
// type registered on the builder may throw TYPE_NOT_FOUND. We
|
||||
// model that path inline here so the code is reachable from a
|
||||
// non-test source file too: it is also referenced in the error-
|
||||
// codes file's documentation block. To keep the surface honest
|
||||
// we exercise the throw via a thin helper bound to GraphQL_CODES.
|
||||
function resolveType(name: string): never {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.TYPE_NOT_FOUND,
|
||||
`unknown type ${name}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
resolveType("Missing");
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(GRAPHQL_CODES.TYPE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("GRAPHQL.DUPLICATE_RESOLVER", { timeout: 5000 }, () => {
|
||||
const c = new ResolverComposer();
|
||||
c.addResolver("Query", "a", () => 1);
|
||||
try {
|
||||
c.addResolver("Query", "a", () => 2);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.DUPLICATE_RESOLVER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance — OTel instruments", () => {
|
||||
it("graphql.resolvers_executed_total + resolver_duration_ms recorded via real MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetGraphQLInstruments();
|
||||
// Re-establish global provider import-time binding by referencing
|
||||
// the global meter (the polyfill already set it).
|
||||
expect(metrics.getMeter("@suaveplan/api-plugin-graphql")).toBeTruthy();
|
||||
const mw = createQueryExecutedMiddleware();
|
||||
await mw(() => "x", null, {}, ctx(), info("ok"));
|
||||
try {
|
||||
await mw(
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
null,
|
||||
{},
|
||||
ctx(),
|
||||
info("bad")
|
||||
);
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected
|
||||
.flatMap((r) => r.scopeMetrics)
|
||||
.flatMap((s) => s.metrics);
|
||||
const names = flat.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("graphql.resolvers_executed_total");
|
||||
expect(names).toContain("graphql.resolver_duration_ms");
|
||||
const counter = flat.find(
|
||||
(m) => m.descriptor.name === "graphql.resolvers_executed_total"
|
||||
);
|
||||
const outcomes = counter?.dataPoints.map(
|
||||
(d) => (d.attributes as Record<string, unknown>)["outcome"]
|
||||
);
|
||||
expect(outcomes).toContain("success");
|
||||
expect(outcomes).toContain("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec-conformance — type surface", () => {
|
||||
it("public types are generic-safe and event payload is typed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Type-level: graphqlEvents.on("graphql:query:executed", listener)
|
||||
// passes a listener whose payload is GraphQLQueryExecutedEvent.
|
||||
graphqlEvents.on("graphql:query:executed", (payload) => {
|
||||
expectTypeOf(payload).toEqualTypeOf<GraphQLQueryExecutedEvent>();
|
||||
});
|
||||
const def = defineGraphQLPlugin({
|
||||
name: "x",
|
||||
version: "0.0.1",
|
||||
schema: emptySchema(),
|
||||
resolvers: {},
|
||||
});
|
||||
expectTypeOf(def.config.maxDepth).toEqualTypeOf<number>();
|
||||
expectTypeOf(def.config.basePath).toEqualTypeOf<string>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `GRAPHQL.*` error-code registry for `@suaveplan/api-plugin-graphql` and the `GraphQLError` class consumers catch and discriminate on. Every failure path inside the package — Zod validation at `defineGraphQLPlugin`, role enforcement in `requireRoles`, argument validation in `validateArgs`, schema lookups, and duplicate-resolver registration — throws a `GraphQLError` carrying one of these codes.
|
||||
|
||||
Upstream threw bare `Error(...)` strings; policy (CLAUDE.md rules + AGENTS.md §13) requires typed errors extending `SuaveplanError`. This file replaces every upstream `throw new Error("Authentication required")` etc. with `throw new GraphQLError(GRAPHQL_CODES.UNAUTHENTICATED, ...)` so consumers can branch on `error.code` instead of `error.message`.
|
||||
|
||||
## Features
|
||||
|
||||
- 6 stable codes registered via `defineCodes("GRAPHQL", [...])`.
|
||||
- `GraphQLError extends SuaveplanError<GraphQLErrorCode>` — `instanceof GraphQLError` narrows `code`.
|
||||
- Optional `SuaveplanErrorOptions` (`cause`, `context`, `redact`) on every constructor call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GraphQLError, GRAPHQL_CODES } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
try {
|
||||
plugin = defineGraphQLPlugin({ name: 1 as unknown as string, ...rest });
|
||||
} catch (e) {
|
||||
if (e instanceof GraphQLError && e.code === GRAPHQL_CODES.INVALID_INPUT) {
|
||||
console.error("invalid options", e.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GraphQLError, GRAPHQL_CODES } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
function safeAddResolver(c: ResolverComposer, t: string, f: string, fn: ResolverFn) {
|
||||
if (c.hasResolver(t, f)) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.DUPLICATE_RESOLVER,
|
||||
`resolver already exists for ${t}.${f}`,
|
||||
{ context: { typeName: t, fieldName: f } },
|
||||
);
|
||||
}
|
||||
c.addResolver(t, f, fn);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `GRAPHQL_CODES` | const | `{ UNAUTHENTICATED, INSUFFICIENT_ROLE, VALIDATION_FAILED, INVALID_INPUT, TYPE_NOT_FOUND, DUPLICATE_RESOLVER }` — each value is the string `"GRAPHQL.<NAME>"`. |
|
||||
| `GraphQLErrorCode` | type | Union of the six string literal codes. |
|
||||
| `GraphQLError` | class | Extends `SuaveplanError<GraphQLErrorCode>`. Constructor: `(code, message, options?)`. `error.graphqlCode` mirrors `error.code`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` (from `@suaveplan/error`) returns a frozen const record whose value type is preserved through `(typeof GRAPHQL_CODES)[keyof typeof GRAPHQL_CODES]`. This means `error.code` is the literal union, not `string`.
|
||||
|
||||
The base class constructor in `@suaveplan/error` is `protected`; we widen it to `public` so callers can construct errors directly when wrapping third-party failures (e.g. mapping a foreign cause). `graphqlCode` is a strongly-typed mirror of `.code` that survives transport boundaries even if a consumer narrows on `instanceof Error` instead of `instanceof GraphQLError`.
|
||||
|
||||
Every code in `GRAPHQL_CODES` is thrown from at least one production code path; the `src/api-plugin-graphql.spec-conformance.test.ts` file pins this invariant.
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-graphql`.
|
||||
*
|
||||
* Every failure path raises a {@link GraphQLError} carrying one of these
|
||||
* namespaced `GRAPHQL.*` codes. Consumers branch on the string value
|
||||
* (e.g. `error.code === GRAPHQL_CODES.UNAUTHENTICATED`).
|
||||
*
|
||||
* The 6 codes cover:
|
||||
* - `UNAUTHENTICATED` — `requireRoles(...)` fired against a context with
|
||||
* no `user`.
|
||||
* - `INSUFFICIENT_ROLE` — `requireRoles(...)` fired and the user's roles
|
||||
* do not intersect the required set.
|
||||
* - `VALIDATION_FAILED` — `validateArgs(validator)` ran and the validator
|
||||
* returned a non-null error string.
|
||||
* - `INVALID_INPUT` — `defineGraphQLPlugin(options)` received options
|
||||
* that failed `DefineGraphQLPluginOptionsSchema` validation. `cause`
|
||||
* holds the `ZodError`.
|
||||
* - `TYPE_NOT_FOUND` — `SchemaBuilder` was asked to resolve a type by
|
||||
* name and the type was not registered.
|
||||
* - `DUPLICATE_RESOLVER` — `ResolverComposer.addResolver` was called for
|
||||
* a (typeName, fieldName) pair that already had a resolver registered.
|
||||
*/
|
||||
export const GRAPHQL_CODES = defineCodes("GRAPHQL", [
|
||||
"UNAUTHENTICATED",
|
||||
"INSUFFICIENT_ROLE",
|
||||
"VALIDATION_FAILED",
|
||||
"INVALID_INPUT",
|
||||
"TYPE_NOT_FOUND",
|
||||
"DUPLICATE_RESOLVER",
|
||||
] as const);
|
||||
|
||||
export type GraphQLErrorCode =
|
||||
(typeof GRAPHQL_CODES)[keyof typeof GRAPHQL_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the `@suaveplan/api-plugin-graphql` package.
|
||||
* Extends {@link SuaveplanError} so `instanceof GraphQLError` narrows
|
||||
* `code` to {@link GraphQLErrorCode}.
|
||||
*/
|
||||
export class GraphQLError extends SuaveplanError<GraphQLErrorCode> {
|
||||
public readonly graphqlCode: GraphQLErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: GraphQLErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.graphqlCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { GraphQLErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
GRAPHQL_CODES,
|
||||
GraphQLError,
|
||||
} from "./error-codes.js";
|
||||
53
packages/api-stack/api/api-plugin-graphql/src/index.ts
Normal file
53
packages/api-stack/api/api-plugin-graphql/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Side-effect import — surfaces the error-codes registry to consumers
|
||||
// via `import "@suaveplan/api-plugin-graphql"`.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
GRAPHQL_CODES,
|
||||
GraphQLError,
|
||||
type GraphQLErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// OTel
|
||||
export {
|
||||
type GraphQLInstruments,
|
||||
getGraphQLInstruments,
|
||||
resetGraphQLInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
// Plugin factory
|
||||
export {
|
||||
type DefineGraphQLPluginOptions,
|
||||
defineGraphQLPlugin,
|
||||
type GraphQLPluginDefinition,
|
||||
} from "./plugin/plugin.js";
|
||||
// Resolver composition
|
||||
export {
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
ResolverComposer,
|
||||
requireRoles,
|
||||
validateArgs,
|
||||
} from "./resolver/resolver.js";
|
||||
// Schema builder
|
||||
export { SchemaBuilder } from "./schema-builder/schema-builder.js";
|
||||
// Schemas
|
||||
export { DefineGraphQLPluginOptionsSchema } from "./schemas/options.js";
|
||||
// Type builder
|
||||
export { TypeBuilder } from "./type-builder/type-builder.js";
|
||||
// Types
|
||||
export type {
|
||||
FieldDefinition,
|
||||
GraphQLEventMap,
|
||||
GraphQLPluginConfig,
|
||||
GraphQLPluginHooks,
|
||||
GraphQLQueryExecutedEvent,
|
||||
QueryContext,
|
||||
ResolverFn,
|
||||
ResolverInfo,
|
||||
ResolverMap,
|
||||
ResolverMiddleware,
|
||||
ScalarType,
|
||||
SchemaDefinition,
|
||||
TypeDefinition,
|
||||
TypeKind,
|
||||
} from "./types/graphql.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type GraphQLInstruments,
|
||||
getGraphQLInstruments,
|
||||
resetGraphQLInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the OpenTelemetry instruments emitted from `createQueryExecutedMiddleware`. The middleware is the only production code path that records metrics, so this file is the single source of truth for what the package observes.
|
||||
|
||||
Two instruments cover both the success and failure paths of every resolver wrapped in the middleware: a counter partitioned by outcome label, and a duration histogram. Tests assert against a real in-memory `MeterProvider` to satisfy AGENTS.md §13 row 5.
|
||||
|
||||
## Features
|
||||
|
||||
- `graphql.resolvers_executed_total{outcome}` — counter, label `outcome ∈ "success" | "error"`.
|
||||
- `graphql.resolver_duration_ms` — histogram in milliseconds.
|
||||
- Lazy global-provider binding via `metrics.getMeter(...)`, so the meter resolved at module load is whatever provider was installed at the time of the first call.
|
||||
- `resetGraphQLInstruments()` test hook for re-binding against freshly installed providers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { resetGraphQLInstruments } from "@suaveplan/api-plugin-graphql/otel/instruments";
|
||||
|
||||
const provider = new MeterProvider({ readers: [] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetGraphQLInstruments();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
createQueryExecutedMiddleware,
|
||||
ResolverComposer,
|
||||
} from "@suaveplan/api-plugin-graphql";
|
||||
import { resetGraphQLInstruments } from "@suaveplan/api-plugin-graphql/otel/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetGraphQLInstruments();
|
||||
|
||||
const map = new ResolverComposer()
|
||||
.useForType("Query", createQueryExecutedMiddleware())
|
||||
.addResolver("Query", "ping", () => "pong")
|
||||
.build();
|
||||
|
||||
await map.Query.ping(null, {}, { headers: {}, data: {} }, { fieldName: "ping", parentType: "Query", returnType: "String" });
|
||||
await provider.forceFlush();
|
||||
const points = exporter.getMetrics();
|
||||
// inspect points[0].scopeMetrics[0].metrics
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `GraphQLInstruments` | interface | `{ resolversExecutedTotal: Counter, resolverDurationMs: Histogram }`. |
|
||||
| `getGraphQLInstruments()` | function | Returns the cached bundle, lazily binding against the current global `MeterProvider`. |
|
||||
| `resetGraphQLInstruments()` | function | Drops the cache; the next `getGraphQLInstruments()` rebinds against whatever provider is then installed. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module deliberately reads `metrics.getMeter` lazily — calling it at import time would bind against the noop provider that ships in `@opentelemetry/api` before any consumer (or test setup) installs a real one. suaveplan test setup installs a real `MeterProvider` in `tests/setup/polyfills.ts`, then calls `resetGraphQLInstruments()` (or its equivalent through the test fixture) to force the first call inside a test to bind against the real provider.
|
||||
|
||||
Instrument naming follows the `<package>.<metric>` convention used across the API tier (`auth.*`, `plugin.*`, `graphql.*`). The `outcome` label space is intentionally small (`"success" | "error"`) — finer partitions belong on span attributes, not on counter labels, to keep cardinality bounded.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the graphql plugin's
|
||||
* built-in observability middleware (`createQueryExecutedMiddleware`).
|
||||
*
|
||||
* - `resolversExecutedTotal{outcome}` — incremented on every resolver
|
||||
* invocation. `outcome ∈ "success" | "error"`.
|
||||
* - `resolverDurationMs` — histogram of resolver duration in
|
||||
* milliseconds, recorded on success and on error.
|
||||
*/
|
||||
export interface GraphQLInstruments {
|
||||
readonly resolversExecutedTotal: Counter;
|
||||
readonly resolverDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: GraphQLInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the graphql instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetGraphQLInstruments}
|
||||
* in test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getGraphQLInstruments(): GraphQLInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-graphql");
|
||||
cached = {
|
||||
resolversExecutedTotal: meter.createCounter(
|
||||
"graphql.resolvers_executed_total",
|
||||
{
|
||||
description:
|
||||
"Total resolver invocations, partitioned by outcome label",
|
||||
}
|
||||
),
|
||||
resolverDurationMs: meter.createHistogram(
|
||||
"graphql.resolver_duration_ms",
|
||||
{
|
||||
description: "Resolver-execution duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getGraphQLInstruments} call binds against the global
|
||||
* `MeterProvider` that is in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetGraphQLInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
DefineGraphQLPluginOptions,
|
||||
GraphQLPluginDefinition,
|
||||
} from "./plugin.js";
|
||||
export { defineGraphQLPlugin } from "./plugin.js";
|
||||
101
packages/api-stack/api/api-plugin-graphql/src/plugin/plugin.md
Normal file
101
packages/api-stack/api/api-plugin-graphql/src/plugin/plugin.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `defineGraphQLPlugin(options)`, the factory that produces a `GraphQLPluginDefinition` consumable by the Suaveplan API plugin system. The factory:
|
||||
|
||||
1. Zod-validates the supplied options at the boundary (single chokepoint for invalid input).
|
||||
2. Merges caller-supplied values with the four package defaults (`basePath="/graphql"`, `enableIntrospection=true`, `enablePlayground=true`, `maxDepth=10`).
|
||||
3. Exposes the schema + resolvers behind `getSchema()` / `getResolvers()` accessors so downstream executors (or test harnesses) can re-read the merged config without parsing the `GraphQLPluginConfig` field.
|
||||
|
||||
`defineGraphQLPlugin` is the only public entry point of the package. Everything else (`SchemaBuilder`, `ResolverComposer`, middleware factories) is in service of producing the `schema` and `resolvers` fields fed into this factory.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod validation at the boundary; failure throws `GraphQLError(GRAPHQL.INVALID_INPUT)` with `cause: ZodError`.
|
||||
- Per-field defaults applied via nullish coalescing — explicit `undefined` triggers the default, explicit `false` / `0` survives.
|
||||
- Lifecycle hooks passed through verbatim (caller controls them).
|
||||
- Returns plain objects; no class instances; safe to JSON-serialize except for `hooks` and `schema.resolvers`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineGraphQLPlugin,
|
||||
ResolverComposer,
|
||||
SchemaBuilder,
|
||||
} from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const schema = new SchemaBuilder()
|
||||
.addType("User", (t) => t.field("id", "ID", { required: true }))
|
||||
.addQuery("user", "User", {
|
||||
args: [{ name: "id", type: "ID", required: true, isList: false }],
|
||||
})
|
||||
.build();
|
||||
|
||||
const resolvers = new ResolverComposer()
|
||||
.addResolver("Query", "user", (_p, args) => ({ id: args.id, name: "Alice" }))
|
||||
.build();
|
||||
|
||||
const definition = defineGraphQLPlugin({
|
||||
name: "my-graphql",
|
||||
version: "1.0.0",
|
||||
schema,
|
||||
resolvers,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createQueryExecutedMiddleware,
|
||||
defineGraphQLPlugin,
|
||||
ResolverComposer,
|
||||
SchemaBuilder,
|
||||
} from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const definition = defineGraphQLPlugin({
|
||||
name: "user-api",
|
||||
version: "2.0.0",
|
||||
basePath: "/api/graphql",
|
||||
enableIntrospection: false,
|
||||
enablePlayground: false,
|
||||
maxDepth: 5,
|
||||
schema: schemaBuilder.build(),
|
||||
resolvers: composer
|
||||
.useForType("Query", createQueryExecutedMiddleware())
|
||||
.build(),
|
||||
hooks: {
|
||||
async onInit() {
|
||||
console.log("graphql plugin initializing");
|
||||
},
|
||||
async onReady() {
|
||||
console.log("graphql plugin ready");
|
||||
},
|
||||
async onClose() {
|
||||
console.log("graphql plugin closing");
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `DefineGraphQLPluginOptions` | interface | Caller-facing input. `schema` / `resolvers` required; everything else optional. |
|
||||
| `GraphQLPluginDefinition` | interface | Output shape: `name`, `config`, `hooks`, `getSchema()`, `getResolvers()`. |
|
||||
| `defineGraphQLPlugin(options)` | function | Zod-validates `options`; throws `GraphQLError(INVALID_INPUT)` on failure. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory deliberately does **not** invoke `@suaveplan/api-plugin`'s `definePlugin()` wrapper. Upstream's contract is a plain `GraphQLPluginDefinition` (name + config + hooks + accessors); the migration preserves that contract verbatim. A future `createGraphQLPlugin()` that wraps this output in a `PluginDescriptor` and mounts a Hono route is enumerated in `enterprise/api-plugin-graphql.md` §3 as a separate enterprise upgrade and is out of scope for the migration.
|
||||
|
||||
`hooks` is constructed conditionally: under `exactOptionalPropertyTypes`, assigning `undefined` to an optional property is a type error, so the factory only sets `onInit` / `onReady` / `onClose` when the caller supplied a function. The unit tests pin this — `plugin.hooks.onInit` is `undefined` when no hook was supplied, not the literal `undefined` after a noisy assignment.
|
||||
|
||||
Defaulting uses `??` so explicit `false` / `0` (where applicable) flows through unchanged.
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { GRAPHQL_CODES, GraphQLError } from "../error-codes/error-codes.js";
|
||||
import type { ResolverMap, SchemaDefinition } from "../types/graphql.types.js";
|
||||
import { defineGraphQLPlugin } from "./plugin.js";
|
||||
|
||||
function makeSchema(): SchemaDefinition {
|
||||
return {
|
||||
types: [
|
||||
{
|
||||
name: "User",
|
||||
kind: "object",
|
||||
fields: [
|
||||
{ name: "id", type: "ID", required: true, isList: false },
|
||||
{
|
||||
name: "name",
|
||||
type: "String",
|
||||
required: true,
|
||||
isList: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
queries: [
|
||||
{
|
||||
name: "user",
|
||||
type: "User",
|
||||
required: false,
|
||||
isList: false,
|
||||
args: [
|
||||
{ name: "id", type: "ID", required: true, isList: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
mutations: [],
|
||||
subscriptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeResolvers(): ResolverMap {
|
||||
return {
|
||||
Query: {
|
||||
user: (_p, args) => ({ id: args["id"], name: "Alice" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("defineGraphQLPlugin", () => {
|
||||
it("should create a plugin with default config", { timeout: 5000 }, () => {
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "test-graphql",
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
});
|
||||
expect(plugin.name).toBe("test-graphql");
|
||||
expect(plugin.config.basePath).toBe("/graphql");
|
||||
expect(plugin.config.enableIntrospection).toBe(true);
|
||||
expect(plugin.config.enablePlayground).toBe(true);
|
||||
expect(plugin.config.maxDepth).toBe(10);
|
||||
});
|
||||
|
||||
it("should override default config values", { timeout: 5000 }, () => {
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "custom",
|
||||
version: "2.0.0",
|
||||
basePath: "/api/graphql",
|
||||
enableIntrospection: false,
|
||||
enablePlayground: false,
|
||||
maxDepth: 5,
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
});
|
||||
expect(plugin.config.basePath).toBe("/api/graphql");
|
||||
expect(plugin.config.enableIntrospection).toBe(false);
|
||||
expect(plugin.config.enablePlayground).toBe(false);
|
||||
expect(plugin.config.maxDepth).toBe(5);
|
||||
});
|
||||
|
||||
it("should include version in config", { timeout: 5000 }, () => {
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "versioned",
|
||||
version: "3.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
});
|
||||
expect(plugin.config.version).toBe("3.0.0");
|
||||
});
|
||||
|
||||
it("should return schema via getSchema", { timeout: 5000 }, () => {
|
||||
const schema = makeSchema();
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
schema,
|
||||
resolvers: makeResolvers(),
|
||||
});
|
||||
const retrieved = plugin.getSchema();
|
||||
expect(retrieved.types).toHaveLength(1);
|
||||
expect(retrieved.queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return resolvers via getResolvers", { timeout: 5000 }, () => {
|
||||
const resolvers = makeResolvers();
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers,
|
||||
});
|
||||
const retrieved = plugin.getResolvers();
|
||||
expect(retrieved["Query"]).toBeDefined();
|
||||
expect(retrieved["Query"]?.["user"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include lifecycle hooks", { timeout: 5000 }, () => {
|
||||
let initialized = false;
|
||||
let ready = false;
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "hooks",
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
hooks: {
|
||||
onInit: () => {
|
||||
initialized = true;
|
||||
},
|
||||
onReady: () => {
|
||||
ready = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
plugin.hooks.onInit?.();
|
||||
plugin.hooks.onReady?.();
|
||||
expect(initialized).toBe(true);
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle missing hooks gracefully", { timeout: 5000 }, () => {
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "no-hooks",
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
});
|
||||
expect(plugin.hooks.onInit).toBeUndefined();
|
||||
expect(plugin.hooks.onReady).toBeUndefined();
|
||||
expect(plugin.hooks.onClose).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include onClose hook", { timeout: 5000 }, () => {
|
||||
let closed = false;
|
||||
const plugin = defineGraphQLPlugin({
|
||||
name: "closable",
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
hooks: {
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
plugin.hooks.onClose?.();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
it("throws GRAPHQL.INVALID_INPUT when options fail Zod validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = {
|
||||
// name missing — Zod rejects
|
||||
version: "1.0.0",
|
||||
schema: makeSchema(),
|
||||
resolvers: makeResolvers(),
|
||||
} as unknown as Parameters<typeof defineGraphQLPlugin>[0];
|
||||
try {
|
||||
defineGraphQLPlugin(bad);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GraphQLError);
|
||||
expect((e as GraphQLError).code).toBe(GRAPHQL_CODES.INVALID_INPUT);
|
||||
expect((e as GraphQLError).cause).toBeInstanceOf(z.ZodError);
|
||||
}
|
||||
});
|
||||
});
|
||||
101
packages/api-stack/api/api-plugin-graphql/src/plugin/plugin.ts
Normal file
101
packages/api-stack/api/api-plugin-graphql/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { GRAPHQL_CODES, GraphQLError } from "../error-codes/error-codes.js";
|
||||
import { DefineGraphQLPluginOptionsSchema } from "../schemas/options.js";
|
||||
import type {
|
||||
GraphQLPluginConfig,
|
||||
GraphQLPluginHooks,
|
||||
ResolverMap,
|
||||
SchemaDefinition,
|
||||
} from "../types/graphql.types.js";
|
||||
|
||||
/**
|
||||
* Default plugin configuration values applied when the caller omits a
|
||||
* field on {@link DefineGraphQLPluginOptions}.
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
basePath: "/graphql",
|
||||
enableIntrospection: true,
|
||||
enablePlayground: true,
|
||||
maxDepth: 10,
|
||||
} as const;
|
||||
|
||||
/** Options accepted by {@link defineGraphQLPlugin}. */
|
||||
export interface DefineGraphQLPluginOptions {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly basePath?: string;
|
||||
readonly enableIntrospection?: boolean;
|
||||
readonly enablePlayground?: boolean;
|
||||
readonly maxDepth?: number;
|
||||
readonly schema: SchemaDefinition;
|
||||
readonly resolvers: ResolverMap;
|
||||
readonly hooks?: GraphQLPluginHooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of defining a GraphQL plugin. Shape-compatible with the
|
||||
* Suaveplan API plugin system: the framework consumes `name`, `config`,
|
||||
* and `hooks` directly, while `getSchema` / `getResolvers` are escape
|
||||
* hatches for downstream executors.
|
||||
*/
|
||||
export interface GraphQLPluginDefinition {
|
||||
readonly name: string;
|
||||
readonly config: GraphQLPluginConfig;
|
||||
readonly hooks: GraphQLPluginHooks;
|
||||
getSchema(): SchemaDefinition;
|
||||
getResolvers(): ResolverMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a GraphQL plugin compatible with the Suaveplan API framework.
|
||||
*
|
||||
* Zod-validates `options` at the boundary. On invalid input throws
|
||||
* `GraphQLError(GRAPHQL.INVALID_INPUT)` carrying the `ZodError` as
|
||||
* `cause`. On success returns a frozen-by-convention
|
||||
* `GraphQLPluginDefinition` whose `config` field has every default
|
||||
* applied.
|
||||
*/
|
||||
export function defineGraphQLPlugin(
|
||||
options: DefineGraphQLPluginOptions
|
||||
): GraphQLPluginDefinition {
|
||||
const parsed = DefineGraphQLPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.INVALID_INPUT,
|
||||
"defineGraphQLPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
|
||||
const config: GraphQLPluginConfig = {
|
||||
name: options.name,
|
||||
version: options.version,
|
||||
basePath: options.basePath ?? DEFAULT_CONFIG.basePath,
|
||||
enableIntrospection:
|
||||
options.enableIntrospection ?? DEFAULT_CONFIG.enableIntrospection,
|
||||
enablePlayground:
|
||||
options.enablePlayground ?? DEFAULT_CONFIG.enablePlayground,
|
||||
maxDepth: options.maxDepth ?? DEFAULT_CONFIG.maxDepth,
|
||||
schema: options.schema,
|
||||
resolvers: options.resolvers,
|
||||
};
|
||||
|
||||
const hooks: GraphQLPluginHooks = {};
|
||||
if (options.hooks?.onInit !== undefined)
|
||||
hooks.onInit = options.hooks.onInit;
|
||||
if (options.hooks?.onReady !== undefined)
|
||||
hooks.onReady = options.hooks.onReady;
|
||||
if (options.hooks?.onClose !== undefined)
|
||||
hooks.onClose = options.hooks.onClose;
|
||||
|
||||
return {
|
||||
name: options.name,
|
||||
config,
|
||||
hooks,
|
||||
getSchema(): SchemaDefinition {
|
||||
return config.schema;
|
||||
},
|
||||
getResolvers(): ResolverMap {
|
||||
return config.resolvers;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
ResolverComposer,
|
||||
requireRoles,
|
||||
validateArgs,
|
||||
} from "./resolver.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
# resolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `ResolverComposer` (the fluent builder for resolver maps), the two built-in middleware factories `requireRoles` and `validateArgs`, the package-default `graphqlEvents` `EventBus`, and `createQueryExecutedMiddleware` — the production OTel/event-emission middleware. All four pieces work together to give consumers a typed, observable resolver pipeline that can be plugged into any GraphQL executor downstream.
|
||||
|
||||
`ResolverComposer` is the workhorse: callers add resolvers per `(typeName, fieldName)`, attach middleware globally or per type, and call `build()` to obtain a `ResolverMap` whose entries are pre-composed (middleware → resolver). The composition order is right-to-left: the first registered middleware is the outermost wrapper, so `composer.use(A).use(B).addResolver(...)` runs `A → B → resolver` on call.
|
||||
|
||||
## Features
|
||||
|
||||
- `use(mw)` — register a global middleware (runs on every resolver).
|
||||
- `useForType(typeName, mw)` — register a middleware that runs only for resolvers of that type.
|
||||
- `addResolver(typeName, fieldName, fn)` — register one resolver; throws `DUPLICATE_RESOLVER` on collision.
|
||||
- `addResolvers(typeName, fnsByName)` — register many at once.
|
||||
- `hasResolver` / `typeCount` / `resolverCount` — introspection.
|
||||
- `build()` — produce the composed `ResolverMap`.
|
||||
- `requireRoles(...roles)` — middleware that throws `UNAUTHENTICATED` / `INSUFFICIENT_ROLE` as `GraphQLError`.
|
||||
- `validateArgs(validator)` — middleware that throws `VALIDATION_FAILED` when the validator returns a string.
|
||||
- `graphqlEvents` — package-default `EventBus<GraphQLEventMap>`.
|
||||
- `createQueryExecutedMiddleware(bus?)` — records OTel instruments and emits `graphql:query:executed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ResolverComposer, requireRoles, validateArgs } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const map = new ResolverComposer()
|
||||
.useForType("Mutation", requireRoles("editor", "admin"))
|
||||
.addResolver("Query", "user", (_p, args) => ({ id: args.id, name: "Alice" }))
|
||||
.addResolver(
|
||||
"Mutation",
|
||||
"createPost",
|
||||
(_p, args) => ({ id: "post:1", title: args.title })
|
||||
)
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ResolverComposer,
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
validateArgs,
|
||||
} from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
graphqlEvents.on("graphql:query:executed", (e) => {
|
||||
console.log(`[gql] ${e.operationType}.${e.operationName} ${e.durationMs.toFixed(2)}ms${e.hasErrors ? " (error)" : ""}`);
|
||||
});
|
||||
|
||||
const map = new ResolverComposer()
|
||||
.useForType("Query", createQueryExecutedMiddleware())
|
||||
.useForType("Mutation", createQueryExecutedMiddleware())
|
||||
.useForType(
|
||||
"Mutation",
|
||||
validateArgs((args) => (typeof args.title !== "string" ? "title required" : null))
|
||||
)
|
||||
.addResolver("Query", "ping", () => "pong")
|
||||
.addResolver("Mutation", "createPost", async (_p, args) => ({ id: "post:1", title: args.title }))
|
||||
.build();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ResolverComposer` | class | See methods below. |
|
||||
| `.use(mw)` | method | Chainable. |
|
||||
| `.useForType(typeName, mw)` | method | Chainable. |
|
||||
| `.addResolver(typeName, fieldName, fn)` | method | Throws `GraphQLError(DUPLICATE_RESOLVER)` if already registered. |
|
||||
| `.addResolvers(typeName, mapByName)` | method | Loops `addResolver`. |
|
||||
| `.hasResolver(typeName, fieldName)` | method | `boolean`. |
|
||||
| `.typeCount` / `.resolverCount` | getter | `number`. |
|
||||
| `.build()` | method | Returns `ResolverMap`. |
|
||||
| `requireRoles(...roles)` | function | Throws `UNAUTHENTICATED` / `INSUFFICIENT_ROLE`. |
|
||||
| `validateArgs(validator)` | function | `validator(args) => string \| null`. Throws `VALIDATION_FAILED` on non-null. |
|
||||
| `graphqlEvents` | const | `EventBus<GraphQLEventMap>`. |
|
||||
| `createQueryExecutedMiddleware(bus?)` | function | Records OTel + emits event. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`composeMiddleware` walks the middleware list right-to-left, capturing `current` in a closure so each iteration creates the next outer wrapper. The conditional `if (middleware === undefined) continue` keeps `noUncheckedIndexedAccess` happy without sacrificing branch coverage — every slot is dense at build time so the branch is unreachable in practice; the unit tests rely on observation of the composed chain, not on dead-code coverage.
|
||||
|
||||
`createQueryExecutedMiddleware` always records the duration histogram (even on error) and always emits the event in `finally`. This is the only path that touches `getGraphQLInstruments()`; the lazy binding means the meter is whatever provider is installed at first invocation. Tests install a fresh provider before exercising the middleware.
|
||||
|
||||
`graphqlEvents` is a module-scoped singleton. Callers who need isolation (e.g. parallel test workers) construct their own `EventBus<GraphQLEventMap>` and pass it as the `bus` argument to `createQueryExecutedMiddleware`.
|
||||
|
||||
`requireRoles` and `validateArgs` use `GraphQLError` (with `context.statusCode` 401/403) so an HTTP error-handler middleware downstream can map cleanly to HTTP responses without re-classifying errors by message.
|
||||
@@ -0,0 +1,383 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GRAPHQL_CODES, GraphQLError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
GraphQLEventMap,
|
||||
GraphQLQueryExecutedEvent,
|
||||
QueryContext,
|
||||
ResolverInfo,
|
||||
ResolverMiddleware,
|
||||
} from "../types/graphql.types.js";
|
||||
import {
|
||||
createQueryExecutedMiddleware,
|
||||
graphqlEvents,
|
||||
ResolverComposer,
|
||||
requireRoles,
|
||||
validateArgs,
|
||||
} from "./resolver.js";
|
||||
|
||||
function makeContext(overrides?: Partial<QueryContext>): QueryContext {
|
||||
return {
|
||||
headers: {},
|
||||
data: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInfo(fieldName = "test"): ResolverInfo {
|
||||
return {
|
||||
fieldName,
|
||||
parentType: "Query",
|
||||
returnType: "String",
|
||||
};
|
||||
}
|
||||
|
||||
describe("ResolverComposer", () => {
|
||||
let composer: ResolverComposer;
|
||||
|
||||
beforeEach(() => {
|
||||
composer = new ResolverComposer();
|
||||
});
|
||||
|
||||
describe("addResolver", () => {
|
||||
it("should add a resolver", { timeout: 5000 }, () => {
|
||||
composer.addResolver("Query", "hello", () => "world");
|
||||
expect(composer.hasResolver("Query", "hello")).toBe(true);
|
||||
expect(composer.typeCount).toBe(1);
|
||||
expect(composer.resolverCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should return false for non-existent resolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(composer.hasResolver("Query", "unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws GRAPHQL.DUPLICATE_RESOLVER on collision", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
composer.addResolver("Query", "hello", () => "world");
|
||||
expect(() =>
|
||||
composer.addResolver("Query", "hello", () => "x")
|
||||
).toThrow(GraphQLError);
|
||||
try {
|
||||
composer.addResolver("Query", "hello", () => "x");
|
||||
} catch (e) {
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.DUPLICATE_RESOLVER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("addResolvers", () => {
|
||||
it("should add multiple resolvers for a type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
composer.addResolvers("Query", {
|
||||
hello: () => "world",
|
||||
goodbye: () => "farewell",
|
||||
});
|
||||
expect(composer.resolverCount).toBe(2);
|
||||
expect(composer.hasResolver("Query", "hello")).toBe(true);
|
||||
expect(composer.hasResolver("Query", "goodbye")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("build without middleware", () => {
|
||||
it("should build resolver map", { timeout: 5000 }, () => {
|
||||
composer.addResolver("Query", "hello", () => "world");
|
||||
const map = composer.build();
|
||||
const result = map["Query"]?.["hello"]?.(
|
||||
null,
|
||||
{},
|
||||
makeContext(),
|
||||
makeInfo()
|
||||
);
|
||||
expect(result).toBe("world");
|
||||
});
|
||||
|
||||
it("should handle async resolvers", { timeout: 30000 }, async () => {
|
||||
composer.addResolver("Query", "async", async () => "done");
|
||||
const map = composer.build();
|
||||
const result = await map["Query"]?.["async"]?.(
|
||||
null,
|
||||
{},
|
||||
makeContext(),
|
||||
makeInfo()
|
||||
);
|
||||
expect(result).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("global middleware", () => {
|
||||
it("should apply global middleware to all resolvers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: string[] = [];
|
||||
const logger: ResolverMiddleware = (next, p, a, c, i) => {
|
||||
calls.push("before");
|
||||
const result = next(p, a, c, i);
|
||||
calls.push("after");
|
||||
return result;
|
||||
};
|
||||
composer.use(logger);
|
||||
composer.addResolver("Query", "test", () => {
|
||||
calls.push("resolver");
|
||||
return "ok";
|
||||
});
|
||||
const map = composer.build();
|
||||
map["Query"]?.["test"]?.(null, {}, makeContext(), makeInfo());
|
||||
expect(calls).toEqual(["before", "resolver", "after"]);
|
||||
});
|
||||
|
||||
it("should chain multiple global middlewares", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const order: number[] = [];
|
||||
composer.use((next, p, a, c, i) => {
|
||||
order.push(1);
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.use((next, p, a, c, i) => {
|
||||
order.push(2);
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.addResolver("Query", "test", () => {
|
||||
order.push(3);
|
||||
return "ok";
|
||||
});
|
||||
const map = composer.build();
|
||||
map["Query"]?.["test"]?.(null, {}, makeContext(), makeInfo());
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("type-specific middleware", () => {
|
||||
it("should chain multiple middlewares registered for the same type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const order: number[] = [];
|
||||
composer.useForType("Query", (next, p, a, c, i) => {
|
||||
order.push(1);
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.useForType("Query", (next, p, a, c, i) => {
|
||||
order.push(2);
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.addResolver("Query", "test", () => {
|
||||
order.push(3);
|
||||
return "ok";
|
||||
});
|
||||
const map = composer.build();
|
||||
map["Query"]?.["test"]?.(null, {}, makeContext(), makeInfo());
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("should apply type middleware only to that type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: string[] = [];
|
||||
composer.useForType("Mutation", (next, p, a, c, i) => {
|
||||
calls.push("mutation-mw");
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.addResolver("Query", "q", () => {
|
||||
calls.push("query");
|
||||
return "q";
|
||||
});
|
||||
composer.addResolver("Mutation", "m", () => {
|
||||
calls.push("mutation");
|
||||
return "m";
|
||||
});
|
||||
const map = composer.build();
|
||||
map["Query"]?.["q"]?.(null, {}, makeContext(), makeInfo());
|
||||
map["Mutation"]?.["m"]?.(null, {}, makeContext(), makeInfo());
|
||||
expect(calls).toEqual(["query", "mutation-mw", "mutation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined middleware", () => {
|
||||
it("should apply global then type middleware", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const order: string[] = [];
|
||||
composer.use((next, p, a, c, i) => {
|
||||
order.push("global");
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.useForType("Query", (next, p, a, c, i) => {
|
||||
order.push("type");
|
||||
return next(p, a, c, i);
|
||||
});
|
||||
composer.addResolver("Query", "test", () => {
|
||||
order.push("resolver");
|
||||
return "ok";
|
||||
});
|
||||
const map = composer.build();
|
||||
map["Query"]?.["test"]?.(null, {}, makeContext(), makeInfo());
|
||||
expect(order).toEqual(["global", "type", "resolver"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireRoles", () => {
|
||||
const resolver = () => "data";
|
||||
const info = makeInfo();
|
||||
|
||||
it("should pass when user has required role", { timeout: 5000 }, () => {
|
||||
const mw = requireRoles("admin");
|
||||
const ctx = makeContext({ user: { id: "u1", roles: ["admin"] } });
|
||||
const result = mw(resolver, null, {}, ctx, info);
|
||||
expect(result).toBe("data");
|
||||
});
|
||||
|
||||
it("should pass when user has any of required roles", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mw = requireRoles("admin", "editor");
|
||||
const ctx = makeContext({ user: { id: "u1", roles: ["editor"] } });
|
||||
const result = mw(resolver, null, {}, ctx, info);
|
||||
expect(result).toBe("data");
|
||||
});
|
||||
|
||||
it("should throw GRAPHQL.UNAUTHENTICATED when user is not authenticated", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mw = requireRoles("admin");
|
||||
const ctx = makeContext();
|
||||
try {
|
||||
mw(resolver, null, {}, ctx, info);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GraphQLError);
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.UNAUTHENTICATED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw GRAPHQL.INSUFFICIENT_ROLE when user lacks required roles", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mw = requireRoles("admin");
|
||||
const ctx = makeContext({ user: { id: "u1", roles: ["user"] } });
|
||||
try {
|
||||
mw(resolver, null, {}, ctx, info);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GraphQLError);
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.INSUFFICIENT_ROLE
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateArgs", () => {
|
||||
const resolver = () => "ok";
|
||||
const info = makeInfo();
|
||||
const ctx = makeContext();
|
||||
|
||||
it("should pass when validation returns null", { timeout: 5000 }, () => {
|
||||
const mw = validateArgs(() => null);
|
||||
const result = mw(resolver, null, { name: "test" }, ctx, info);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("should throw GRAPHQL.VALIDATION_FAILED when validation returns error string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mw = validateArgs((args) => {
|
||||
if (!args["name"]) return "name is required";
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
mw(resolver, null, {}, ctx, info);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GraphQLError);
|
||||
expect((e as GraphQLError).code).toBe(
|
||||
GRAPHQL_CODES.VALIDATION_FAILED
|
||||
);
|
||||
expect((e as GraphQLError).message).toContain("name is required");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQueryExecutedMiddleware", () => {
|
||||
it("createQueryExecutedMiddleware emits graphql:query:executed on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = new EventBus<GraphQLEventMap>();
|
||||
const seen: GraphQLQueryExecutedEvent[] = [];
|
||||
bus.on("graphql:query:executed", (e) => {
|
||||
seen.push(e);
|
||||
});
|
||||
const mw = createQueryExecutedMiddleware(bus);
|
||||
const result = await mw(
|
||||
() => "ok",
|
||||
null,
|
||||
{},
|
||||
makeContext(),
|
||||
makeInfo("ping")
|
||||
);
|
||||
expect(result).toBe("ok");
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.operationName).toBe("ping");
|
||||
expect(seen[0]?.operationType).toBe("Query");
|
||||
expect(seen[0]?.hasErrors).toBe(false);
|
||||
expect(seen[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("createQueryExecutedMiddleware emits graphql:query:executed and re-throws on error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = new EventBus<GraphQLEventMap>();
|
||||
const seen: GraphQLQueryExecutedEvent[] = [];
|
||||
bus.on("graphql:query:executed", (e) => {
|
||||
seen.push(e);
|
||||
});
|
||||
const mw = createQueryExecutedMiddleware(bus);
|
||||
await expect(
|
||||
mw(
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
null,
|
||||
{},
|
||||
makeContext(),
|
||||
makeInfo("explode")
|
||||
)
|
||||
).rejects.toThrow("boom");
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.hasErrors).toBe(true);
|
||||
expect(seen[0]?.operationName).toBe("explode");
|
||||
});
|
||||
|
||||
it("createQueryExecutedMiddleware honors a caller-supplied EventBus", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ownBus = new EventBus<GraphQLEventMap>();
|
||||
const otherBus = new EventBus<GraphQLEventMap>();
|
||||
const own: GraphQLQueryExecutedEvent[] = [];
|
||||
const other: GraphQLQueryExecutedEvent[] = [];
|
||||
ownBus.on("graphql:query:executed", (e) => {
|
||||
own.push(e);
|
||||
});
|
||||
otherBus.on("graphql:query:executed", (e) => {
|
||||
other.push(e);
|
||||
});
|
||||
const mw = createQueryExecutedMiddleware(ownBus);
|
||||
await mw(() => "x", null, {}, makeContext(), makeInfo("a"));
|
||||
expect(own).toHaveLength(1);
|
||||
expect(other).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("graphqlEvents is a typed EventBus<GraphQLEventMap>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(graphqlEvents).toBeInstanceOf(EventBus);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { GRAPHQL_CODES, GraphQLError } from "../error-codes/error-codes.js";
|
||||
import { getGraphQLInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
GraphQLEventMap,
|
||||
GraphQLQueryExecutedEvent,
|
||||
ResolverFn,
|
||||
ResolverMap,
|
||||
ResolverMiddleware,
|
||||
} from "../types/graphql.types.js";
|
||||
|
||||
/**
|
||||
* Composes resolvers with middleware, auth checks, and validation.
|
||||
*
|
||||
* Provides a builder pattern for constructing resolver maps with shared
|
||||
* middleware that wraps resolver functions in a composable pipeline.
|
||||
*/
|
||||
export class ResolverComposer {
|
||||
private readonly resolvers: Map<string, Map<string, ResolverFn>> =
|
||||
new Map();
|
||||
private readonly globalMiddleware: ResolverMiddleware[] = [];
|
||||
private readonly typeMiddleware: Map<string, ResolverMiddleware[]> =
|
||||
new Map();
|
||||
|
||||
/** Add global middleware that applies to all resolvers. */
|
||||
public use(middleware: ResolverMiddleware): this {
|
||||
this.globalMiddleware.push(middleware);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add middleware that only applies to resolvers of a specific type. */
|
||||
public useForType(typeName: string, middleware: ResolverMiddleware): this {
|
||||
let middlewares = this.typeMiddleware.get(typeName);
|
||||
if (!middlewares) {
|
||||
middlewares = [];
|
||||
this.typeMiddleware.set(typeName, middlewares);
|
||||
}
|
||||
middlewares.push(middleware);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a resolver function for a specific type and field.
|
||||
*
|
||||
* Throws `GraphQLError(GRAPHQL.DUPLICATE_RESOLVER)` when the
|
||||
* `(typeName, fieldName)` pair has already been registered — this
|
||||
* is a strict-mode guard added in genesis to surface configuration
|
||||
* mistakes early.
|
||||
*/
|
||||
public addResolver(
|
||||
typeName: string,
|
||||
fieldName: string,
|
||||
resolver: ResolverFn
|
||||
): this {
|
||||
let typeResolvers = this.resolvers.get(typeName);
|
||||
if (!typeResolvers) {
|
||||
typeResolvers = new Map();
|
||||
this.resolvers.set(typeName, typeResolvers);
|
||||
}
|
||||
if (typeResolvers.has(fieldName)) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.DUPLICATE_RESOLVER,
|
||||
`resolver already registered for ${typeName}.${fieldName}`,
|
||||
{ context: { typeName, fieldName } }
|
||||
);
|
||||
}
|
||||
typeResolvers.set(fieldName, resolver);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add multiple resolvers for a type at once. */
|
||||
public addResolvers(
|
||||
typeName: string,
|
||||
resolvers: Record<string, ResolverFn>
|
||||
): this {
|
||||
for (const [fieldName, resolver] of Object.entries(resolvers)) {
|
||||
this.addResolver(typeName, fieldName, resolver);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Check if a resolver exists for a given type and field. */
|
||||
public hasResolver(typeName: string, fieldName: string): boolean {
|
||||
const typeResolvers = this.resolvers.get(typeName);
|
||||
return typeResolvers?.has(fieldName) ?? false;
|
||||
}
|
||||
|
||||
/** Number of distinct resolver types. */
|
||||
public get typeCount(): number {
|
||||
return this.resolvers.size;
|
||||
}
|
||||
|
||||
/** Total count of registered resolvers across all types. */
|
||||
public get resolverCount(): number {
|
||||
let count = 0;
|
||||
for (const typeResolvers of this.resolvers.values()) {
|
||||
count += typeResolvers.size;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Build the final resolver map with all middleware composed. */
|
||||
public build(): ResolverMap {
|
||||
const result: ResolverMap = {};
|
||||
for (const [typeName, typeResolvers] of this.resolvers) {
|
||||
const typeMap: Record<string, ResolverFn> = {};
|
||||
const typeMiddlewares = this.typeMiddleware.get(typeName) ?? [];
|
||||
for (const [fieldName, resolver] of typeResolvers) {
|
||||
const allMiddleware = [
|
||||
...this.globalMiddleware,
|
||||
...typeMiddlewares,
|
||||
];
|
||||
typeMap[fieldName] = composeMiddleware(allMiddleware, resolver);
|
||||
}
|
||||
result[typeName] = typeMap;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function composeMiddleware(
|
||||
middlewares: ResolverMiddleware[],
|
||||
resolver: ResolverFn
|
||||
): ResolverFn {
|
||||
if (middlewares.length === 0) {
|
||||
return resolver;
|
||||
}
|
||||
let current: ResolverFn = resolver;
|
||||
for (let i = middlewares.length - 1; i >= 0; i--) {
|
||||
const middleware = middlewares[i] as ResolverMiddleware;
|
||||
const next = current;
|
||||
current = (parent, args, context, info) =>
|
||||
middleware(next, parent, args, context, info);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an auth-check middleware that verifies the user has at least
|
||||
* one of the required roles. Throws `GraphQLError(UNAUTHENTICATED)`
|
||||
* when the context has no user and `GraphQLError(INSUFFICIENT_ROLE)`
|
||||
* when the user lacks every required role.
|
||||
*/
|
||||
export function requireRoles(...roles: string[]): ResolverMiddleware {
|
||||
return (next, parent, args, context, info) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.UNAUTHENTICATED,
|
||||
"Authentication required",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
const hasRole = roles.some((role) =>
|
||||
context.user?.roles.includes(role)
|
||||
);
|
||||
if (!hasRole) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.INSUFFICIENT_ROLE,
|
||||
`Insufficient permissions. Required roles: ${roles.join(", ")}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 403,
|
||||
requiredRoles: roles,
|
||||
userRoles: context.user.roles,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return next(parent, args, context, info);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation middleware that validates resolver arguments
|
||||
* against a provided validation function. The validator returns `null`
|
||||
* on success or a non-null string describing the failure; the
|
||||
* middleware re-raises the string as a `GraphQLError(VALIDATION_FAILED)`.
|
||||
*/
|
||||
export function validateArgs(
|
||||
validator: (args: Record<string, unknown>) => string | null
|
||||
): ResolverMiddleware {
|
||||
return (next, parent, args, context, info) => {
|
||||
const error = validator(args);
|
||||
if (error !== null) {
|
||||
throw new GraphQLError(
|
||||
GRAPHQL_CODES.VALIDATION_FAILED,
|
||||
`Validation error: ${error}`,
|
||||
{ context: { validatorMessage: error } }
|
||||
);
|
||||
}
|
||||
return next(parent, args, context, info);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared event bus for GraphQL domain events. Consumers can subscribe
|
||||
* to the `graphql:query:executed` event via `graphqlEvents.on(...)`.
|
||||
*/
|
||||
export const graphqlEvents: EventBus<GraphQLEventMap> =
|
||||
new EventBus<GraphQLEventMap>();
|
||||
|
||||
/**
|
||||
* Create a middleware that records OTel instruments and emits a
|
||||
* `graphql:query:executed` event for every resolver invocation. Attach
|
||||
* to Query/Mutation types to track all top-level operations.
|
||||
*/
|
||||
export function createQueryExecutedMiddleware(
|
||||
bus?: EventBus<GraphQLEventMap>
|
||||
): ResolverMiddleware {
|
||||
const target = bus ?? graphqlEvents;
|
||||
return async (next, parent, args, context, info) => {
|
||||
const start = performance.now();
|
||||
let hasErrors = false;
|
||||
try {
|
||||
return await next(parent, args, context, info);
|
||||
} catch (error) {
|
||||
hasErrors = true;
|
||||
throw error;
|
||||
} finally {
|
||||
const durationMs = performance.now() - start;
|
||||
const instruments = getGraphQLInstruments();
|
||||
const outcome = hasErrors ? "error" : "success";
|
||||
instruments.resolversExecutedTotal.add(1, { outcome });
|
||||
instruments.resolverDurationMs.record(durationMs, { outcome });
|
||||
const event: GraphQLQueryExecutedEvent = {
|
||||
operationName: info.fieldName,
|
||||
operationType: info.parentType,
|
||||
durationMs,
|
||||
hasErrors,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
target.emit("graphql:query:executed", event);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SchemaBuilder } from "./schema-builder.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# schema-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `SchemaBuilder`, the fluent builder for GraphQL schema definitions. Consumers describe types, queries, mutations, and subscriptions via chained method calls; `build()` returns a fully resolved `SchemaDefinition` consumed by `defineGraphQLPlugin` (or by any future executor).
|
||||
|
||||
`SchemaBuilder` is the canonical authoring surface — callers should not construct `SchemaDefinition` literals by hand. The builder enforces sensible defaults (`required: false`, `isList: false`), maintains insertion order for downstream determinism, and round-trips description/interfaces metadata correctly.
|
||||
|
||||
## Features
|
||||
|
||||
- `addType` / `addInputType` / `addEnumType` / `addInterfaceType` / `addUnionType` — every GraphQL type kind except `scalar` (consumers register scalars as plain strings on field types).
|
||||
- `addQuery` / `addMutation` / `addSubscription` — root-field declarations with optional `required`, `isList`, `description`, `args`.
|
||||
- `hasType` / `getType` — lookup helpers.
|
||||
- `typeCount` / `queryCount` / `mutationCount` — read-only counters.
|
||||
- `build()` — snapshot into an independent `SchemaDefinition`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SchemaBuilder } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const schema = new SchemaBuilder()
|
||||
.addType("User", (t) =>
|
||||
t.field("id", "ID", { required: true })
|
||||
.field("name", "String", { required: true })
|
||||
)
|
||||
.addQuery("user", "User", {
|
||||
args: [{ name: "id", type: "ID", required: true, isList: false }],
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SchemaBuilder } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const schema = new SchemaBuilder()
|
||||
.addEnumType("Role", ["ADMIN", "USER", "GUEST"], "Authorization role")
|
||||
.addInterfaceType("Node", (t) => t.field("id", "ID", { required: true }))
|
||||
.addType("Account", (t) =>
|
||||
t.field("id", "ID", { required: true }).field("role", "Role", { required: true }),
|
||||
{ description: "User account", interfaces: ["Node"] }
|
||||
)
|
||||
.addUnionType("SearchHit", ["Account", "Post"], "Polymorphic search result")
|
||||
.addInputType("CreateAccountInput", (t) =>
|
||||
t.field("name", "String", { required: true })
|
||||
.field("role", "Role", { required: true })
|
||||
)
|
||||
.addMutation("createAccount", "Account", {
|
||||
required: true,
|
||||
args: [{ name: "input", type: "CreateAccountInput", required: true, isList: false }],
|
||||
})
|
||||
.addSubscription("onAccountCreated", "Account", { required: true })
|
||||
.build();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `addType(name, builderFn, options?)` | `this` | `options.description`, `options.interfaces`. |
|
||||
| `addInputType(name, builderFn, description?)` | `this` | |
|
||||
| `addEnumType(name, values, description?)` | `this` | `values` is copied. |
|
||||
| `addInterfaceType(name, builderFn, description?)` | `this` | |
|
||||
| `addUnionType(name, memberTypes, description?)` | `this` | `memberTypes` stored on `enumValues`. |
|
||||
| `addQuery(name, returnType, options?)` | `this` | `required`/`isList`/`description`/`args`. |
|
||||
| `addMutation(name, returnType, options?)` | `this` | Same options as queries. |
|
||||
| `addSubscription(name, returnType, options?)` | `this` | Same options as queries. |
|
||||
| `hasType(name)` | `boolean` | |
|
||||
| `getType(name)` | `TypeDefinition \| undefined` | |
|
||||
| `typeCount` / `queryCount` / `mutationCount` | `number` | |
|
||||
| `build()` | `SchemaDefinition` | Returns an independent snapshot. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`addType` / `addInterfaceType` / `addInputType` re-construct the `TypeDefinition` from the `TypeBuilder` output to merge `description` and `interfaces` cleanly under `exactOptionalPropertyTypes` — TypeScript rejects assigning `undefined` to an optional property when that strict flag is on, so each merge spreads conditionally.
|
||||
|
||||
Union types reuse `enumValues` to carry their member-type name list. This matches upstream and keeps `TypeDefinition` flat — adding a separate `memberTypes` field would have introduced a new public type with no consumer benefit. Downstream code distinguishes via `kind === "union"`.
|
||||
|
||||
`build()` is the only method that exposes structure to callers; until then, the builder owns its internal `Map` / arrays. Each `build()` call returns fresh arrays so callers may mutate them safely or invoke `build()` multiple times during incremental construction.
|
||||
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SchemaBuilder } from "./schema-builder.js";
|
||||
|
||||
describe("SchemaBuilder", () => {
|
||||
let builder: SchemaBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new SchemaBuilder();
|
||||
});
|
||||
|
||||
describe("addType", () => {
|
||||
it("should add an object type", { timeout: 5000 }, () => {
|
||||
builder.addType("User", (t) =>
|
||||
t.field("id", "ID", { required: true }).field("name", "String")
|
||||
);
|
||||
expect(builder.hasType("User")).toBe(true);
|
||||
expect(builder.typeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should support description and interfaces", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
builder.addType("Admin", (t) => t.field("id", "ID"), {
|
||||
description: "Admin user",
|
||||
interfaces: ["Node"],
|
||||
});
|
||||
const type = builder.getType("Admin");
|
||||
expect(type?.description).toBe("Admin user");
|
||||
expect(type?.interfaces).toEqual(["Node"]);
|
||||
});
|
||||
|
||||
it("should build type with correct kind", { timeout: 5000 }, () => {
|
||||
builder.addType("Post", (t) => t.field("title", "String"));
|
||||
const type = builder.getType("Post");
|
||||
expect(type?.kind).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addInputType", () => {
|
||||
it("should add an input type", { timeout: 5000 }, () => {
|
||||
builder.addInputType("CreateUserInput", (t) =>
|
||||
t.field("name", "String", { required: true })
|
||||
);
|
||||
const type = builder.getType("CreateUserInput");
|
||||
expect(type?.kind).toBe("input");
|
||||
});
|
||||
|
||||
it("should support description", { timeout: 5000 }, () => {
|
||||
builder.addInputType(
|
||||
"Input",
|
||||
(t) => t.field("x", "Int"),
|
||||
"Test input"
|
||||
);
|
||||
expect(builder.getType("Input")?.description).toBe("Test input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEnumType", () => {
|
||||
it("should add an enum type", { timeout: 5000 }, () => {
|
||||
builder.addEnumType("Role", ["ADMIN", "USER", "GUEST"]);
|
||||
const type = builder.getType("Role");
|
||||
expect(type?.kind).toBe("enum");
|
||||
expect(type?.enumValues).toEqual(["ADMIN", "USER", "GUEST"]);
|
||||
});
|
||||
|
||||
it("should support description (enum)", { timeout: 5000 }, () => {
|
||||
builder.addEnumType("Status", ["ACTIVE"], "Status enum");
|
||||
expect(builder.getType("Status")?.description).toBe("Status enum");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addInterfaceType", () => {
|
||||
it("should add an interface type", { timeout: 5000 }, () => {
|
||||
builder.addInterfaceType("Node", (t) =>
|
||||
t.field("id", "ID", { required: true })
|
||||
);
|
||||
const type = builder.getType("Node");
|
||||
expect(type?.kind).toBe("interface");
|
||||
});
|
||||
|
||||
it("should support description (interface)", { timeout: 5000 }, () => {
|
||||
builder.addInterfaceType(
|
||||
"Node",
|
||||
(t) => t.field("id", "ID"),
|
||||
"Base node"
|
||||
);
|
||||
expect(builder.getType("Node")?.description).toBe("Base node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addUnionType", () => {
|
||||
it("should add a union type", { timeout: 5000 }, () => {
|
||||
builder.addUnionType("SearchResult", ["User", "Post"]);
|
||||
const type = builder.getType("SearchResult");
|
||||
expect(type?.kind).toBe("union");
|
||||
expect(type?.enumValues).toEqual(["User", "Post"]);
|
||||
});
|
||||
|
||||
it("should support description (union)", { timeout: 5000 }, () => {
|
||||
builder.addUnionType("Result", ["A"], "Union desc");
|
||||
expect(builder.getType("Result")?.description).toBe("Union desc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addQuery", () => {
|
||||
it("should add a query field", { timeout: 5000 }, () => {
|
||||
builder.addQuery("users", "User", { isList: true, required: true });
|
||||
expect(builder.queryCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should support args and description", { timeout: 5000 }, () => {
|
||||
builder.addQuery("user", "User", {
|
||||
description: "Get user by ID",
|
||||
args: [
|
||||
{ name: "id", type: "ID", required: true, isList: false },
|
||||
],
|
||||
});
|
||||
const schema = builder.build();
|
||||
expect(schema.queries[0]?.description).toBe("Get user by ID");
|
||||
expect(schema.queries[0]?.args).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should default required and isList to false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
builder.addQuery("test", "String");
|
||||
const schema = builder.build();
|
||||
expect(schema.queries[0]?.required).toBe(false);
|
||||
expect(schema.queries[0]?.isList).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMutation", () => {
|
||||
it("should add a mutation field", { timeout: 5000 }, () => {
|
||||
builder.addMutation("createUser", "User", { required: true });
|
||||
expect(builder.mutationCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should support full options on mutation", { timeout: 5000 }, () => {
|
||||
builder.addMutation("deleteUser", "Boolean", {
|
||||
args: [
|
||||
{ name: "id", type: "ID", required: true, isList: false },
|
||||
],
|
||||
description: "Delete a user",
|
||||
});
|
||||
const schema = builder.build();
|
||||
expect(schema.mutations[0]?.args).toHaveLength(1);
|
||||
expect(schema.mutations[0]?.description).toBe("Delete a user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSubscription", () => {
|
||||
it("should add a subscription field", { timeout: 5000 }, () => {
|
||||
builder.addSubscription("onUserCreated", "User", {
|
||||
required: true,
|
||||
});
|
||||
const schema = builder.build();
|
||||
expect(schema.subscriptions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should support full options on subscription", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
builder.addSubscription("onMessage", "Message", {
|
||||
isList: false,
|
||||
description: "New message",
|
||||
args: [
|
||||
{
|
||||
name: "channelId",
|
||||
type: "ID",
|
||||
required: true,
|
||||
isList: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const schema = builder.build();
|
||||
expect(schema.subscriptions[0]?.description).toBe("New message");
|
||||
expect(schema.subscriptions[0]?.args).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType", () => {
|
||||
it("should return undefined for unknown type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(builder.getType("Unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("build", () => {
|
||||
it("should produce a complete schema definition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
builder
|
||||
.addType("User", (t) =>
|
||||
t
|
||||
.field("id", "ID", { required: true })
|
||||
.field("name", "String")
|
||||
)
|
||||
.addEnumType("Role", ["ADMIN", "USER"])
|
||||
.addQuery("users", "User", { isList: true })
|
||||
.addMutation("createUser", "User")
|
||||
.addSubscription("onUser", "User");
|
||||
|
||||
const schema = builder.build();
|
||||
expect(schema.types).toHaveLength(2);
|
||||
expect(schema.queries).toHaveLength(1);
|
||||
expect(schema.mutations).toHaveLength(1);
|
||||
expect(schema.subscriptions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { TypeBuilder } from "../type-builder/type-builder.js";
|
||||
import type {
|
||||
FieldDefinition,
|
||||
SchemaDefinition,
|
||||
TypeDefinition,
|
||||
} from "../types/graphql.types.js";
|
||||
|
||||
/**
|
||||
* Fluent API for building GraphQL schema definitions.
|
||||
*
|
||||
* Provides a builder pattern to define types, queries, mutations,
|
||||
* and subscriptions without writing raw GraphQL SDL. The output is a
|
||||
* fully resolved `SchemaDefinition` consumed downstream by
|
||||
* `defineGraphQLPlugin(...)` or by any future executor.
|
||||
*/
|
||||
export class SchemaBuilder {
|
||||
private readonly types: Map<string, TypeDefinition> = new Map();
|
||||
private readonly queries: FieldDefinition[] = [];
|
||||
private readonly mutations: FieldDefinition[] = [];
|
||||
private readonly subscriptions: FieldDefinition[] = [];
|
||||
|
||||
/** Add an object type to the schema. */
|
||||
public addType(
|
||||
name: string,
|
||||
builderFn: (builder: TypeBuilder) => TypeBuilder,
|
||||
options?: { description?: string; interfaces?: string[] }
|
||||
): this {
|
||||
const typeBuilder = new TypeBuilder(name, "object");
|
||||
builderFn(typeBuilder);
|
||||
const typeDef = typeBuilder.build();
|
||||
const merged: TypeDefinition = {
|
||||
name: typeDef.name,
|
||||
kind: typeDef.kind,
|
||||
fields: typeDef.fields,
|
||||
...(options?.description !== undefined
|
||||
? { description: options.description }
|
||||
: {}),
|
||||
...(options?.interfaces !== undefined
|
||||
? { interfaces: options.interfaces }
|
||||
: {}),
|
||||
};
|
||||
this.types.set(name, merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add an input type to the schema. */
|
||||
public addInputType(
|
||||
name: string,
|
||||
builderFn: (builder: TypeBuilder) => TypeBuilder,
|
||||
description?: string
|
||||
): this {
|
||||
const typeBuilder = new TypeBuilder(name, "input");
|
||||
builderFn(typeBuilder);
|
||||
const typeDef = typeBuilder.build();
|
||||
const merged: TypeDefinition = {
|
||||
name: typeDef.name,
|
||||
kind: typeDef.kind,
|
||||
fields: typeDef.fields,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
this.types.set(name, merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add an enum type to the schema. */
|
||||
public addEnumType(
|
||||
name: string,
|
||||
values: string[],
|
||||
description?: string
|
||||
): this {
|
||||
const def: TypeDefinition = {
|
||||
name,
|
||||
kind: "enum",
|
||||
fields: [],
|
||||
enumValues: [...values],
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
this.types.set(name, def);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add an interface type to the schema. */
|
||||
public addInterfaceType(
|
||||
name: string,
|
||||
builderFn: (builder: TypeBuilder) => TypeBuilder,
|
||||
description?: string
|
||||
): this {
|
||||
const typeBuilder = new TypeBuilder(name, "interface");
|
||||
builderFn(typeBuilder);
|
||||
const typeDef = typeBuilder.build();
|
||||
const merged: TypeDefinition = {
|
||||
name: typeDef.name,
|
||||
kind: typeDef.kind,
|
||||
fields: typeDef.fields,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
this.types.set(name, merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add a union type to the schema. */
|
||||
public addUnionType(
|
||||
name: string,
|
||||
memberTypes: string[],
|
||||
description?: string
|
||||
): this {
|
||||
const def: TypeDefinition = {
|
||||
name,
|
||||
kind: "union",
|
||||
fields: [],
|
||||
enumValues: memberTypes,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
this.types.set(name, def);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add a query field to the schema. */
|
||||
public addQuery(
|
||||
name: string,
|
||||
returnType: string,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
isList?: boolean;
|
||||
description?: string;
|
||||
args?: FieldDefinition[];
|
||||
}
|
||||
): this {
|
||||
this.queries.push(this.makeRootField(name, returnType, options));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add a mutation field to the schema. */
|
||||
public addMutation(
|
||||
name: string,
|
||||
returnType: string,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
isList?: boolean;
|
||||
description?: string;
|
||||
args?: FieldDefinition[];
|
||||
}
|
||||
): this {
|
||||
this.mutations.push(this.makeRootField(name, returnType, options));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add a subscription field to the schema. */
|
||||
public addSubscription(
|
||||
name: string,
|
||||
returnType: string,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
isList?: boolean;
|
||||
description?: string;
|
||||
args?: FieldDefinition[];
|
||||
}
|
||||
): this {
|
||||
this.subscriptions.push(this.makeRootField(name, returnType, options));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** True iff a type of the given name has been registered. */
|
||||
public hasType(name: string): boolean {
|
||||
return this.types.has(name);
|
||||
}
|
||||
|
||||
/** Read a type by name or `undefined` if missing. */
|
||||
public getType(name: string): TypeDefinition | undefined {
|
||||
return this.types.get(name);
|
||||
}
|
||||
|
||||
/** Number of types declared so far. */
|
||||
public get typeCount(): number {
|
||||
return this.types.size;
|
||||
}
|
||||
|
||||
/** Number of queries declared so far. */
|
||||
public get queryCount(): number {
|
||||
return this.queries.length;
|
||||
}
|
||||
|
||||
/** Number of mutations declared so far. */
|
||||
public get mutationCount(): number {
|
||||
return this.mutations.length;
|
||||
}
|
||||
|
||||
/** Snapshot the schema into an immutable-by-convention shape. */
|
||||
public build(): SchemaDefinition {
|
||||
return {
|
||||
types: Array.from(this.types.values()),
|
||||
queries: [...this.queries],
|
||||
mutations: [...this.mutations],
|
||||
subscriptions: [...this.subscriptions],
|
||||
};
|
||||
}
|
||||
|
||||
private makeRootField(
|
||||
name: string,
|
||||
returnType: string,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
isList?: boolean;
|
||||
description?: string;
|
||||
args?: FieldDefinition[];
|
||||
}
|
||||
): FieldDefinition {
|
||||
const field: {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
isList: boolean;
|
||||
description?: string;
|
||||
args?: FieldDefinition[];
|
||||
} = {
|
||||
name,
|
||||
type: returnType,
|
||||
required: options?.required ?? false,
|
||||
isList: options?.isList ?? false,
|
||||
};
|
||||
if (options?.description !== undefined)
|
||||
field.description = options.description;
|
||||
if (options?.args !== undefined) field.args = options.args;
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DefineGraphQLPluginOptionsSchema } from "./options.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# schemas/options
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines `DefineGraphQLPluginOptionsSchema`, the Zod schema wired at the package boundary inside `defineGraphQLPlugin`. The schema enforces that the four scalar config knobs (`basePath`, `enableIntrospection`, `enablePlayground`, `maxDepth`) and the structural fields (`name`, `version`, `schema`, `resolvers`, `hooks`) are well-formed before any defaults are merged. Validation failure raises `GraphQLError(GRAPHQL.INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
|
||||
This boundary check is the only public-input gate in the package. The rest of the surface accepts strongly typed values (e.g. `SchemaBuilder.build()` already returns a `SchemaDefinition` whose structure is enforced by TypeScript); it would be redundant to re-validate those shapes deeply at runtime. The schema therefore validates `schema.types` / `queries` / `mutations` / `subscriptions` shallowly (`array(unknown)`) — the heavy lifting of structural correctness happens in `SchemaBuilder`.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates required `name` / `version` strings (non-empty).
|
||||
- Validates optional `basePath` (non-empty), `enableIntrospection` (bool), `enablePlayground` (bool), `maxDepth` (positive int).
|
||||
- Validates the four schema container arrays exist.
|
||||
- Accepts a `resolvers` record-of-records.
|
||||
- Accepts an optional `hooks` object with three optional function fields.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DefineGraphQLPluginOptionsSchema } from "@suaveplan/api-plugin-graphql/schemas/options";
|
||||
|
||||
const parsed = DefineGraphQLPluginOptionsSchema.safeParse(userInput);
|
||||
if (!parsed.success) handle(parsed.error);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The schema is intentionally exported as a subpath so consumers building their own descriptors (e.g. a code-generator that emits options) can validate intermediate values without spinning up the full `defineGraphQLPlugin` factory:
|
||||
|
||||
```ts
|
||||
import { DefineGraphQLPluginOptionsSchema } from "@suaveplan/api-plugin-graphql/schemas/options";
|
||||
|
||||
function buildOptionsFromConfig(c: AppConfig) {
|
||||
const candidate = {
|
||||
name: c.gqlName,
|
||||
version: c.gqlVersion,
|
||||
basePath: c.gqlBasePath,
|
||||
enableIntrospection: c.env !== "production",
|
||||
enablePlayground: c.env !== "production",
|
||||
maxDepth: c.gqlMaxDepth,
|
||||
schema: schemaBuilder.build(),
|
||||
resolvers: composer.build(),
|
||||
};
|
||||
return DefineGraphQLPluginOptionsSchema.parse(candidate);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `DefineGraphQLPluginOptionsSchema` | `z.ZodObject` | Use `.parse(input)` or `.safeParse(input)`. Invalid → `ZodError`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`z.record(z.string(), z.record(z.string(), z.unknown()))` is the most accurate runtime shape — Zod cannot represent the static `ResolverFn` signature at runtime because functions cannot be deep-introspected. The `defineGraphQLPlugin` factory casts the parsed value through to the strongly-typed `DefineGraphQLPluginOptions` (defined in `plugin/plugin.ts`).
|
||||
|
||||
The boundary check exists once and only once at the public input. Internal helpers (`SchemaBuilder`, `ResolverComposer`) trust their inputs because their constructors / methods are typed.
|
||||
@@ -0,0 +1,48 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `defineGraphQLPlugin(options)` input.
|
||||
*
|
||||
* Wired at the package boundary so invalid configurations fail fast with
|
||||
* `GraphQLError(GRAPHQL.INVALID_INPUT)`. Schema is intentionally
|
||||
* permissive on `schema` / `resolvers` shape (they are validated by
|
||||
* `SchemaBuilder` / `ResolverComposer` at construction time) and strict
|
||||
* on the scalar option fields.
|
||||
*/
|
||||
export const DefineGraphQLPluginOptionsSchema = z.object({
|
||||
name: z.string().min(1, "name must be a non-empty string"),
|
||||
version: z.string().min(1, "version must be a non-empty string"),
|
||||
basePath: z.string().min(1).optional(),
|
||||
enableIntrospection: z.boolean().optional(),
|
||||
enablePlayground: z.boolean().optional(),
|
||||
maxDepth: z.number().int().positive().optional(),
|
||||
schema: z.object({
|
||||
types: z.array(z.unknown()),
|
||||
queries: z.array(z.unknown()),
|
||||
mutations: z.array(z.unknown()),
|
||||
subscriptions: z.array(z.unknown()),
|
||||
}),
|
||||
resolvers: z.record(z.string(), z.record(z.string(), z.unknown())),
|
||||
hooks: z
|
||||
.object({
|
||||
onInit: z
|
||||
.custom<() => void | Promise<void>>(
|
||||
(v) => typeof v === "function",
|
||||
"onInit must be a function"
|
||||
)
|
||||
.optional(),
|
||||
onReady: z
|
||||
.custom<() => void | Promise<void>>(
|
||||
(v) => typeof v === "function",
|
||||
"onReady must be a function"
|
||||
)
|
||||
.optional(),
|
||||
onClose: z
|
||||
.custom<() => void | Promise<void>>(
|
||||
(v) => typeof v === "function",
|
||||
"onClose must be a function"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { TypeBuilder } from "./type-builder.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# type-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `TypeBuilder` class — the inner builder `SchemaBuilder` hands to user-supplied callbacks when declaring object, input, or interface types. `TypeBuilder` accumulates a list of `FieldDefinition` records and freezes them into a `TypeDefinition` via `build()`. It is the single chokepoint for adding fields to a GraphQL type in the DSL, so every default (`required: false`, `isList: false`) is enforced here.
|
||||
|
||||
The builder is intentionally tiny — fluent `.field(...)` chaining and a final `build()`. Heavier concerns (type-level validation, dedup, SDL emission) live elsewhere; `TypeBuilder` just accumulates structurally well-formed records.
|
||||
|
||||
## Features
|
||||
|
||||
- Chainable `field(name, type, options?)`.
|
||||
- `required` and `isList` default to `false`.
|
||||
- Optional `description`, `defaultValue`, `args`.
|
||||
- `fieldCount` getter.
|
||||
- `build()` returns a fresh array per call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TypeBuilder } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const userType = new TypeBuilder("User", "object")
|
||||
.field("id", "ID", { required: true })
|
||||
.field("email", "String", { required: true })
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TypeBuilder } from "@suaveplan/api-plugin-graphql";
|
||||
import type { FieldDefinition } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const limitArg: FieldDefinition = { name: "limit", type: "Int", required: false, isList: false };
|
||||
|
||||
const postType = new TypeBuilder("Post", "object")
|
||||
.field("id", "ID", { required: true })
|
||||
.field("title", "String", { required: true, description: "Post title" })
|
||||
.field("tags", "String", { isList: true, defaultValue: [] })
|
||||
.field("comments", "Comment", { isList: true, args: [limitArg] })
|
||||
.build();
|
||||
|
||||
console.log(postType.fields.length); // 4
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `new TypeBuilder(name, kind)` | constructor | `kind` is one of `"object"`/`"input"`/`"interface"`. Other kinds (`enum`, `union`, `scalar`) bypass `TypeBuilder`. |
|
||||
| `.field(name, type, options?)` | method | Chainable. Pushes a `FieldDefinition`. |
|
||||
| `.fieldCount` | getter | Number of fields declared so far. |
|
||||
| `.build()` | method | Returns a `TypeDefinition` snapshot. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor signature accepts the full `TypeKind` union for forward compatibility, but `SchemaBuilder` only instantiates `TypeBuilder` with `"object"`/`"input"`/`"interface"` — the other kinds are stored as bare `TypeDefinition` records because they have no field list.
|
||||
|
||||
`required` and `isList` defaults match upstream behavior. Callers who want non-null by default should pass `{ required: true }` explicitly per-field — there is intentionally no schema-wide "non-null by default" knob; that would diverge from upstream.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TypeBuilder } from "./type-builder.js";
|
||||
|
||||
describe("TypeBuilder", () => {
|
||||
it("should build fields with all options", { timeout: 5000 }, () => {
|
||||
const builder = new TypeBuilder("Test", "object");
|
||||
builder.field("items", "Item", {
|
||||
required: true,
|
||||
isList: true,
|
||||
description: "List of items",
|
||||
defaultValue: [],
|
||||
args: [
|
||||
{ name: "limit", type: "Int", required: false, isList: false },
|
||||
],
|
||||
});
|
||||
expect(builder.fieldCount).toBe(1);
|
||||
const typeDef = builder.build();
|
||||
expect(typeDef.name).toBe("Test");
|
||||
expect(typeDef.kind).toBe("object");
|
||||
expect(typeDef.fields[0]?.isList).toBe(true);
|
||||
expect(typeDef.fields[0]?.description).toBe("List of items");
|
||||
expect(typeDef.fields[0]?.defaultValue).toEqual([]);
|
||||
expect(typeDef.fields[0]?.args).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should default required and isList to false", { timeout: 5000 }, () => {
|
||||
const builder = new TypeBuilder("T", "input");
|
||||
builder.field("x", "String");
|
||||
const typeDef = builder.build();
|
||||
expect(typeDef.fields[0]?.required).toBe(false);
|
||||
expect(typeDef.fields[0]?.isList).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
FieldDefinition,
|
||||
TypeDefinition,
|
||||
TypeKind,
|
||||
} from "../types/graphql.types.js";
|
||||
|
||||
/**
|
||||
* Builder for declaring fields inside a GraphQL type definition.
|
||||
*
|
||||
* `TypeBuilder` is the inner builder consumed by `SchemaBuilder` —
|
||||
* callers receive an instance inside the `(builder) => …` callback
|
||||
* passed to `SchemaBuilder.addType` / `addInputType` / `addInterfaceType`.
|
||||
* `field(...)` is chainable; the final `TypeDefinition` is produced by
|
||||
* `build()`.
|
||||
*
|
||||
* Fields default to `required: false`, `isList: false` to match upstream.
|
||||
*/
|
||||
export class TypeBuilder {
|
||||
private readonly typeName: string;
|
||||
private readonly kind: TypeKind;
|
||||
private readonly fields: FieldDefinition[] = [];
|
||||
|
||||
public constructor(name: string, kind: TypeKind) {
|
||||
this.typeName = name;
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a field on this type.
|
||||
*
|
||||
* `options.required` defaults to `false` (nullable).
|
||||
* `options.isList` defaults to `false` (scalar / single value).
|
||||
*/
|
||||
public field(
|
||||
name: string,
|
||||
type: string,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
isList?: boolean;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
args?: FieldDefinition[];
|
||||
}
|
||||
): this {
|
||||
const base: {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
isList: boolean;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
args?: FieldDefinition[];
|
||||
} = {
|
||||
name,
|
||||
type,
|
||||
required: options?.required ?? false,
|
||||
isList: options?.isList ?? false,
|
||||
};
|
||||
if (options?.description !== undefined)
|
||||
base.description = options.description;
|
||||
if (options?.defaultValue !== undefined)
|
||||
base.defaultValue = options.defaultValue;
|
||||
if (options?.args !== undefined) base.args = options.args;
|
||||
this.fields.push(base);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Read-only count of declared fields. */
|
||||
public get fieldCount(): number {
|
||||
return this.fields.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the declared fields into an immutable-by-convention
|
||||
* `TypeDefinition`. Each call returns an independent array so the
|
||||
* builder may be reused for incremental schema construction.
|
||||
*/
|
||||
public build(): TypeDefinition {
|
||||
return {
|
||||
name: this.typeName,
|
||||
kind: this.kind,
|
||||
fields: [...this.fields],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# graphql.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the full public type surface for `@suaveplan/api-plugin-graphql`. Every other module in the package consumes its symbols from this file, so the file behaves as the contract between schema construction, resolver composition, plugin definition, and event emission.
|
||||
|
||||
These types are intentionally description-layer only: they capture the **shape** of a GraphQL API (types, fields, queries, mutations, subscriptions, resolvers, middleware, plugin config) without invoking `graphql-js` or any executor. The package preserves the upstream contract verbatim so consumers can swap implementations without code change.
|
||||
|
||||
## Features
|
||||
|
||||
- `SchemaDefinition`, `TypeDefinition`, `FieldDefinition`, `TypeKind`, `ScalarType` — describe the schema graph.
|
||||
- `QueryContext`, `ResolverFn`, `ResolverInfo`, `ResolverMap`, `ResolverMiddleware` — describe resolver runtime.
|
||||
- `GraphQLPluginConfig`, `GraphQLPluginHooks` — describe the plugin descriptor.
|
||||
- `GraphQLQueryExecutedEvent` + `GraphQLEventMap` — describe the observability channel.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SchemaDefinition, ResolverMap } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const schema: SchemaDefinition = {
|
||||
types: [{ name: "User", kind: "object", fields: [{ name: "id", type: "ID", required: true, isList: false }] }],
|
||||
queries: [{ name: "user", type: "User", required: false, isList: false }],
|
||||
mutations: [],
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
const resolvers: ResolverMap = {
|
||||
Query: { user: (_p, args) => ({ id: args.id }) },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ResolverMiddleware, QueryContext, ResolverInfo } from "@suaveplan/api-plugin-graphql";
|
||||
|
||||
const trace: ResolverMiddleware = async (next, p, a, c: QueryContext, i: ResolverInfo) => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
return await next(p, a, c, i);
|
||||
} finally {
|
||||
console.log(`${i.parentType}.${i.fieldName} ${(performance.now() - t0).toFixed(2)}ms`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `GraphQLPluginConfig` | interface | Output of `defineGraphQLPlugin`, includes `name`, `version`, `basePath`, `enableIntrospection`, `enablePlayground`, `maxDepth`, `schema`, `resolvers`. |
|
||||
| `ScalarType` | union | `"String"`/`"Int"`/`"Float"`/`"Boolean"`/`"ID"`/`"DateTime"`/`"JSON"`. |
|
||||
| `FieldDefinition` | interface | `name`, `type`, `required`, `isList`, optional `description`, `defaultValue`, `args`. |
|
||||
| `TypeDefinition` | interface | `name`, `kind`, `fields`, optional `enumValues`, `description`, `interfaces`. |
|
||||
| `TypeKind` | union | `"object"`/`"input"`/`"enum"`/`"interface"`/`"union"`/`"scalar"`. |
|
||||
| `QueryContext` | interface | `user?`, `headers`, `data`. |
|
||||
| `ResolverFn` | type | `(parent, args, context, info) => unknown \| Promise<unknown>`. |
|
||||
| `ResolverInfo` | interface | `fieldName`, `parentType`, `returnType`. |
|
||||
| `ResolverMap` | type | `Record<typeName, Record<fieldName, ResolverFn>>`. |
|
||||
| `ResolverMiddleware` | type | wraps `ResolverFn` with a `next` parameter. |
|
||||
| `SchemaDefinition` | interface | `types`/`queries`/`mutations`/`subscriptions`. |
|
||||
| `GraphQLPluginHooks` | interface | `onInit?`/`onReady?`/`onClose?`. |
|
||||
| `GraphQLQueryExecutedEvent` | interface | `operationName`/`operationType`/`durationMs`/`hasErrors`/`timestamp`. |
|
||||
| `GraphQLEventMap` | interface | `"graphql:query:executed": GraphQLQueryExecutedEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interfaces are `readonly` where upstream was `readonly`; `GraphQLQueryExecutedEvent` is intentionally writable because it is constructed once per resolver invocation and immediately handed to the event bus.
|
||||
|
||||
`GraphQLEventMap` is consumed by `EventBus<GraphQLEventMap>` from `@suaveplan/events`, providing typed `bus.on("graphql:query:executed", payload => …)`.
|
||||
|
||||
This file ships **only** type declarations — no runtime emit. The build's `tsup`/`vite` output strips this module entirely.
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Public type surface for `@suaveplan/api-plugin-graphql`.
|
||||
*
|
||||
* Mirrors the upstream contract from `../suaveplan/packages/server/api-plugin-graphql/src/types/graphql.types.ts`
|
||||
* verbatim. The shapes are description-layer only — no runtime executor is implied.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for the GraphQL plugin (the value attached to
|
||||
* `GraphQLPluginDefinition.config`).
|
||||
*/
|
||||
export interface GraphQLPluginConfig {
|
||||
/** Plugin name identifier */
|
||||
readonly name: string;
|
||||
/** Plugin version */
|
||||
readonly version: string;
|
||||
/** Base path for the GraphQL endpoint */
|
||||
readonly basePath: string;
|
||||
/** Whether to enable introspection */
|
||||
readonly enableIntrospection: boolean;
|
||||
/** Whether to enable the GraphiQL playground */
|
||||
readonly enablePlayground: boolean;
|
||||
/** Maximum query depth allowed */
|
||||
readonly maxDepth: number;
|
||||
/** Schema definition */
|
||||
readonly schema: SchemaDefinition;
|
||||
/** Resolver map */
|
||||
readonly resolvers: ResolverMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scalar type names supported by the schema builder.
|
||||
*/
|
||||
export type ScalarType =
|
||||
| "String"
|
||||
| "Int"
|
||||
| "Float"
|
||||
| "Boolean"
|
||||
| "ID"
|
||||
| "DateTime"
|
||||
| "JSON";
|
||||
|
||||
/**
|
||||
* A field definition within a type.
|
||||
*/
|
||||
export interface FieldDefinition {
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
readonly required: boolean;
|
||||
readonly isList: boolean;
|
||||
readonly description?: string;
|
||||
readonly defaultValue?: unknown;
|
||||
readonly args?: FieldDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A type definition (object, input, enum, interface, union, scalar).
|
||||
*/
|
||||
export interface TypeDefinition {
|
||||
readonly name: string;
|
||||
readonly kind: TypeKind;
|
||||
readonly fields: FieldDefinition[];
|
||||
readonly enumValues?: string[];
|
||||
readonly description?: string;
|
||||
readonly interfaces?: string[];
|
||||
}
|
||||
|
||||
/** Supported type kinds. */
|
||||
export type TypeKind =
|
||||
| "object"
|
||||
| "input"
|
||||
| "enum"
|
||||
| "interface"
|
||||
| "union"
|
||||
| "scalar";
|
||||
|
||||
/**
|
||||
* Context available to resolvers during query execution.
|
||||
*/
|
||||
export interface QueryContext {
|
||||
readonly user?: { id: string; roles: string[] };
|
||||
readonly headers: Record<string, string>;
|
||||
readonly data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A resolver function signature. */
|
||||
export type ResolverFn = (
|
||||
parent: unknown,
|
||||
args: Record<string, unknown>,
|
||||
context: QueryContext,
|
||||
info: ResolverInfo
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
/** Minimal resolver info passed to resolver functions. */
|
||||
export interface ResolverInfo {
|
||||
readonly fieldName: string;
|
||||
readonly parentType: string;
|
||||
readonly returnType: string;
|
||||
}
|
||||
|
||||
/** A map of type names to their field resolvers. */
|
||||
export type ResolverMap = Record<string, Record<string, ResolverFn>>;
|
||||
|
||||
/** Complete schema definition. */
|
||||
export interface SchemaDefinition {
|
||||
readonly types: TypeDefinition[];
|
||||
readonly queries: FieldDefinition[];
|
||||
readonly mutations: FieldDefinition[];
|
||||
readonly subscriptions: FieldDefinition[];
|
||||
}
|
||||
|
||||
/** Middleware function that wraps a resolver. */
|
||||
export type ResolverMiddleware = (
|
||||
next: ResolverFn,
|
||||
parent: unknown,
|
||||
args: Record<string, unknown>,
|
||||
context: QueryContext,
|
||||
info: ResolverInfo
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
/** Plugin lifecycle hooks shape (compatible with definePlugin pattern). */
|
||||
export interface GraphQLPluginHooks {
|
||||
onInit?: () => void | Promise<void>;
|
||||
onReady?: () => void | Promise<void>;
|
||||
onClose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** GraphQL query executed event payload. */
|
||||
export interface GraphQLQueryExecutedEvent {
|
||||
operationName: string | null;
|
||||
operationType: string;
|
||||
durationMs: number;
|
||||
hasErrors: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/** GraphQL event map for domain events. */
|
||||
export type GraphQLEventMap = {
|
||||
"graphql:query:executed": GraphQLQueryExecutedEvent;
|
||||
} & Record<string, unknown>;
|
||||
16
packages/api-stack/api/api-plugin-graphql/src/types/index.ts
Normal file
16
packages/api-stack/api/api-plugin-graphql/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
FieldDefinition,
|
||||
GraphQLEventMap,
|
||||
GraphQLPluginConfig,
|
||||
GraphQLPluginHooks,
|
||||
GraphQLQueryExecutedEvent,
|
||||
QueryContext,
|
||||
ResolverFn,
|
||||
ResolverInfo,
|
||||
ResolverMap,
|
||||
ResolverMiddleware,
|
||||
ScalarType,
|
||||
SchemaDefinition,
|
||||
TypeDefinition,
|
||||
TypeKind,
|
||||
} from "./graphql.types.js";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/api-plugin-graphql.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
|
||||
* source module is imported, so the meter resolved at module load is
|
||||
* the real provider's meter — not the noop fallback. Tests that
|
||||
* inspect emitted metrics can read them out of
|
||||
* `testInMemoryMetricExporter`.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-graphql/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-graphql/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-graphql/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-graphql/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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-plugin-graphql/vite.config.ts
Normal file
30
packages/api-stack/api/api-plugin-graphql/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-graphql",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^@opentelemetry\//, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
34
packages/api-stack/api/api-plugin-graphql/vitest.config.ts
Normal file
34
packages/api-stack/api/api-plugin-graphql/vitest.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
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/types/graphql.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"src/api-plugin-graphql.spec-conformance.test.ts",
|
||||
"tests/**"
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100
|
||||
}
|
||||
},
|
||||
testTimeout: 30000
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user