RFC for apollo federation support in HGE. PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4607 GitOrigin-RevId: c154c58532394ef25166d39ff2e100a00448c111
14 KiB
Apollo federation v1 support
Original issue #3064.
Apollo requires a bunch of fields and types to be exposed in schema in order to use the graphql server as a federated subgraph. The complete requirement can be found here.
Product perspective
Requirements
We want the following things:
- Hasura should mount on Apollo federated gateway
- Other subgraphs can use Hasura. At a minimum, this means that table types (generated by Hasura) should be available for other subgraphs.
Setup experience
Apollo federation (AF) support should be enabled via an env variable (or a global metadata field? TBD). When AF is enabled, it will add the required AF schema objects to the final generated schema. This will allow point 1. in requirements above.
Secondly, you will have to resolve entities for each type that you want to
federate so that it can be used in other subgraphs. For this, you will have to
enable AF through table metadata for the individual table (i.e. via
*_track_table
API). The *_track_table
API's args will look something like
the following:
{
"source": "default",
"table": "Author",
"configuration": {},
"apollo_federation_config": {
"enable": "v1"
}
}
This API design will enable us to add more features (by adding new key-value
pairs in apollo_federation_config
) such as:
- Extending the support to v2 directives in future such as adding
@sharable
to some columns. - Changing
@key
directivefields
(or primary key for apollo federation in simple terms).
Behaviour
When a table is tracked with enable AF option, the type of the table in the
schema will have the @key
directive added. This will allow point 2. in the
requirements above. In the first version, the @key
field will be populated by
the table’s primary key. For example:
type Review @key(fields: "id") {
id: Integer!
body: String
author: User
product: Product
}
@key(fields: “id”)
will be automatically added where id
is the primary key
for a table called Review
.
Evaluating feature
This feature will be released as an experimental feature first, so that users can use the feature only if they want to explore this. Using the user feedback, we can incrementally improve the feature by adding more supports, fixing bugs and improving performance.
Implementation perspective
Spec
According to the apollo spec:
To make a GraphQL service subgraph-capable, it needs the following:
- Implementation of the federation schema specification
- Support for fetching service capabilities
- Implementation of stub type generation for references
- Implementation of request resolving for entities.
SDL generation
To use HGE as a federated subgraph, we need to expose _service
field, which
is of type _Service
, which contains field called sdl
of type String
. So,
first, we need to generate SDL (schema definition language) for the HGE schema
while building the schema field parsers.
For generating the SDL, we can use the Printer.schemaDocument
from the
graphql-parser-hs library on SchemaDocument
. We can easily generate the
SchemaDocument
from SchemaIntrospection
using the following:
getSchemaDocument:: G.SchemaIntrospection -> G.SchemaDocument
getSchemaDocument (G.SchemaIntrospection typeDefMap) =
G.SchemaDocument completeSchema
where
allTypeDefns = map G.TypeSystemDefinitionType (Map.elems typeDefMap)
rootOpTypeDefns = getRootOpTypeDefns -- define this
completeSchema = rootOpTypeDefns : allTypeDefns
Then we can easily generate the SDL by:
generateSDL :: G.SchemaIntrospection -> Text
generateSDL = Builder.run . Printer.schemaDocument . getSchemaDocument
To add this new field and type in the schema, we would have to create
FieldParser
for each of the fields _service
and sdl
after creating the
SchemaIntrospection
and then expose these in the GQL context. This should be
a trivial change. Please note that the schema introspection will be role
dependent, thus the SDL will not expose fields/types that the current role
doesn't have access to.
An example for the SDL generated for the following setup is:
HGE setup
A tracked table, called users with fields id
and name
using the
graphql-default
naming convention.
Generated SDL
(Shortened version)
schema {
query: query_root
mutation: mutation_root
subscription: subscription_root
}
type query_root {
usersAggregate(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): UsersAggregate!
users(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): [Users!]!
usersByPk(id: Int!): Users
}
type Users @key(fields: "id") {
id: Int!
name: String!
}
.
.
.
Click here for full SDL
schema {
query: query_root
mutation: mutation_root
subscription: subscription_root
}
scalar Float
scalar Int
scalar String
type __Directive {
args: __InputValue
description: String!
isRepeatable: String!
locations: String!
name: String!
}
type __EnumValue {
deprecationReason: String!
description: String!
isDeprecated: String!
name: String!
}
type __Field {
args: __InputValue
deprecationReason: String!
description: String!
isDeprecated: String!
name: String!
type: __Type
}
type __InputValue {
defaultValue: String!
description: String!
name: String!
type: __Type
}
type __Schema {
description: String!
directives: __Directive
mutationType: __Type
queryType: __Type
subscriptionType: __Type
types: __Type
}
type __Type {
description: String!
enumValues(includeDeprecated: Boolean = false): __EnumValue
fields(includeDeprecated: Boolean = false): __Field
inputFields: __InputValue
interfaces: __Type
kind: __TypeKind!
name: String!
ofType: __Type
possibleTypes: __Type
}
type query_root {
usersAggregate(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): UsersAggregate!
users(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): [Users!]!
usersByPk(id: Int!): Users
}
type subscription_root {
usersAggregate(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): UsersAggregate!
users(
where: UsersBoolExp
orderBy: [UsersOrderBy!]
limit: Int
offset: Int
distinctOn: [UsersSelectColumn!]
): [Users!]!
usersByPk(id: Int!): Users
}
type UsersAvgFields {
id: Float
}
type UsersAggregateFields {
avg: UsersAvgFields
count(distinct: Boolean, columns: [UsersSelectColumn!]): Int!
max: UsersMaxFields
min: UsersMinFields
stddev: UsersStddevFields
stddevPop: UsersStddevPopFields
stddevSamp: UsersStddevSampFields
sum: UsersSumFields
varPop: UsersVarPopFields
varSamp: UsersVarSampFields
variance: UsersVarianceFields
}
type UsersMaxFields {
id: Int
name: String
}
type UsersMinFields {
id: Int
name: String
}
type UsersStddevFields {
id: Float
}
type UsersStddevPopFields {
id: Float
}
type UsersStddevSampFields {
id: Float
}
type UsersSumFields {
id: Int
}
type UsersVarPopFields {
id: Float
}
type UsersVarSampFields {
id: Float
}
type UsersVarianceFields {
id: Float
}
type UsersAggregate {
aggregate: UsersAggregateFields
nodes: [Users!]!
}
type Users @key(fields: "id") {
id: Int!
name: String!
}
type mutation_root {
deleteUsers(where: UsersBoolExp!): UsersMutationResponse
deleteUsersByPk(id: Int!): Users
insertUsersOne(onConflict: UsersOnConflict, object: UsersInsertInput!): Users
insertUsers(
onConflict: UsersOnConflict
objects: [UsersInsertInput!]!
): UsersMutationResponse
updateUsers(
_set: UsersSetInput
_inc: UsersIncInput
where: UsersBoolExp!
): UsersMutationResponse
updateUsersByPk(
_set: UsersSetInput
_inc: UsersIncInput
pk_columns: UsersPkColumnsInput!
): Users
}
type UsersMutationResponse {
returning: [Users!]!
affected_rows: Int!
}
enum __TypeKind {
ENUM
INPUT_OBJECT
INTERFACE
LIST
NON_NULL
OBJECT
SCALAR
UNION
}
enum orderBy {
ascNullsFirst
asc
ascNullsLast
desc
descNullsFirst
descNullsLast
}
enum UsersSelectColumn {
id
name
}
enum UsersConstraint {
users_pkey
}
enum UsersUpdateColumn {
id
name
}
input Int_cast_exp {
String: StringComparisonExp
}
input IntComparisonExp {
_cast: Int_cast_exp
_eq: Int
_gt: Int
_gte: Int
_in: [Int!]
_isNull: Boolean
_lt: Int
_lte: Int
_neq: Int
_nin: [Int!]
}
input StringComparisonExp {
_eq: String
_gt: String
_gte: String
_in: [String!]
_isNull: Boolean
_lt: String
_lte: String
_neq: String
_nin: [String!]
_niregex: String
_nregex: String
_nsimilar: String
_nilike: String
_nlike: String
_iregex: String
_regex: String
_similar: String
_ilike: String
_like: String
}
input UsersBoolExp {
_and: [UsersBoolExp!]
_not: UsersBoolExp
_or: [UsersBoolExp!]
id: IntComparisonExp
name: StringComparisonExp
}
input UsersOrderBy {
id: orderBy
name: orderBy
}
input UsersIncInput {
id: Int
}
input UsersInsertInput {
id: Int
name: String
}
input UsersSetInput {
id: Int
name: String
}
input UsersOnConflict {
constraint: UsersConstraint!
update_columns: [UsersUpdateColumn!]! = []
where: UsersBoolExp
}
input UsersPkColumnsInput {
id: Int!
}
Entities Union
Apollo federation needs an _entities
field as part of the specification,
which is defined as follows:
# a union of all types that use the @key directive
scalar _Any
union _Entity
extend type Query {
_entities(representations: [_Any!]!): [_Entity]!
}
To add this field, first we need to create an union
of all the types that
have the directive key
. Please note that the types might be defined in the
HGE (from DB sources or action types) or they might be exposed via the remote
schema.
For DB sources, we will add key directive to the select
type with field
set
to the primary key of the table (we can allow users to select the field that
they want to specify in the key
directive).
For actions, we need to modify the set_custom_types
API to include the
directives first.
For remote schema we already store the directives that are coming from the upstream, so we can use them.
Now, to create the union of the types, we already have a function called
selectionSetUnion
which creates Parser
for the union:
selectionSetUnion ::
(MonadParse n, Traversable t) =>
Name ->
Maybe Description ->
-- | The member object types.
t (Parser 'Output n b) ->
Parser 'Output n (t b)
We need to be a little careful here as we would want to remove the Parser
s
based on the role access, for example if a role doesn't have access to the
@key
directive feields, then it makes sense to omit those Parser
s.
Next, we need to evaluate the _entities
query. Consider the following:
query MyQuery {
_entities(representations: [{"__typename": "UsersData", "id": "1"}, {"__typename": "TwoPks", "id1": "1", "id2": "2"}]) {
... on TwoPks {
internalData
}
... on UsersData {
id
name
}
}
}
For evaluating this we need to do the following:
- Get selection set for each of the union types in the selection set of the
_entities
query. In the above query, we will have two selection sets (one forTwoPks
and one forUsersData
). - Generate the arguments using the arguments of the query. For the above
query, for the 1st selection set (
TowPks
), we will have the argument(id1: "1", id2: "2")
. - Next we would have to use the parsers for the fields (
TwoPksByPk
andUsersDataByPk
) to evaluate theField
(constructed using the selection set and arguments in above steps). - Finally we would concatenate the results in a list.
Note: The above method of evaluating the query may do multiple fetches from the database, which might be something that can be optimised in further iterations.
Implementation
There are two main functions from the perspective of implementation:
-
generateSDL
: This function generates the SDL of a given schema. It uses the schema introspection inorder to build the SDL. The schema introspection is generated while building the parsers. The type definition of the function is:generateSDL :: G.SchemaIntrospection -> Text generateSDL = undefined
-
mkEntityUnionFieldParser
: This function will create aFieldParser
for the entities query. This uses the union selection setParser
and theFieldParser
s of fields having@key
directive in order to evaluate the query. The union selection setParser
can be generated usingselectionSetUnion
and theFieldParsers
can be collected based on the schema introspection (for getting the types with@key
directive) and queryFieldParsers
. The type definition of the function is:mkEntityUnionFieldParser :: P.Parser 'P.Output (P.Parse) [[P.ParsedSelection a1]] -> [(G.Name, [G.Directive Void], FieldParser (P.Parse) (NamespacedField (QueryRootField UnpreparedValue)))] -> FieldParser (P.Parse) (NamespacedField (QueryRootField UnpreparedValue)) mkEntityUnionFieldParser bodyParser fieldParsers = undefined
Please note that the
fieldParsers
is a list of triple tuple, which have the type name, directives associated with type name and theFieldParser
associated with the type.
Future work
There is a lot that can be improved in the v1 implementation. To list a few:
- Include action types under the apollo federation as well. This will require a few internal representational changes (such as adding directives in action types).
- Let the user choose the fields that are used in
@key
directives. This will enable users to choose fields other than primary key. - Look into supporting apollo federation v2.