16 KiB
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 nobasePath/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.configreflects every override verbatim
Scenario: lifecycle hooks pass through unchanged
- WHEN caller supplies
hooks: { onInit, onReady, onClose } - THEN
definition.hooksexposes 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/resolversor with a non-stringname - THEN the call throws
GraphQLErrorwitherror.code === "GRAPHQL.INVALID_INPUT"anderror.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*Typemethod - THEN
getType(name).kindreturns the matching kind ("object" | "input" | "enum" | "interface" | "union")
Scenario: query / mutation / subscription registration
- WHEN caller invokes
addQuery/addMutation/addSubscriptionwithrequired,isList,description,argsoptions - THEN the corresponding entry in
build().{queries,mutations,subscriptions}carries every option verbatim, withrequired/isListdefaulting tofalse
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 === falseandisList === 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)returnstruefor every registered slot andfalseotherwise
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.rolescontains 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.useris undefined - THEN the middleware throws
GraphQLErrorwithcode === "GRAPHQL.UNAUTHENTICATED"
Scenario: insufficient role throws INSUFFICIENT_ROLE
- WHEN
context.user.rolesshares no element with the required set - THEN the middleware throws
GraphQLErrorwithcode === "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
GraphQLErrorwithcode === "GRAPHQL.VALIDATION_FAILED"and.messagecontaining 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:executedevent is observed withhasErrors === false,operationName === info.fieldName, anddurationMs >= 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 === trueAND the original error propagates
Scenario: OTel instruments record under real MeterProvider
- WHEN a real in-memory
MeterProvideris installed and the middleware runs once successfully and once with a throw - THEN
graphql.resolvers_executed_totalexposes data points withoutcome=successandoutcome=errorANDgraphql.resolver_duration_msexposes 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 thecodefield on a caughtGraphQLError
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
expectTypeOfintrospectsgraphqlEvents.on("graphql:query:executed", listener) - THEN
listeneris 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"