redwoodjs / redwood

The App Framework for Startups
https://redwoodjs.com
MIT License
16.93k stars 973 forks source link

Support GraphQL Schema stitching #3640

Open dthyresson opened 2 years ago

dthyresson commented 2 years ago

Would be curious if there is any plan for schema stitching, or remote joins like Hasura does. Currently, I have shopify api in a separate package and using urql to access it on the pages I need. There's probably a better way to handle it in Redwood.

Originally posted by @nnennajohn in https://github.com/redwoodjs/redwood/issues/3627#issuecomment-951911624

See: Combinging schemas in GraphQL tools.

Perhaps Redwood can make it easier to combine third party schemas, like some Shopify apis

dthyresson commented 2 years ago

@nnennajohn You would mind elaborating on your original question and maybe describe the use case?

Then @dac09 and I will have a think as see what might be possible and check back with you.

nnennajohn commented 2 years ago

Thanks for setting this up @dthyresson .

The main use case for this is to leverage existing apps to add functionality to your SAAS applications, without having to build from scratch. A lot of large organizations use a hosted CMS because it's really easier for marketers, designers, content-editors etc to make updates to pages, without investing in building a full blown CMS that does essentially what several existing offerings already do. Why build a full in-house CMS if your content-editors can use say Ghost, WP, GraphCMS, Contentful, Content-Stack, etc. Same goes for E-commerce.

Now, most of these apps already offer graphql endpoints. In building an app that consumes data from them, you'd either have to resort to good-old rest/redux approach, a federated graph, or perhaps my hacky approach of nested providers and webhooks. This assumes you have a more complex app, where content-management is a minor subset of your eng needs.

If Redwood provides a means to stitch schemas, it becomes more appealing to larger-scale apps that have needs beyonds a simple data model with prisma. The prisma models can still be used for all integral parts of the app, and minor parts can be offloaded to the externals schema. In my case, Shopify for subscriptions(still stripe under the hood), as I want to have access to the rest of Shopify's ecosystem around social marketing, analytics, etc. And for the site blog, some external CMS. For the actual app's functionality, its all prisma models.

Hope that all makes sense.

I spent quite some time trying to get it to work yesterday, by recreating the createGraphQLHandler function. Copied from the repo into my local functions/graphql.ts and updated schema to be a stichedSchema. I think I almost got it working, but it choked on something to do with aws-lambda. When I stitch the schemas outside of redwood and run with a simple express/apollo-server, it works. So I think maybe it's attempting to run the entire schema a certain way. Not really sure. Gave up after a few hours :)

Thanks again for opening the conversation of this.

The general idea is below. Quoting my previous comment:

import { introspectSchema } from '@graphql-tools/wrap'
import { fetch } from 'cross-fetch'
import { print } from 'graphql'
import { FilterRootFields, RenameTypes, FilterTypes } from '@graphql-tools/wrap'
import { stitchSchemas } from '@graphql-tools/stitch'

async function shopifyRemoteExecutor({ document, variables }) {
  const query = print(document)
  const fetchResult = await fetch(
    process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_API_ENDPOINT,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token':
          process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN,
      },
      body: JSON.stringify({ query, variables }),
    }
  )
  return fetchResult.json()
}

const shopifySchema = {
  schema: await introspectSchema(shopifyRemoteExecutor),
  executor: shopifyRemoteExecutor,
  // https://www.graphql-tools.com/docs/schema-wrapping
  transforms: [
    new RenameTypes((name) => `Shop${name}`),
  ],
}

async function graphCMSRemoteExecutor({ document, variables }) {
  const query = print(document)
  const fetchResult = await fetch(process.env.NEXT_PUBLIC_GRAPH_CMS_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_GRAPH_CMS_API_ACCESS_TOKEN}`,
    },
    body: JSON.stringify({ query, variables }),
  })
  return fetchResult.json()
}

const graphCMSSchema = {
  schema: await introspectSchema(graphCMSRemoteExecutor),
  executor: graphCMSRemoteExecutor,
  // https://www.graphql-tools.com/docs/schema-wrapping
  transforms: [
    new FilterRootFields((operation, rootField) => rootField !== 'collections'),
    new RenameRootFields((_operation, name) => `Cms${name}`),
    new RenameTypes((name) => `Cms${name}`),
  ],
}

export const gatewaySchema = stitchSchemas({
  subschemas: [
    shopifySchema,
    graphCMSSchema,
    // redwoodSchema?
  ],
  // mergeTypes: true, // << default in v7
  // typeMergingOptions: {}
})

The Redwood makeMergedSchema takes sdl and services as options. But perhaps it could take additional schemas. And instead of returning the result of addResolversToSchema, can switch to returning a call to stitchSchemas if additionalSchemas exist, passing the result of addResolversToSchema into the array passed to stitchSchemas with the addtionalSchemas.

Ok. That was long. Some faux code:

Inside the makeMergedSchema func:

const redwoodSchema = addResolversToSchema({
    schema,
    resolvers,
    resolverValidationOptions,
    inheritResolversFromInterfaces,
  })

if(additionalSchema) {
  return stitchSchemas({
    subschemas: [
      redwoodSchema,
      ...additionalSchema,
    ],
   ...additionalSchemaOptions // like typeMergingOptions
  })
}

return redwoodSchema
nnennajohn commented 2 years ago

Really would love to get this to work, as I love everything else about Redwood and its convention over configuration approach.

dthyresson commented 2 years ago

@nnennajohn I have a question - if you were able to combine the Shopify and GraphCMS schemas with your Redwood api's, how would you write your resolvers and services?

Would you have a "shop" service and then implement the queries and mutations using the shopifyRemoteExecutor as a client?

Another option is GraphQL Mesh: https://www.graphql-mesh.com

And the SDK implementation: https://www.graphql-mesh.com/docs/recipes/as-sdk instead of the Gateway (which runs a separate server).

I experimented with this over a year ago, but this might be more aligned with what you want to do -- since you actually want to call out toe Shopify and GraphCMS with an authenticated client.

nnennajohn commented 2 years ago

Hi @dthyresson

Thanks for following up on this. Actually, you don't need a separate shop service. If you stitch schema, it intelligently delegate to the underlying executor, and you still end up with one server.

Below is an example of what I spun up, just to try to get it working. In the below instance, localSchema would be replaced with redwoodSchema. The localSchema I have below is built with prisma and nexus-prisma which I use to write the local resolvers. I end up with one schema, which I stitch with shop and cms and pass to apollo server.

Below is an example. Including files, localSchema, externalSchema, serverFile and end-result in Apollo. Used nexus mostly for brevity. I've never used it in prod and don't think it is ready. Just a heads up.

LocalSchema:

localSchema

ExternalSchemas:

externalSchema

FinalServer: Simplified for brevity.

stitchedSchemaServer

End Result: Able to deploy one graph and query them all. Similar to well - One Graph example query is localSchema, cmsCategories is from graphcms and products is from shopify.

Screen Shot 2021-10-28 at 11 10 43 PM

Now, if I could use this in Redwood, life would be great! :)

nnennajohn commented 2 years ago

This is obviously simplified. But since this is all coming from one server, you can use auth and role based access control on all fields including the externalSchema using something like graphql shield, extra layer of security or whatever middleware you want on the server. Also, one typegen, single source of truth,

ajcwebdev commented 2 years ago

You could do this with StepZen pretty simply (it's where I work, so I'm of course biased). I already have a bunch of example projects out there showing how to do it:

You end up with a schema and a few directives like so:

type Product {
  id: ID!
  handle: String
  title: String
}

type Query {
  products: [Product]
    @rest(
      resultroot: "products[]"
      endpoint: "https://$api_key:$api_password@$store_name.myshopify.com/admin/api/2020-01/products.json"
      configuration: "shopify_config"
    )
}
type PostItems {
  items: [PostItem]
}

type PostItem {
  name: String
  content: Content
}

type Content {
  title: String
  intro: String
}

type Query {
  PostItems: PostItems
    @graphql(
      endpoint: "https://gapi.storyblok.com/v1/api"
      headers: [
        { name:"Token" value:"$token" }
      ]
      configuration: "storyblok_config"
    )
}

Then on the Redwood side you can call an external API that StepZen deploys for you and you send regular GraphQL queries in your services.

import { GraphQLClient } from 'graphql-request'

export const request = async (query = {}) => {
  const endpoint = process.env.API_ENDPOINT

  const graphQLClient = new GraphQLClient(endpoint, {
    headers: {
      authorization: 'apikey ' + process.env.API_KEY
    },
  })
  try {
    return await graphQLClient.request(query)
  } catch (err) {
    console.log(err)
    return err
  }
}
import { request } from 'src/lib/client'
import { gql } from 'graphql-request'

export const products = async () => {
  const GET_PRODUCTS_QUERY = gql`
    query getProducts {
      products {
        title
        id
        handle
      }
    }
  `

  const data = await request(GET_PRODUCTS_QUERY)

  return data['products']
}
nnennajohn commented 2 years ago

Hi @ajcwebdev ,

This is quite different from your setup with stepzen, which requires you writing the typedef for each item, then running a separate stepzen server, and manually telling your sdl items which endpoint to resolve to.

In the case of a stitched schema, the only additional configuration is the RemoteExecutor func, and you get access to all shopify api. Also, no additional servers.

I want to be able to pass in an array of additional schemas to the createGraphQLHandler, with a configuration for type conflict or just nest like the One Graph setup, and everything should just work.

dac09 commented 2 years ago

In the case of a stitched schema, the only additional configuration is the RemoteExecutor func, and you get access to all shopify api. Also, no additional servers.

Useful to know, thanks @nnennajohn! Also thanks for sharing your implementation - ~I wonder if this is a ApolloServer specific feature. Something to look into~ Came across this through graphql-tools, so a lot to explore https://github.com/gmac/schema-stitching-handbook

Once we have time to evaluate this, I suspect we won't be using the same createGraphqlHandler function, given the specific usecase (and potentially the other configurations that may be needed), we probably want to have a different builder function!

The challenge for me with all of this graphql stuff is that pretty much everything is just called a "schema". What does that even mean? Just the schema file, or does it mean this special remote schema, or does it mean DocumentNode - you never know. 🙄

nnennajohn commented 2 years ago

@dac09 Thanks for sharing that repo. Looks like a great resource to figure this out. Diving in.

BBurnworth commented 2 years ago

More input to bump this concept. Schema stitching, or something like this, would be very helpful for my use case. We have an existing internal company application where we have built a client/app/roles user management system. Our framework allows us to have many client organizations which may have multiple, and possibly distinct apps, available to them. Also within each org, each user has differential app availability and roles.

We use a postgres DB with a graphql api. We have the user/client/app/roles tables in one schema, and in the same database we have a second schema for the app data. I am going in circles trying to figure out how to connect to our user management system with prisma/RW. I do not want to mess with the existing table structure, so I really want to leverage our existing graphql api and keep the rw data in a separate table or db.

I have seen the prisma issue for supporting querying across multiple schemas. DT actually commented with a solution that he found works for this (https://github.com/prisma/prisma/issues/1122#issuecomment-812076938). I am not well versed, so I have not yet figured out how to implement his solution... but, this issue is currently a WIP on the Prisma roadmap, so maybe it will be available relatively soon. This would probably close this issue for me, as long as RW supports this feature when it comes out.

However, after thinking about broader use cases, like @nnennajohn's case or the schema-stitching handbook samples, this seems like a pretty common and very useful feature. I want to keep writing, but my understanding of the terminology and concepts involved will confuse you. I know ajc has some stepzen cookbooks that illustrate this functionality using stepzen. However, I like @nnennajohn's idea in reply to ajc, of having a stitched schema with no extra servers, all written within my RW app.