graphql-engine/rfcs/apollo-federation.md
paritosh-08 4f5cb43954 RFC: apollo federation support
RFC for apollo federation support in HGE.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4607
GitOrigin-RevId: c154c58532394ef25166d39ff2e100a00448c111
2022-07-12 09:30:38 +00:00

588 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 tables 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.