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

Created on 20 Jun 2018  路  19Comments  路  Source: ardatan/graphql-tools

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.

Most helpful comment

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

All 19 comments

I have the same problem. I need to validate input fields but function _resolve_ in _visitInputFieldDefinition_ doesn't fire

I have the same problem. Anybody found a solution?

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.

Agreed, I would also really like to see a resolve on input types. However I found this on the Apollo blog which uses a custom scalar handler to validate instead. Not really ideal but it works.

@ryall could you share your solution which uses scalar for input field auth?

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;
      },
    });
  }
}

Hitting the same issue, is there any way around it / is the resolve property on the roadmap to be added to input types?

It would be really nice to be able to validate an entire input object with one directive, but cannot see how without each of the fields having a resolve prop 馃槴

@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);
  }
}

@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.

I resorted to wrapping the mutation instead, and explicitly throwing if an input type is wrapped. Schema:

directive @hasRole(
    requires: RoleAccessType
) on FIELD_DEFINITION | OBJECT

type Mutation {
    updateConfig (
        patch: ConfigPatch!
    ): Config @hasRole(requires: IsAdmin)
}

Directive:

export class HasRoleDirective extends SchemaDirectiveVisitor {
    visitInputObject(type: GraphQLInputObjectType) {
        console.error(`HasRoleDirective cannot wrap input "${type.name}", wrap a type instead!`);
        process.abort();
    }
}

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

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

@cristo-rabani

const mutationsForInput = this.getMutations(({args = []}) => {
    return args.find(arg => arg && arg.type && arg.type.ofType === objectType;
});

Something is missing here. Where do you get args from?

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

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.

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.

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

Was this page helpful?
0 / 5 - 0 ratings

Related issues

confuser picture confuser  路  4Comments

avnersorek picture avnersorek  路  3Comments

flippidippi picture flippidippi  路  3Comments

ericclemmons picture ericclemmons  路  4Comments

freiksenet picture freiksenet  路  4Comments