diff --git a/examples/production-app/graphql/directives.ts b/examples/production-app/graphql/directives.ts index 2530f56e..4a3be685 100644 --- a/examples/production-app/graphql/directives.ts +++ b/examples/production-app/graphql/directives.ts @@ -1,45 +1,26 @@ -import { defaultFieldResolver, GraphQLError, GraphQLSchema } from "graphql"; -import { Int } from "grats"; +import { GraphQLError, GraphQLFieldResolver } from "graphql"; +import { Int, FieldDirective } from "grats"; import { Ctx } from "../ViewerContext.js"; -import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils"; /** * Some fields cost credits to access. This directive specifies how many credits * a given field costs. * + * By returning `FieldDirective`, Grats will automatically wrap the resolver + * function with this directive's implementation — no manual `mapSchema` needed. + * * @gqlDirective cost on FIELD_DEFINITION */ -export function debitCredits(args: { credits: Int }, context: Ctx): void { - if (context.credits < args.credits) { - // Using `GraphQLError` here ensures the error is not masked by Yoga. - throw new GraphQLError( - `Insufficient credits remaining. This field cost ${args.credits} credits.`, - ); - } - context.credits -= args.credits; -} - -type CostArgs = { credits: Int }; - -// Monkey patches the `resolve` function of fields with the `@cost` directive -// to deduct credits from the user's account when the field is accessed. -export function applyCreditLimit(schema: GraphQLSchema): GraphQLSchema { - return mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const costDirective = getDirective(schema, fieldConfig, "cost", [ - "grats", - "directives", - ]); - if (costDirective == null || costDirective.length === 0) { - return fieldConfig; +export function debitCredits(args: { credits: Int }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + if (context.credits < args.credits) { + // Using `GraphQLError` here ensures the error is not masked by Yoga. + throw new GraphQLError( + `Insufficient credits remaining. This field cost ${args.credits} credits.`, + ); } - - const originalResolve = fieldConfig.resolve ?? defaultFieldResolver; - fieldConfig.resolve = (source, args, context, info) => { - debitCredits(costDirective[0] as CostArgs, context); - return originalResolve(source, args, context, info); - }; - return fieldConfig; - }, - }); + context.credits -= args.credits; + return next(source, resolverArgs, context, info); + }; } diff --git a/examples/production-app/schema.graphql b/examples/production-app/schema.graphql index 9f88e738..01a3864b 100644 --- a/examples/production-app/schema.graphql +++ b/examples/production-app/schema.graphql @@ -4,6 +4,9 @@ """ Some fields cost credits to access. This directive specifies how many credits a given field costs. + +By returning `FieldDirective`, Grats will automatically wrap the resolver +function with this directive's implementation — no manual `mapSchema` needed. """ directive @cost(credits: Int!) on FIELD_DEFINITION diff --git a/examples/production-app/schema.ts b/examples/production-app/schema.ts index b77fe4fb..571d4fd8 100644 --- a/examples/production-app/schema.ts +++ b/examples/production-app/schema.ts @@ -7,6 +7,7 @@ import type { GqlScalar } from "grats"; import type { GqlDate as DateInternal } from "./graphql/CustomScalars.js"; import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLList, GraphQLString, GraphQLScalarType, GraphQLID, GraphQLInterfaceType, GraphQLBoolean, GraphQLInputObjectType } from "graphql"; import { id as likeIdResolver, id as userIdResolver, id as postIdResolver, node as queryNodeResolver, nodes as queryNodesResolver } from "./graphql/Node.js"; +import { debitCredits, debitCredits as debitCredits_1 } from "./graphql/directives.js"; import { nodes as postConnectionNodesResolver, posts as queryPostsResolver } from "./models/PostConnection.js"; import { nodes as likeConnectionNodesResolver, likes as queryLikesResolver, postLikes as subscriptionPostLikesResolver } from "./models/LikeConnection.js"; import { getVc } from "./ViewerContext.js"; @@ -91,9 +92,9 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { }] } }, - resolve(source, args, _context, info) { + resolve: debitCredits({ credits: 10 })(function resolve(source, args, _context, info) { return source.likes(args, info); - } + }) }, publishedAt: { description: "The date and time at which the post was created.", @@ -388,9 +389,9 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { }] } }, - resolve(_source, args, context, info) { + resolve: debitCredits_1({ credits: 10 })(function resolve(_source, args, context, info) { return queryLikesResolver(args, getVc(context), info); - } + }) }, node: { description: "Fetch a single `Node` by its globally unique ID.", @@ -674,7 +675,7 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { directives: [...specifiedDirectives, new GraphQLDirective({ name: "cost", locations: [DirectiveLocation.FIELD_DEFINITION], - description: "Some fields cost credits to access. This directive specifies how many credits\na given field costs.", + description: "Some fields cost credits to access. This directive specifies how many credits\na given field costs.\n\nBy returning `FieldDirective`, Grats will automatically wrap the resolver\nfunction with this directive's implementation \u2014 no manual `mapSchema` needed.", args: { credits: { type: new GraphQLNonNull(GraphQLInt) diff --git a/examples/production-app/server.ts b/examples/production-app/server.ts index 39e3ecb8..9c432d24 100644 --- a/examples/production-app/server.ts +++ b/examples/production-app/server.ts @@ -4,10 +4,8 @@ import { getSchema } from "./schema.js"; import { VC } from "./ViewerContext.js"; import { scalarConfig } from "./graphql/CustomScalars.js"; import { useDeferStream } from "@graphql-yoga/plugin-defer-stream"; -import { applyCreditLimit } from "./graphql/directives.js"; -let schema = getSchema({ scalars: scalarConfig }); -schema = applyCreditLimit(schema); +const schema = getSchema({ scalars: scalarConfig }); const yoga = createYoga({ schema, diff --git a/llm-docs/docblock-tags/directive-annotations.md b/llm-docs/docblock-tags/directive-annotations.md index 57ebab77..92a1a6a6 100644 --- a/llm-docs/docblock-tags/directive-annotations.md +++ b/llm-docs/docblock-tags/directive-annotations.md @@ -43,7 +43,13 @@ While the GraphQL Spec does not actually specify that arguments passed to direct Directive annotations added to your schema will be included in Grats' generated `.graphql` file. For directives meant to be consumed by clients or other infrastructure, this should be sufficient. -For directives which are intended to be used during execution, they must be included in the `graphql-js` class `GraphQLSchema` which Grats generates. Unfortunately `GraphQLSchema` does not support a first-class mechanism for including directive annotations. To work around this, **Grats includes directives under as part of the relevant GraphQL class' `extensions` object namespaced under a `grats` key.** +For field directives that need to run logic at runtime (auth, rate limiting, logging, etc.), the recommended approach is to have your directive function return [`FieldDirective`](./directive-definitions.md#field-directive-wrappers). Grats will automatically wrap the field resolver with your directive function — no manual wiring needed. + +You can find an example of this in action in the [`production-app`](../examples/production-app.md) example where we define a field directive `@cost` which implements API rate limiting. + +### Manual directive access via extensions + +For directives on non-field locations, or when you need more control, Grats also includes directive annotations as part of the relevant GraphQL class' `extensions` object namespaced under a `grats` key: ```ts const foo = { @@ -60,4 +66,4 @@ const foo = { }; ``` -You can find an example of this in action in the [`production-app`](../examples/production-app.md) example where we define a field directive `@cost` which implements API rate limiting. +This can be consumed using tools like `@graphql-tools/utils` with `mapSchema` and `getDirective`. diff --git a/llm-docs/docblock-tags/directive-definitions.md b/llm-docs/docblock-tags/directive-definitions.md index ecf01bc1..95d1a90b 100644 --- a/llm-docs/docblock-tags/directive-definitions.md +++ b/llm-docs/docblock-tags/directive-definitions.md @@ -14,7 +14,7 @@ function cost(args: { credits: Int }) { } ``` -While the directive is defined as a function, unlike field resolvers, _Grats will not invoke your function_. However, having a function whose type matches the arguments of your directive can be useful for writing code which will accept the arguments of your directive. +By default, the directive is defined as a metadata-only function — Grats will not invoke it. However, having a function whose type matches the arguments of your directive can be useful for writing code which will accept the arguments of your directive. To annotate part of your schema with a directive, see [`@gqlAnnotate`](./directive-annotations.md). @@ -72,3 +72,25 @@ function myDirective(args: { // ... } ``` + +## Field Directive Wrappers + +For directives on `FIELD_DEFINITION` that need to execute logic at runtime (e.g. auth checks, rate limiting, logging), you can have your directive function return `FieldDirective` from `grats`. When Grats sees this return type, it will automatically wrap the field's resolver with your directive function — no manual `mapSchema` wiring required. + +```tsx +import { Int, FieldDirective } from "grats"; +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next) => (source, args, context, info) => { + // Custom logic runs before the resolver + return next(source, args, context, info); + }; +} +``` + +The directive function is called with its arguments and must return a function that takes the next resolver and returns a wrapped resolver. Multiple `FieldDirective` directives on the same field compose naturally — the outermost directive in the annotation list wraps first. + +For directives that are purely metadata (consumed by clients or infrastructure rather than during execution), omit the return type or return `void`. diff --git a/llm-docs/guides/permissions.md b/llm-docs/guides/permissions.md index e5c459be..8d5ed54e 100644 --- a/llm-docs/guides/permissions.md +++ b/llm-docs/guides/permissions.md @@ -81,11 +81,13 @@ class User { ## Permission Directives -If you do decide to enforce permissions in the GraphQL layer, one approach is to use [Directives](https://graphql.org/learn/queries/#directives) to annotate fields with the permissions required to access them, and then use a custom [Schema Directive Visitor](https://www.apollographql.com/docs/graphql-tools/schema-directives/) to wrap the field resolvers with permission checks. +If you do decide to enforce permissions in the GraphQL layer, one approach is to use [Directives](https://graphql.org/learn/queries/#directives) to annotate fields with the permissions required to access them. + +By returning [`FieldDirective`](../docblock-tags/directive-definitions.md#field-directive-wrappers) from your directive function, Grats will automatically wrap the field resolver with your permission check — no manual schema transformation needed. This approach means that the permission requirements end up visible in your generated GraphQL schema. It can be useful for clients to know what permissions are required to access certain fields, but in some cases permissions are not intended to be public knowledge, so be sure to consider whether this is appropriate for your use case. -Note that schema directives are not exposed through GraphqL introspection, so they will not be visible to clients who access the schema that way. +Note that schema directives are not exposed through GraphQL introspection, so they will not be visible to clients who access the schema that way. Usage on each restricted field looks like this: @@ -148,11 +150,11 @@ type User { } ``` -Then, after we create our schema, we can use `@graphql-tools/utils` to wrap each field annotated with the directives in a function which first applies the permission check: +The directive implementation uses `FieldDirective` to wrap the resolver with a permission check: ```ts -import { defaultFieldResolver, GraphQLError, GraphQLSchema } from "graphql"; -import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils"; +import { GraphQLError } from "graphql"; +import { FieldDirective } from "grats"; /** @gqlContext */ type Ctx = { @@ -167,36 +169,18 @@ export enum Role { } /** - * Indicates that a field require the specified roles to access. + * Indicates that a field requires the specified role to access. * @gqlDirective assert on FIELD_DEFINITION */ -export function requiresRole(args: { is: Role }, context: Ctx): void { - if (args.is !== context.role) { - // Using `GraphQLError` here ensures the error is not masked by Yoga. - throw new GraphQLError("You do not have permission to access this field."); - } -} - -// Monkey patches the `resolve` function of fields with the `@requiresRole` -export function applyRolePermissions(schema: GraphQLSchema): GraphQLSchema { - return mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const assertDirective = getDirective(schema, fieldConfig, "assert", [ - "grats", - "directives", - ]); - if (assertDirective == null || assertDirective.length === 0) { - return fieldConfig; - } - - const originalResolve = fieldConfig.resolve ?? defaultFieldResolver; - fieldConfig.resolve = (source, args, context, info) => { - requiresRole(assertDirective[0] as { is: Role }, context); - return originalResolve(source, args, context, info); - }; - return fieldConfig; - }, - }); +export function requiresRole(args: { is: Role }): FieldDirective { + return (next) => (source, resolverArgs, context: Ctx, info) => { + if (args.is !== context.role) { + throw new GraphQLError( + "You do not have permission to access this field.", + ); + } + return next(source, resolverArgs, context, info); + }; } ``` diff --git a/src/Extractor.ts b/src/Extractor.ts index 0c2d9d3d..c192cd68 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -556,16 +556,20 @@ class Extractor { name = this.gql.name(id, id.text); } - this.definitions.push( - this.gql.directiveDefinition( - node, - name, - args, - tagData.repeatable, - tagData.locations, - description, - ), + const directive = this.gql.directiveDefinition( + node, + name, + args, + tagData.repeatable, + tagData.locations, + description, ); + + // Store the TS function declaration so the resolveFieldDirectives + // transform can check the return type with the type checker. + directive.tsFunctionDeclaration = node; + + this.definitions.push(directive); } extractDirectiveArgs( diff --git a/src/GraphQLAstExtensions.ts b/src/GraphQLAstExtensions.ts index b940fad9..60516736 100644 --- a/src/GraphQLAstExtensions.ts +++ b/src/GraphQLAstExtensions.ts @@ -1,3 +1,4 @@ +import * as ts from "typescript"; import { ResolverSignature } from "./resolverSignature.js"; import { TsIdentifier } from "./utils/helpers.js"; @@ -88,6 +89,19 @@ declare module "graphql" { isAmbiguous?: boolean; } + export interface DirectiveDefinitionNode { + /** + * Grats metadata: Export information for directives that return FieldDirective. + * When present, the directive function wraps field resolvers at runtime. + */ + exported?: ExportDefinition; + /** + * Grats metadata: The TypeScript function declaration for this directive. + * Used by the resolveFieldDirectives transform to check the return type. + */ + tsFunctionDeclaration?: ts.FunctionDeclaration; + } + export interface EnumValueDefinitionNode { /** * Grats metadata: The TypeScript name of the enum value. diff --git a/src/TypeContext.ts b/src/TypeContext.ts index d82706b5..be7660f9 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -301,6 +301,16 @@ export class TypeContext implements ITypeContext, ITypeContextForResolveTypes { return ok(nameDefinition.name.value); } + /** + * Given a TypeScript node, resolve its symbol to a declaration, following + * aliases. Returns null if the symbol or declaration cannot be found. + */ + resolveNodeDeclaration(node: ts.Node): ts.Declaration | null { + const symbol = this.checker.getSymbolAtLocation(node); + if (symbol == null) return null; + return this.findSymbolDeclaration(symbol); + } + private maybeTsDeclarationForTsName( node: ts.EntityName, ): ts.Declaration | null { diff --git a/src/Types.ts b/src/Types.ts index e5702d9c..1f939eb6 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1,4 +1,5 @@ import type { + GraphQLFieldResolver, GraphQLResolveInfo, GraphQLScalarLiteralParser, GraphQLScalarSerializer, @@ -53,3 +54,13 @@ export type GqlScalar = { */ parseLiteral?: GraphQLScalarLiteralParser; }; + +/** + * Return type for directive functions that should wrap field resolvers + * at runtime. When a directive function returns `FieldDirective`, Grats will + * automatically compose the directive wrapper around the resolver in the + * generated schema. + */ +export type FieldDirective = ( + next: GraphQLFieldResolver, +) => GraphQLFieldResolver; diff --git a/src/codegen/resolverCodegen.ts b/src/codegen/resolverCodegen.ts index f174f1ca..9b181410 100644 --- a/src/codegen/resolverCodegen.ts +++ b/src/codegen/resolverCodegen.ts @@ -1,4 +1,10 @@ -import { ConstDirectiveNode, GraphQLField } from "graphql"; +import { + ConstDirectiveNode, + DirectiveDefinitionNode, + GraphQLField, + GraphQLSchema, + valueFromASTUntyped, +} from "graphql"; import * as ts from "typescript"; import { SEMANTIC_NON_NULL_DIRECTIVE } from "../publicDirectives.js"; import { @@ -7,7 +13,7 @@ import { } from "../codegenHelpers.js"; import { nullThrows } from "../utils/helpers.js"; import { ResolverArgument, ResolverDefinition, Metadata } from "../metadata.js"; -import TSAstBuilder from "./TSAstBuilder.js"; +import TSAstBuilder, { JsonValue } from "./TSAstBuilder.js"; import { ExportDefinition } from "../GraphQLAstExtensions.js"; const RESOLVER_ARGS = ["source", "args", "context", "info"] as const; @@ -299,6 +305,103 @@ export default class ResolverCodegen { return { ...method, body: F.createBlock(newBodyStatements, true) }; } + // If any directives annotated on this field return `FieldDirective`, wrap + // the resolve method by composing each directive's wrapper around the + // resolver function. Given directives A and B, the output is: + // resolve: A({ arg: val })(B({ arg: val })(function resolve(...) { ... })) + maybeApplyFieldDirectiveWrappers( + field: GraphQLField, + method_: ts.MethodDeclaration | null, + methodName: string, + schema: GraphQLSchema, + ): ts.ObjectLiteralElementLike | null { + const fieldDirectives = field.astNode?.directives ?? []; + + // Build a map of directive name -> definition for quick lookup + const directiveDefs = new Map(); + for (const d of schema.getDirectives()) { + if (d.astNode?.exported != null) { + directiveDefs.set(d.name, d.astNode); + } + } + + // Collect the field directive wrappers that need to be applied + const wrappers: Array<{ + def: DirectiveDefinitionNode; + annotation: ConstDirectiveNode; + }> = []; + for (const annotation of fieldDirectives) { + const def = directiveDefs.get(annotation.name.value); + if (def != null) { + wrappers.push({ def, annotation }); + } + } + + if (wrappers.length === 0) { + return method_; + } + + // We need a concrete method to wrap + const method = method_ ?? this.defaultResolverMethod(methodName); + + // Convert the method declaration into a function expression + const funcExpr = F.createFunctionExpression( + method.modifiers as readonly ts.Modifier[] | undefined, + method.asteriskToken, + methodName, + method.typeParameters, + method.parameters, + method.type, + method.body ?? F.createBlock([]), + ); + + // Wrap the function expression with each directive call. + // Innermost directive is last in the list (applied first at runtime). + let wrapped: ts.Expression = funcExpr; + for (let i = wrappers.length - 1; i >= 0; i--) { + const { def, annotation } = wrappers[i]; + const exported = nullThrows(def.exported); + const localName = this.ts.getUniqueName( + exported.exportName ?? "directive", + ); + this.ts.importUserConstruct( + exported.tsModulePath, + exported.exportName, + localName, + false, + ); + + // Build the directive arguments object + const directiveArgs: ts.ObjectLiteralElementLike[] = []; + if (annotation.arguments != null) { + for (const arg of annotation.arguments) { + directiveArgs.push( + F.createPropertyAssignment( + arg.name.value, + this.ts.json(valueFromASTUntyped(arg.value) as JsonValue), + ), + ); + } + } + + // directiveFn({ arg: value })(wrapped) + wrapped = F.createCallExpression( + F.createCallExpression( + F.createIdentifier(localName), + undefined, + directiveArgs.length > 0 + ? [F.createObjectLiteralExpression(directiveArgs, false)] + : [], + ), + undefined, + [wrapped], + ); + } + + // Return as a property assignment: `resolve: wrappedExpression` + return F.createPropertyAssignment(methodName, wrapped); + } + defaultResolverMethod(methodName: string): ts.MethodDeclaration { this.ts.import("graphql", [ { name: "defaultFieldResolver", isTypeOnly: false }, diff --git a/src/codegen/schemaCodegen.ts b/src/codegen/schemaCodegen.ts index ffb9f3bd..189fd33f 100644 --- a/src/codegen/schemaCodegen.ts +++ b/src/codegen/schemaCodegen.ts @@ -653,11 +653,18 @@ class Codegen { parentTypeName, sourceExport, ); - return [ + const withSemanticNull = this.resolvers.maybeApplySemanticNullRuntimeCheck( field, resolve, "resolve", + ); + return [ + this.resolvers.maybeApplyFieldDirectiveWrappers( + field, + withSemanticNull, + "resolve", + this._schema, ), ]; } diff --git a/src/lib.ts b/src/lib.ts index 1582b17d..f598f9b3 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -37,6 +37,7 @@ import { Metadata } from "./metadata.js"; import { validateDirectiveArguments } from "./validations/validateDirectiveArguments.js"; import { coerceDefaultEnumValues } from "./transforms/coerceDefaultEnumValues.js"; import { validateSomeTypesAreDefined } from "./validations/validateSomeTypesAreDefined.js"; +import { resolveFieldDirectives } from "./transforms/resolveFieldDirectives.js"; // Export the TypeScript plugin implementation used by // grats-ts-plugin @@ -109,6 +110,10 @@ export function extractSchemaAndDoc( validateDuplicateContextOrInfo(snapshot.nameDefinitions.values()), ); + // Resolve which directive definitions return `FieldDirective` and + // record their export information for codegen. + resolveFieldDirectives(ctx, snapshot.definitions); + const docResult = new ResultPipe(validationResult) // Filter out any `implements` clauses that are not GraphQL interfaces. .map(() => filterNonGqlInterfaces(ctx, snapshot.definitions)) diff --git a/src/tests/fixtures/directives/fieldDirectiveWrapper.ts b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts new file mode 100644 index 00000000..759b2ebb --- /dev/null +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts @@ -0,0 +1,22 @@ +import { Int, FieldDirective } from "../../../Types"; +import { GraphQLFieldResolver } from "graphql"; + +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + return next(source, args, context, info); + }; +} + +/** + * All likes in the system. + * @gqlQueryField + * @gqlAnnotate rateLimit(max: 10) + */ +export function likes(): string { + return "hello"; +} diff --git a/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md new file mode 100644 index 00000000..92b28865 --- /dev/null +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md @@ -0,0 +1,90 @@ +# directives/fieldDirectiveWrapper.ts + +## Input + +```ts title="directives/fieldDirectiveWrapper.ts" +import { Int, FieldDirective } from "../../../Types"; +import { GraphQLFieldResolver } from "graphql"; + +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + return next(source, args, context, info); + }; +} + +/** + * All likes in the system. + * @gqlQueryField + * @gqlAnnotate rateLimit(max: 10) + */ +export function likes(): string { + return "hello"; +} +``` + +## Output + +### SDL + +```graphql +"""Limits the rate of field resolution.""" +directive @rateLimit(max: Int!) on FIELD_DEFINITION + +type Query { + """All likes in the system.""" + likes: String @rateLimit(max: 10) +} +``` + +### TypeScript + +```ts +import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString } from "graphql"; +import { likes as queryLikesResolver, rateLimit } from "./fieldDirectiveWrapper"; +export function getSchema(): GraphQLSchema { + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + likes: { + description: "All likes in the system.", + name: "likes", + type: GraphQLString, + extensions: { + grats: { + directives: [{ + name: "rateLimit", + args: { + max: 10 + } + }] + } + }, + resolve: rateLimit({ max: 10 })(function resolve() { + return queryLikesResolver(); + }) + } + }; + } + }); + return new GraphQLSchema({ + directives: [...specifiedDirectives, new GraphQLDirective({ + name: "rateLimit", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Limits the rate of field resolution.", + args: { + max: { + type: new GraphQLNonNull(GraphQLInt) + } + } + })], + query: QueryType, + types: [QueryType] + }); +} +``` \ No newline at end of file diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts b/src/tests/integrationFixtures/fieldDirective/index.ts new file mode 100644 index 00000000..9ff0d3f0 --- /dev/null +++ b/src/tests/integrationFixtures/fieldDirective/index.ts @@ -0,0 +1,52 @@ +import { Int, FieldDirective } from "../../../Types.js"; +import { GraphQLFieldResolver } from "graphql"; + +const log: string[] = []; + +/** + * Logs field access before resolving. + * @gqlDirective on FIELD_DEFINITION + */ +export function logged(args: { label: string }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; +} + +/** + * Uppercases the result of a string field. + * @gqlDirective on FIELD_DEFINITION + */ +export function uppercased(args: { enabled: boolean }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return args.enabled ? String(result).toUpperCase() : result; + }; +} + +/** + * @gqlQueryField + * @gqlAnnotate logged(label: "greeting") + * @gqlAnnotate uppercased(enabled: true) + */ +export function greeting(): string { + return "hi"; +} + +/** + * Returns the log of directive invocations. + * @gqlQueryField + */ +export function getLog(): string[] { + return log; +} + +export const query = ` + query { + greeting + getLog + } +`; diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md new file mode 100644 index 00000000..248e17b3 --- /dev/null +++ b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md @@ -0,0 +1,73 @@ +# fieldDirective/index.ts + +## Input + +```ts title="fieldDirective/index.ts" +import { Int, FieldDirective } from "../../../Types.js"; +import { GraphQLFieldResolver } from "graphql"; + +const log: string[] = []; + +/** + * Logs field access before resolving. + * @gqlDirective on FIELD_DEFINITION + */ +export function logged(args: { label: string }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; +} + +/** + * Uppercases the result of a string field. + * @gqlDirective on FIELD_DEFINITION + */ +export function uppercased(args: { enabled: boolean }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return args.enabled ? String(result).toUpperCase() : result; + }; +} + +/** + * @gqlQueryField + * @gqlAnnotate logged(label: "greeting") + * @gqlAnnotate uppercased(enabled: true) + */ +export function greeting(): string { + return "hi"; +} + +/** + * Returns the log of directive invocations. + * @gqlQueryField + */ +export function getLog(): string[] { + return log; +} + +export const query = ` + query { + greeting + getLog + } +`; +``` + +## Output + +### Query Result + +```json +{ + "data": { + "greeting": "HI", + "getLog": [ + "greeting" + ] + } +} +``` \ No newline at end of file diff --git a/src/tests/integrationFixtures/fieldDirective/schema.ts b/src/tests/integrationFixtures/fieldDirective/schema.ts new file mode 100644 index 00000000..f204813e --- /dev/null +++ b/src/tests/integrationFixtures/fieldDirective/schema.ts @@ -0,0 +1,67 @@ +import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLString, GraphQLBoolean, specifiedDirectives, GraphQLObjectType, GraphQLList } from "graphql"; +import { getLog as queryGetLogResolver, greeting as queryGreetingResolver, uppercased, logged } from "./index.js"; +export function getSchema(): GraphQLSchema { + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + getLog: { + description: "Returns the log of directive invocations.", + name: "getLog", + type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + resolve() { + return queryGetLogResolver(); + } + }, + greeting: { + name: "greeting", + type: GraphQLString, + extensions: { + grats: { + directives: [ + { + name: "logged", + args: { + label: "greeting" + } + }, + { + name: "uppercased", + args: { + enabled: true + } + } + ] + } + }, + resolve: logged({ label: "greeting" })(uppercased({ enabled: true })(function resolve() { + return queryGreetingResolver(); + })) + } + }; + } + }); + return new GraphQLSchema({ + directives: [...specifiedDirectives, new GraphQLDirective({ + name: "logged", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Logs field access before resolving.", + args: { + label: { + type: new GraphQLNonNull(GraphQLString) + } + } + }), new GraphQLDirective({ + name: "uppercased", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Uppercases the result of a string field.", + args: { + enabled: { + type: new GraphQLNonNull(GraphQLBoolean) + } + } + })], + query: QueryType, + types: [QueryType] + }); +} diff --git a/src/transforms/resolveFieldDirectives.ts b/src/transforms/resolveFieldDirectives.ts new file mode 100644 index 00000000..09f6b419 --- /dev/null +++ b/src/transforms/resolveFieldDirectives.ts @@ -0,0 +1,64 @@ +import { DefinitionNode, Kind } from "graphql"; +import * as ts from "typescript"; +import { relativePath } from "../gratsRoot.js"; +import { LIBRARY_IMPORT_NAME } from "../Extractor.js"; +import { TypeContext } from "../TypeContext.js"; + +const FIELD_DIRECTIVE_TYPE_NAME = "FieldDirective"; + +/** + * After extraction, directive definitions have a reference to their TS function + * declaration but we don't yet know if they return `FieldDirective`. This + * transform uses the TypeContext to resolve the return type and, when it points + * to grats' `FieldDirective`, records the export information needed for codegen + * to wrap field resolvers. + */ +export function resolveFieldDirectives( + ctx: TypeContext, + definitions: DefinitionNode[], +): DefinitionNode[] { + for (const def of definitions) { + if (def.kind !== Kind.DIRECTIVE_DEFINITION) continue; + const node = def.tsFunctionDeclaration; + if (node == null) continue; + + if (returnsFieldDirective(ctx, node)) { + const tsModulePath = relativePath(node.getSourceFile().fileName); + def.exported = { + tsModulePath, + exportName: node.name?.text ?? null, + }; + } + + // Clean up the TS node reference — it's no longer needed after this + // transform and shouldn't leak into later stages. + delete def.tsFunctionDeclaration; + } + return definitions; +} + +/** + * Check if a function declaration's return type resolves to grats' + * `FieldDirective` type by following type references through the TypeContext. + */ +function returnsFieldDirective( + ctx: TypeContext, + node: ts.FunctionDeclaration, +): boolean { + const returnTypeNode = node.type; + if (returnTypeNode == null) return false; + if (!ts.isTypeReferenceNode(returnTypeNode)) return false; + + const decl = ctx.resolveNodeDeclaration(returnTypeNode.typeName); + if (decl == null) return false; + if (!ts.isTypeAliasDeclaration(decl)) return false; + if (decl.name.text !== FIELD_DIRECTIVE_TYPE_NAME) return false; + + // Check if this declaration is from the grats module's Types file. + // Matches both node_modules/grats/... and local source paths. + const sourceFile = decl.getSourceFile().fileName; + return ( + sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/src/Types`) || + sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/dist/src/Types`) + ); +} diff --git a/website/docs/04-docblock-tags/11-directive-definitions.mdx b/website/docs/04-docblock-tags/11-directive-definitions.mdx index 9b808df8..488a48be 100644 --- a/website/docs/04-docblock-tags/11-directive-definitions.mdx +++ b/website/docs/04-docblock-tags/11-directive-definitions.mdx @@ -4,6 +4,7 @@ import DirectiveLocations from "!!raw-loader!./snippets/11-directive-locations.o import DirectiveArgs from "!!raw-loader!./snippets/11-directive-args.out"; import DirectiveArgsDefault from "!!raw-loader!./snippets/11-directive-args-defaults.out"; import DirectiveArgsDeprecated from "!!raw-loader!./snippets/11-directive-args-deprecated.out"; +import FieldDirectiveWrapper from "!!raw-loader!./snippets/11-field-directive.out"; # Directive Definitions @@ -13,7 +14,7 @@ You can define GraphQL directives by placing a `@gqlDirective` before a: -While the directive is defined as a function, unlike field resolvers, _Grats will not invoke your function_. However, having a function whose type matches the arguments of your directive can be useful for writing code which will accept the arguments of your directive. +By default, the directive is defined as a metadata-only function — Grats will not invoke it. However, having a function whose type matches the arguments of your directive can be useful for writing code which will accept the arguments of your directive. To annotate part of your schema with a directive, see [`@gqlAnnotate`](./12-directive-annotations.mdx). @@ -42,3 +43,13 @@ Default values in this style can be defined by using the `=` operator with destr Arguments can be marked as `@deprecated` by using the `@deprecated` JSDoc tag: + +## Field Directive Wrappers + +For directives on `FIELD_DEFINITION` that need to execute logic at runtime (e.g. auth checks, rate limiting, logging), you can have your directive function return `FieldDirective` from `grats`. When Grats sees this return type, it will automatically wrap the field's resolver with your directive function — no manual `mapSchema` wiring required. + + + +The directive function is called with its arguments and must return a function that takes the next resolver and returns a wrapped resolver. Multiple `FieldDirective` directives on the same field compose naturally — the outermost directive in the annotation list wraps first. + +For directives that are purely metadata (consumed by clients or infrastructure rather than during execution), omit the return type or return `void`. diff --git a/website/docs/04-docblock-tags/12-directive-annotations.mdx b/website/docs/04-docblock-tags/12-directive-annotations.mdx index a774fe7a..f30814d7 100644 --- a/website/docs/04-docblock-tags/12-directive-annotations.mdx +++ b/website/docs/04-docblock-tags/12-directive-annotations.mdx @@ -24,7 +24,13 @@ While the GraphQL Spec does not actually specify that arguments passed to direct Directive annotations added to your schema will be included in Grats' generated `.graphql` file. For directives meant to be consumed by clients or other infrastructure, this should be sufficient. -For directives which are intended to be used during execution, they must be included in the `graphql-js` class `GraphQLSchema` which Grats generates. Unfortunately `GraphQLSchema` does not support a first-class mechanism for including directive annotations. To work around this, **Grats includes directives under as part of the relevant GraphQL class' `extensions` object namespaced under a `grats` key.** +For field directives that need to run logic at runtime (auth, rate limiting, logging, etc.), the recommended approach is to have your directive function return [`FieldDirective`](./11-directive-definitions.mdx#field-directive-wrappers). Grats will automatically wrap the field resolver with your directive function — no manual wiring needed. + +You can find an example of this in action in the [`production-app`](../05-examples/10-production-app.md) example where we define a field directive `@cost` which implements API rate limiting. + +### Manual directive access via extensions + +For directives on non-field locations, or when you need more control, Grats also includes directive annotations as part of the relevant GraphQL class' `extensions` object namespaced under a `grats` key: ```ts const foo = { @@ -41,4 +47,4 @@ const foo = { }; ``` -You can find an example of this in action in the [`production-app`](../05-examples/10-production-app.md) example where we define a field directive `@cost` which implements API rate limiting. +This can be consumed using tools like `@graphql-tools/utils` with `mapSchema` and `getDirective`. diff --git a/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts b/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts new file mode 100644 index 00000000..9593824f --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts @@ -0,0 +1,15 @@ +// trim-start +import { Int, FieldDirective } from "grats"; +import { GraphQLFieldResolver } from "graphql"; +// trim-end +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + // Custom logic runs before the resolver + return next(source, args, context, info); + }; +} diff --git a/website/docs/04-docblock-tags/snippets/11-field-directive.out b/website/docs/04-docblock-tags/snippets/11-field-directive.out new file mode 100644 index 00000000..7f508d98 --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.out @@ -0,0 +1,36 @@ +// trim-start +import { Int, FieldDirective } from "grats"; +import { GraphQLFieldResolver } from "graphql"; +// trim-end +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + // Custom logic runs before the resolver + return next(source, args, context, info); + }; +} + +=== SNIP === +"""Limits the rate of field resolution.""" +directive @rateLimit(max: Int!) on FIELD_DEFINITION +=== SNIP === +import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLInt, specifiedDirectives } from "graphql"; +export function getSchema(): GraphQLSchema { + return new GraphQLSchema({ + directives: [...specifiedDirectives, new GraphQLDirective({ + name: "rateLimit", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Limits the rate of field resolution.", + args: { + max: { + type: new GraphQLNonNull(GraphQLInt) + } + } + })], + types: [] + }); +} diff --git a/website/docs/05-guides/09-permissions.mdx b/website/docs/05-guides/09-permissions.mdx index aad1d94c..07f903da 100644 --- a/website/docs/05-guides/09-permissions.mdx +++ b/website/docs/05-guides/09-permissions.mdx @@ -30,21 +30,23 @@ The `VC` object can also be passed through model constructors to avoid needing t ## Permission Directives -If you do decide to enforce permissions in the GraphQL layer, one approach is to use [Directives](https://graphql.org/learn/queries/#directives) to annotate fields with the permissions required to access them, and then use a custom [Schema Directive Visitor](https://www.apollographql.com/docs/graphql-tools/schema-directives/) to wrap the field resolvers with permission checks. +If you do decide to enforce permissions in the GraphQL layer, one approach is to use [Directives](https://graphql.org/learn/queries/#directives) to annotate fields with the permissions required to access them. + +By returning [`FieldDirective`](../04-docblock-tags/11-directive-definitions.mdx#field-directive-wrappers) from your directive function, Grats will automatically wrap the field resolver with your permission check — no manual schema transformation needed. This approach means that the permission requirements end up visible in your generated GraphQL schema. It can be useful for clients to know what permissions are required to access certain fields, but in some cases permissions are not intended to be public knowledge, so be sure to consider whether this is appropriate for your use case. -Note that schema directives are not exposed through GraphqL introspection, so they will not be visible to clients who access the schema that way. +Note that schema directives are not exposed through GraphQL introspection, so they will not be visible to clients who access the schema that way. Usage on each restricted field looks like this: -Then, after we create our schema, we can use `@graphql-tools/utils` to wrap each field annotated with the directives in a function which first applies the permission check: +The directive implementation uses `FieldDirective` to wrap the resolver with a permission check: ```ts -import { defaultFieldResolver, GraphQLError, GraphQLSchema } from "graphql"; -import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils"; +import { GraphQLError, GraphQLFieldResolver } from "graphql"; +import { FieldDirective } from "grats"; /** @gqlContext */ type Ctx = { @@ -59,36 +61,19 @@ export enum Role { } /** - * Indicates that a field require the specified roles to access. + * Indicates that a field requires the specified role to access. * @gqlDirective assert on FIELD_DEFINITION */ -export function requiresRole(args: { is: Role }, context: Ctx): void { - if (args.is !== context.role) { - // Using `GraphQLError` here ensures the error is not masked by Yoga. - throw new GraphQLError("You do not have permission to access this field."); - } -} - -// Monkey patches the `resolve` function of fields with the `@requiresRole` -export function applyRolePermissions(schema: GraphQLSchema): GraphQLSchema { - return mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const assertDirective = getDirective(schema, fieldConfig, "assert", [ - "grats", - "directives", - ]); - if (assertDirective == null || assertDirective.length === 0) { - return fieldConfig; +export function requiresRole(args: { is: Role }): FieldDirective { + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + if (args.is !== context.role) { + throw new GraphQLError( + "You do not have permission to access this field.", + ); } - - const originalResolve = fieldConfig.resolve ?? defaultFieldResolver; - fieldConfig.resolve = (source, args, context, info) => { - requiresRole(assertDirective[0] as { is: Role }, context); - return originalResolve(source, args, context, info); - }; - return fieldConfig; - }, - }); + return next(source, resolverArgs, context, info); + }; } ```