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

[RFC] Plugin system #200

Closed
wants to merge 8 commits into from
Closed

[RFC] Plugin system #200

wants to merge 8 commits into from

Conversation

tgriesser
Copy link
Member

This is a WIP branch for a plugin system that will allow us to tap in and extend the core behavior and execution of Nexus resolvers at several levels of the schema construction.

The plugin system will:

  1. Define the typings to merge with the existing options for the nexus type, field, schema
  2. Decorate the resolver, either before or after the field is executed.

This approach means we can dynamically add type-safe additions to the augment the options accepted at the schema, type, or field level. The specifics for how this will work best around type-merging are still being determined, and still requires some refactoring of the current generated types.

The behavior will depend on how we wrap the resolve method. Current thinking is that the plugins will operate first-to-last before the field resolver, and last-to-first for the after hook of the resolver:

plugins: [A, B, C]

will execute the "chain":

A (before), B (before), C (before), resolver, C (after), B (after), A (after)

Each plugin will have a pluginDefinition definition block, which may return a plain object with a before or after property (or both). The before will execute before the resolver, and has a signature of:

before(root, args, ctx, info, nextVal)

Returning nextVal, a plain object frozen with Object.freeze will signal the execution to continue to the next item in the execution list. Returning another value will cause that to be the resolved value for the next item in the chain.

The after has a similar signature, with the addition of the result as the first member, and breakVal as the final member.

after(result, root, args, ctx, info, breakVal)

Returning breakVal will early-exit from the rest of the items in the chain. Returning undefined will continue to the next member in the chain. Returning any other value will replace the current result value as it continues through the execution chain.

The naming of these is subject to change, and some of the implementation may change as we evaluate the real-world needs of plugins we aim to implement.


To understand how this would work in practice, let's take a look at an example of field-level authorization, something which is baked in the library, but could (and will be) implemented purely as a plugin:

import { plugin } from "../plugin";
import { GraphQLFieldResolver } from "graphql";

export const AuthorizationPlugin = plugin({
  name: "Authorization",
  fieldDefTypes: `authorize?: AuthorizeResolver<TypeName, FieldName>`,
  pluginDefinition(config) {
    if (!config.nexusFieldConfig) {
      return;
    }
    const authorizeFn = (config.nexusFieldConfig as any)
      .authorize as GraphQLFieldResolver<any, any>;
    if (authorizeFn) {
      return {
        async before(root, args, ctx, info, next) {
          const authResult = await authorizeFn(root, args, ctx, info);
          if (authResult === true) {
            return next;
          }
          if (authResult === false) {
            throw new Error("Not authorized");
          }
          const {
            fieldName,
            parentType: { name: parentTypeName },
          } = info;
          if (authResult === undefined) {
            throw new Error(
              `Nexus authorize for ${parentTypeName}.${fieldName} Expected a boolean or Error, saw ${authResult}`
            );
          }
          return next;
        },
      };
    }
  },
});

The fieldDefTypes will be added to a global NexusGenPluginFieldConfig which is implemented as part of the object field options typing. An authorize field is now made available for us, and if provided will be called before the field is resolved to check whether it is authorized.

This PR will also aim to include several common plugins as core offerings along side of this feature:

  • Authorization: taking the current implementation of auth and refactoring into a plugin)
  • Scoping: Something similar to the scoping in graphql ruby
  • Tracing: Instrumenting the execution of the resolvers
  • Nullability Guard: If a non-null field encounters a null, provide a sensible default and log the error, rather than failing the entire query response

Also, possibly:

  • Validation: Ahead of time validation of input arguments

There are a lot of changes in this branch, because when I initially had started on it, I had begun to refactor a few internal pieces as well. Given the scope of the changes for plugins, however, I will also look to open smaller PRs separately to handle those refactors. Aiming to have the work on plugins completed by the end of Sept.

Any feedback/questions is welcome!

* develop:
  Fix inline functions for custom scalars (#174)
  Fix types in Ghost example
  Fix nexus-prisma doc title and label (#166)
  [RFR] Allow schema construction even with unknown types (#155)
  Add docs for nexus-prisma v2 (#164)
  Fix wrong fieldName type on subscriptionField (and fix backingTypes.spec.ts) (#159)
* develop:
  Add dynamicObjectProperty, fix #161 (#178)
@jasonkuhrt
Copy link
Member

jasonkuhrt commented Sep 22, 2019

Hey @tgriesser, few things on my mind;

  1. Have you explored how plugins will be used? I see the minimal diff from current API being something like:

    import * as Nexus from 'nexus'
    import * as NexusFoo from 'nexus-foo'
    import * as types from './types'
    
    const schema = Nexus.makeSchema({
      plugins: [NexusFoo.create()],
      types: [types],
    })
  2. We found the use-case in nexus-prisma that we'd like to be able to hook into the typegen process. When nexus runs typegen, it would call a typegen func (if present) on each registered plugin. The typegen func would return a string containing the wouldbe contents of a TypeScript declaration file. Said string would be written to disk along with nexus core typegen. The nuances of configurability here and good defaults about where to generate typegen to, I have already thought about, but before sharing more, want to align on the premise here.

  3. We found the use-case in nexus-prisma that we need access to the user's app type defs. For example nexus-prisma can auto-publish an enum from the prisma layer, but if a same-name enum has already been defined by the user in the app, then nexus-prisma would consider this an override, and not publish its default version of the enum. Also, I think a nexus plugin should get access to not juse the type defs from the app but also any type defs introduced by upstream plugins. So for example nexus-prisma adds dynamic output properties .crud and .model. Plugins registered after nexus-prisma would see these type defs in addition to all those in the user's app source code.

CC @Weakky

@jasonkuhrt
Copy link
Member

jasonkuhrt commented Sep 22, 2019

  1. Forgot one more point, but it seems rather basic and possibly covered by:

    Define the typings to merge with the existing options for the nexus type, field, schema

    But I'm not sure [1]. So the fourth point is simply that nexus plugins should be able to contribute back type defs. Currently nexus-prisma is used such that it returns type defs that the user than passes to Nexus.makeSchema. Example:

    const prismaTypes = nexusPrismaPlugin()
    const schema = Nexus.makeSchema({ types: [userTypes, prismaTypes], ...

    This manual passing of prismaTypes should be encapsulated in the plugin system.

    In other words the same way that in 3 (previous comment) plugins can receive type defs as input they must also be able to send type defs back [to nexus] as output.


[1] I'm not sure what Define the typings to merge with the existing options for the nexus type, field, schema means exactly. Could you show off an minimal example plugin that exercises all the possible hook points/plugin features?

@jasonkuhrt
Copy link
Member

There are a lot of changes in this branch ...

@Weakky and I can help with this. I see that all the test suite stuff related to dynamic outputs for example can be extracted to its own PR.

I haven't dived into this PR yet, so maybe it will be obvious, but probably it will help if you can outline some of the parts of this PR that definitely should be factored out : )

Also, rebasing this PR atop develop is something I will try to help with soon too.

@tgriesser
Copy link
Member Author

Have you explored how plugins will be used? I see the minimal diff from current API being something like:

Yep, that would be the api (the types array). Was thinking there would be a few core plugins that ship with Nexus, for instance the current authorization code could be extracted into a "plugin", maybe a few other common utilities to serve as examples.

I'm not sure what Define the typings to merge with the existing options for the nexus type, field, schema means exactly. Could you show off an minimal example plugin that exercises all the possible hook points/plugin features?

I'm working on another branch which is manually picking off the relevant bits from this branch and using some of the integration test tooling you added to create an example with all of the intended features. Will have something to demonstrate this in the next few days.

I haven't dived into this PR yet, so maybe it will be obvious, but probably it will help if you can outline some of the parts of this PR that definitely should be factored out : )

It might not be worth it at this point to dive too into this PR in its current state - I pulled out some bits in #225 that were in here to improve the overall code coverage. There are a few other pieces, like minor non-breaking type changes that could be extracted out as well to keep the plugin diff as minimal as possible.

Also, rebasing this PR atop develop is something I will try to help with soon too.

Don't worry about it - this branch has a bunch of things happening at once, and has diverged quite
a bit from develop. It'll probably just be easier to continue from a new branch and keep this around for reference.

@tgriesser
Copy link
Member Author

We found the use-case in nexus-prisma that we need access to the user's app type defs. For example nexus-prisma can auto-publish an enum from the prisma layer, but if a same-name enum has already been defined by the user in the app, then nexus-prisma would consider this an override, and not publish its default version of the enum. Also, I think a nexus plugin should get access to not use the type defs from the app but also any type defs introduced by upstream plugins. So for example nexus-prisma adds dynamic output properties .crud and .model. Plugins registered after nexus-prisma would see these type defs in addition to all those in the user's app source code.

This is interesting, and a bit different use case than what I had been thinking of for plugins - though it's perfectly valid and I don't see any reason why it wouldn't be possible.

I think we might need to cleanup and add a formalized "public" API for the internal Builder & TypegenPrinter to access this information. I'm also interested in exploring how we can better co-locate more type metadata with the concrete "named" GraphQL types, perhaps using the now official "extensions" property to store this data: graphql/graphql-js#2097

@jasonkuhrt

This comment has been minimized.

@jasonkuhrt
Copy link
Member

@tgriesser btw let me know if there's a better place to have this conversation :)

@jasonkuhrt jasonkuhrt added this to In Progress in Labs Team via automation Sep 24, 2019
@jasonkuhrt
Copy link
Member

@tgriesser ignore my latest comments as they are superseded by on subsequent slack chats and the spec in notion.

@jasonkuhrt jasonkuhrt added the type/feat Add a new capability or enhance an existing one label Oct 3, 2019
@jasonkuhrt
Copy link
Member

@tgriesser In light of #242 should we close this one?

@tgriesser
Copy link
Member Author

Yep, that sounds good

@tgriesser tgriesser closed this Oct 3, 2019
Labs Team automation moved this from In Progress to Shipped Oct 3, 2019
@tgriesser tgriesser deleted the plugin-system branch October 3, 2019 17:35
@jasonkuhrt jasonkuhrt removed this from Shipped in Labs Team Mar 11, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type/feat Add a new capability or enhance an existing one
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants