Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SchemaDirectiveVisitor.visitInputFieldDefinition resolver doesn't fire #858

Closed
curtisallen opened this issue Jun 20, 2018 · 21 comments
Closed

Comments

@curtisallen
Copy link

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.

@yusencode
Copy link

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

@kuksik
Copy link

kuksik commented Aug 10, 2018

I have the same problem. Anybody found a solution?

@nekuz0r
Copy link

nekuz0r commented Aug 30, 2018

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.

@ryall
Copy link

ryall commented Sep 10, 2018

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.

@purplemana
Copy link

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

@ryall
Copy link

ryall commented Oct 3, 2018

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

@matthew-petrie
Copy link

matthew-petrie commented Nov 9, 2018

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

@sami616
Copy link

sami616 commented Jan 12, 2019

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 😫

@gaillota
Copy link

gaillota commented Jan 17, 2019

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

@Nqsty
Copy link

Nqsty commented Jan 24, 2019

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

@WhatIsHeDoing
Copy link

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

@PinkyJie
Copy link

PinkyJie commented Jun 4, 2019

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

@cristo-rabani
Copy link

cristo-rabani commented Jun 8, 2019

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

@carloschneider
Copy link

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

@cristo-rabani
Copy link

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

@owenallenaz
Copy link
Contributor

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.

@yaacovCR
Copy link
Collaborator

yaacovCR commented Mar 8, 2020

See #789

@barbieri
Copy link

barbieri commented Mar 8, 2020

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.

@yaacovCR
Copy link
Collaborator

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

@filozof6
Copy link

filozof6 commented Jun 30, 2021

I have created a hacky solution with: https://github.com/fastify/fastify-request-context

On context initialization I inject the graphql context into the global context and then use the docs example: https://www.apollographql.com/docs/apollo-server/schema/custom-scalars/

And on parse literal I get the context and do whatever I need.

I think this is a bad practice but its much more concise that other aforementioned examples

@factoidforrest
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests