mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
4f5cb43954
RFC for apollo federation support in HGE. PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4607 GitOrigin-RevId: c154c58532394ef25166d39ff2e100a00448c111
588 lines
14 KiB
Markdown
588 lines
14 KiB
Markdown
# Apollo federation v1 support
|
||
|
||
|
||
Original issue [#3064](https://github.com/hasura/graphql-engine/issues/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](https://www.apollographql.com/docs/federation/federation-spec/).
|
||
|
||
## Product perspective
|
||
|
||
### Requirements
|
||
|
||
We want the following things:
|
||
1. Hasura should mount on Apollo federated gateway
|
||
2. 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:
|
||
```json
|
||
{
|
||
"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:
|
||
1. Extending the support to v2 directives in future such as adding `@sharable`
|
||
to some columns.
|
||
2. Changing `@key` directive `fields` (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:
|
||
>
|
||
> 1. Implementation of the federation schema specification
|
||
> 2. Support for fetching service capabilities
|
||
> 3. Implementation of stub type generation for references
|
||
> 4. 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:
|
||
|
||
```haskell
|
||
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:
|
||
|
||
```haskell
|
||
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)
|
||
```graphql
|
||
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!
|
||
}
|
||
|
||
.
|
||
.
|
||
.
|
||
```
|
||
|
||
<details>
|
||
|
||
<summary>Click here for full SDL</summary>
|
||
|
||
|
||
```graphql
|
||
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!
|
||
}
|
||
```
|
||
</details>
|
||
|
||
### Entities Union
|
||
Apollo federation needs an `_entities` field as part of the specification,
|
||
which is defined as follows:
|
||
|
||
```graphql
|
||
# 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:
|
||
```haskell
|
||
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:
|
||
```graphql
|
||
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:
|
||
|
||
1. 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 for
|
||
`TwoPks` and one for `UsersData`).
|
||
2. 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")`.
|
||
3. Next we would have to use the parsers for the fields (`TwoPksByPk` and
|
||
`UsersDataByPk`) to evaluate the `Field` (constructed using the selection set
|
||
and arguments in above steps).
|
||
4. 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:
|
||
1. `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:
|
||
```haskell
|
||
generateSDL :: G.SchemaIntrospection -> Text
|
||
generateSDL = undefined
|
||
```
|
||
|
||
2. `mkEntityUnionFieldParser`: This function will create a `FieldParser` for
|
||
the entities query. This uses the union selection set `Parser` and the
|
||
`FieldParser`s of fields having `@key` directive in order to evaluate the
|
||
query. The union selection set `Parser` can be generated using
|
||
`selectionSetUnion` and the `FieldParsers` can be collected based on the
|
||
schema introspection (for getting the types with `@key` directive) and query
|
||
`FieldParsers`. The type definition of the function is:
|
||
```haskell
|
||
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 the `FieldParser`
|
||
associated with the type.
|
||
|
||
### Future work
|
||
|
||
There is a lot that can be improved in the v1 implementation. To list a few:
|
||
1. Include action types under the apollo federation as well. This will require
|
||
a few internal representational changes (such as adding directives in action
|
||
types).
|
||
2. Let the user choose the fields that are used in `@key` directives. This
|
||
will enable users to choose fields other than primary key.
|
||
3. Look into supporting apollo federation v2.
|