Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin-graphql/specs/api-plugin-graphql/spec.md
2026-05-27 23:29:53 +01:00

214 lines
16 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-graphql (add-api-plugin-graphql)
**Package**: `@suaveplan/api-plugin-graphql`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: defineGraphQLPlugin produces a typed GraphQLPluginDefinition with defaulted config
The package SHALL expose `defineGraphQLPlugin(options)` returning a `GraphQLPluginDefinition` whose `config` field has `basePath`, `enableIntrospection`, `enablePlayground`, and `maxDepth` populated from caller-supplied values when present and from the package defaults (`/graphql`, `true`, `true`, `10`) otherwise. The returned object SHALL expose `name`, `config`, `hooks`, `getSchema()`, and `getResolvers()`.
#### Scenario: defaults applied when options omit config fields
- **WHEN** caller invokes `defineGraphQLPlugin({ name, version, schema, resolvers })` with no `basePath` / `enableIntrospection` / `enablePlayground` / `maxDepth`
- **THEN** `definition.config.basePath === "/graphql"`, `enableIntrospection === true`, `enablePlayground === true`, `maxDepth === 10`
#### Scenario: caller overrides defaults
- **WHEN** caller passes explicit values for all four
- **THEN** `definition.config` reflects every override verbatim
#### Scenario: lifecycle hooks pass through unchanged
- **WHEN** caller supplies `hooks: { onInit, onReady, onClose }`
- **THEN** `definition.hooks` exposes the same callable references
**Verified by:** `src/plugin/plugin.test.ts::"should create a plugin with default config"` AND `src/plugin/plugin.test.ts::"should override default config values"` AND `src/plugin/plugin.test.ts::"should include lifecycle hooks"` AND `src/plugin/plugin.test.ts::"should handle missing hooks gracefully"` AND `src/plugin/plugin.test.ts::"should include onClose hook"` AND `src/plugin/plugin.test.ts::"should return schema via getSchema"` AND `src/plugin/plugin.test.ts::"should return resolvers via getResolvers"` AND `src/plugin/plugin.test.ts::"should include version in config"`
---
### Requirement: defineGraphQLPlugin Zod-validates its options at the boundary
The package SHALL run `DefineGraphQLPluginOptionsSchema.safeParse(options)` inside `defineGraphQLPlugin` and SHALL throw `GraphQLError(GRAPHQL.INVALID_INPUT)` carrying the `ZodError` as `cause` when validation fails.
#### Scenario: invalid options reject
- **WHEN** caller passes a value missing `name` / `version` / `schema` / `resolvers` or with a non-string `name`
- **THEN** the call throws `GraphQLError` with `error.code === "GRAPHQL.INVALID_INPUT"` and `error.cause instanceof z.ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"throws GRAPHQL.INVALID_INPUT when options fail Zod validation"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INVALID_INPUT"`
---
### Requirement: SchemaBuilder constructs SchemaDefinition without raw SDL
The package SHALL expose `SchemaBuilder` with `addType`, `addInputType`, `addEnumType`, `addInterfaceType`, `addUnionType`, `addQuery`, `addMutation`, `addSubscription`, `hasType`, `getType`, the read-only counters (`typeCount`, `queryCount`, `mutationCount`), and `build()` returning a `SchemaDefinition` whose `types` / `queries` / `mutations` / `subscriptions` arrays are independent snapshots.
#### Scenario: object / input / enum / interface / union types
- **WHEN** the caller invokes each `add*Type` method
- **THEN** `getType(name).kind` returns the matching kind ("object" | "input" | "enum" | "interface" | "union")
#### Scenario: query / mutation / subscription registration
- **WHEN** caller invokes `addQuery` / `addMutation` / `addSubscription` with `required`, `isList`, `description`, `args` options
- **THEN** the corresponding entry in `build().{queries,mutations,subscriptions}` carries every option verbatim, with `required` / `isList` defaulting to `false`
#### Scenario: description + interfaces on object type
- **WHEN** `addType(name, builder, { description, interfaces })` runs
- **THEN** `getType(name)` returns `{description, interfaces}` matching the input
**Verified by:** `src/schema-builder/schema-builder.test.ts::"should add an object type"` AND `src/schema-builder/schema-builder.test.ts::"should support description and interfaces"` AND `src/schema-builder/schema-builder.test.ts::"should build type with correct kind"` AND `src/schema-builder/schema-builder.test.ts::"should add an input type"` AND `src/schema-builder/schema-builder.test.ts::"should support description"` AND `src/schema-builder/schema-builder.test.ts::"should add an enum type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (enum)"` AND `src/schema-builder/schema-builder.test.ts::"should add an interface type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (interface)"` AND `src/schema-builder/schema-builder.test.ts::"should add a union type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (union)"` AND `src/schema-builder/schema-builder.test.ts::"should add a query field"` AND `src/schema-builder/schema-builder.test.ts::"should support args and description"` AND `src/schema-builder/schema-builder.test.ts::"should default required and isList to false"` AND `src/schema-builder/schema-builder.test.ts::"should add a mutation field"` AND `src/schema-builder/schema-builder.test.ts::"should support full options on mutation"` AND `src/schema-builder/schema-builder.test.ts::"should add a subscription field"` AND `src/schema-builder/schema-builder.test.ts::"should support full options on subscription"` AND `src/schema-builder/schema-builder.test.ts::"should return undefined for unknown type"` AND `src/schema-builder/schema-builder.test.ts::"should produce a complete schema definition"`
---
### Requirement: TypeBuilder chains field declarations
The package SHALL expose `TypeBuilder` with a `field(name, type, options?)` chainable method, a `fieldCount` getter, and a `build()` that returns a `TypeDefinition` with the configured `name`, `kind`, and an independent `fields` array. `required` and `isList` SHALL default to `false`.
#### Scenario: field with every option
- **WHEN** caller calls `field("items", "Item", { required: true, isList: true, description, defaultValue, args })`
- **THEN** the resulting `TypeDefinition.fields[0]` carries every option verbatim
#### Scenario: defaults
- **WHEN** caller calls `field("x", "String")` with no options
- **THEN** the resulting field has `required === false` and `isList === false`
**Verified by:** `src/type-builder/type-builder.test.ts::"should build fields with all options"` AND `src/type-builder/type-builder.test.ts::"should default required and isList to false"`
---
### Requirement: ResolverComposer composes middleware around resolver functions
The package SHALL expose `ResolverComposer` with `use(mw)`, `useForType(type, mw)`, `addResolver(type, field, fn)`, `addResolvers(type, map)`, `hasResolver(type, field)`, `typeCount`, `resolverCount`, and `build()` returning a `ResolverMap`. Composition SHALL apply global middleware first, then per-type middleware, then the resolver — equivalent to right-to-left wrapping. Async resolvers SHALL be awaited transparently through the chain.
#### Scenario: addResolver / addResolvers / hasResolver
- **WHEN** caller registers resolvers via either method
- **THEN** `hasResolver(type, field)` returns `true` for every registered slot and `false` otherwise
#### Scenario: global middleware chain
- **WHEN** two global middlewares wrap a resolver
- **THEN** invoking the built resolver calls middleware-1, middleware-2, the resolver, in that order
#### Scenario: per-type middleware only applies to that type
- **WHEN** `useForType("Mutation", mw)` is set and both Query and Mutation resolvers run
- **THEN** the middleware fires only for the Mutation resolver
#### Scenario: combined global + type middleware
- **WHEN** both a global and a type middleware are set
- **THEN** global runs before type, type runs before resolver
#### Scenario: async resolver awaited
- **WHEN** the registered resolver returns a Promise
- **THEN** `await map.Query.fieldName(...)` resolves to the awaited value
**Verified by:** `src/resolver/resolver.test.ts::"should add a resolver"` AND `src/resolver/resolver.test.ts::"should return false for non-existent resolver"` AND `src/resolver/resolver.test.ts::"should add multiple resolvers for a type"` AND `src/resolver/resolver.test.ts::"should build resolver map"` AND `src/resolver/resolver.test.ts::"should handle async resolvers"` AND `src/resolver/resolver.test.ts::"should apply global middleware to all resolvers"` AND `src/resolver/resolver.test.ts::"should chain multiple global middlewares"` AND `src/resolver/resolver.test.ts::"should apply type middleware only to that type"` AND `src/resolver/resolver.test.ts::"should apply global then type middleware"`
---
### Requirement: requireRoles middleware enforces authentication and role intersection
The package SHALL expose `requireRoles(...roles)` returning a `ResolverMiddleware` that throws `GraphQLError(GRAPHQL.UNAUTHENTICATED)` when `context.user` is absent and `GraphQLError(GRAPHQL.INSUFFICIENT_ROLE)` when the user's `roles` do not intersect the required set. When the check passes, control SHALL delegate to `next(parent, args, context, info)` unchanged.
#### Scenario: authenticated user with required role passes
- **WHEN** `context.user.roles` contains one of the required roles
- **THEN** the resolver runs and its return value flows through
#### Scenario: authenticated user with any-of roles passes
- **WHEN** the required set is `["admin", "editor"]` and the user has only `"editor"`
- **THEN** the resolver runs
#### Scenario: missing user throws UNAUTHENTICATED
- **WHEN** `context.user` is undefined
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.UNAUTHENTICATED"`
#### Scenario: insufficient role throws INSUFFICIENT_ROLE
- **WHEN** `context.user.roles` shares no element with the required set
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.INSUFFICIENT_ROLE"`
**Verified by:** `src/resolver/resolver.test.ts::"should pass when user has required role"` AND `src/resolver/resolver.test.ts::"should pass when user has any of required roles"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.UNAUTHENTICATED when user is not authenticated"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.INSUFFICIENT_ROLE when user lacks required roles"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.UNAUTHENTICATED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INSUFFICIENT_ROLE"`
---
### Requirement: validateArgs middleware enforces argument validation
The package SHALL expose `validateArgs(validator)` returning a `ResolverMiddleware` that calls `validator(args)`. When the validator returns a non-null string, the middleware SHALL throw `GraphQLError(GRAPHQL.VALIDATION_FAILED)` carrying the validator's message; when it returns `null`, control SHALL pass to `next`.
#### Scenario: validator returns null
- **WHEN** the validator returns `null`
- **THEN** the resolver runs and its return value flows through
#### Scenario: validator returns an error string
- **WHEN** the validator returns `"name is required"`
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.VALIDATION_FAILED"` and `.message` containing the validator's string
**Verified by:** `src/resolver/resolver.test.ts::"should pass when validation returns null"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.VALIDATION_FAILED when validation returns error string"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.VALIDATION_FAILED"`
---
### Requirement: createQueryExecutedMiddleware emits graphql:query:executed event and OTel instruments
The package SHALL expose `createQueryExecutedMiddleware(bus?)` returning a `ResolverMiddleware` that times resolver execution and emits a typed `graphql:query:executed` event onto the supplied `EventBus<GraphQLEventMap>` (or `graphqlEvents` when no bus is supplied). The payload SHALL include `operationName` (`info.fieldName`), `operationType` (`info.parentType`), `durationMs`, `hasErrors`, and `timestamp`. The middleware SHALL increment `graphql.resolvers_executed_total{outcome}` and record `graphql.resolver_duration_ms` for every invocation regardless of success or failure.
#### Scenario: successful invocation emits event with hasErrors=false
- **WHEN** the wrapped resolver returns normally
- **THEN** a `graphql:query:executed` event is observed with `hasErrors === false`, `operationName === info.fieldName`, and `durationMs >= 0`
#### Scenario: thrown resolver emits event with hasErrors=true and re-throws
- **WHEN** the wrapped resolver throws
- **THEN** the same event type fires with `hasErrors === true` AND the original error propagates
#### Scenario: OTel instruments record under real MeterProvider
- **WHEN** a real in-memory `MeterProvider` is installed and the middleware runs once successfully and once with a throw
- **THEN** `graphql.resolvers_executed_total` exposes data points with `outcome=success` and `outcome=error` AND `graphql.resolver_duration_ms` exposes histogram data points
**Verified by:** `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware emits graphql:query:executed on success"` AND `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware emits graphql:query:executed and re-throws on error"` AND `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware honors a caller-supplied EventBus"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"graphql.resolvers_executed_total + resolver_duration_ms recorded via real MeterProvider"`
---
### Requirement: error-codes registry exposes every GRAPHQL.* code as a thrown failure
The package SHALL define `GRAPHQL_CODES = defineCodes("GRAPHQL", [...])` with the six codes `UNAUTHENTICATED`, `INSUFFICIENT_ROLE`, `VALIDATION_FAILED`, `INVALID_INPUT`, `TYPE_NOT_FOUND`, `DUPLICATE_RESOLVER`. Every code SHALL be thrown from at least one production path. `GraphQLError extends SuaveplanError<GraphQLErrorCode>`.
#### Scenario: every code is reachable
- **WHEN** the conformance test triggers each documented failure path
- **THEN** every `GRAPHQL.*` code appears as the `code` field on a caught `GraphQLError`
**Verified by:** `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.UNAUTHENTICATED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INSUFFICIENT_ROLE"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.VALIDATION_FAILED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INVALID_INPUT"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.TYPE_NOT_FOUND"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.DUPLICATE_RESOLVER"`
---
### Requirement: type surface is preserved and the public API is generic-safe
The package SHALL export every type declared by upstream verbatim: `SchemaDefinition`, `TypeDefinition`, `TypeKind`, `FieldDefinition`, `ScalarType`, `QueryContext`, `ResolverFn`, `ResolverInfo`, `ResolverMiddleware`, `ResolverMap`, `GraphQLPluginConfig`, `GraphQLPluginHooks`, `GraphQLQueryExecutedEvent`, `GraphQLEventMap`, `DefineGraphQLPluginOptions`, `GraphQLPluginDefinition`. The `EventBus<GraphQLEventMap>` typing SHALL flow `graphql:query:executed` payloads through `bus.on(...)` listeners without casts.
#### Scenario: type-level event payload narrows
- **WHEN** `expectTypeOf` introspects `graphqlEvents.on("graphql:query:executed", listener)`
- **THEN** `listener` is typed `(payload: GraphQLQueryExecutedEvent) => void | Promise<void>`
**Verified by:** `src/api-plugin-graphql.spec-conformance.test.ts::"public types are generic-safe and event payload is typed"`
<!-- markdownlint-enable MD024 -->