grats icon indicating copy to clipboard operation
grats copied to clipboard

Idea for discussion: Directive implementations

Open louy opened this issue 9 months ago • 4 comments

So it seems that the most common way to implement directives in graphql is as a transformer via mapSchema. This is the recommended approach by both yoga and apollo-server.

Almost all directives seem to be implemented like the following:

import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';

/** @gqlDirective on FIELD_DEFINITION | OBJECT */
export function directiveFn({}: {}) {} // grats directive fn

export directiveTransformer(schema: GraphQLSchema) {
  // Capture/initialise some state for this schema. For example, a map of types to their directive arguments
  const typeDirectiveArgumentMaps: Record<string, Parameters<typeof directiveFn>[0]> = {};
  return mapSchema(schema, {
    [MapperKind.TYPE]: type => {
      const myDirective = getDirective(schema, type, 'directiveName')?.[0] as
        | Parameters<typeof directiveFn>[0]
        | undefined;
      if (myDirective) {
        typeDirectiveArgumentMaps[type.name] = myDirective;
      }
      return undefined;
    },
    [MapperKind.OBJECT_FIELD]: (
      fieldConfig: GraphQLFieldConfig<any, Context>,
      _fieldName,
      typeName,
    ) => {
      const myDirective =
        (getDirective(schema, fieldConfig, 'directiveName')?.[0] as
          | Parameters<typeof directiveFn>[0]
          | undefined) ?? typeDirectiveArgumentMaps[typeName];
      if (myDirective) {
        const { /* args */ } = myDirective;
        // Some logic to modify field config
        const { resolve = defaultFieldResolver } = fieldConfig;
        fieldConfig.resolve = function (source, args, context, info) {
           // Logic to modify resolver here
          return resolve(source, args, context, info);
        };
        return fieldConfig;
      }
    },
  });
}

Now I thought we could call the directive function from grats once per supported field:

import {SchemaMapper} from '@graphql-tools/utils';

// Built-in types
type GratsDirectiveArg<Kind extends keyof SchemaMapper> =
  {
    [key in Kind]: [key, ...Parameters<SchemaMapper[key]>],
  }[Kind];
type GratsDirectiveReturn<Kind extends keyof SchemaMapper> = ReturnType<SchemaMapper[key]>;

/** @gqlDirective */
export function directiveFn<Kind extends 'FIELD_DEFINITION' | 'OBJECT'>( // Directive locations can be inferred from this
  [mapperKind, ...mapperArgs]: GratsDirectiveArg<Kind>,
  {arg}: {arg: Int}, // This is now the result of calling `getDirective` on the current mapped type
): GratsDirectiveReturn<Kind> {
  // But this loses flexibilty like being able to map over kinds where the directive isn't applicable
  switch (mapperKind) {
    case MapperKind.OBJECT_FIELD: {
      const [fieldConfig, fieldName, typeName, schema] = mapperArgs;
      // .. Modify field definition
      return fieldConfig; // Type-safe
    }
    // Etc...
  }
}

The problem with the above is where to put schema state like typeDirectiveArgumentMaps. Weak maps on the schema object can be used for this, and they should work for all usecases that I've seen so far. The 2nd problem is mapping over kinds where the directive doesn't apply, which is common for object-only directives.

Alternatively, another signature could be:

import {mapSchema} from '@graphql-tools/utils';

/** @gqlDirective */
export function directiveFn(
  {arg}: {arg: Int}, // Args always useless
  schema: GraphQLSchema
): GraphQLSchema {
  // This is basically a transform function. First argument is useless
  return mapSchema(schema, { /* mapper logic goes here */ })
}

But I dislike this because it's too repetitive, args can't be captured into a type, and default values are kinda useless.

I think the right approach is likely somewhere in the middle: a function that is a transform, that takes schema as its only argument and returns a schema, but allows directive arguments type to be used when calling getDirective.

This is a follow up from #166

louy avatar Mar 16 '25 13:03 louy

I've updated my example project which uses a server directive to use mapSchema and getDirective.

https://github.com/captbaritone/grats/blob/b8f383e36e6fb959ca662660b65578dc85981bc8/examples/production-app/graphql/directives.ts

You can still build a single function which takes your schema and applies the behavior you want, but you will have to define the part of the logic which actually consumes the directive arguments as a separate function. There's nothing particularly cute or clever about this approach, but I think it's also... fine? It seems straight forward to read and it's not too hard to make it fairly encapsulated.

That said your post got me thinking. Specifically for schema directives (remember that Grats can also define directives on client things within operations) I wonder if Grats could do something that is a bit clever. Similar to resolver functions if a directive function's first argument is something like GraphQLFieldConfig it could infer that this is a directive on a FIELD_DEFINITION, but also auto import the resolver function into the generated schema file and auto wrap the config object.

This would avoid any need for extracting the directive from extensions as well as the dependency on mapSchema. I forget if we already explored this approach and rejected it, but I might take a stab at prototyping support for this and see how far I can get.

captbaritone avatar Mar 23 '25 04:03 captbaritone

You can see a sketch of how I'm imagining Grats could behave (what you would write and what Grats would produce) here: https://github.com/captbaritone/grats/pull/182/files

captbaritone avatar Mar 23 '25 04:03 captbaritone

Playing with this a bit more, and I think it's not quite general purpose enough. It works well for fields, and maybe types, but probably not for everything. For example, a directive on ARGUMENT_DEFINITION probably wants to impact how the resolver behaves at runtime, and being able to mutate/transform the actual argument config is probably not sufficient.

captbaritone avatar Mar 23 '25 23:03 captbaritone

I'm not a big fan of this approach as it is quite limited (grats would have to build support for every directive location) and it doesn't allow overloading very well.

I think that declaring directives as functions might be the issue, they don't fit the model, but rather they are transforms.

On the other hand, types & interfaces do not have default values.

I wonder if there's something else that can be used for directives that's not a function invocation per directive, but rather something that either takes the entire schema and transforms it (with some type hints that can be used to construct the directive signature) or something that has no logic (and leave transforms out of scope of directive declarations).

Maybe a directive declaration can be an object (representing default args), with the type signature being the arguments, and the values being default values? (Although I don't know how that would work with required arguments that have no defaults, maybe a type helper Partial would solve this?)

E.g.

// in grats

// Store the non-partial type
type Directive<Args extends Object> = Partial<Args> & {$args: Args};
// Read directive args, setting defaults when available
function getDirective<D extends {$args: any}>(someConfig, directiveName: string, defaultArgs: D): D['$args'] | undefined {
  let directive = getDirective(typeConfig, directiveName);
  if (!directive) return undefined;
  return Object.assign({}, defaultArgs, directive) as D['$args'];
}
// userland

import {Directive, getDirective} from 'grats';
/** @gqlDirective on FIELD_DEFINITION | OBJECT */
export const semanticNonNull: Directive<{
  /** Docs for the argument */
  levels: Int[]
}> = {
  // Default value (if specified)
  levels: [0],
};

export function semanticNonNullTransform(schema: GraphQLSchema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT]: typeConfig => {
      const semanticNonNullDirective = getDirective(typeConfig, 'semanticNonNull');
      // Users can do whatevery the want
    },
  });
}

louy avatar Mar 24 '25 13:03 louy