StellateHQ / fuse

Fuse: The fastest way to build and query great APIs with TypeScript
https://fusedata.dev
MIT License
551 stars 13 forks source link

RFC: Federation/stitching/composition/… #61

Open mxstbr opened 11 months ago

mxstbr commented 11 months ago

Summary

Many companies that use GraphQL at scale today use GraphQL Federation, which essentially composes many microservices that expose a part of the GraphQL schema.

When there is a backend team that's already using GraphQL and liking it, being able to stitch their schema into the data layer might be appealing.

Another use case is third-party APIs that are already GraphQL.

Another use case is companies that are already using GraphQL and want to stitch their existing API into Fuse—but I'm unsure why they would use Fuse.js on top of that 🤔

Proposed Solution

Fuse.js could either:

  1. Support composing/connecting subgraphs (which would require us to reimplement Federation)
  2. Support stitching in other GraphQL APIs, including existing supergraphs
mxstbr commented 10 months ago

Related to #89; potentially data sources could also be the solution for stitching at least? 🤔 (probably not true federated subgraph support though) cc @JoviDeCroock

mxstbr commented 10 months ago

With existing GraphQL APIs as a migration path to Fuse.js.

CleanShot 2023-12-14 at 16 39 46@2x

https://x.com/jamannnnnn/status/1735319901089267799?s=20

JoviDeCroock commented 10 months ago

There are a few scenario's of where people could have an existing GraphQL API, listing them up here

I think the first two could quite easily be solved by leveraging schema-stitching where the existing (micro-)service would be a stitched endpoint with its own executor.

For Federation this becomes a bit more complex as either we become the router, which has a lot of complexities involved as is or we stitch in the router. With the former approach I feel like there isn't much prior art in terms of doing schema-extensions from the router, which in this case we would most likely need to provide value over just using federation... The latter approach I haven't looked too deep into.

By going with the stitching approach we would need to solve the directional issue of connecting both graphs, where in the stitching world this is done by means of programatically specifying the touching points i.e.

stitchSchemas({
  subschemas: [
    {
      schema: schema1,
      merge: {
        Entity: {
          fieldName: 'entityFromSchema1ById',
          selectionSet: '{ id }',
          args: obj => ({ id: obj.id })
        }
      }
    },
    {
      schema: schema2,
      merge: {
        Entity: {
          fieldName: 'entityFromSchema2ById',
          selectionSet: '{ id }',
          args: obj => ({ id: obj.id })
        }
      }
    }
  ],
})

this basically needs us to specify how to merge the same entity of schema1 with the one from schema2 and how each of these schemas queries them. From our point of view we could do this both with node as well as the automatically generated entry-point so in that regards we should have a pretty easy job of creating heuristics for these merges, however it might not be as easy for the unknown schema's coming in and would require a lot of checks in dev mode and a pre-compile mode in build.

From a runtime perspective all of this seems very solve-able, this however brings me to the editor experience, early on in the fuse design process we opted to not expose the SchemaBuilder to the user and instead provide our own set of plugins and expose our own set of functions that we think you need to build a good API.

In theory we can add all these types in the SchemaBuilder generic which we don't have access to or isn't really dynamically insert-able. A solution to that could be that we override the Pothos.defaultTypes in a global type, which would be similar to how we extend the FieldBuilder for the list() as seen here.

Not sure yet about the typing path but this could be a way forward.

Pothos does offer the Add plugin for embedding types, however reasoning more about connecting graphs in stitching it might not be needed as types can be merged, which means that when we create a node or an object that in theory it can just be merged, the only question-mark arising here is how the global-ids will interact...

Sorry for the braindump 😅

mxstbr commented 10 months ago

I wonder if we could connect this with #89; conceptually, you could see another GraphQL API (whether a full graph or a subgraph) as a data source for your data layer.

Another thought is that potentially stitching a whole schema into the data layer isn't the right way to think about it. Maybe a way to think about it that'd feel more native would be to say a node can be sourced from a type from another GraphQL API.

That could look something like:

export const GitHubRepositoryNode = node({
  name: 'GitHubRepository',
  datasource: new GraphQLDatasource({
    url: gitHubAPIUrl,
    schema: …, // Required if `url` isn't introspectable or something?
    type: 'Repository' // Source for this node is the Repository type of the underlying schema
  }),
  fields: (t) => ({
    // Has access to all scalar fields
    name: t.exposeString('name')
    owner: t.exposeString('owner')
    issue: t.expose('issue', {
      // Tradeoff: Connections to other types requires implementing a node for them, too
      type: GitHubIssue,
    })
  })
})

export const GitHubIssue = node({
  name: 'GitHubIssue',
  datasource: new GraphQLDatasource({
    url: gitHubAPIUrl,
    schema: …,
    type: 'Issue'
  }),
  fields: (t) => ({
    number: t.exposeInt('number')
  })
})

The main tradeoff (as noted in the code comment) is that connections to other types require implementing a node for them, too which is obviously more work than "just" stitching a whole API into another API.