214 lines
16 KiB
Markdown
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 -->
|