graphql-tools: SchemaDirectiveVisitor.visitInputFieldDefinition resolver doesn't fire

While attempting to build a custom auth directive I’m unable to get wrapped resolvers to invoke when working with Input types. Is this the expected behavor? In my case I’d like to use a custom directive to limit write access via mutation input arguments. Consider the following example

const { defaultFieldResolver } = require('graphql')
const { SchemaDirectiveVisitor, makeExecutableSchema } = require('graphql-tools')
const { graphqlExpress } = require('apollo-server-express')
const bodyParser = require('body-parser')
const express = require('express')

const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
  extended: true
}))

const typeDefs = `
directive @auth(
  requires: Role = USER,
  action: Action = READ,
) on INPUT_FIELD_DEFINITION

enum Role {
  ADMIN
  USER
}

enum Action {
  READ
  MODIFY
}

type Author {
  id: ID!
  firstName: String
  lastName: String
  role: String
}

input AuthorInput {
  id: ID!
  firstName: String
  lastName: String
  role: String @auth(requires: ADMIN, action: MODIFY)
}

type Mutation {
  submitUser(
    author: AuthorInput!
  ): Author
}

type Query {
  authors: [Author]
}

schema {
  query: Query
  mutation: Mutation
}
`
const resolvers = {
  Mutation: {
    submitUser (_, { author }) {
      // save author
      console.log('saving author...')
      return author
    }
  }
}

class AuthDirective extends SchemaDirectiveVisitor {
  visitInputFieldDefinition (field, details) {
    console.log('visitInputFieldDefinition')
    const { resolve = defaultFieldResolver } = field
    field.resolve = function (...args) {
      console.log('Custom resolver')
      // Auth logic would go here
      return resolve.apply(this, args)
    }
  }
}

const executableSchema = makeExecutableSchema({
  typeDefs,
  resolvers,
  schemaDirectives: { auth: AuthDirective }
})

app.use('/graphql', graphqlExpress((request) => {
  return {
    schema: executableSchema
  }
}))

app.listen(8000, () => console.log(':8000 Listening'))

Starting the server prints

visitInputFieldDefinition
:8000 Listening

Submitting the following graphQL mutation

mutation addPerson {
  submitUser(author: {
    id: "123"
    firstName: "Payton"
    lastName: "Manning"
    role: "admin"
  }) {
    id
    role
  }
}

Prints

saving author...

I’d expect to see

Invoking custom resolver
saving author...

Most examples I see that use custom directives and input types only change filed.type, but I’m hoping to invoke some custom auth logic before the mutation resolver runs. #640 seems like it should help here but I’ve been unable to get my auth resolver to invoke.

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 47
  • Comments: 21 (1 by maintainers)

Most upvoted comments

Hi guys,

I have implemented a directive that is checking access control for Input Fields on mutations. My implementation gives access to context and value on the input field. ( Of course, it’s proof of concept but it can be helpful for someone)

import {defaultFieldResolver} from 'graphql';
import {SchemaDirectiveVisitor} from 'graphql-tools';
import filter from 'lodash/filter';
import union from 'lodash/union';

export default class AuthDirective extends SchemaDirectiveVisitor {
    getMutations(predicate = null) {
        if (!this._mutations) {
            this._mutations = Object.values(
                this.schema.getMutationType().getFields()
            );
        }
        if (!predicate) {
            return this._mutations || [];
        }
        return filter(this._mutations, predicate);
    }

    visitInputFieldDefinition(field, {objectType}) {
        const {name, defaultValue} = field;
        addAuthInfoToDescription(field, this.args.roles);
        const mutationsForInput = this.getMutations(({args = []}) => {
            return args.find(arg => arg && arg.type && arg.type.ofType === objectType;
        });
        mutationsForInput.forEach(mutation => {
            const {resolve = defaultFieldResolver} = mutation;
            mutation.resolve = function staffResolve(...args) {
                const params = args[1];
                // some lookup...  
                const subKey = Object.values(params).find(el => el && el[name]);
                if (
                    params[name] !== defaultValue ||
                    (subKey && subKey[name] !== defaultValue)
                ) {
                    const context = args[2];
                    //  throws an error if no auth
                    ensureIsAuth(context, this.args.roles);
                }
                return resolve.apply(this, args);
            };
        });
    }

  visitArgumentDefinition(argument, {field}) {
        const {name, defaultValue} = argument;
        const {resolve = defaultFieldResolver} = field;
        addAuthInfoToDescription(argument, this.args.roles);
        field.resolve = function staffResolve(...args) {
            const params = args[1];
            if (params[name] !== defaultValue) {
                const context = args[2];
                //  throws an error if no auth
                ensureIsAuth(context, this.args.roles);
            }
            return resolve.apply(this, args);
        };
    }

    visitObject(type) {
        this.ensureFieldsWrapped(type);
        type.__staff = true;
        type.__staffRoles = this.args.roles || [];
        addAuthInfoToDescription(type, this.args.roles);
    }

    visitFieldDefinition(field, details) {
        this.ensureFieldsWrapped(details.objectType);
        field.__staff = true;
        field.__staffRoles = this.args.roles || [];
        addAuthInfoToDescription(field, this.args.roles);
    }

    ensureFieldsWrapped(objectType) {
        // Mark the GraphQLObjectType object to avoid re-wrapping:
        if (objectType._staffFieldsWrapped) return;
        objectType._staffFieldsWrapped = true;

        const fields = objectType.getFields();

        Object.keys(fields).forEach(fieldName => {
            const field = fields[fieldName];
            const {resolve = defaultFieldResolver} = field;
            field.resolve = function staffResolve(...args) {
                if (field.__staff || objectType.__staff) {
                    const context = args[2];
                    //  throws an error if no auth
                   ensureIsAuth(context, union(
                       field.__staffRoles,
                       objectType.__staffRoles
                   ));
                }
                return resolve.apply(this, args);
            };
        });
    }
}
// Adds annotation to the schema, helpful e.g. in a playground
function addAuthInfoToDescription(field, roles) {
    roles = roles || [];
    if (!roles.length) {
        roles.push('AUTH');
    }
    field.description = `**REQUIRE:** ${roles.join(
        ', '
    )} \n ${field.description || ''}`;
}
##############
# Directives #
##############
"Authorisation"
directive @authf(
    roles: [SomeAuthRoles]
) on OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION

Here you go. It works a bit differently from the example I linked before. I prefer one directive per constraint rather than smashing them all together.

This works by defining a ValidationType which wraps the original type (through the directive definition) and adds validation when it is parsed. If it fails, it throws a ValidationError. I use the validator NPM package to do the actual validation logic.

# Validators
directive @email on INPUT_FIELD_DEFINITION
directive @length(min: Int, max: Int) on INPUT_FIELD_DEFINITION

input AccountCreateInput {
  email: String! @email
  password: String! @length(min: 8)
}
export class ValidationTypeError extends Error {}

class EmailDirective extends SchemaDirectiveVisitor {
  visitInputFieldDefinition(field: GraphQLInputField): GraphQLInputField | void | null {
    field.type = ValidationType.create(field.type, new EmailConstraint());
  }
}

class LengthDirective extends SchemaDirectiveVisitor {
  visitInputFieldDefinition(field: GraphQLInputField): GraphQLInputField | void | null {
    field.type = ValidationType.create(field.type, new LengthConstraint(this.args));
  }
}

export const DIRECTIVES: Record<string, typeof SchemaDirectiveVisitor> = {
  email: EmailDirective,
  length: LengthDirective,
};
interface ValidationConstraint {
  getName(): string;
  validate(value);
  getCompatibleScalarKinds(): string[];
}

export class EmailConstraint implements ValidationConstraint {
  getName(): string {
    return 'Email';
  }

  validate(value) {
    if (_.isString(value) && !validator.isEmail(value)) {
      throw new ValidationError('email', value);
    }
  }

  getCompatibleScalarKinds(): string[] {
    return [Kind.STRING];
  }
}

export class LengthConstraint implements ValidationConstraint {
  private readonly args: { [name: string]: any };

  constructor(args: { [name: string]: any }) {
    this.args = args;
  }

  getName(): string {
    return 'Length';
  }

  validate(value) {
    if (_.isString(value) && !validator.isByteLength(value, this.args)) {
      throw new ValidationError('length', value, this.args);
    }
  }

  getCompatibleScalarKinds(): string[] {
    return [Kind.STRING];
  }
}
/**
 * A validation type is injected from a validation directive and serves the purpose of
 * applying the passed constraint to the type.
 *
 * Unfortunately input types don't currently have a "resolve" mechanism from directives
 * so this is a workaround
 */
export class ValidationType extends GraphQLScalarType {
  /**
   * Create a new validation type with the existing type wrapped inside
   */
  static create(type, constraint: ValidationConstraint) {
    // Wrap scalar types directly
    if (type instanceof GraphQLScalarType) {
      return new this(type, constraint);
    }

    // If the root is a non-null type, we should wrap the inner type instead
    if (type instanceof GraphQLNonNull && type.ofType instanceof GraphQLScalarType) {
      return new GraphQLNonNull(new this(type.ofType, constraint));
    }

    throw new Error(`Type ${type} cannot be validated. Only scalars are accepted`);
  }

  /**
   * Create the wrapper type and validation handler for the constraint on the type
   */
  private constructor(type, constraint: ValidationConstraint) {
    super({
      name: `Is${constraint.getName()}`,
      description: 'Scalar type wrapper for input validation',

      /**
       * Server -> Client
       */
      serialize(value) {
        return type.serialize(value);
      },

      /**
       * Client (Variable) -> Server
       */
      parseValue(value) {
        const parsedValue = type.parseValue(value);

        constraint.validate(parsedValue);

        return parsedValue;
      },

      /**
       * Client (Param) -> Server
       */
      parseLiteral(valueNode: ValueNode, variables?: Maybe<{ [key: string]: any }>) {
        const parsedValue = type.parseLiteral(valueNode, variables);

        constraint.validate(parsedValue);

        return parsedValue;
      },
    });
  }
}

@sami616 you should be able to get the parent from the details, and then all the fields from the parent

const fields = objectType.getFields();

Object.keys(fields).forEach(fieldName => {
  const field = fields[fieldName];
  const { resolve = defaultFieldResolver } = field;
  field.resolve = async function (...args) {
	// Custom validation here

	resolve.apply(this, args);
  }
}

Closing for now as working as designed with package from community available to help streamline.

This problem is really challenging to work around. We’d really like to allow basic data transforms on our input types to reduce code bloat. We often have stuff where we want to do something like email: String! @trim @lower and it’s nearly impossible to horribly awkward. The mechanic for normal types works great, but for input it feels basically impossible.

Using @cristo-rabani 's code above I was able to do something similar in typescript(with a lot of ts-ignores). This might be handy for anyone else who is trying to make it work in typescript or might find the variable names unclear, although it’s still pretty terse:

visitInputFieldDefinition(field: GraphQLInputField, details: any) {
    const { name, defaultValue } = field;
    // @ts-ignore
    const schema: GraphQLSchema = this.schema;
    // @ts-ignore
    const objectType: GraphQLInputObjectType = details.objectType;
    const mutationType = schema.getMutationType();
    if (!mutationType) {
      return;
    }

    const mutationsForInput = Object.values(mutationType.getFields()).filter(({ args = [] }): { args: any[] } => {
      // @ts-ignore
      return args.find((arg) => arg && arg.type === objectType);
    });

    mutationsForInput.forEach((mutation) => {
      const originalResolver = mutation.resolve;
      if (!originalResolver) {
        throw new Error('Cant make stuff without resolver');
      }
      mutation.resolve = async function wrappedResolver(...resolverArguments: any[]) {
        // @ts-ignore
        const args: GraphQLArgument[] = resolverArguments[1];
        if (!args) {
          throw new Error('Cannot wrap function without args');
        }

        // @ts-ignore
        const argument = Object.values(args).find((el) => el && el[name]);
        if (argument) {
          // @ts-ignore
          const value = argument[name];
          if (!grantService) {
            throw new Error('LunaSec Grant Service was not configured for the graphql token directive.');
          }
          const context: ExpressContext = resolverArguments[2];
          if (value && value !== defaultValue) {
            await SOME_ASYNC_FUNCTION // throws if there is an error.  Could also overwrite the value if needed
          }
        }

        // @ts-ignore
        return originalResolver.apply(this, resolverArguments);
      };
    });
  }

Seriously, graphql should trye to make this easier. Obviously it’s a hard problem, based on the above code, but clearly a lot of people need it! We shouldn’t have to do something so crazy. 🙃

Solved this with https://github.com/profusion/apollo-validation-directives/ but it wasn’t that simple, had to mark all input types requiring validation, then walk all the fields to see if any had input arguments that would lead to the validated type (even nested), if so then wrap the resolver to first validate the argument (or nested) and just after that call the resolver. That also introduces a validationErrors extra argument that is injected into such fields, it’s populated whenever an input field is nullable, matches the output behavior.

Hi @carloschneider, args is only definition from schema, you pass predicate function to filter whitch mutations have input.

Got the same issue, just need to figure out a way to access context somehow inside visitInputFieldDefinition.

@gaillota This indeed works for visitFieldDefinition but an input doesn’t have a resolve function so that doesn’t work with visitInputDefinition. visitInputDefinition seems to be called on schema creation therefore no context to be accessed to have auth directives on input.

The reason is quite simple, field argument in visitInputFieldDefinition is of type GraphQLInputField and there is no resolve property in GraphQLInputField.

Facing the same issue, wanted to restrict mutation on specific field based on user authorization.