This article came from a few months ago, but I didn’t publish it because I thought the content was relatively small (not many people have contacted GraphQL, and even less people know about Directive). This time I’m cooking cold rice on a whim. In fact, I have been thinking of a series of GraphQL articles, covering the beginning, actual practice and then principle, but I have no motivation. Maybe I will try to start after I enter this year.

The basic definition

GraphQL Directives are special Directives that are injected into (or written in)GraphQL Schema, beginning with @, for example:

directive @camelCase on FIELD_DEFINITION directive @date(format: String = "DD-MM-YYYY") on FIELD_DEFINITION directive @auth( requires: Role = ADMIN,) on the OBJECT | FIELD_DEFINITION enum Role {ADMIN REVIEWER USER UNKNOWN} query {USER @ auth (the requires: LOGIN){ name date @formatDate(template: "YYYY-MM-DD") desc @camelCase } }Copy the code

It can take arguments (such as @auth(requires: LOGIN)) or call directly (such as @camelCase). The distinction between the two directives is made at implementation time, such as the formatDate directive, which can be either @formatDate or @formatDate(template: “YYYY-MM-DD”). In an instruction implementation, you only need to specify a default parameter, such as

const { format = defaultFormat } = this. args;Copy the code

The source code to define

GraphQL Directives are defined in the source:

export class GraphQLDirective {
  name: string;
  description: Maybe<string>;
  locations: Array<DirectiveLocationEnum>;
  isRepeatable: boolean;
  args: Array<GraphQLArgument>;
  extensions: Maybe<Readonly<GraphQLDirectiveExtensions>>;
  astNode: Maybe<DirectiveDefinitionNode>;

  constructor(config: Readonly<GraphQLDirectiveConfig>);

  toConfig(): GraphQLDirectiveConfig & {
    args: GraphQLFieldConfigArgumentMap;
    isRepeatable: boolean;
    extensions: Maybe<Readonly<GraphQLDirectiveExtensions>>;
  };

  toString(): string;
  toJSON(): string;
  inspect(): string;
}

export interface GraphQLDirectiveConfig {
  name: string; description? : Maybe<string>;
  locations: Array<DirectiveLocationEnum>; args? : Maybe<GraphQLFieldConfigArgumentMap>; isRepeatable? : Maybe<boolean>; extensions? : Maybe<Readonly<GraphQLDirectiveExtensions>>; astNode? : Maybe<DirectiveDefinitionNode>; }Copy the code

Explain the parameters in general:

  • The name of the directive, which is ultimately used in the schema

  • Description Function

  • Locations Where the locations command takes effect:

    export const DirectiveLocation: {
      // Request Definitions
      // "root level"
      QUERY: 'QUERY';
      MUTATION: 'MUTATION';
      SUBSCRIPTION: 'SUBSCRIPTION';
      FIELD: 'FIELD';
      FRAGMENT_DEFINITION: 'FRAGMENT_DEFINITION';
      FRAGMENT_SPREAD: 'FRAGMENT_SPREAD';
      INLINE_FRAGMENT: 'INLINE_FRAGMENT';
      VARIABLE_DEFINITION: 'VARIABLE_DEFINITION';
    
      // Type System Definitions
      // Levels in various types
      SCHEMA: 'SCHEMA';
      SCALAR: 'SCALAR';
      OBJECT: 'OBJECT';
      FIELD_DEFINITION: 'FIELD_DEFINITION';
      ARGUMENT_DEFINITION: 'ARGUMENT_DEFINITION';
      INTERFACE: 'INTERFACE';
      UNION: 'UNION';
      ENUM: 'ENUM';
      ENUM_VALUE: 'ENUM_VALUE';
      INPUT_OBJECT: 'INPUT_OBJECT';
      INPUT_FIELD_DEFINITION: 'INPUT_FIELD_DEFINITION';
    };
    
    export type DirectiveLocationEnum = typeof DirectiveLocation[keyof typeof DirectiveLocation];
    
    Copy the code

Operation instruction based on GraphQL-tools

In GraphQL-Tools, a series of VisitXXX methods are used to achieve this effect, such as:

import { SchemaDirectiveVisitor } from "graphql-tools";

export class DeprecatedDirective extends SchemaDirectiveVisitor {
  visitSchema(schema: GraphQLSchema) {}
  visitObject(object: GraphQLObjectType) {}
  visitFieldDefinition(field: GraphQLField<any.any>) {}
  visitArgumentDefinition(argument: GraphQLArgument) {}
  visitInterface(iface: GraphQLInterfaceType) {}
  visitInputObject(object: GraphQLInputObjectType) {}
  visitInputFieldDefinition(field: GraphQLInputField) {}
  visitScalar(scalar: GraphQLScalarType) {}
  visitUnion(union: GraphQLUnionType) {}
  visitEnum(type: GraphQLEnumType) {}
  visitEnumValue(value: GraphQLEnumValue) {}
  
  // There are also three static methods
  // getDirectiveDeclaration
  // implementsVisitorMethod
  // visitSchemaDirectives
}
Copy the code

The 11 methods here correspond to the 11 type-level definitions in DirectiveLocation (the root level is used by the GraphQL runtime built-in)

  • Args, here is a k-V map, defined as follows:

    export interface GraphQLFieldConfigArgumentMap {
      [key: string]: GraphQLArgumentConfig;
    }
    export interfaceGraphQLArgumentConfig { description? : Maybe<string>;
      type: GraphQLInputType; defaultValue? :any; deprecationReason? : Maybe<string>; extensions? : Maybe<Readonly<GraphQLArgumentExtensions>>; astNode? : Maybe<InputValueDefinitionNode>; }Copy the code

    Defines parameters related configurations.

    Note that deprecationReason should be deprecationReason for the parameter, because I only saw isDeprecated and deprecationReason on a few types like GraphQLField, Only a few methods, such as visitFieldDefinition (to which GraphQLField is an argument), can discard fields (you can’t discard an entire object type or even an entire schema).

    You can also look at the implementation in the GraphQL built-in directive @include:

    export const GraphQLIncludeDirective = new GraphQLDirective({
      name: 'include'.description:
        'Directs the executor to include this field or fragment only when the 'if' argument is true. '.locationsFIELD, DirectiveLocation. FRAGMENT_SPREAD, DirectiveLocation. INLINE_FRAGMENT,],args: {
        if: {
          type: newGraphQLNonNull (GraphQLBoolean),description: 'that Included the when true. ',},},});Copy the code

    Args here. If is the parameter ~

    I was going to take a look at how the built-in directives parse, but I didn’t find any code in include

  • IsRepeatable, whether the instruction can be repeated, whether there are multiple instructions with the same name on the same location.

  • Extensions, directive extension information. Modifying this parameter is not fed back to the responding extensions.

  • AstNode, the ast node information generated by the command.

The practical application

Change the resolver

The most important function of the directive is to dynamically change the format of the returned data at run time, including the value and field structure. For example, in the visitFieldDefinition method, we can directly change the field. The resolve method is used to change the result of a field resolution, as in:

export class DateFormatDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field: GraphQLField<any.any>) {
    const { resolve = defaultFieldResolver } = field;
    const { format = defaultFormat } = this. args; The field. resolve =async(... args) => {console. log(`@date invoked on ${args[3]. parentType}.${args[3]. fieldName}`
      );
      const date = awaitResolve. apply(this, args);returnDateFormatter (date, format); }; The field.type= GraphQLString; }}Copy the code

In this example, we format the returned Date format. Since Date is not a built-in scalar format, DateScalarType provided by some tripartite libraries usually resolves Date to a timestamp, which is an Int. Therefore, if we change the format to “2021-02-06”, You need to change the field type to prevent errors.

There are also key messages on the field such as SUBSCRIBE (the parse function for the subscribe operation) and Extensions (the extensions attached to the field). And so on.

On GraphQLObject(the arguments to the visitObject method), we can even get all of the fields of the object type (getFields) and their implementation interfaces (getInterfaces), etc. (Field-level directives can also access the parent type, field. ObjectType can be used as an objectType.

Actual combat: @auth command

Another important function of the directive is the metadata functionality similar to the TS decorator. One decorator adds metadata, and later decorators collect metadata to use (just as the class decorator gets metadata from the method/property/parameter decorator), such as the @auth directive. This is done by taking the required permissions defined on all fields of the current object type, comparing the user permissions (usually stored in the resolve context parameter), and deciding whether to allow.

export const enumAuthDirectiveRoleEnum {ADMIN, REVIEWER, USER, UNKNOWN,}type AuthEnumMembers = keyof typeof AuthDirectiveRoleEnum;

type AuthGraphQLObjectType = GraphQLObjectType & {
  _requiredAuthRole: AuthEnumMembers;
  _authFieldsWrapped: boolean;
};

type AuthGraphQLField<T, K> = GraphQLField<T, K> & {
  _requiredAuthRole: AuthEnumMembers;
};

const getUser = async (token: string) :Promise<AuthEnumMembers[]> => {
  return ["USER"];
};

export class AuthDirective extends SchemaDirectiveVisitor {
  visitObject(type: AuthGraphQLObjectType) {
    console. log(`@auth invoked at visitObject The ${type. name}`);
    this. ensureFieldsWrapped(type);
    type. _requiredAuthRole =this. The args. requires; }visitFieldDefinition(
    field: AuthGraphQLField<any.any>, details: {objectType: AuthGraphQLObjectType; }) {
    console. log(`@auth invoked at visitFieldDefinition The ${field. name}`);

    this. EnsureFieldsWrapped (the details. objectType); The field. _requiredAuthRole =this. The args. requires; }ensureFieldsWrapped(objectType: AuthGraphQLObjectType) {
    if(the objectType. _authFieldsWrapped)return; The objectType. _authFieldsWrapped =true;

    constFields = (objectType. getFields()as unknown) as AuthGraphQLField<
      any.any> [];Object. Keys (fields). forEach((fieldName) = > {
      const field = fields[fieldName] as AuthGraphQLField<any.any>;
      const{ resolve = defaultFieldResolver } = field; The field. resolve =async(... args) => {constRequiredRole = field. _requiredAuthRole | | objectType. _requiredAuthRole;console. log("requiredRole: ", requiredRole);if(! requiredRole) {returnResolve. apply(this, args); }// const context = args[2];
        // const userRoles = await getUser(context? . headers? . authToken ?? "");

        // if (! UserRoles. includes(requiredRole)) {
        // throw new Error("not authorized");
        // }

        returnResolve. apply(this, args); }; }); }public static getDirectiveDeclaration(
    directiveName: string.schema: GraphQLSchema
  ): GraphQLDirective {
    console. log(directiveName);constPreviousDirective = schema. getDirective(directiveName);console. log("previousDirective: ", previousDirective);if(previousDirective) {previousDirective. The args. forEach((arg) = > {
        if(arg. name ==="requires") {
          arg。defaultValue = "REVIEWER"; }});return previousDirective;
    }

    return new GraphQLDirective({
      name: directiveName.locationsOBJECT, DirectiveLocation. FIELD_DEFINITION,args: {
        requires: {
          type: schema. getType("AuthDirectiveRoleEnum") asGraphQLEnumType,defaultValue: "USER",},},}); }}Copy the code

There are quite a few details:

  • @auth can be placed on either field or objectType. When an objectType is marked, it is not repeated because the fields it contains depend on the metadata of the objectType when parsing.

  • GetDirectiveDeclaration this method is usually used when directives have been added to a Schema by multiple people, and you are not sure if the directive you added already exists. Then you need to explicitly declare the resulting instruction using this method.

    • If the directive already exists, such as @auth here, but you need to change the default value of the parameter or other information, and you don’t want to change every directive in the schema, just change the parameter information here.

       if(previousDirective) {previousDirective. The args. forEach((arg) = > {
              if(arg. name ==="requires") {
                arg。defaultValue = "REVIEWER"; }});return previousDirective;
          }
      Copy the code
    • Otherwise, you should return a brand new directive instance (this is not necessary at all, as the SchemaDirectiveVisitor method generates it automatically)

      return new GraphQLDirective({
            name: directiveName.locationsOBJECT, DirectiveLocation. FIELD_DEFINITION,args: {
              requires: {
                type: schema. getType("AuthDirectiveRoleEnum") asGraphQLEnumType,defaultValue: "USER",},},});Copy the code

      Since you’re actually using the native GraphQL method here, you need to directly use its internal API, such as DirectiveLocation, and so on.

  • The AuthDirectiveRoleEnum here is actually something we’ve already done via TypeGraphQL. RegisterEnum is registered, but you need to ensure that the enumeration is used in the Schema before it is added to the generated Schema.

    Furthermore, you would need to rewrite the Enum to ensure that the type is reliable.

    export const enumAuthDirectiveRoleEnum {ADMIN, REVIEWER, USER, UNKNOWN,}type AuthEnumMembers = keyof typeof AuthDirectiveRoleEnum;
    
    type AuthGraphQLObjectType = GraphQLObjectType & {
      _requiredAuthRole: AuthEnumMembers;
      _authFieldsWrapped: boolean;
    };
    
    type AuthGraphQLField<T, K> = GraphQLField<T, K> & {
      _requiredAuthRole: AuthEnumMembers;
    };
    Copy the code

Constant enumerations cannot be passed as arguments to the registration function.

Because TypeGraphQL uses TypeScript classes and decorators to write GraphQL Schema, TypeGraphQL necessarily has less freedom than native SDL. For example, there is no way to apply enumerations to unions, enums, and enumerated values. This will probably have to wait for the author to address the issue, such as adding instruction related configurations to registerEnum options.

Practical: @ the fetch

When the GraphQL API involves a third party DataSource (for example, as a BFF), one solution is to use a library similar to Apollo-datasource, where the third party’s DataSource fetching methods are mounted into the Context and then called by the Resolver. Another option is to use directives, which, as mentioned earlier, can dynamically modify the resolver’s execution logic.

The implementation of @fetch is also relatively simple. Just call the fetch method to fetch the URL in the parameter. This instruction is usually used only for simple request logic and is not recommended for extensive use.

import{defaultFieldResolver, GraphQLField}from "graphql";
import { SchemaDirectiveVisitor } from "graphql-tools";
import fetch from "node-fetch";

export class FetchDirective extends SchemaDirectiveVisitor {
  public visitFieldDefinition(field: GraphQLField<any.any>) {
    const { resolve = defaultFieldResolver } = field;
    const { url } = this. args; The field. resolve =async(... args) => {const fetchRes = await fetch(url);
      console. log(`@fetch invoked on ${args[3]. parentType}.${args[3]. fieldName}`
      );
      console. log(`Fetch Status: The ${fetchRes. status}`);
      const result = awaitResolve. apply(this, args);returnresult; }; }}Copy the code

Real: string processing instruction

String-related instructions are among the most widely used types, such as Uppercase, Lowercase, Capitalize, and so on. Lodash provides many of these types of processing methods, so we can use them directly.

One more thing to consider before you start is that you can’t just put all cases in the same directive and then specify logic in arguments like @String (Uppercase) or @String (Lowercase). The correct approach is to specify a separate directive for each processing, such as @upper lower, etc. And because the built-in logic of these instructions is actually similar (get the parse result – apply the change – return the new result), the same logic is decoupled.

Here we use the Mixin approach, with the base class receiving instruction names (such as upper) and conversion methods (from LoDash).

export type StringTransformer = (arg: string) = > string;

const CreateStringDirectiveMixin = (
  directiveNameArg: string.transformer: StringTransformer
): typeof SchemaDirectiveVisitor => {
  class StringDirective extends SchemaDirectiveVisitor {
    visitFieldDefinition(field: GraphQLField<any.any>) {
      const{ resolve = defaultFieldResolver } = field; The field. resolve =async(... args) => {console. log(` @${directiveNameArg} invoked on ${args[3]. parentType}.${args[3]. fieldName}`
        );
        const result = awaitResolve. apply(this, args);if (typeof result === "string") {
          return transformer(result);
        }

        return result;
      };
    } 

    public static getDirectiveDeclaration(
      directiveName: string.schema: GraphQLSchema
    ): GraphQLDirective {
      constPreviousDirective = schema. getDirective(directiveName);if (previousDirective) {
        return previousDirective;
      }

      return new GraphQLDirective({
        name: directiveNameArg.locations[DirectiveLocation. FIELD_DEFINITION],}); }}return StringDirective;
};
Copy the code

Create a batch at a time:

import{lowerCase, upperCase, camelCase, startCase, capitalize, kebabCase, trim, snakeCase,}from "lodash";

export const UpperDirective = CreateStringDirectiveMixin("upper", upperCase);export const LowerDirective = CreateStringDirectiveMixin("lower", lowerCase);export const CamelCaseDirective = CreateStringDirectiveMixin(
  "camelCase"CamelCase);export const StartCaseDirective = CreateStringDirectiveMixin(
  "startCase", startCase);export const CapitalizeDirective = CreateStringDirectiveMixin(
  "capitalize"Capitalize);export const KebabCaseDirective = CreateStringDirectiveMixin(
  "kebabCase", kebabCase);export const TrimDirective = CreateStringDirectiveMixin("trim", trim);export const SnakeCaseDirective = CreateStringDirectiveMixin(
  "snake", snakeCase);Copy the code