One of the core responsibilities of Keystone is to generate a GraphQL API. In the common use case, this API consists of CRUD operations over a collection of lists. It also provides authentication mutations, custom types/queries/mutations, as well as an application version query.
The current implementation of each of these features is tightly woven into the core of Keystone. This tight coupling is a problem, as it blurs the lines between each of the concerns, as well as making it hard to introduce new features into the GraphQL API.
I would like to propose the introduction of a GraphQL Provider Framework. While this sounds big and impressive, it can be easily summarised by the following class API definition:
class Provider {
constructor() {}
getTypes({ schemaName }) {
return [];
}
getQueries({ schemaName }) {
return [];
}
getMutations({ schemaName }) {
return [];
}
getTypeResolvers({ schemaName }) {
return {};
}
getQueryResolvers({ schemaName }) {
return {};
}
getMutationResolvers({ schemaName }) {
return {};
}
}
Each feature of Keystone which generates graphQL API surface area would be encapsulated in a Provider class, which has methods for generating the type definitions and resolvers required by Apollo.
Keystone would keep track of an array of providers, and iterate over these to generate the combined set of type definitions and resolvers which make up the complete graphQL API.
The code which currently lives in Keystone.getAdminSchema() and Keystone.getTypeDefs() would be greatly simplified to iteration over the list of providers. All the functionality would be shifted into four separate providers:
ListCRUDProviderListAuthProviderAppVersionProviderCustomProviderProvider to Keystone?A system writer would be able to implement their own Provider class and then call Keystone.addProvider({ provider }).
getTypedefs and getResolvers as the Provider API?The way Keystone currently combines the different pieces of the graphQL API means that the types, queries and mutations all need to be handled individually. We could definitely write some kind of BaseProvider class which had getTypedefs() and getResolvers() and leveraged the other methods to generate valid results which could be passed directly into ApolloServer. I'm 50/50 on whether we want to make this the default or not.
AppVersionProvider functionality will be able to live in one place in a single module, rather than being spread throughout Keystone/index.js.I've prototyped out an implementation of this API (#2420) and have verified that this API is sufficient to match the existing functionality. Also, the refactor does not appear to introduce any complexity beyond what already exists. I'm rather confident that this is a good design, but I'm open to questions or concerns that I may have overlooked.
I will implement a series of PRs, each of which introduces a single piece of the functionality required to fully convert to this API. This will hopefully make it easier to review each individual PR and also provide us with some checkpoints to identify any bugs are design issues which arise during the conversion process.
Love it! The core has evolved over time into what it is today. Bringing a proper architecture to it will be a big win, and I'm looking forward to that code being "just iterating" 馃憤
The separation of type{resolver}, query{resolver}, and mutation{resolver} makes sense for the main case of lists & versions. However, when we look at authentication, we see it also provides two other things: GraphQL Context and Express Middleware.
I'd like to propose we extend the definition of a "Provider" with a couple more methods:
class Provider {
constructor() {}
// ...
getContext({ context }) {
return context;
}
prepareApp({ app }) {
// Setup the Express app with middlware, etc
// no return necessary
}
}
We can then iterate over the providers when preparing the middlewares and graphQL context too.
Note I haven't given any consideration to how providers are communicated across to, say, the GraphQLApp from Keystone core, etc.
@mitchellhamilton brought up the npm lib merge-graphql-schemas which could be used to replace our "just iterating" part. I've used this on a project in the past and it works quite well.
It would make implementing getTypedefs() and getResolvers() even easier, plus enables some other nice things like extending pre-existing types, etc.
I'd like to propose we extend the definition of a "Provider" with a couple more methods:
class Provider { constructor() {} // ... getContext({ context }) { return context; } prepareApp({ app }) { // Setup the Express app with middlware, etc // no return necessary } }We can then iterate over the providers when preparing the middlewares and graphQL context too.
I think these are both interesting ideas. Definitely the AppVersionProvider and ListAuthProvider are concerned with the context and/or middleware stacks. I'm not sure that we necessarily want to include that functionality within the Provider class itself, or whether to make that functionality part of a higher level structure which can compose (perhaps) a Provider, a ContextManager and a MiddlewareProvider (or something along those lines).
I propose we defer adding these methods to the Provider API until the core functionality is implemented and we can fully assess how each Provider couples to context and middleware and what the best way to support that might be.
@mitchellhamilton brought up the npm lib
merge-graphql-schemaswhich could be used to replace our "just iterating" part. I've used this on a project in the past and it works quite well.It would make implementing
getTypedefs()andgetResolvers()even easier, plus enables some other nice things like extending pre-existing types, etc.
I think this is definitely an interesting direction to head in. One of the considerations around the current API is that I want to be able to make the changes incrementally, which necessitates following the iteration pattern until the refactoring is complete. I think that once we have finished refactoring the existing functionality we will be in a position to consider taking advantage of libraries like merge-graphql-schemas. I'm wary of explicitly making the use of that library a goal, but I think it's definitely a good direction for us to head in and I think that once we get closer we'll be better placed to make decisions on whether that, or something similar, will provide us with improved functionality.
Would this make sense to have GraphQL Subscription as part of custom provider. I know the lists don't support subscription yet but I can have custom code for subscription which I dont want to mount another graphql middleware
class Provider {
constructor() {}
// -----
getSubscriptions({ schemaName }) {
return [];
}
getSubscriptionResolvers({ schemaName }) {
return {};
}
}
Support for Subscription should be prioritized in the roadmap
Yep, this change will definitely unlock the ability to add support for Subscriptions to Keystone 馃憤
Most helpful comment
Would this make sense to have GraphQL Subscription as part of custom provider. I know the lists don't support subscription yet but I can have custom code for subscription which I dont want to mount another graphql middleware