first commit
This commit is contained in:
@@ -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,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.
|
||||
101
docs/api-stack/api/api-plugin-graphql/src/plugin/plugin.md
Normal file
101
docs/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,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,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.
|
||||
62
docs/api-stack/api/api-plugin-graphql/src/schemas/options.md
Normal file
62
docs/api-stack/api/api-plugin-graphql/src/schemas/options.md
Normal file
@@ -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,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,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.
|
||||
Reference in New Issue
Block a user