From 7fe7744b318583554f041417ed0abbf13db8a08c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 3 Apr 2026 13:55:11 -0700 Subject: [PATCH 1/5] Prototype FieldDirective: auto-wrap resolvers with directive functions When a `@gqlDirective` function's return type is `FieldDirective` (exported from grats), Grats automatically composes the directive wrapper around the field resolver in the generated schema. This eliminates the need for manual `mapSchema` + `getDirective` wiring for the common case of field-level directive behavior (auth, rate limiting, logging, etc). The directive function is called with its annotation arguments and returns a higher-order function that wraps the resolver: resolve: rateLimit({ max: 10 })(function resolve(source) { ... }) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Extractor.ts | 39 +++++-- src/GraphQLAstExtensions.ts | 8 ++ src/Types.ts | 11 ++ src/codegen/resolverCodegen.ts | 107 +++++++++++++++++- src/codegen/schemaCodegen.ts | 9 +- .../directives/fieldDirectiveWrapper.ts | 23 ++++ .../fieldDirectiveWrapper.ts.expected.md | 91 +++++++++++++++ 7 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 src/tests/fixtures/directives/fieldDirectiveWrapper.ts create mode 100644 src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md diff --git a/src/Extractor.ts b/src/Extractor.ts index 0c2d9d3d..e36670d3 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -556,16 +556,37 @@ 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, ); + + // If the directive function returns `FieldDirective` from grats, record + // the export information so codegen can wrap field resolvers at runtime. + if (this.returnsFieldDirective(node)) { + const tsModulePath = relativePath(node.getSourceFile().fileName); + directive.exported = { + tsModulePath, + exportName: node.name?.text ?? null, + }; + } + + this.definitions.push(directive); + } + + // Check if a directive function's return type annotation is `FieldDirective`. + // This is a syntactic check — we match the identifier name. + returnsFieldDirective(node: ts.FunctionDeclaration): boolean { + const returnType = node.type; + if (returnType == null) return false; + if (!ts.isTypeReferenceNode(returnType)) return false; + const typeName = returnType.typeName; + if (!ts.isIdentifier(typeName)) return false; + return typeName.text === "FieldDirective"; } extractDirectiveArgs( diff --git a/src/GraphQLAstExtensions.ts b/src/GraphQLAstExtensions.ts index b940fad9..8929f649 100644 --- a/src/GraphQLAstExtensions.ts +++ b/src/GraphQLAstExtensions.ts @@ -88,6 +88,14 @@ 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; + } + export interface EnumValueDefinitionNode { /** * Grats metadata: The TypeScript name of the enum value. diff --git a/src/Types.ts b/src/Types.ts index e5702d9c..df8f49ae 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/tests/fixtures/directives/fieldDirectiveWrapper.ts b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts new file mode 100644 index 00000000..ae24ec83 --- /dev/null +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts @@ -0,0 +1,23 @@ +import { Int, FieldDirective } from "../../../Types"; + +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next) => (source, args, context, info) => { + return next(source, args, context, info); + }; +} + +/** @gqlType */ +type Query = unknown; + +/** + * All likes in the system. + * @gqlField + * @gqlAnnotate rateLimit(max: 10) + */ +export function likes(_: Query): 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..42ab8fdc --- /dev/null +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md @@ -0,0 +1,91 @@ +# directives/fieldDirectiveWrapper.ts + +## Input + +```ts title="directives/fieldDirectiveWrapper.ts" +import { Int, FieldDirective } from "../../../Types"; + +/** + * Limits the rate of field resolution. + * @gqlDirective on FIELD_DEFINITION + */ +export function rateLimit(args: { max: Int }): FieldDirective { + return (next) => (source, args, context, info) => { + return next(source, args, context, info); + }; +} + +/** @gqlType */ +type Query = unknown; + +/** + * All likes in the system. + * @gqlField + * @gqlAnnotate rateLimit(max: 10) + */ +export function likes(_: Query): 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(source) { + return queryLikesResolver(source); + }) + } + }; + } + }); + 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 From c2a07b695664c5fea10aac73e03cf5e0df5aa958 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 3 Apr 2026 14:18:23 -0700 Subject: [PATCH 2/5] Use type-aware check for FieldDirective, add docs and integration tests - Move FieldDirective detection from syntactic name check in Extractor to a type-aware transform (resolveFieldDirectives) that uses the TS checker to verify the return type resolves to grats' FieldDirective type - Update production-app example to use FieldDirective (removes mapSchema) - Update directive definitions docs with FieldDirective section - Update directive annotations docs to recommend FieldDirective approach - Update permissions guide to use FieldDirective pattern - Add integration test that verifies directives execute at runtime (both logging and result transformation) Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/production-app/graphql/directives.ts | 52 ++++-------- examples/production-app/schema.graphql | 3 + examples/production-app/schema.ts | 11 +-- examples/production-app/server.ts | 4 +- .../docblock-tags/directive-annotations.md | 10 ++- .../docblock-tags/directive-definitions.md | 24 +++++- llm-docs/guides/permissions.md | 50 ++++-------- src/Extractor.ts | 23 +----- src/GraphQLAstExtensions.ts | 6 ++ src/Types.ts | 5 +- src/lib.ts | 5 ++ .../fieldDirective/index.ts | 52 ++++++++++++ .../fieldDirective/index.ts.expected.md | 73 +++++++++++++++++ .../fieldDirective/schema.ts | 60 ++++++++++++++ src/transforms/resolveFieldDirectives.ts | 79 +++++++++++++++++++ .../11-directive-definitions.mdx | 13 ++- .../12-directive-annotations.mdx | 10 ++- .../snippets/11-field-directive.grats.ts | 13 +++ .../snippets/11-field-directive.out | 34 ++++++++ website/docs/05-guides/09-permissions.mdx | 50 ++++-------- 20 files changed, 439 insertions(+), 138 deletions(-) create mode 100644 src/tests/integrationFixtures/fieldDirective/index.ts create mode 100644 src/tests/integrationFixtures/fieldDirective/index.ts.expected.md create mode 100644 src/tests/integrationFixtures/fieldDirective/schema.ts create mode 100644 src/transforms/resolveFieldDirectives.ts create mode 100644 website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts create mode 100644 website/docs/04-docblock-tags/snippets/11-field-directive.out diff --git a/examples/production-app/graphql/directives.ts b/examples/production-app/graphql/directives.ts index 2530f56e..78554504 100644 --- a/examples/production-app/graphql/directives.ts +++ b/examples/production-app/graphql/directives.ts @@ -1,45 +1,25 @@ -import { defaultFieldResolver, GraphQLError, GraphQLSchema } from "graphql"; -import { Int } from "grats"; +import { GraphQLError } 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; - } - - 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; - }, - }); +export function debitCredits(args: { credits: Int }): FieldDirective { + return (next) => (source, resolverArgs, context: Ctx, 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.`, + ); + } + 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 e36670d3..c192cd68 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -565,30 +565,13 @@ class Extractor { description, ); - // If the directive function returns `FieldDirective` from grats, record - // the export information so codegen can wrap field resolvers at runtime. - if (this.returnsFieldDirective(node)) { - const tsModulePath = relativePath(node.getSourceFile().fileName); - directive.exported = { - tsModulePath, - exportName: node.name?.text ?? null, - }; - } + // 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); } - // Check if a directive function's return type annotation is `FieldDirective`. - // This is a syntactic check — we match the identifier name. - returnsFieldDirective(node: ts.FunctionDeclaration): boolean { - const returnType = node.type; - if (returnType == null) return false; - if (!ts.isTypeReferenceNode(returnType)) return false; - const typeName = returnType.typeName; - if (!ts.isIdentifier(typeName)) return false; - return typeName.text === "FieldDirective"; - } - extractDirectiveArgs( node: ts.FunctionDeclaration, ): InputValueDefinitionNode[] | null { diff --git a/src/GraphQLAstExtensions.ts b/src/GraphQLAstExtensions.ts index 8929f649..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"; @@ -94,6 +95,11 @@ declare module "graphql" { * 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 { diff --git a/src/Types.ts b/src/Types.ts index df8f49ae..63362852 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -61,6 +61,7 @@ export type GqlScalar = { * automatically compose the directive wrapper around the resolver in the * generated schema. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type FieldDirective = ( - next: GraphQLFieldResolver, -) => GraphQLFieldResolver; + next: GraphQLFieldResolver, +) => GraphQLFieldResolver; diff --git a/src/lib.ts b/src/lib.ts index 1582b17d..74960a1c 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(checker, 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/integrationFixtures/fieldDirective/index.ts b/src/tests/integrationFixtures/fieldDirective/index.ts new file mode 100644 index 00000000..78ababeb --- /dev/null +++ b/src/tests/integrationFixtures/fieldDirective/index.ts @@ -0,0 +1,52 @@ +import { Int, FieldDirective } from "../../../Types.js"; + +const log: string[] = []; + +/** + * Logs field access before resolving. + * @gqlDirective on FIELD_DEFINITION + */ +export function logged(args: { label: string }): FieldDirective { + return (next) => (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; +} + +/** + * Doubles the result of a string field. + * @gqlDirective on FIELD_DEFINITION + */ +export function doubled(_args: never): FieldDirective { + return (next) => (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return result + result; + }; +} + +/** @gqlType */ +type Query = unknown; + +/** + * @gqlField + * @gqlAnnotate logged(label: "greeting") + * @gqlAnnotate doubled + */ +export function greeting(_: Query): string { + return "hi"; +} + +/** + * Returns the log of directive invocations. + * @gqlField + */ +export function getLog(_: Query): 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..1b4aa43b --- /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"; + +const log: string[] = []; + +/** + * Logs field access before resolving. + * @gqlDirective on FIELD_DEFINITION + */ +export function logged(args: { label: string }): FieldDirective { + return (next) => (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; +} + +/** + * Doubles the result of a string field. + * @gqlDirective on FIELD_DEFINITION + */ +export function doubled(_args: never): FieldDirective { + return (next) => (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return result + result; + }; +} + +/** @gqlType */ +type Query = unknown; + +/** + * @gqlField + * @gqlAnnotate logged(label: "greeting") + * @gqlAnnotate doubled + */ +export function greeting(_: Query): string { + return "hi"; +} + +/** + * Returns the log of directive invocations. + * @gqlField + */ +export function getLog(_: Query): string[] { + return log; +} + +export const query = ` + query { + greeting + getLog + } +`; +``` + +## Output + +### Query Result + +```json +{ + "data": { + "greeting": "hihi", + "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..4a5649a9 --- /dev/null +++ b/src/tests/integrationFixtures/fieldDirective/schema.ts @@ -0,0 +1,60 @@ +import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLString, specifiedDirectives, GraphQLObjectType, GraphQLList } from "graphql"; +import { getLog as queryGetLogResolver, greeting as queryGreetingResolver, logged, doubled } 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(source) { + return queryGetLogResolver(source); + } + }, + greeting: { + name: "greeting", + type: GraphQLString, + extensions: { + grats: { + directives: [ + { + name: "doubled", + args: {} + }, + { + name: "logged", + args: { + label: "greeting" + } + } + ] + } + }, + resolve: doubled()(logged({ label: "greeting" })(function resolve(source) { + return queryGreetingResolver(source); + })) + } + }; + } + }); + return new GraphQLSchema({ + directives: [...specifiedDirectives, new GraphQLDirective({ + name: "doubled", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Doubles the result of a string field." + }), new GraphQLDirective({ + name: "logged", + locations: [DirectiveLocation.FIELD_DEFINITION], + description: "Logs field access before resolving.", + args: { + label: { + type: new GraphQLNonNull(GraphQLString) + } + } + })], + query: QueryType, + types: [QueryType] + }); +} diff --git a/src/transforms/resolveFieldDirectives.ts b/src/transforms/resolveFieldDirectives.ts new file mode 100644 index 00000000..09345c48 --- /dev/null +++ b/src/transforms/resolveFieldDirectives.ts @@ -0,0 +1,79 @@ +import { DefinitionNode, Kind } from "graphql"; +import * as ts from "typescript"; +import { relativePath } from "../gratsRoot.js"; +import { LIBRARY_IMPORT_NAME } from "../Extractor.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 TypeScript type checker 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( + checker: ts.TypeChecker, + 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(checker, 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 the type checker's symbol resolution. + */ +function returnsFieldDirective( + checker: ts.TypeChecker, + node: ts.FunctionDeclaration, +): boolean { + const returnTypeNode = node.type; + if (returnTypeNode == null) return false; + if (!ts.isTypeReferenceNode(returnTypeNode)) return false; + + const symbol = checker.getSymbolAtLocation(returnTypeNode.typeName); + if (symbol == null) return false; + + // Follow aliases (e.g. re-exports) + const resolved = symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; + + // Check that the resolved symbol's declaration is in the grats module + const declarations = resolved.declarations; + if (declarations == null || declarations.length === 0) return false; + + for (const decl of declarations) { + if (!ts.isTypeAliasDeclaration(decl)) continue; + if (decl.name.text !== FIELD_DIRECTIVE_TYPE_NAME) continue; + + // 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; + if ( + sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/src/Types`) || + sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/dist/src/Types`) + ) { + return true; + } + } + + return false; +} 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..cfced90b --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts @@ -0,0 +1,13 @@ +// trim-start +import { Int, FieldDirective } from "grats"; +// trim-end +/** + * 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); + }; +} 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..bc3f9709 --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.out @@ -0,0 +1,34 @@ +// trim-start +import { Int, FieldDirective } from "grats"; +// trim-end +/** + * 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); + }; +} + +=== 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..c2d46a64 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 } from "graphql"; +import { FieldDirective } from "grats"; /** @gqlContext */ type Ctx = { @@ -59,36 +61,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); + }; } ``` From 4a525c8155dbc24e2a07fe3c4671cccf989c81f9 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 3 Apr 2026 14:32:01 -0700 Subject: [PATCH 3/5] Fix CI: lint, prettier, and type errors in integration test - Remove unused eslint-disable in Types.ts - Format resolveFieldDirectives.ts with prettier - Fix integration test: use args with typed result instead of `never` args (which caused empty call) and `unknown` concatenation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Types.ts | 1 - .../fieldDirective/index.ts | 8 ++--- .../fieldDirective/index.ts.expected.md | 10 +++---- .../fieldDirective/schema.ts | 29 ++++++++++++------- src/transforms/resolveFieldDirectives.ts | 7 +++-- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/Types.ts b/src/Types.ts index 63362852..1f939eb6 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -61,7 +61,6 @@ export type GqlScalar = { * automatically compose the directive wrapper around the resolver in the * generated schema. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type FieldDirective = ( next: GraphQLFieldResolver, ) => GraphQLFieldResolver; diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts b/src/tests/integrationFixtures/fieldDirective/index.ts index 78ababeb..7325196d 100644 --- a/src/tests/integrationFixtures/fieldDirective/index.ts +++ b/src/tests/integrationFixtures/fieldDirective/index.ts @@ -14,13 +14,13 @@ export function logged(args: { label: string }): FieldDirective { } /** - * Doubles the result of a string field. + * Uppercases the result of a string field. * @gqlDirective on FIELD_DEFINITION */ -export function doubled(_args: never): FieldDirective { +export function uppercased(args: { enabled: boolean }): FieldDirective { return (next) => (source, resolverArgs, context, info) => { const result = next(source, resolverArgs, context, info); - return result + result; + return args.enabled ? String(result).toUpperCase() : result; }; } @@ -30,7 +30,7 @@ type Query = unknown; /** * @gqlField * @gqlAnnotate logged(label: "greeting") - * @gqlAnnotate doubled + * @gqlAnnotate uppercased(enabled: true) */ export function greeting(_: Query): string { return "hi"; diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md index 1b4aa43b..5060d26d 100644 --- a/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md +++ b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md @@ -19,13 +19,13 @@ export function logged(args: { label: string }): FieldDirective { } /** - * Doubles the result of a string field. + * Uppercases the result of a string field. * @gqlDirective on FIELD_DEFINITION */ -export function doubled(_args: never): FieldDirective { +export function uppercased(args: { enabled: boolean }): FieldDirective { return (next) => (source, resolverArgs, context, info) => { const result = next(source, resolverArgs, context, info); - return result + result; + return args.enabled ? String(result).toUpperCase() : result; }; } @@ -35,7 +35,7 @@ type Query = unknown; /** * @gqlField * @gqlAnnotate logged(label: "greeting") - * @gqlAnnotate doubled + * @gqlAnnotate uppercased(enabled: true) */ export function greeting(_: Query): string { return "hi"; @@ -64,7 +64,7 @@ export const query = ` ```json { "data": { - "greeting": "hihi", + "greeting": "HI", "getLog": [ "greeting" ] diff --git a/src/tests/integrationFixtures/fieldDirective/schema.ts b/src/tests/integrationFixtures/fieldDirective/schema.ts index 4a5649a9..acfcc9a1 100644 --- a/src/tests/integrationFixtures/fieldDirective/schema.ts +++ b/src/tests/integrationFixtures/fieldDirective/schema.ts @@ -1,5 +1,5 @@ -import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLString, specifiedDirectives, GraphQLObjectType, GraphQLList } from "graphql"; -import { getLog as queryGetLogResolver, greeting as queryGreetingResolver, logged, doubled } from "./index.js"; +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", @@ -19,20 +19,22 @@ export function getSchema(): GraphQLSchema { extensions: { grats: { directives: [ - { - name: "doubled", - args: {} - }, { name: "logged", args: { label: "greeting" } + }, + { + name: "uppercased", + args: { + enabled: true + } } ] } }, - resolve: doubled()(logged({ label: "greeting" })(function resolve(source) { + resolve: logged({ label: "greeting" })(uppercased({ enabled: true })(function resolve(source) { return queryGreetingResolver(source); })) } @@ -41,10 +43,6 @@ export function getSchema(): GraphQLSchema { }); return new GraphQLSchema({ directives: [...specifiedDirectives, new GraphQLDirective({ - name: "doubled", - locations: [DirectiveLocation.FIELD_DEFINITION], - description: "Doubles the result of a string field." - }), new GraphQLDirective({ name: "logged", locations: [DirectiveLocation.FIELD_DEFINITION], description: "Logs field access before resolving.", @@ -53,6 +51,15 @@ export function getSchema(): GraphQLSchema { 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 index 09345c48..7e11b337 100644 --- a/src/transforms/resolveFieldDirectives.ts +++ b/src/transforms/resolveFieldDirectives.ts @@ -52,9 +52,10 @@ function returnsFieldDirective( if (symbol == null) return false; // Follow aliases (e.g. re-exports) - const resolved = symbol.flags & ts.SymbolFlags.Alias - ? checker.getAliasedSymbol(symbol) - : symbol; + const resolved = + symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; // Check that the resolved symbol's declaration is in the grats module const declarations = resolved.declarations; From 2caa05566aa750bf7bbc502e661f4a3a3328a4e5 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 3 Apr 2026 15:41:34 -0700 Subject: [PATCH 4/5] Use @gqlQueryField in tests, add explicit resolver types in examples - Replace `type Query = unknown` + `@gqlField` with `@gqlQueryField` in both unit and integration test fixtures - Add explicit `next: GraphQLFieldResolver<...>` type annotations in all FieldDirective examples: docs snippets, permissions guide, production-app, and test fixtures Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/production-app/graphql/directives.ts | 23 +++++++------- .../directives/fieldDirectiveWrapper.ts | 15 +++++----- .../fieldDirectiveWrapper.ts.expected.md | 19 ++++++------ .../fieldDirective/index.ts | 30 +++++++++---------- .../fieldDirective/index.ts.expected.md | 30 +++++++++---------- .../fieldDirective/schema.ts | 8 ++--- .../snippets/11-field-directive.grats.ts | 10 ++++--- .../snippets/11-field-directive.out | 10 ++++--- website/docs/05-guides/09-permissions.mdx | 19 ++++++------ 9 files changed, 84 insertions(+), 80 deletions(-) diff --git a/examples/production-app/graphql/directives.ts b/examples/production-app/graphql/directives.ts index 78554504..4a3be685 100644 --- a/examples/production-app/graphql/directives.ts +++ b/examples/production-app/graphql/directives.ts @@ -1,4 +1,4 @@ -import { GraphQLError } from "graphql"; +import { GraphQLError, GraphQLFieldResolver } from "graphql"; import { Int, FieldDirective } from "grats"; import { Ctx } from "../ViewerContext.js"; @@ -12,14 +12,15 @@ import { Ctx } from "../ViewerContext.js"; * @gqlDirective cost on FIELD_DEFINITION */ export function debitCredits(args: { credits: Int }): FieldDirective { - return (next) => (source, resolverArgs, context: Ctx, 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.`, - ); - } - context.credits -= args.credits; - return next(source, resolverArgs, context, info); - }; + 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.`, + ); + } + context.credits -= args.credits; + return next(source, resolverArgs, context, info); + }; } diff --git a/src/tests/fixtures/directives/fieldDirectiveWrapper.ts b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts index ae24ec83..759b2ebb 100644 --- a/src/tests/fixtures/directives/fieldDirectiveWrapper.ts +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts @@ -1,23 +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) => (source, args, context, info) => { - return next(source, args, context, info); - }; + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + return next(source, args, context, info); + }; } -/** @gqlType */ -type Query = unknown; - /** * All likes in the system. - * @gqlField + * @gqlQueryField * @gqlAnnotate rateLimit(max: 10) */ -export function likes(_: Query): string { +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 index 42ab8fdc..92b28865 100644 --- a/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md +++ b/src/tests/fixtures/directives/fieldDirectiveWrapper.ts.expected.md @@ -4,26 +4,25 @@ ```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) => (source, args, context, info) => { - return next(source, args, context, info); - }; + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + return next(source, args, context, info); + }; } -/** @gqlType */ -type Query = unknown; - /** * All likes in the system. - * @gqlField + * @gqlQueryField * @gqlAnnotate rateLimit(max: 10) */ -export function likes(_: Query): string { +export function likes(): string { return "hello"; } ``` @@ -66,8 +65,8 @@ export function getSchema(): GraphQLSchema { }] } }, - resolve: rateLimit({ max: 10 })(function resolve(source) { - return queryLikesResolver(source); + resolve: rateLimit({ max: 10 })(function resolve() { + return queryLikesResolver(); }) } }; diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts b/src/tests/integrationFixtures/fieldDirective/index.ts index 7325196d..9ff0d3f0 100644 --- a/src/tests/integrationFixtures/fieldDirective/index.ts +++ b/src/tests/integrationFixtures/fieldDirective/index.ts @@ -1,4 +1,5 @@ import { Int, FieldDirective } from "../../../Types.js"; +import { GraphQLFieldResolver } from "graphql"; const log: string[] = []; @@ -7,10 +8,11 @@ const log: string[] = []; * @gqlDirective on FIELD_DEFINITION */ export function logged(args: { label: string }): FieldDirective { - return (next) => (source, resolverArgs, context, info) => { - log.push(args.label); - return next(source, resolverArgs, context, info); - }; + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; } /** @@ -18,29 +20,27 @@ export function logged(args: { label: string }): FieldDirective { * @gqlDirective on FIELD_DEFINITION */ export function uppercased(args: { enabled: boolean }): FieldDirective { - return (next) => (source, resolverArgs, context, info) => { - const result = next(source, resolverArgs, context, info); - return args.enabled ? String(result).toUpperCase() : result; - }; + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return args.enabled ? String(result).toUpperCase() : result; + }; } -/** @gqlType */ -type Query = unknown; - /** - * @gqlField + * @gqlQueryField * @gqlAnnotate logged(label: "greeting") * @gqlAnnotate uppercased(enabled: true) */ -export function greeting(_: Query): string { +export function greeting(): string { return "hi"; } /** * Returns the log of directive invocations. - * @gqlField + * @gqlQueryField */ -export function getLog(_: Query): string[] { +export function getLog(): string[] { return log; } diff --git a/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md index 5060d26d..248e17b3 100644 --- a/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md +++ b/src/tests/integrationFixtures/fieldDirective/index.ts.expected.md @@ -4,6 +4,7 @@ ```ts title="fieldDirective/index.ts" import { Int, FieldDirective } from "../../../Types.js"; +import { GraphQLFieldResolver } from "graphql"; const log: string[] = []; @@ -12,10 +13,11 @@ const log: string[] = []; * @gqlDirective on FIELD_DEFINITION */ export function logged(args: { label: string }): FieldDirective { - return (next) => (source, resolverArgs, context, info) => { - log.push(args.label); - return next(source, resolverArgs, context, info); - }; + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + log.push(args.label); + return next(source, resolverArgs, context, info); + }; } /** @@ -23,29 +25,27 @@ export function logged(args: { label: string }): FieldDirective { * @gqlDirective on FIELD_DEFINITION */ export function uppercased(args: { enabled: boolean }): FieldDirective { - return (next) => (source, resolverArgs, context, info) => { - const result = next(source, resolverArgs, context, info); - return args.enabled ? String(result).toUpperCase() : result; - }; + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + const result = next(source, resolverArgs, context, info); + return args.enabled ? String(result).toUpperCase() : result; + }; } -/** @gqlType */ -type Query = unknown; - /** - * @gqlField + * @gqlQueryField * @gqlAnnotate logged(label: "greeting") * @gqlAnnotate uppercased(enabled: true) */ -export function greeting(_: Query): string { +export function greeting(): string { return "hi"; } /** * Returns the log of directive invocations. - * @gqlField + * @gqlQueryField */ -export function getLog(_: Query): string[] { +export function getLog(): string[] { return log; } diff --git a/src/tests/integrationFixtures/fieldDirective/schema.ts b/src/tests/integrationFixtures/fieldDirective/schema.ts index acfcc9a1..f204813e 100644 --- a/src/tests/integrationFixtures/fieldDirective/schema.ts +++ b/src/tests/integrationFixtures/fieldDirective/schema.ts @@ -9,8 +9,8 @@ export function getSchema(): GraphQLSchema { description: "Returns the log of directive invocations.", name: "getLog", type: new GraphQLList(new GraphQLNonNull(GraphQLString)), - resolve(source) { - return queryGetLogResolver(source); + resolve() { + return queryGetLogResolver(); } }, greeting: { @@ -34,8 +34,8 @@ export function getSchema(): GraphQLSchema { ] } }, - resolve: logged({ label: "greeting" })(uppercased({ enabled: true })(function resolve(source) { - return queryGreetingResolver(source); + resolve: logged({ label: "greeting" })(uppercased({ enabled: true })(function resolve() { + return queryGreetingResolver(); })) } }; 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 index cfced90b..9593824f 100644 --- a/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.grats.ts @@ -1,13 +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) => (source, args, context, info) => { - // Custom logic runs before the resolver - return next(source, args, context, info); - }; + 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 index bc3f9709..7f508d98 100644 --- a/website/docs/04-docblock-tags/snippets/11-field-directive.out +++ b/website/docs/04-docblock-tags/snippets/11-field-directive.out @@ -1,15 +1,17 @@ // 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) => (source, args, context, info) => { - // Custom logic runs before the resolver - return next(source, args, context, info); - }; + return (next: GraphQLFieldResolver) => + (source, args, context, info) => { + // Custom logic runs before the resolver + return next(source, args, context, info); + }; } === SNIP === diff --git a/website/docs/05-guides/09-permissions.mdx b/website/docs/05-guides/09-permissions.mdx index c2d46a64..07f903da 100644 --- a/website/docs/05-guides/09-permissions.mdx +++ b/website/docs/05-guides/09-permissions.mdx @@ -45,7 +45,7 @@ Usage on each restricted field looks like this: The directive implementation uses `FieldDirective` to wrap the resolver with a permission check: ```ts -import { GraphQLError } from "graphql"; +import { GraphQLError, GraphQLFieldResolver } from "graphql"; import { FieldDirective } from "grats"; /** @gqlContext */ @@ -65,14 +65,15 @@ export enum Role { * @gqlDirective assert on FIELD_DEFINITION */ 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); - }; + return (next: GraphQLFieldResolver) => + (source, resolverArgs, context, info) => { + if (args.is !== context.role) { + throw new GraphQLError( + "You do not have permission to access this field.", + ); + } + return next(source, resolverArgs, context, info); + }; } ``` From ddcc5f34ff0ab2ce7586c634a7a0eac6fc6e92b8 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 3 Apr 2026 15:50:11 -0700 Subject: [PATCH 5/5] Accept TypeContext instead of raw checker in resolveFieldDirectives Use ctx.resolveNodeDeclaration() (new public method on TypeContext) instead of directly calling checker.getSymbolAtLocation and checker.getAliasedSymbol. This keeps the checker encapsulated within TypeContext. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/TypeContext.ts | 10 +++++ src/lib.ts | 2 +- src/transforms/resolveFieldDirectives.ts | 54 +++++++++--------------- 3 files changed, 30 insertions(+), 36 deletions(-) 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/lib.ts b/src/lib.ts index 74960a1c..f598f9b3 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -112,7 +112,7 @@ export function extractSchemaAndDoc( // Resolve which directive definitions return `FieldDirective` and // record their export information for codegen. - resolveFieldDirectives(checker, snapshot.definitions); + resolveFieldDirectives(ctx, snapshot.definitions); const docResult = new ResultPipe(validationResult) // Filter out any `implements` clauses that are not GraphQL interfaces. diff --git a/src/transforms/resolveFieldDirectives.ts b/src/transforms/resolveFieldDirectives.ts index 7e11b337..09f6b419 100644 --- a/src/transforms/resolveFieldDirectives.ts +++ b/src/transforms/resolveFieldDirectives.ts @@ -2,18 +2,19 @@ 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 TypeScript type checker to resolve the return type and, - * when it points to grats' `FieldDirective`, records the export information - * needed for codegen to wrap field resolvers. + * 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( - checker: ts.TypeChecker, + ctx: TypeContext, definitions: DefinitionNode[], ): DefinitionNode[] { for (const def of definitions) { @@ -21,7 +22,7 @@ export function resolveFieldDirectives( const node = def.tsFunctionDeclaration; if (node == null) continue; - if (returnsFieldDirective(checker, node)) { + if (returnsFieldDirective(ctx, node)) { const tsModulePath = relativePath(node.getSourceFile().fileName); def.exported = { tsModulePath, @@ -38,43 +39,26 @@ export function resolveFieldDirectives( /** * Check if a function declaration's return type resolves to grats' - * `FieldDirective` type by following the type checker's symbol resolution. + * `FieldDirective` type by following type references through the TypeContext. */ function returnsFieldDirective( - checker: ts.TypeChecker, + ctx: TypeContext, node: ts.FunctionDeclaration, ): boolean { const returnTypeNode = node.type; if (returnTypeNode == null) return false; if (!ts.isTypeReferenceNode(returnTypeNode)) return false; - const symbol = checker.getSymbolAtLocation(returnTypeNode.typeName); - if (symbol == null) 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; - // Follow aliases (e.g. re-exports) - const resolved = - symbol.flags & ts.SymbolFlags.Alias - ? checker.getAliasedSymbol(symbol) - : symbol; - - // Check that the resolved symbol's declaration is in the grats module - const declarations = resolved.declarations; - if (declarations == null || declarations.length === 0) return false; - - for (const decl of declarations) { - if (!ts.isTypeAliasDeclaration(decl)) continue; - if (decl.name.text !== FIELD_DIRECTIVE_TYPE_NAME) continue; - - // 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; - if ( - sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/src/Types`) || - sourceFile.includes(`/${LIBRARY_IMPORT_NAME}/dist/src/Types`) - ) { - return true; - } - } - - 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`) + ); }