# Capability Spec Delta: api-plugin-graphql (add-api-plugin-graphql) **Package**: `@suaveplan/api-plugin-graphql` **Status**: ACCEPTED ## 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` (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`. #### 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` 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` **Verified by:** `src/api-plugin-graphql.spec-conformance.test.ts::"public types are generic-safe and event payload is typed"`