Enforcing Entity Ownership in Apollo Federation

Designating entity ownership in Apollo Federation 2


In Federation 2, the notion of "extending" an entity type is strictly conceptual. All definitions of a type in different subgraphs are merged according to the "shareability" of fields. In the following example, neither subgraph really owns or extends the Product entity. Instead, they both contribute fields to it.

GraphQL
subgraph-a.graphql
1type Product @key(fields: "id") {
2  id: ID!
3  name: String
4}
GraphQL
subgraph-b.graphql
1type Product @key(fields: "id") {
2  id: ID!
3  reviews: [Review]
4}

Federation 1 required that one of these definitions used the extend keyword or @extends directive. Federation 2 drops this requirement to improve the flexibility of composition and reduce the possibility of hard composition errors.

However, in some situations you still might want to designate an "owner" of an entity and make "entity extension" a first-class concept in your supergraph.

One example is the ability assert which subgraph is responsible for documenting an entity. If two subgraphs add different descriptions to a type, composition selects one of those descriptions and emits a hint informing you of the inconsistency:

Text
1HINT: [INCONSISTENT_DESCRIPTION]: Element "Product" has inconsistent
2descriptions across subgraphs. The supergraph will use description
3(from subgraph "one"):
4  """
5  The Product type lorem ipsum dolar sit amet.
6  """
7In subgraph "two", the description is:
8  """
9  This is my description of the Product type.
10  """
ⓘ note
When a description is inconsistent across subgraphs, composition selects the description from the first subgraph alphabetically by name.

A mechanism for deciding the "owner" of the type allows tools such as linters to catch these inconsistencies early in the development process.

Creating an @owner directive

You can add an @owner directive to your supergraph using the @composeDirective functionality introduced in Federation 2.2.

GraphQL
subgraph-a.graphql
1extend schema
2  @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@composeDirective"])
3  @link(url: "https://graphql.mycompany.dev/owner/v1.0", import: ["@owner"])
4  @composeDirective(name: "@owner")
5
6directive @owner(team: String!) on OBJECT
7
8type Product @key(fields: "id") @owner(team: "subgraph-a") {
9  id: ID!
10  name: String
11}

The @owner directive now appears in the supergraph. Because we did not define the directive as repeatable, subgraphs cannot define it with different arguments.

Click to view the generated supergraph schema
GraphQL
supergraph.graphql
1schema
2  @link(url: "https://specs.apollo.dev/link/v1.0")
3  @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
4  @link(url: "https://graphql.mycompany.dev/owner/v1.0", import: ["@owner"]) {
5  query: Query
6}
7
8directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
9
10directive @join__field(
11  graph: join__Graph
12  requires: join__FieldSet
13  provides: join__FieldSet
14  type: String
15  external: Boolean
16  override: String
17  usedOverridden: Boolean
18) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
19
20directive @join__graph(name: String!, url: String!) on ENUM_VALUE
21
22directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
23
24directive @join__type(
25  graph: join__Graph!
26  key: join__FieldSet
27  extension: Boolean! = false
28  resolvable: Boolean! = true
29  isInterfaceObject: Boolean! = false
30) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR
31
32directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
33
34directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
35
36directive @owner(team: String!) on OBJECT
37
38scalar join__FieldSet
39
40enum join__Graph {
41  ONE @join__graph(name: "one", url: "http://localhost:4001")
42}
43
44scalar link__Import
45
46enum link__Purpose {
47  """
48  `SECURITY` features provide metadata necessary to securely resolve fields.
49  """
50  SECURITY
51
52  """
53  `EXECUTION` features provide metadata necessary for operation execution.
54  """
55  EXECUTION
56}
57
58type Product @join__type(graph: ONE, key: "id") @owner(team: "subgraph-a") {
59  id: ID!
60  name: String
61}
62
63type Query @join__type(graph: ONE) {
64  products: [Product]
65}

Writing a lint rule using the @owner directive

Here's an example of a @graphql-eslint rule for subgraph schemas that uses the @owner directive to determine if a description is required:

Click to expand
JavaScript
1const { getDirective } = require("@graphql-tools/utils");
2const { buildSchema } = require("graphql");
3
4module.exports = {
5  rules: {
6    /** @type {import("@graphql-eslint/eslint-plugin").GraphQLESLintRule} */
7    "subgraph-owned-type-has-description": {
8      create(context) {
9        const schema = buildSchema(context.getSourceCode().text, {
10          assumeValidSDL: true, // subgraph schemas may not be valid on their own
11        });
12
13        return {
14          /**
15           * For each object type defintion, look for an `@owner` directive.
16           * If it exist, require a description.
17           * If it doesn't, disallow a description.
18           */
19          ObjectTypeDefinition(node) {
20            const type = schema.getType(node.name.value);
21            const owner = getDirective(schema, type, "owner");
22
23            if (owner && !node.description) {
24              context.report({
25                node,
26                message: "Description is required on owned types",
27              });
28            }
29
30            if (!owner && node.description) {
31              context.report({
32                node,
33                message: "Description not allowed on unowned types",
34              });
35            }
36          },
37        };
38      },
39    },
40  },
41};

Using @owner to determine required approvers

Another use case for the @owner directive is to determine required reviewers when a schema change affects a type owned by another team.

The exact process depends on your source control and continuous integration systems. The following example steps assume you're using GitHub for both.

  1. Add a pull_request workflow:

    YAML
    .github/workflows/add-reviewers.yaml
    1name: Add required reviewers for owned GraphQL types
    2on: [pull_request]
  2. Determine the affected types in the schema change:

    JavaScript
    1import { diff } from "@graphql-inspector/core";
    2import { buildSchema } from "graphql";
    3
    4const differences = diff(
    5  buildSchema(schemaFromBaseRef, { assumeValidSDL: false }),
    6  buildSchema(schemaFromCurrentRef, { assumeValidSDL: false })
    7);
    8
    9/* Derive a list of affected types from the result:
    10[
    11  {
    12    "criticality": {
    13      "level": "NON_BREAKING"
    14    },
    15    "type": "FIELD_ADDED",
    16    "message": "Field 'newField' was added to object type 'Product'",
    17    "path": "Product.newField"
    18  }
    19]
    20*/
  3. Obtain the supergraph schema.

    You can use rover supergraph fetch or retrieve it using the Apollo Platform API .

  4. Extract the owners for the affected types:

    JavaScript
    1import { getDirective } from "@graphql-tools/utils";
    2
    3const supergraphSchema = buildSchema(supergraphSdl);
    4const affectedTeams = [];
    5
    6for (const typeName of affectedTypes) {
    7  const type = supergraphSchema.getType(typeName);
    8
    9  const owner = getDirective(schema, type, "owner")?.[0];
    10
    11  if (owner) {
    12    affectedTeams.push(owner.team);
    13  }
    14}
  5. Add the team as reviewers on the pull request:

    JavaScript
    1import { Octokit } from "@octokit/action";
    2
    3const octokit = new Octokit();
    4
    5const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/");
    6
    7await octokit.pulls.requestReviewers({
    8  owner,
    9  repo,
    10  pull_number: pullNumber, // ${{ github.event.number }}
    11  team_reviewers: affectedTeams,
    12});