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

Feature request: Nexus plugins #98

Closed
jferrettiboke opened this issue Mar 27, 2019 · 20 comments
Closed

Feature request: Nexus plugins #98

jferrettiboke opened this issue Mar 27, 2019 · 20 comments
Assignees
Labels
type/discussion Discussion about proposals, etc.

Comments

@jferrettiboke
Copy link

I would LOVE to see support to create plugins in Nexus so that we can create plugins like nexus-prisma.

I think this is the best way to do schema stitching within Nexus.

My current use case, as an example, is with Stripe. I am working on stripe-graphql and I would like to use it for some projects within Nexus to do schema stitching but in a more powerful way so that I can save time.

This "powerful way" allow us to:

  • Select/hide only the fields we want.
  • Rename fields.
  • Override the resolver.
const Card = stripeObjectType({
  name: "Card",
  definition(t) {
    t.stripeFields([
      "id",
      "brand",
      "last4",
      { name: "exp_month", alias: "expMonth" },
      { name: "exp_year", alias: "expYear" }
    ])
  },
});

Pros that I see:

  • It is easy to use/understand.
  • It is more clear than the current ugly schema stitching.
  • Nexus becomes more robust.
  • Type-safe with TypeScript.
  • With the ability to create plugins in Nexus, developers will be familiarized with the same syntax for any plugin.
  • nexus-prisma won't have to maintain its own plugin, just the logic.

However, I got a question. How to use more than one plugin for the same object type? (I am not sure if there are important use cases for that.)

How many of you would be up for this? Let's see the support.

@tgriesser
Copy link
Member

I would LOVE to see support to create plugins in Nexus so that we can create plugins like nexus-prisma.

Actually considered this when building Nexus, (adding the ability to extend the t. namespace) and opted against it, in favor of composition. Prisma is sort of a special case in what they're doing with mapping a against a completely different generated GraphQL schema internally.

For cases like what you're suggesting you can usually just create "higher order functions" that expose a custom/configurable options object to achieve something like what you're going for:

const cardFieldTypeMapping = {
  id: 'ID',
  exp_month: 'Int',
  exp_year: 'Int',
  brand: 'String',
  last4: 'String'
}

interface TodoStripeTypes {
  // ... not taking the time to define these right now, 
  // but it should be fairly straightforward
} 

function stripeObjectType(cfg: TodoStripeTypes) {
   const { 
     definition, 
     cardFields = ['id', 'brand', 'last4', 'exp_month', 'exp_year'] 
   } = cfg
   return objectType({
      name: cfg.name,
      definition(t) {
        cardFields.forEach(field => {
          if (typeof field === 'string') {
            t.field(field, { type: cardFieldTypeMapping[field] })
          } else if (field && typeof field === 'object')  {
            Object.keys(field).forEach((key) => {
              t.field(field[key], { 
                type: cardFieldTypeMapping[key], 
                resolve: (root) => root[key] 
              })
            })
          }
        })
        if (typeof definition === 'function') {
          definition(t)
        }
      }
   })
}

then you can do:

const Card = stripeObjectType({
  name: "Card",
  cardFields: ["id", "brand", "last4", { exp_month: "expMonth" }, { exp_year: "expYear" }]
});

and optionally add new fields by providing a definition block:

const Card = stripeObjectType({
  name: "Card",
  cardFields: ["id", "brand", "last4", { exp_month: "expMonth" }, { exp_year: "expYear" }],
  definition(t) {
    t.field('filteredCharges', {
      type: 'SomeCustomType',
      list: true,
      resolve() {
        // ...
      }
    })
  }
});

How many of you would be up for this? Let's see the support.

Before we use a popular vote to determine this feature, let's think more through the cases we're solving for and how they can be accomplished with the existing features of nexus. I do plan on simplifying some of the patterns used internally by nexus-prisma within nexus in order to help make the schema-composition work a little clearer than it does now.

@tgriesser tgriesser added the type/discussion Discussion about proposals, etc. label Mar 27, 2019
@jferrettiboke
Copy link
Author

@tgriesser Thanks for your detailed response. However, it is still confusing to me.

I would like that stripe-graphql exposes stripeObjectType so that I can use it in my projects. I already did this but I think I am missing something because I am not connecting the schemes. How can I do so? Does it require extra config? Right now I am getting an error which is "Error: prismaObjectType can only be used by makePrismaSchema". I have no idea about this.

PS: I know this issue is labeled as discussion but I would like to get it working so I can see pros and cons, and bring to this thread a more robust opinion.

@tgriesser
Copy link
Member

Are you using makePrismaSchema or makeSchema to construct your schema?

@jferrettiboke
Copy link
Author

Everything works well in the same repo (stripe-graphql) but this is not what I want. My issue is when I import stripeObjectType from stripe-graphql in another project (for example, my-awesome-app. How should I wire up both schemes? (I am using Yoga2 for the app.)

@Weakky
Copy link
Member

Weakky commented Mar 29, 2019

@tgriesser I think there's no easy way right now for jferrettiboke to accomplish what's he's trying to do.

If I'm understanding his problem well, he's trying to "merge" several schemas at the same time: One from prisma, and one from stripe.

However, afaik the current plugin architecture doesn't seem composable.

nexus-prisma has its own makeSchema function to get access to the prisma's graphql schema, but if you wanted to build your graphql server from yet another graphql source, then you'd have to build your own PrismaStripeSchemaBuilder and makePrismaAndStripeSchema() to read the schemas from both prisma and stripe.

TLDR: I think prisma's use-case is not special and we probably need a way to easily be able to compose these plugins and schemas.

On your example for instance, where would stripeObjectType read the schema from? And how would you access t.prismaFields() at the same time?

const Card = stripeObjectType({
  name: "Card",
  cardFields: ["id", "brand", "last4", { exp_month: "expMonth" }, { exp_year: "expYear" }],
  definition(t) {
    t.field('filteredCharges', {
      type: 'SomeCustomType',
      list: true,
      resolve() {
        // ...
      }
    })
  }
});

@jferrettiboke
Copy link
Author

jferrettiboke commented Mar 29, 2019

@Weakky I think you are very close to what I need. However, my use case is not about using Prisma (but other developers could have this use case where they need to mix both plugins at the same time).

I will provide more context so both of you can understand better my needs.

  1. stripe-graphql is a Stripe GraphQL API which exports the schema, provide bindings and also a stripeObjectType function to be used within Nexus in order to simplify the process of connecting with Stripe (this is very similar to what nexus-prisma does).
  2. Developers set up their own projects and use stripe-graphql as a third-party library.
  3. If they use stripeObjectType, they will avoid wasting time creating fields and resolvers. However, they can rename some specific fields and/or create some custom resolvers which could return data using the Stripe bindings provided by stripe-graphql.

The main use case for stripeObjectType is to simplify and save time to developers. So, instead of creating a whole entire structure of Stripe types and fields by their own, they can extend things very easy using stripeObjectType.

On your example for instance, where would stripeObjectType read the schema from?

The ideal way would be that the stripeObjectType function could work without merging its own schema from stripe-graphql with the schema of the app, but I think this is not possible and they must be merged together at some point.

And how would you access t.prismaFields() at the same time?

As I already said, I don't have that need yet. However, some developers could be interested in extending an object type from stripeObjectType but also from prismaObjectType at the same time. As a quick example, think in a Customer object type. They could want to expose fields from the database using prismaObjectType but also the cards field from stripeObjectType.

For more context, here is app example using Yoga2 with stripeObjectType. You have also here the stripeObjectType implementation for more context. Note that this is just a POC in order to illustrate better what my current use case is all about.

@jferrettiboke
Copy link
Author

I have been thinking about how I would like to see this. Here's my proposal. What do you guys think?

 // Creates the `Customer` type in app schema
export const Customer = objectType({
  name: "Customer",
  definition(t) {
    // Refers to the `User` type in Prisma schema
    t.prismaObjectType({
      name: "User",
      fields: ["email"]
    });

    // Refers to the `Customer` type in Prisma schema
    t.prismaObjectType({
      name: "Customer",
      fields: ["stripeCustomerId"]
    });

    // Refers to the `Customer` type in Stripe schema
    t.stripeObjectType({
      name: "Customer",
      fields: [
        "id",
        "cards",
        {
          name: "created",
          alias: "createdAt"
        }
      ]
    });

    // Custom field
    t.field("subscription", {
      ...t.stripeType.subscription,
      resolve(root, args, ctx) {
        // Custom implementation
      }
    });
  }
});

Output:

type Customer {
  email: String!
  stripeCustomerId: ID!
  id: ID!
  cards: [Card!]!
  createdAt: DateTime!
  subscription: Subscription
}

In this way, we can extend very easily without having to type by ourselves third-party schemes and/or fields.

@tgriesser
Copy link
Member

Could this also be accomplished by having the stripeObjectType take the "builder" as an argument?

import { stripeType, stripeObjectType } from 'stripe-graphql'

 // Creates the `Customer` type in app schema
export const Customer = objectType({
  name: "Customer",
  definition(t) {
    // Refers to the `User` type in Prisma schema
    t.prismaObjectType({
      name: "User",
      fields: ["email"]
    });

    // Refers to the `Customer` type in Prisma schema
    t.prismaObjectType({
      name: "Customer",
      fields: ["stripeCustomerId"]
    });

    // Refers to the `Customer` type in Stripe schema
    stripeObjectType(t, {
      name: "Customer",
      fields: [
        "id",
        "cards",
        {
          name: "created",
          alias: "createdAt"
        }
      ]
    });

    // Custom field
    t.field("subscription", {
      ...stripeType.subscription,
      resolve(root, args, ctx) {
        // Custom implementation
      }
    });
  }
});

I believe this is the API I had originally suggested to Prisma specifically because it makes things a lot more difficult internally and from a type-generation standpoint to have things dynamically attached to the "builder" in the way that nexus-prisma does, and it's something I'll consider but I'm not sure it's something that will be generalized in the way you're describing in the short-term.

On a related note that might be relevant for your stripe-graphql project (which looks pretty neat btw), I personally find the merging of type fields from various schemas into a single type to be an anti-pattern, particularly when they're coming from external API sources like Stripe (with Prisma, you at least are in control of the data-source).

If I had a Customer object in my application, I would much prefer to have a field named stripeCustomer which would be an association to a StripeCustomer type rather than merging all of the fields into a single type, makes it clearer where the information is resolved from, simpler to cache, etc:

type Customer {
  email: String!
  stripeCustomer: StripeCustomer
}

@P4sca1
Copy link
Contributor

P4sca1 commented May 7, 2019

I integrated nexus with graphql-shield in the following way:

import {
  objectType,
  extendType,
  queryField,
  mutationField,
  subscriptionField,
} from 'nexus'
import {
  NexusObjectTypeConfig,
  NexusExtendTypeConfig,
  GetGen,
  FieldOutConfig,
  SubscribeFieldConfig,
} from 'nexus/dist/core'
import {
  ShieldRule,
  IRuleFieldMap,
  IRuleTypeMap,
} from 'graphql-shield/dist/types'
import { merge, capitalize } from 'lodash'
import { Rules } from './rules'
import { graphql as Log } from '../log'

export const isShieldRule = (
  rule: ShieldRule | IRuleFieldMap | IRuleTypeMap
): rule is ShieldRule => {
  // FieldMaps and TypeMaps have the Object constructor, whereas
  // Rule and LogicRule have a dedicated constructor (e.g. Rule, RuleAnd, RuleTrue, ...)
  return rule.constructor.name !== 'Object'
}

enum ParentType {
  NONE,
  QUERY,
  MUTATION,
  SUBSCRIPTION,
}

const mergeShieldRule = (
  type: ParentType,
  field: string,
  rule: ShieldRule | IRuleFieldMap
) => {
  // Some logic here
}

export const shieldedObjectType = <TypeName extends string>(
  config: NexusObjectTypeConfig<TypeName> & {
    rules?: ShieldRule | IRuleFieldMap
  }
) => {
  if (config.rules) {
    mergeShieldRule(ParentType.NONE, config.name, config.rules)
  }

  return objectType(config)
}

export const shieldedExtendType = <
  TypeName extends GetGen<'objectNames', string> | 'Query' | 'Mutation'
>(
  config: NexusExtendTypeConfig<TypeName> & {
    rules?: ShieldRule | IRuleFieldMap
  }
) => {
  if (config.rules) {
    mergeShieldRule(ParentType.NONE, config.type, config.rules)
  }

  return extendType(config)
}

export const shieldedQueryField = <FieldName extends string>(
  fieldName: FieldName,
  config:
    | FieldOutConfig<'Query', FieldName> & {
        rule?: ShieldRule
      }
    | (() => FieldOutConfig<'Query', FieldName> & {
        rule?: ShieldRule
      })
) => {
  if (typeof config === 'function') {
    // eslint-disable-next-line no-param-reassign
    config = config()
  }

  if (config.rule) {
    mergeShieldRule(ParentType.QUERY, fieldName, config.rule)
  }

  return queryField(fieldName, config)
}

export const shieldedMutationField = <FieldName extends string>(
  fieldName: FieldName,
  config:
    | FieldOutConfig<'Mutation', FieldName> & {
        rule?: ShieldRule
      }
    | (() => FieldOutConfig<'Mutation', FieldName> & {
        rule?: ShieldRule
      })
) => {
  if (typeof config === 'function') {
    // eslint-disable-next-line no-param-reassign
    config = config()
  }

  if (config.rule) {
    mergeShieldRule(ParentType.MUTATION, fieldName, config.rule)
  }

  return mutationField(fieldName, config)
}

export const shieldedSubscriptionField = <FieldName extends string>(
  fieldName: string,
  config:
    | SubscribeFieldConfig<'Subscription', FieldName> & { rule?: ShieldRule }
    | (() => SubscribeFieldConfig<'Subscription', FieldName> & {
        rule?: ShieldRule
      })
) => {
  if (typeof config === 'function') {
    // eslint-disable-next-line no-param-reassign
    config = config()
  }

  if (config.rule) {
    mergeShieldRule(ParentType.SUBSCRIPTION, fieldName, config.rule)
  }

  return subscriptionField(fieldName, config)
}

Sorry for the huge code block. I realised that I need to overwrite a bunch of nexus types (objectType, queryField, ...) to add the rule field to the config.
There are some problems with this approach though when it comes to combining it with other object type modifications.

It would be great if there was an api to register plugins, which can add fields to different object types and do something with it. The added fields could then be automatically added to the generated nexus typings for automatic type safety.

I haven't thought a lot about a good api to realise this. In my case it could look something like this:

import { registerPlugin } from 'nexus'

registerPlugin({
  objectType: {
    rule: {
      type: '' // Somehow get the type info here.
      onRegister: config => {
        // Do something with the  added field here.
      }
  }
})

@tgriesser
Copy link
Member

Check out #143 - it's an initial pass at solving the problem of defining custom definition block fields.

@jferrettiboke
Copy link
Author

jferrettiboke commented Jun 4, 2019

@tgriesser I just checked out dynamicOutputMethod and play a bit with it. It works well within the same schema. However, I am not sure if I can delegate to another different schema.

To illustrate better my needs, I can give you an example of what I would like to achieve.

stripe-graphql/src/StripeObjectType.ts

import { dynamicOutputMethod } from "nexus";
import generatedTypes from "../generated/mapped-types";

interface TypeFields {
  [key: string]: string | any;
}

interface Types {
  [key: string]: TypeFields;
}

const types: Types = generatedTypes;

interface Field {
  name: string;
  alias: string;
}

interface StripeObjectType {
  name: string;
  fields: Array<string | Field>;
}

export const StripeObjectType = dynamicOutputMethod({
  name: "stripeObjectType",
  typeDefinition: `(opts: {
    name: string,
    fields: Array<string | {
      name: string;
      alias: string;
    }>
  }): void`,
  factory({ typeDef: t, args: [{ name, fields }] }) {
    if (fields && fields.includes("*")) {
      fields = Object.keys(types[name]).map(key => key);
    }

    fields.forEach(field => {
      if (typeof field === "string") {
        t.field(field, { type: types[name][field] });
      } else if (field && typeof field === "object") {
        t.field(field.alias, {
          type: types[name][field.name],
          resolve: root => root[field.name]
        });
      }
    });
  }
});

app/src/graphql/Query.ts

import { objectType, stringArg } from "nexus";
import { StripeObjectType as SOT } from "stripe-graphql";

export const StripeObjectType = SOT;

export const Query = objectType({
  name: "Query",
  definition(t) {
    // I DON'T WANT THIS! Time is gold.
    t.string("customer", {
      args: {
        id: stringArg()
      },
      async resolve(root, args, ctx) {
        return await stripe.customer({ id });
      }
    });

    // I WOULD LIKE SOMETHING LIKE THIS! Save me time and I will love you forever!

    // Expose all fields in `Query`.
    t.stripeObjectType({
      name: "Query",
      fields: ["*"]
    });

    // Expose only `customer` and `customers` fields in `Query`.
    t.stripeObjectType({
      name: "Query",
      fields: ["customer", "customers"]
    });

    // Rename `customer` field to `getCustomer` in `Query` and expose it.
    t.stripeObjectType({
      name: "Query",
      fields: [{ name: "customer", alias: "getCustomer" }]
    });

    // Expose all args for `customer` field in `Query`.
    t.stripeObjectType({
      name: "Query",
      fields: [{ name: "customer", args: ["*"] }]
    });

    // Rename `id` argument to `customerId` for `customer` field in `Query`.
    t.stripeObjectType({
      name: "Query",
      fields: [
        { name: "customer", args: [{ name: "id", alias: "customerId" }] }
      ]
    });

    // Custom field.
    t.field("customField", {
      ...t.stripeType.customers,
      resolve(root, args, ctx) {
        // Custom implementation
      }
    });
  }
});

Would something like this work? Can I merge parts of other schemes into my app schema? I just want to compose my app schema from third-party schemes easily and save time without having to mirror by myself everything.

@jferrettiboke
Copy link
Author

@tgriesser I just found this. This is what I need in Nexus. I would like to merge and extend my schema with other third-party schemes. Is this possible in Nexus today?

@jferrettiboke
Copy link
Author

jferrettiboke commented Jun 22, 2019

It seems like Hasura is trying to solve the same issue with “remote joins”. Having a similar feature in Nexus would be awesome. Please, let’s consider to prioritize this. We should have a easy way in Nexus to connect different data-sources. https://blog.hasura.io/remote-joins-a-graphql-api-to-join-database-and-other-data-sources/

@Weakky
Copy link
Member

Weakky commented Jun 25, 2019

Hey 👋,

I'd like give a quick sum-up of the state of what we think plugins could be after some discussions with @tgriesser at GraphQL Conf:

In a sense, dynamicOutputMethod and dynamicInputMethod already provides a solid foundation for (type-safe) plugins. The next version of nexus-prisma already greatly benefits from that by simplifying a lot the surface API.

We think plugins can be used for 3 different use-cases:

  1. Augmenting the DefinitionBlock capabilities (the t param). (using dynamicOutputMethod, dynamicInputMethod, and eventually dynamicOuputObjectProperty (as explained in Feature request: Add dynamicObjectProperty #161)).
    These are useful to add any kind of additional behavior to your daily development workflow. nexus-prisma is now entirely built on that feature.

  2. Augmenting type/field options. A plugin could be used to add new options to type/field configurations. One immediate use-case that we thought about was middlewares. The whole middleware system could probably be developed as a nexus plugin

  3. CLI plugins. This should probably be put in a separate issue, but nexus should soon have a CLI to help with daily workflows (such as scaffolding, generating types, dev command etc..), and we think the CLI itself should also be plugin-based to allow for third-party libraries to integrate with it. At Prisma, we think this plugin system could partly replace yoga2, by providing scaffolding capabilities specific to nexus-prisma

What do you think?

@fullStackDataSolutions
Copy link

fullStackDataSolutions commented Jul 16, 2019

Hello All,

I'm also combining multiple GraphQL End Points, two of them being Prisma.

But I have two endpoints that are external. I'm using GraphQL Binding to connect to them and access their data in the context, however I can't figure out how to easily integrate them into Nexus.

GraphQL-Binding has already created TS files, which I believe I just need to integrate into the makeSchema function:

import * as path from "path";
import Query from "./Query";
import { makeSchema } from "nexus";

const schema = makeSchema({
  types: [Query],
  outputs: {
    schema: path.join(
      __dirname,
      "../../generated/ordData/schema.graphql"
    ),
    typegen: path.join(__dirname, "../../generated/ordData/types.ts")
  },
  typegenAutoConfig: {
    sources: [
      {
        source: path.join(__dirname, "../../generated/types.ts"),
        alias: "t"
      }
    ],
    contextType: "t.<No idea what to put here>"
  }
});

export { schema as default };

I am them combining sachems with:

import { mergeSchemas } from "graphql-tools";

Is this even possible? I think that there has be be a way to use Nexus with Graphql-binding and seems like the easiest and best solution to this issue.

Thanks for the help in advance.

@jasonkuhrt
Copy link
Member

Related #200

@jasonkuhrt jasonkuhrt pinned this issue Sep 22, 2019
@jasonkuhrt jasonkuhrt added this to Backlog in Labs Team via automation Sep 24, 2019
@jasonkuhrt jasonkuhrt moved this from Backlog to Sprint in Labs Team Sep 24, 2019
@jasonkuhrt
Copy link
Member

jasonkuhrt commented Sep 24, 2019

For anyone interested I have started to put together a proposal for my take on plugins here https://www.notion.so/prismaio/Nexus-Plugin-System-cf2025b1f1084e4e98035c2acc2d3723.

@alidcast
Copy link

alidcast commented Sep 25, 2019

is there support for generating dynamic root types (not just t.field but objectType outputs)? it seems to currently work with dynamicOutputMethod but not sure if the approach I took is correct or meant to be supported.

my use-case is that I need to filter an objects nested relation fields by the same parent status.

i.e. if I query a notebook Notebook that has published: true then for it's Notes I'd like to ensure only published ones are shown as well.

this could be solved if apollo-server gave us access to grandparent in resolvers but short of that here's code for how I'm currently handling it with nexus:

// create a notebook factory using `DynamicOutputMethod`
dynamicOutputMethod({
  name: 'notebook',
  typeDefinition: `args: {
    typeName: string,
    fieldName: string,
    where: { published: boolean }
  }`,
 factory({ typeDef: t, args }) {
    args.forEach(({ typeName, fieldName, where: { published } }) => {
      const CustomNotebook = objectType({
        name: typeName,
        definition: t => {
          t.string('name')
          t.field('notes', { 
            type: 'Notes', 
            resolve: async (notebook, _, { db }) => db.Note.findMany({ 
              where: { notebookId: notebook.id, published },
            })
          })
        },
      })  

      t.field(fieldName, {
        type: CustomNotebook,
        args: { slugPath: stringArg() },
        resolve: (authUser, { id }, { db }) => db.Notebook.find({
          where: { id, ownerId: authUser.id, published }
        })
      })
    })
})

//  instantiate notebook object type as custom field definition 
objectType({
  type: 'Profile',
  definition: t => {
   t.notebook({
      typeName: 'PublishedNotebook',
      fieldName: 'publishedNotebook',
      where: { published: true }
    })
 }
})

@jasonkuhrt
Copy link
Member

Hey @alidcastano thanks for sharing that. Your solution seems reasonable but I've noted your comment to think about later, and if there's a way the underlying API can better serve your use-case. It's interesting.

If you're motivated, please open a new feature request for an API that would better serve your use-case. Maybe we'll conclude that your approach is the idiomatic way but I think we need to talk that through more.

@tgriesser
Copy link
Member

Closing, since Plugins are now possible in the latest 0.12.0-rc.4. Feel free to open additional issues / PRs to improve the docs, they're a little bare bones at the moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type/discussion Discussion about proposals, etc.
Projects
None yet
Development

No branches or pull requests

7 participants