In GraphQL, Resolver’s existence is similar to Controller level in RESTFul API, which is more obvious in Node framework such as NestJS and MidwayJS, which provide @Controller decorator. A simple example of Midway Koa’s Onion middleware model that can tamper with requests and responses is this:

import { Provide } from '@midwayjs/decorator';
import { IWebMiddleware, IMidwayKoaContext, IMidwayKoaNext } from '@midwayjs/koa';

@Provide(a)export class ReportMiddleware implements IWebMiddleware {
  resolve() {
    return async (ctx: IMidwayKoaContext, next: IMidwayKoaNext) => {
      const startTime = Date.now();
      await next();
      console.log(Date.now() - startTime); }; }}Copy the code

We know that the middleware models of Koa and Express are different. Koa’s middleware is executed in the order of registration, middleware 1 in – middleware 2 in – middleware 2 out – middleware 1 out, while Express simply executes in the order of registration. The last middleware is usually responsible for responding to the request.

Is it possible to implement middleware in GraphQL before and after Resolver? Resolvers in GraphQL Schema are stored like this (if you didn’t know before, congratulations!) :

export declare class GraphQLSchema {
  description: Maybe<string>;
  getTypeMap(): TypeMap;
  / /... Other irrelevant definitions
}

declare type TypeMap = ObjMap<GraphQLNamedType>;

// GraphQLObjectType is a subtype of GraphQLNamedType
export declare class GraphQLObjectType<TSource = any, TContext = any> {
  name: string;
  description: Maybe<string>;
  getFields(): GraphQLFieldMap<TSource, TContext>;
}

export declare type GraphQLFieldMap<TSource, TContext> = ObjMap<
  GraphQLField<TSource, TContext>
>;

export interface GraphQLField<TSource, TContext, TArgs = any> {
  name: string;
  description: Maybe<string>;
  type: GraphQLOutputType;
  // Here it is!resolve? : GraphQLFieldResolver<TSource, TContext, TArgs>; }Copy the code

Following the type, we find that Resolver is defined on the GraphQL Field, which means when we define Resolver as follows:

const resolvers: IResolvers = {
  Query: {
    hello: (root, args, context, info) = > {
      return `Hello ${args.name ? args.name : "world"}! `;
    },
    bye: (root, args, context, info) = > {
      return `Bye ${args.name ? args.name : "world"}! `; ,}}};Copy the code

The top-level Query fields: hello and bye are bound to the corresponding functions respectively. See execut.ts for the execution logic in the GraphQL source code.

function executeField() :PromiseOrValue<unknown> {
  const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]);
  
  const resolveFn = fieldDef.resolve ?? exeContext.fieldResolver;

  try {
    const contextValue = exeContext.contextValue;
    const result = resolveFn(source, args, contextValue, info);
  } catch (rawError) {
  }
}
Copy the code

As you can see, the Resolver execution actually passes the parameters required in the GraphQL proposal into the function:

  • Source, the processing information of the upper level Field Resolver. For the top-level Resolver, the GraphQL Server framework like ApolloServer provides an additional rootValue as its source. And in Apollo this parameter is named parent.
  • Args, the parameter required by the current Field, is parsed from Operation.
  • Context: a value shared by resolvers at all levels, used for authentication, DataLoader registration, burying points, etc.
  • Info, the information that is specific to this operation, needs to go through another methodbuildResolveInfoAssembly, see specific informationdefinition.ts

There is nothing special about Resolver. All we need to do is take the definition of the function itself and its input parameters, and then execute a middleware function before and after it. Furthermore, with access to the types and fields available, we can easily control the application level of middleware, such as default global, only Field (not Field), only top-level Field, and so on. This is analogous to the concepts of global middleware and routing middleware in Midway.

Once the implementation possibilities are identified, we expect the middleware to look something like this:

const middleware1 = (rawResolver, source, args, context, info) => { console.log('In! '); const result = await rawResolver(root, args, context, info); console.log('Out! '); return result; }Copy the code

Middleware registration should also be simple, passing in GraphQL Schema and middleware directly:

constmiddlewareRegisteredSchema = applySchema(rawSchema, middleware1, middleware2, ...) ;Copy the code

RawSchema means you have to build a Schema in advance, Such as makeExecutableSchema provided by @graphql-tools/schema or buildSchemaSync provided by TypeGraphQL and other similar tools.

In the applySchema method, we first iterate through the middleware array, performing a registration for each middleware. It is important to note that the order we expect is similar to Koa’s onion model, i.e. mw1 in – MW2 in – actual logic – MW2 out – MW1 out. Therefore, the middleware with a lower position in the registration process needs to be registered first to ensure that it is in the middle of the middleware queue. We can simply use the reduceRight method to achieve:

export constapplyMiddleware = <TSource = any, TContext = any, TArgs = any>( schema: GraphQLSchema, ... middlewares: IMiddlewareResolver<TSource, TContext, TArgs>[] ): GraphQLSchema => { const modifiedSchema = middlewares.reduceRight( (prevSchema, middleware) => attachSingleMiddlewareToSchema(prevSchema, middleware), schema ); return modifiedSchema; };Copy the code

We need in attachSingleMiddlewareToSchema approach to complete the registration of middleware, this step we need to:

  • For Query and Mutation (as well as Mutation Subscription, but not implemented here for simplicity), take all its fields and tamper with the Resolver for each Field
  • Add the new Resolver back to the Schema we use here@graphql-tools/schemaaddResolversToSchema Methods to carry out
const attachSingleMiddlewareToSchema = <
  TSource = any,
  TContext = any,
  TArgs = any
>(
  schema: GraphQLSchema,
  middleware: IMiddlewareResolver<TSource, TContext, TArgs>
): GraphQLSchema= > {
  const typeMap = schema.getTypeMap();

  const modifiedResolvers: IResolvers = Object.keys(typeMap)
    .filter((type) = > ["Query"."Mutation"].includes(type))
    .reduce(
      (resolvers, type) = > ({
        ...resolvers,
        [type]: attachSingleMiddlewareToObjectType(
          typeMap[type] as GraphQLObjectType,
          middleware
        ),
      }),
      {}
    );

  const modifiedSchema = addResolversToSchema({
    schema,
    resolvers: modifiedResolvers,
    updateResolversInPlace: false.resolverValidationOptions: {
      requireResolversForResolveType: "ignore",}});return modifiedSchema;
};
Copy the code

Through updateResolversInPlace and resolverValidationOptions parameters, we ensure that the original Resolver will be overwritten.

Here then is the most important attachSingleMiddlewareToObjectType method, we need to get all the GraphQL on the ObjectType Field and in order to modify their resolve attributes:

const attachSingleMiddlewareToObjectType = <
  TSource = any,
  TContext = any,
  TArgs = any> (type: GraphQLObjectType<TSource, TContext>,
  middleware: IMiddlewareResolver<TSource, TContext, TArgs>
): IResolvers<TSource, TContext> => {
  const fieldMap = type.getFields();

  const modifiedFieldResolvers: IResolvers<TSource, TContext> = Object.keys(
    fieldMap
  ).reduce((resolvers, fieldName) = > {
    const currentField = fieldMap[fieldName];
    // @ts-expect-error 
    const{ isDeprecated, ... rest } = currentField;const argsMap = currentField.args.reduce(
      (acc, cur) = > ({
        ...acc,
        [cur.name]: cur,
      }),
      {} as Record<string, GraphQLArgument>
    );

    constparsedField = { ... rest,args: argsMap,
    };

    
    constmodifiedFieldData = parsedField.resolve && parsedField.resolve ! == defaultFieldResolver ? {... parsedField,resolve: wrapResolverInMiddleware(parsedField.resolve, middleware), } : { ... parsedField,resolve: defaultFieldResolver };

    return {
      ...resolvers,
      [fieldName]: modifiedFieldData,
    };
  }, {});
  return modifiedFieldResolvers;
};
Copy the code
  • Used in previous versions of GraphQL16isDeprecatedIdentifies Field Deprecation for future usedeprecationReasonlogo
  • Assign the args attribute to theGraphQLArgument[](read only) convert toRecord<string, GraphQLArgument>This is because GraphQL actually passes args to the Resolver, so make sure the middleware gets the same form as the original Resolver.
  • If the Field does not define a Resolver, or uses the default built-in Resolver (which returns a key with the same name as the Field from source), then we do not do middleware processing and return directly, otherwise we usewrapResolverInMiddlewareTo complete a last-minute kick: middleware injection.

Finally, wrapResolverInMiddleware is a simple higher-order function:

function wrapResolverInMiddleware<TSource.TContext.TArgs> (resolver: GraphQLFieldResolver
       
        , middleware: IMiddlewareResolver
        ,>
       ,>) :GraphQLFieldResolver<TSource.TContext.TArgs> {
  return (parent, args, ctx, info) = >
    middleware(
      (_parent = parent, _args = args, _ctx = ctx, _info = info) = >
        resolver(_parent, _args, _ctx, _info),
      parent,
      args,
      ctx,
      info
    );
}
Copy the code

To put it to practical use, use ApolloServer to create a simple GraphQL Server:

import { ApolloServer } from "apollo-server";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { applyMiddleware } from "./graphql-middleware-core/core";
import type { IMiddleware, IResolvers } from "./graphql-middleware-core/core";

const typeDefs = ` type Query { hello(name: String): String } `;

const resolvers: IResolvers = {
  Query: {
    hello: (root, args, context, info) = > {
      console.log(`3. Core: resolver: hello`);
      return `Hello ${args.name ? args.name : "world"}! `; ,}}};const logInput: IMiddleware = async (resolve, root, args, context, info) => {
  console.log(`1. logInput Start: The ${JSON.stringify(args)}`);
  const result = await resolve(root, args, context, info);
  console.log(`5. logInput End`);
  return result;
};

const logResult: IMiddleware = async (resolve, root, args, context, info) => {
  console.log(`2. logResult Start`);
  const result = await resolve(root, args, context, info);
  console.log(`4. logResult End: The ${JSON.stringify(result)}`);
  return result;
};

const schema = makeExecutableSchema({ typeDefs, resolvers });

const schemaWithMiddleware = applyMiddleware(schema, logInput, logResult);

const server = new ApolloServer({
  schema: schemaWithMiddleware,
});

(async() = > {await server.listen({ port: 8008 });

  console.log(`http://localhost:8008`); }) ();Copy the code

We registered two simple middleware, logInput to print input parameters and logResult to print results, and expected the final print results to be in ordinal order, using the following statement in GraphQL Playground or Apollo Studio:

query TestQuery {
  hello
}
Copy the code

Console print result:

1. logInput Start: {}
2. logResult Start
3. Core: resolver: hello
4. logResult End: "Hello world!"
5. logInput End
Copy the code

You can see that the results we expected are already working.

In fact, this processing logic is at the heart of GraphQL-Middleware, This library is also the base of GraphQL Middleware for graphQL-Shield GraphQL-Middleware – Apollo-upload-Server, which provides partial functions such as authentication, uploading, logging, etc.

If GraphQL Middleware provides free Middleware registration logic, you can just pass in a legitimate GraphQL Schema, no matter what tool you use to build it. We mentioned above that TypeGraphQL also provides an API to build schemas, which in fact provides middleware related functionality itself.

Using TypeGraphQL, we can use Class and Decorator syntax to describe GraphQL Schema, such as the GraphQL Schema shown below

type Recipe {
  id: ID!
  title: String!
  description: String
  creationDate: Date!
  ingredients: [String! ] ! }Copy the code

The corresponding Class code:

@ObjectType(a)class Recipe {
  @Field(type= > ID)
  id: string;

  @Field(a)title: string;

  @Field({ nullable: true}) description? :string;

  @Field(a)creationDate: Date;

  @Field(type= > [String])
  ingredients: string[];
}
Copy the code

Corresponding Resolver code:

@Resolver(a)class RecipeResolver {

  @Query(returns= > [Recipe])
  async recipes(): Promise<Recipe[]> {
    // ...}}Copy the code

Declare a middleware and add:

export const ResolveTime: MiddlewareFn = async ({ info }, next) => {
  const start = Date.now();
  await next();
  const resolveTime = Date.now() - start;
  console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`);
};


@Resolver(a)export class RecipeResolver {
  @Query(a)@UseMiddleware(ResolveTime)
  randomValue(): number {
    return Math.random(); }}Copy the code

In such ResolveTime RecipeResolver. RandomValue effect. In fact, we can even define it directly in the ObjectType Class, so that all attributes that refer to the Field will take effect:

@ObjectType(a)export class Recipe {
  
  @Field(type= > [Int])
  @UseMiddleware(LogAccess)
  ratings: number[];
}
Copy the code

TypeGraphQL also supports global middleware forms. Similarly, we need to make changes to the full GraphQL Schema, which in this case happens in buildSchema:

const schema = await buildSchema({
  resolvers: [RecipeResolver],
  globalMiddlewares: [ErrorInterceptor, ResolveTime],
});
Copy the code

The middleware in TypeGraphQL is obviously much more powerful than GraphQL-Middleware, but since the latter actually provides abstract, tool-neutral middleware registration capabilities, the comparison isn’t really meaningful.

In the next GraphQL article, we’ll take a look at GraphQL Diretives, in terms of implementation, usage, how it works, and how it compares to GraphQL Middleware in this article.

Thank you for your reading