graphql-nexus / nexus

Code-First, Type-Safe, GraphQL Schema Construction
https://nexusjs.org
MIT License
3.4k stars 275 forks source link

There is any [Plugins] contribution guide? #502

Open filoscoder opened 4 years ago

filoscoder commented 4 years ago

Hi,

In my team, on the server-side, we're loving this nexus schema thing.

We're using nexus all over our project resolvers, and we are founding some gaps to fill, and I thought it would be fun to contribute with a specific plugin inside the schema. (ref)

Oh, by the way, I'm planning to build something to check customizable arguments validation.

santialbo commented 4 years ago

I'm very curious to see what you built. We also built a few plugins including:

filoscoder commented 4 years ago

I'm very curious to see what you built. We also built a few plugins including:

  • An offset/limit based pagination plugin with other capabilities
  t.paginatedField("users", {
    type: "User",
    searchable: true,
    sortableBy: ["name", "createdAt"], // <- type-safe, pulling property names from the specified type.
    ...
  });
  • An argument validation plugin
export const doSomething = mutationField(
  "doSomething",
  {
    ...
    args: {...},
    validateArgs: validateAnd(
      notEmptyArray((args) => args.petitionIds, "petitionIds"),
      notEmptyArray((args) => args.userIds, "userIds"),
      maxLength((args) => args.message, "message", 1000)
    ),
    ...
});

Actually I'm just planning to build the plugin. I didn't build it yet. What we need is an argument validation, I found very interesting your approach. Seems very customizable and pretty straight forward.

What is the return value from the validateAnd() method?

santialbo commented 4 years ago

In my plugin all validators have the following type.

export type FieldValidateArgsResolver<
  TypeName extends string,
  FieldName extends string
> = (
  root: core.RootValue<TypeName>,
  args: core.ArgsValue<TypeName, FieldName>,
  context: core.GetGen<"context">,
  info: GraphQLResolveInfo
) => core.MaybePromise<void>;

export function validateAnd<TypeName extends string, FieldName extends string>(
  ...validators: FieldValidateArgsResolver<TypeName, FieldName>[]
) {
  return (async (root, args, ctx, info) => {
    await Promise.all(
      validators.map((validator) => validator(root, args, ctx, info))
    );
  }) as FieldValidateArgsResolver<TypeName, FieldName>;
}

Have a look at what other people have built: https://github.com/sytten/nexus-shield by @Sytten seems to be very well thought. I probably wouldn't have built mine if I had found his earlier.

Sytten commented 4 years ago

Glad I could help, I also looked at the example plugins when I started. Note that I currently have a typing issue with mine on the objectType level (typing on attribute level is fine) which I was not able to resolve yet. I am happy to write a start guide once the whole thing is a bit more stable. The biggest pain is really when TypeName or FieldName decides that is sticks to string rather than being specialized. @jasonkuhrt might want to discuss if we could improve that actually (off topic though).

filoscoder commented 4 years ago

Thank you both @santialbo @Sytten, this helps me a lot!

besides, I'm following up this guide provided by nexusjs

santialbo commented 4 years ago

I think those plugins are different from @nexus/schema plugins (which are the ones I was talking about) In order to write mine I followed the existing plugins on https://github.com/graphql-nexus/schema/tree/develop/src/plugins

Sytten commented 4 years ago

Yeah nexus plugins are another beast, I have a branch to support the nexus framework for my plugin but I currently only support schema since I don't use the framework.

filoscoder commented 4 years ago

@santialbo thanks for your aclaration.. I was struggling with the 'beast' hehe I'll share here when I got something ready.

filoscoder commented 4 years ago

Arguments Validator plugin

This plugin works inside the @nexus/schema standalone component of the Nexus Framework

This plugin helps us validate configured nexus arguments values during the execution of our queries and mutations. It does this by defining the validation field when nexus queryField and mutationField configuration it's defined. In order to proceed with validation, it needs to provide the path of the arguments as an Array. If an invalid value is found, a very kind Error message will be thrown indicating the path of the argument, which data type it is, and finally which was the value of the argument that causes the crashing.

Installation

import { argsValidatorPlugin } from './utils/argsValidatorPlugin';

const schema = makeSchema({
  // ... types,
  plugins: [
    // ... other plugins
    argsValidatorPlugin(),
  ],
// ... etc,
})

Usage 1: simple path

// signIn mutation resolver
export const signIn = mutationField('signInEmail', {
  type: 'Auth',
  args: {
    email: stringArg({ nullable: false }),
    password: stringArg({ nullable: false }),
  },
  validation: {
    shouldValidate: (args) => {
        // Some resolver logic...
       return true; // must be true to proceed with validation
  },
    argsPath: ['email', 'password'],
  },
  resolve: async (_parent, args, ctx) => {
        // Some resolver logic...
  },
});

Usage 2: deep path

// @Nexus/schema type definition
export const UserInputType = inputObjectType({
  name: 'UserCreateInput',
  definition(t) {
    t.string('email', { nullable: false });
    t.string('password', { nullable: false });
    t.string('name');
    t.string('nickname');
    t.date('birthday');
    t.gender('gender');
    t.string('phone');
    t.string('statusMessage');
  },
});

// signUp mutation resolver
export const signUp = mutationField('signUp', {
  type: 'User',
  args: {
    user: 'UserCreateInput',
  },
  validation: {
    shouldValidate: (args) => {
        // Some resolver logic...
       return true; // must be true to proceed with validation
  },
    argsPath: ['user.email', 'user.password', 'user.name', 'user.nickname'],
  },
  resolve: async (_parent, { user }, ctx) => {
         // Some resolver logic...
});

Validation data types

More patterns (like email valid pattern or customized password pattern) & data types will be covered.

For now, these are the data types that validation works:

  1. string: An empty string is an invalid value.
  2. number: NaN is an invalid value.
  3. object: Null is an invalid value.
  4. undefined is invalid.

Error message

When a mutation with an empty string value is executed. signIn_mutation_with_emptyString

Result

empty_string_error_message

References

I built an argument validator plugin. Above is how was implemented. (Thanks for your advice @santialbo, @Sytten) What are your thoughts about it? All kinds of feedbacks are welcomed 👍🏾

Sytten commented 4 years ago

@filoscoder looks great, maybe a link to the repo would be better? My first impression is: why not put the verifier on the input type directly instead of trying to match the path?

export const UserInputType = inputObjectType({
  name: 'UserCreateInput',
  definition(t) {
    t.string('email', { nullable: false, validate: { /* stuff here */ } });
    t.string('password', { nullable: false });
    t.string('name');
    t.string('nickname');
    t.date('birthday');
    t.gender('gender');
    t.string('phone');
    t.string('statusMessage');
  },
});
filoscoder commented 4 years ago

@filoscoder looks great, maybe a link to the repo would be better?

The plugin is arranged inside of a private repo, but I'm planning to build it as an independent package. When it became public I'll share the repo link here!

My first impression is: why not put the verifier on the input type directly instead of trying to match the path?

export const UserInputType = inputObjectType({
  name: 'UserCreateInput',
  definition(t) {
    t.string('email', { nullable: false, validate: { /* stuff here */ } });
    t.string('password', { nullable: false });
    t.string('name');
    t.string('nickname');
    t.date('birthday');
    t.gender('gender');
    t.string('phone');
    t.string('statusMessage');
  },
});

Good point @Sytten, but I implemented the plugin to check if the values are valid when the resolver is executed. I didn't want to make users confused about how to use the validation plugin. That's why I put the validation config inside the mutationField.

If I follow to put the verifier on the input type, as you suggest, this is how I'd proceed :

Sytten commented 4 years ago

In theory I believe that each field of an input is also visited/resolved, but I am not sure if nexus supports that.

filoscoder commented 4 years ago

In theory I believe that each field of an input is also visited/resolved, but I am not sure if nexus supports that.

You're right, each field is _visited, but as much as I know, only when the fields are created not when a query or mutation is resolved. Anyways I'm writing some test code, and I think for the next week will be ready on npm 👍🏾

ben-walker commented 3 years ago

Thanks all for providing some code samples and example plugin projects, super helpful!

That said, I'm writing a plugin inside a larger @nexus/schema project right now and one thing that hasn't clicked for me yet is the actual typing of fieldDefTypes. If you do everything correctly, is the new plugin resolver field supposed to be strongly typed in the schema? No matter what I do my argSchema plugin field just has an any type :(, whereas I'd like it to enforce that a valid Joi schema is passed in.

I've got the ArgSchemaResolver based off code found here https://github.com/graphql-nexus/schema/blob/develop/src/plugins/fieldAuthorizePlugin.ts:

export type ArgSchemaResolver<
  TypeName extends string,
  FieldName extends string
> = (
  root: RootValue<TypeName>,
  args: ArgsValue<TypeName, FieldName>,
  ctx: GetGen<"context">,
  info: GraphQLResolveInfo
) => MaybePromise<boolean | Error>;

The ArgSchemaResolverImport:

const ArgSchemaResolverImport = printedGenTypingImport({
  module: "nexus-plugin-arg-validation",
  bindings: ["ArgSchemaResolver"],
});

And finally the fieldDefTypes:

const fieldDefTypes = printedGenTyping({
  optional: true,
  name: "argSchema",
  type: "ArgSchemaResolver<TypeName, FieldName>",
  imports: [ArgSchemaResolverImport],
});

tbh I don't even know where to specify the Joi.ObjectSchema type within all that. If anyone has an example of how to define a type for the fieldDefTypes property that would be greatly appreciated!

Sytten commented 3 years ago

@ben-walker You are on the right track, your issue is the ArgSchemaResolver, it should have been something like:

type ArgSchemaResolver = Joi.ObjectSchema

If you want to change the schema based on the input. then something like:

type ArgSchemaResolver<
  TypeName extends string,
  FieldName extends string
> = (
  root: RootValue<TypeName>,
  args: ArgsValue<TypeName, FieldName>,
  ctx: GetGen<"context">,
  info: GraphQLResolveInfo
) => MaybePromise<Joi.ObjectSchema>;

Then in the function onCreateFieldResolver, you can retrieve that schema/function and do your validation then. So say in example 1 (direct schema):

const schema = config.fieldConfig.extensions?.nexus?.config.argSchema;
ben-walker commented 3 years ago

Thanks @Sytten! Still having some "type troubles" though, or maybe I'm just expecting the wrong outcome here.

When I declare the plugin's fieldDefTypes like this:

export type ArgSchemaResolver = Joi.Schema;

const fieldDefTypes = printedGenTyping({
  optional: true,
  name: "argSchema",
  description: "A joi schema to validate resolver args against",
  type: "ArgSchemaResolver", // TODO: This should be strongly typed as joi.Schema
});

Using this new field in the schema still shows the type as any:

t.field("register", {
      type: "AuthPayload",
      args: {
        email: stringArg({ required: true }),
        username: stringArg({ required: true }),
        password: stringArg({ required: true }),
      },
      argSchema: Joi.object({ // <-- argSchema has "any" type here
        email: Joi.string().email().required(),
        username: Joi.string().min(3).max(20).trim().required(),
        password: Joi.string().min(8).required(),
      }),
...

And then grabbing the field in onCreateFieldResolver() returns an any value as well:

console.log(config.fieldConfig.extensions?.nexus?.config.argSchema); // <-- argSchema is "any" here as well

Maybe it's not possible to define a type like I'm expecting? I've seen other plugin authors use explicit type checks in the onCreateFieldResolver function, so perhaps I just have to try and live my best any life, not sure.

Sytten commented 3 years ago

It is possible to type the argsSchema on your field for sure, but it is not possible for the config.fieldConfig.extensions?.nexus?.config.argSchema. Can you paste what you have in your generated TS typegen?

ben-walker commented 3 years ago

Ahh the TS typegen was the clue I needed, thanks! I solved it with the below, not sure if this is the best approach but it seems to work:

const ArgSchemaResolverImport = printedGenTypingImport({
  module: "joi",
  bindings: ["Schema"],
});

const fieldDefTypes = printedGenTyping({
  optional: true,
  name: "argSchema",
  description: "A joi schema to validate resolver args against",
  type: "Schema", // TODO: This should be strongly typed as joi.Schema
  imports: [ArgSchemaResolverImport],
});

Which results in a typegen file like this:

import * as ContextModule from "../../../api/nexus/context"
import * as prisma from "../../prisma/client/index"
import { Schema } from "joi"

...

declare global {
  interface NexusGenPluginTypeConfig<TypeName extends string> {
  }
  interface NexusGenPluginFieldConfig<TypeName extends string, FieldName extends string> {
    /**
     * A joi schema to validate resolver args against
     */
    argSchema?: Schema
  }
  interface NexusGenPluginSchemaConfig {
  }
}

So in the above, argSchema? in the typegen file has the Joi.Schema type, and likewise in the nexus schema itself.

Sytten commented 3 years ago

Yeah that is it. I think usually nexus prefers namespace import with * as ... to avoid name conflicts, but this works too.