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

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 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"