Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 16 additions & 35 deletions examples/production-app/graphql/directives.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, Ctx>) =>
(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);
};
}
3 changes: 3 additions & 0 deletions examples/production-app/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions examples/production-app/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions examples/production-app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions llm-docs/docblock-tags/directive-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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`.
24 changes: 23 additions & 1 deletion llm-docs/docblock-tags/directive-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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`.
50 changes: 17 additions & 33 deletions llm-docs/guides/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
};
}
```

Expand Down
22 changes: 13 additions & 9 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions src/GraphQLAstExtensions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as ts from "typescript";
import { ResolverSignature } from "./resolverSignature.js";
import { TsIdentifier } from "./utils/helpers.js";

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/TypeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
GraphQLFieldResolver,
GraphQLResolveInfo,
GraphQLScalarLiteralParser,
GraphQLScalarSerializer,
Expand Down Expand Up @@ -53,3 +54,13 @@ export type GqlScalar<TInternal> = {
*/
parseLiteral?: GraphQLScalarLiteralParser<TInternal>;
};

/**
* 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<any, any>,
) => GraphQLFieldResolver<any, any>;
Loading
Loading