Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apollo Federation #3064

Closed
maxpain opened this issue Oct 8, 2019 · 21 comments
Closed

Apollo Federation #3064

maxpain opened this issue Oct 8, 2019 · 21 comments
Assignees
Labels
a/collab-ci-cd k/ideas Discuss new ideas / pre-proposals / roadmap

Comments

@maxpain
Copy link
Contributor

maxpain commented Oct 8, 2019

Since the graphql schema stitching way is deprecated in Apollo, we are limited to the functionality of Apollo Server.

We really need the support of the Apollo Federation in Hasura.

You guys are promoting the Hasura with advice to use it as a gateway and connect other microservices with a business logic via remote schemas feature, but we want to use graphql-engine only as microservice behind Apollo Gateway in our project. We need most features of Apollo, like tracing, performance metrics, error tracking, safe listing (whitelisting), caching, schema directives, file uploads, custom event-driven subscriptions, automatic persisted queries, vscode plugin

I know about the "allow list" feature in graphql-engine, but it is not useful in real life, because you need something like Apollo Operation Registry. Our CI pushes the operations list to the registry every time when we deploying a new version of our web application on react and apollo client. It is a very convenient way because Apollo has tooling for CI with the ability to check schema changes before pushing these changes to production.

Unfortunately, in the GraphQL community, we become third-class citizens.

So, can Hasura offer at least a small part of features Apollo? We made sure that no.

@marionschleifer marionschleifer added the k/ideas Discuss new ideas / pre-proposals / roadmap label Oct 9, 2019
@carlosbaraza
Copy link

This is really important to us as well. Using Hasura as a service should be a fairly simple thing to do.

@0xR
Copy link

0xR commented Oct 10, 2019

You can add a middleware service with graphql-transform-federation to add federation decorators to an existing hasura schema. This makes it possible to use hasura behind an apollo federation gateway. Let me know what you think.

@tsaiDavid
Copy link

@dmi3y - see above re: Federation

@kylerjensen
Copy link

For us, it would be really valuable if Hasura would conform to the federation spec out-of-the-box. The whole reason for our using Hasura is to save time and eliminate the amount of redundant code we have to write. Having to integrate graphql-transform-federation isn't very straightforward because we have to define all the keys. Hasura already has all the information needed to conform the spec because it has a list of all the primary and foreign keys.

@rektide
Copy link

rektide commented Jan 31, 2020

I'm coming in to a company that's a couple months into using Apollo Server.

I would really like to be able to introduce Hasura.

Without this feature, I don't feel like there is an expedient way for me to be able to introduce Hasura. It seems like my best option, at the moment, would be to figure out graphql-transform-federation, but I am not an expert on Apollo Federation & it's intimidating figuring out what this integration story is going to look like on my own.

This ticket is, imo, the #1 most important thing Hasura could be doing to gain adoption & users. Hasura is new technology compared to Apollo Server, & this ticket could make it radically more feasible for teams to bring Hasura into production & get started with it. Please consider prioritizing this ticket.

@thebetterjort
Copy link

Hasura needs to bring this federated gateways into its realm. It's so crucial in bigger tech, where we have thousands of microservices. It's not feasable for bigger players to use hasura until then.

@VictorPuga
Copy link

I also find it valuable that Hasura implements the integration to the Apollo Graph Manager, since it provides very helpful information about the GraphQL execution. Maybe if this could be an opt in instead of being available by default. I don't know if this could affect the overall performance of the graphql-engine

@jarvisuser90
Copy link

I work for a multinational corporation and we are starting to adopt GraphQL across our organization. We took some time to evaluate Hasura. We loved everything about it. However, due to the lack of Apollo Federation support in Hasura, we decided to use PostGraphile. Until Hasura adds Apollo Federation support, we just can't use it.

@tirumaraiselvan
Copy link
Contributor

@jarvisuser90 Hi there! Thank you for your kind words. This is something we’ve been thinking about a lot. Could we setup a quick call with you to learn what your federation requirements are like? It’d be super helpful context for us as we think through apollo federation support. I’m at [email protected].

@ntziolis
Copy link

We also need apollo federation support. We are currently investigating using:

If anyone has any experience to share we are very keen to here about it. Especially if there are any issues that would deem this non production ready.

@mac2000
Copy link

mac2000 commented Aug 22, 2020

Went here because did realize that there is SaaS for Hasura - just tell me where the buy button is, it is a brilliant product, just can not wait to put my hands on it, but:

things like transform and mesh will introduce following issues:

  • it is yet another deployment which might crash - then why do we need highly available SaaS solution from Hasura?
  • it will force us to manually map resolve reference fields to those which Hasura did generated
  • because of that deployment process will become harder
  • also it will be harder to utilize apollo service check and service push features

We already in a production with many different services written in different stacks and using different storages, I just can not imagine what needs to happen for us to throw it all away and switch to Hasura as a gateway

BTW there is similar ticket in dgraph repository, so for example if I choose to use both storages which one should I choose as a gateway?

Ideally I wish to be able to add more and more services to our graph by just buying Hasura, Dgraph, etc SaaS solutions without hastling with transforms and meshes it will dramatically improve T2M

PS: did implemented federation for dotnet-graphql it is not so hard and can be pluggable, hope to see this feature in future, will be your client ASAP after this, we really need something like your product

@tehpsalmist
Copy link

I've noticed that this issue is not on the roadmap. Is it being considered at all? As other folks mentioned above, this is just about essential in order for us to use hasura with our systems.

The federation-transform package looks promising enough, but the configuration-heavy nature of it is a fair concern. Furthermore, hasura docs give a warning about layers on top of hasura negatively impacting performance, but aren't clear about how or why, so it's difficult to say with confidence that this package wouldn't also ruin performance or cause unpredictable performance.

Any update, insight, or workaround would be very helpful.

@mac2000
Copy link

mac2000 commented Dec 2, 2020

@tehpsalmist unfortunately without the support of union types workarounds won't work as expected, e.g. for the federation to work all you need to do is to add two dedicated endpoints, one to resolve _service.sdl and another for _entities

so we are kind of waiting for this #2311 first

@mac2000
Copy link

mac2000 commented Apr 30, 2021

JFYI: dgraph just did it - starting from v21.03 Apollo Federation is built in, still waiting for it to become available in their SaaS hopefully will see something similar in future here as well

@eduhenke
Copy link

eduhenke commented Jun 7, 2021

Linking a relevant StackOverflow/Gist(by @tirumaraiselvan) url for a workaround:

@tarekrached
Copy link

Hey @tirumaraiselvan, any updates on Hasura's thinking around this? We'd love to be able to federate Hasura, too.

@cozmo
Copy link

cozmo commented Aug 30, 2021

We needed this for our production data layer, we use Apollo federation to stitch together multiple standalone backends, and we wanted to use hasura as one such backend. In lieu official support, we were able to to implement a customer data source that translated the Apollo federation queries. This is "better" than the above solution(s) since it does fully support federation, albeit at the cost of relatively complex AST mutation.

Posted here out of hope that it'll be useful for someone else who needs this, however I don't really want to be on the hook for long term support so not making a module etc yet.

hasura-data-source.ts

// Hasura doesn't support Apollo federation https://github.com/hasura/graphql-engine/issues/3064
// There are tools that exist for wrapping non federated services with federation, such as
// https://github.com/0xR/graphql-transform-federation, but they work by implementing a graphql resolver
// with another query to the underlying service. This breaks down with more complex queries
// (such as queries with aliases) https://github.com/0xR/graphql-transform-federation/issues/15
//
// The "right" solution is to provide the federation server a graphql data source that implements the
// federation spec https://www.apollographql.com/docs/federation/federation-spec/
//
// This spec has two main components that matter for us
//
// 1. Return an SDL (a schema document) that has the @key directives and `extend` directives.
// 2. Support queries against the `_entities` field.
//
// Both of these are implemented in the below data source.
// - `process()` is the public method exposed by `GraphQLDataSource` classes. It takes in a graphql query, and exposes a graphql response.
//    This data source implements this method to transform queries against the `_entities` field, such as
//    ---
//    _entities(representations: $representations) {
//      ... on TypeA {
//        fieldA
//        alias: fieldB {
//          fieldC
//        }
//      }
//      ... on TypeB {
//        fieldA
//        fieldB
//      }
//    }
//    ---
//    into queries that hasura can handle, such as
//    ---
//    query {
//      representation_0: TypeA_by_pk(id: "id") {
//        fieldA
//         alias: fieldB {
//           fieldC
//         }
//       }
//      }
//      representation_1: TypeB_by_pk(id: "id") {
//        fieldA
//        fieldB
//      }
//    }
//    ---
//   It then transform the response back on the way out. Importantly, it _also_ works with non `_entity` queries, so mutations and normal queries
//   continue to work
// - The `getSdl` returns a SDL in the format that apollo federation expects. This method is largly inspired by the aforemention
//   https://github.com/0xR/graphql-transform-federation

import { GraphQLDataSource } from "@apollo/gateway";
import { GraphQLRequestContext, GraphQLResponse } from "apollo-server-types";

import { fetch } from "cross-fetch";
import {
  parse,
  visit,
  print,
  SelectionSetNode,
  DocumentNode,
  printSchema,
  buildClientSchema,
  GraphQLSchema,
  GraphQLInputObjectType,
  getIntrospectionQuery,
} from "graphql";
import {
  createDirectiveWithFields,
  valueToNode,
} from "astNodeFactories.ts";

type HasuraTypeMap = Record<string, Set<string>>;

// An item in the representations array that the federation server sends
type Representation = {
  __typename: string;
  [field: string]: string;
};

type Options = {
  url: string;
};

export class HasuraDataSource implements GraphQLDataSource {
  private url: string;
  private schema: Promise<GraphQLSchema>;

  constructor(options: Options) {
    this.url = options.url;
    this.schema = this.getSchema();
  }

  // Returns the GraphQLSchema hasura reports
  private async getSchema() {
    const introspectionResult = await (
      await fetch(this.url, {
        method: "POST",
        headers: {
          "content-type": "application/json",
        },
        body: JSON.stringify({ query: getIntrospectionQuery() }),
      })
    ).json();

    return buildClientSchema(introspectionResult.data);
  }

  async process(
    request: Pick<GraphQLRequestContext, "request" | "context">
  ): Promise<GraphQLResponse> {
    // Parse the incoming query into an AST
    const queryAST = parse(request.request.query || "");

    // Keep track of the entities we're querying (if any). Each record is a mapping of __typename to the
    // SelectionSet (the fields we're querying on the relevant type)
    const entitiesToQuery: Record<string, SelectionSetNode> = {};

    // Keep track of the representations variable, which represents the entities that we need to request
    // from hasura (could be non existant if this is a normal query).
    let representations: Representation[] = [];

    // Get a map of type names to the primary key fields.
    const hasuraTypeMap = this.getHasuraTypes(await this.schema);

    let representationVariableName: string | null = null;

    // Create a new query by mutating the original one. The goals of this are laid out in the comment
    // at the top of this file, and explained inline.
    const newQuery = print(
      visit(queryAST, {
        OperationDefinition: (node) => {
          // Iterate over operation definitions, which correspond to things like `Query` and `Mutation`.
          // each one has a `selectionSet`, which encodes the fields on the operation that we're selecting.
          // We use this selectionSet to look for queries that select the _entities field, so we can remove
          // it and switch it into query against normal fields.
          const { selectionSet } = node;
          const entitySelect = selectionSet.selections.find(
            (selectionSetNode) =>
              node.operation === "query" &&
              selectionSetNode.kind == "Field" &&
              selectionSetNode.name.value === "_entities"
          );
          if (!entitySelect || entitySelect.kind !== "Field") {
            // If we can't find the entitySelection, then just return this OperationDefinition unmodified since
            // it doesn't need to be modified.
            return;
          }

          // We need to figure out what variable name was passed to the representations variable in the _entities field, since
          // we use it later on.
          const representationArgument = entitySelect.arguments?.find(
            (arg) => arg.name.value === "representations"
          );
          if (
            !representationArgument ||
            representationArgument.value.kind !== "Variable"
          ) {
            // It's conceivable that someone could pass representations to the _entities field without passing them as a variable
            // (instead just inlining it) but this doesn't happen in practice and would be a huge pain so just error in that case.
            throw new Error(
              "_entities selected without an expected representation argument"
            );
          }

          representationVariableName = representationArgument.value.name.value;

          // Now that we've found the entitySelection, we look at each selection within that selection.
          // These selections encode what fields we want off the type and should send to hasura.
          // For example, one selection may look like
          // ... on TypeA {
          //   fieldA
          //    alias: fieldB {
          //      fieldC
          //    }
          // }
          // and we will transform it into
          // TypeA_by_pk(id: "id from representations") {
          //   fieldA
          //     alias: fieldB {
          //       fieldC
          //     }
          //   }
          entitySelect.selectionSet?.selections.forEach((selection) => {
            if (selection.kind !== "InlineFragment") {
              throw new Error(
                "Unexpectedly found an non InlineFragment in the entity SelectionSet"
              );
            }
            // This is the name of the type we want to find from Hasura
            const type = selection.typeCondition?.name.value;
            if (!type) {
              return;
            }
            // Add this type to the entities we want to query, mapped to the fields we want to query for.
            entitiesToQuery[type] = selection.selectionSet;
          });

          representations =
            request.request.variables?.[representationVariableName] || [];

          // Now we want to filter the root level selectionSet to _remove_ the entity selection (which we don't
          // want to send to hasura), and replace it with queries against the `Type_by_pk` fields.
          node.selectionSet.selections = node.selectionSet.selections
            .filter((node) => {
              if (node.kind !== "Field") {
                // If we find a non field node just leave it alone
                return true;
              }
              // Else return false (to drop) the _entities field, but keep all others.
              return node.name.value !== "_entities";
            })
            .concat(
              representations.map((rep, i) => ({
                // For each representation (from the representations variable), we add a query against the
                // Type_by_pk field. Of note, we alias this query as `representation_{N}: Type_by_pk(id:...)`. This allows
                // for querying for multiple of the same type, which is very common.
                kind: "Field",
                name: {
                  kind: "Name",
                  value: `${rep.__typename}_by_pk`,
                },
                alias: {
                  kind: "Name",
                  value: `representation_${i}`,
                },
                // We load the set of primary keys from the hasura type map. For most Types this is a single
                // string called id, but for tables with compound primary keys, there might be multiple strings
                // one for each column in the primary key
                arguments: [...hasuraTypeMap[rep.__typename]].map(
                  (primaryKey) => ({
                    kind: "Argument",
                    name: {
                      kind: "Name",
                      // value of variable is the name of the key
                      value: primaryKey,
                    },
                    value: valueToNode(rep[primaryKey]),
                  })
                ),
                directives: [],
                selectionSet: entitiesToQuery[rep.__typename],
              }))
            );
        },
        VariableDefinition: {
          // As we're "leaving" variable definiton nodes, we remove the "representations" variable if it exists,
          // since it's not used after the translation occurs. This needs to happen as we "leave" since we don't know the
          // name of this variable until after we "enter" the OperationDefinition below
          leave: (node) => {
            if (
              node.variable.name.value === representationVariableName &&
              node.type.kind == "NonNullType" &&
              node.type.type.kind === "ListType"
            ) {
              return null;
            }
            return;
          },
        },
      })
    );

    // Now that we have the new query, execute it against hasura.

    // Remove the representations variable from the variables since it's no longer referenced by anything
    const cleanedVariables = representationVariableName
      ? {
          ...request.request.variables,
          [representationVariableName]: undefined,
        }
      : request.request.variables;

    const resp = await fetch(this.url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        query: newQuery,
        variables: cleanedVariables,
      }),
    });
    const resultData = await resp.json();

    if (resultData.errors && !resultData.data) {
      return resultData;
    }

    // Pull the entities from the response data out back into an array matching the requested representations
    const _entities = representations.map(
      (rep, i) => resultData.data[`representation_${i}`]
    );

    // And remove them from the response object.
    const dataWithoutEntries = Object.fromEntries(
      Object.entries(resultData.data || {}).filter(
        ([k]) => !k.startsWith("representation_")
      )
    );

    // And return the resulting data, which matches the format of the orignal query.
    return {
      data: representations.length
        ? {
            ...dataWithoutEntries,
            _entities,
          }
        : dataWithoutEntries,
    };
  }

  // This function scans the types exposed by hasura and returns the subset of those
  // that should be shared across schemas and queryable. It finds them by looking for
  // types ("Type") with a corrosponding "Type_pk_columns_input". Additionally it uses
  // the Type_pk_columns_input to determine which fields should be used as the key for
  // this type, which is needed both for the SDL (to annotate with the @key()) directives
  // correct, and also at query time (to query from `Type_by_pk`) correctly.
  private getHasuraTypes(schema: GraphQLSchema): HasuraTypeMap {
    const types: HasuraTypeMap = {};

    for (const type of Object.keys(schema.getTypeMap())) {
      const pkType = schema.getType(`${type}_pk_columns_input`);

      if (!pkType || !(pkType instanceof GraphQLInputObjectType)) {
        continue;
      }

      types[type] = new Set(Object.keys(pkType.getFields()));
    }

    return types;
  }

  // This function loads the schema from hasura and annotates it as if hasura supported federation.
  // It's relatively unchanged from the implementation in https://github.com/0xR/graphql-transform-federation
  async getSdl(): Promise<DocumentNode> {
    this.schema = this.getSchema();
    const schema = await this.schema;

    // Get the AST of the schema by parsing the printed schema...feels obtuse but it works
    const ast = parse(printSchema(schema));

    // We'll need these to know which fields need to have @key directives added
    const hasuraTypes = this.getHasuraTypes(schema);

    // Create a set of types which we need to augment, which we will burn down in the ast traversal
    const objectTypesToAugment = new Set(Object.keys(hasuraTypes));

    const withDirectives: DocumentNode = visit(ast, {
      DirectiveDefinition: (node) => {
        // Hasura adds a @cached directive that is used for hasura cloud. Since directives must match across
        // all subgraphs, we strip this out.
        if (
          node.name.value === "cached" &&
          node.description?.value?.includes("Hasura Cloud")
        ) {
          return null;
        }
        return node;
      },
      ObjectTypeDefinition: (node) => {
        // For each type definition
        // If it's in the todo list...
        if (objectTypesToAugment.has(node.name.value)) {
          // Pull the set of key fields from the mapping
          const keyFields = [...hasuraTypes[node.name.value]];
          // Remove from the todo list
          objectTypesToAugment.delete(node.name.value);
          // And return the node with the new @key directives added on.
          return {
            ...node,
            directives: (node.directives || []).concat(
              keyFields.map((keyField) =>
                createDirectiveWithFields("key", keyField)
              )
            ),
            kind: node.kind,
          };
        } else if (node.name.value === "Query") {
          // And the extends Query to the query type definition
          return {
            ...node,
            directives: node.directives || [],
            kind: "ObjectTypeExtension",
          };
        } else if (node.name.value === "subscription_root") {
          // Remove the subscription root since it doesn't work with federation.
          return null;
        }
      },
    });

    if (objectTypesToAugment.size !== 0) {
      throw new Error(
        `Could not add key directives or extend types: ${Array.from(
          objectTypesToAugment
        ).join(", ")}`
      );
    }

    // Return the new schema
    return withDirectives;
  }
}

astNodeFactories.ts

import { NameNode, StringValueNode, DirectiveNode, ValueNode } from "graphql";

function createNameNode(value: string): NameNode {
  return {
    kind: "Name",
    value,
  };
}

function createStringValueNode(value: string, block = false): StringValueNode {
  return {
    kind: "StringValue",
    value,
    block,
  };
}

function createDirectiveNode(
  name: string,
  directiveArguments: { [argumentName: string]: ValueNode } = {}
): DirectiveNode {
  return {
    kind: "Directive",
    name: createNameNode(name),
    arguments: Object.entries(directiveArguments).map(
      ([argumentName, value]) => ({
        kind: "Argument",
        name: createNameNode(argumentName),
        value,
      })
    ),
  };
}

export function createDirectiveWithFields(
  directiveName: string,
  fields: string
) {
  return createDirectiveNode(directiveName, {
    fields: createStringValueNode(fields),
  });
}

export function valueToNode(value: string | number | boolean): ValueNode {
  if (typeof value === "string") {
    return {
      kind: "StringValue",
      value: value,
      block: false,
    };
  } else if (typeof value === "boolean") {
    return {
      kind: "BooleanValue",
      value: value,
    };
  } else if (typeof value === "number" && Number.isInteger(value)) {
    return {
      kind: "IntValue",
      value: value.toString(),
    };
  } else if (typeof value === "number" && !Number.isInteger(value)) {
    return {
      kind: "FloatValue",
      value: value.toString(),
    };
  } else {
    throw new Error("Unexpected value from valueToNode");
  }
}

index.ts

const gateway = new ApolloGateway({
  // We are polling our schemas for updates every 10 seconds. If you don't want to poll for changes you'll 
  // need to instantiate this differently.
  experimental_pollInterval: 10000,
  async experimental_updateServiceDefinitions() {
    const hasuraSdl = await hasuraDataSource.getSdl();
    
    return {
      serviceDefinitions: [
        {
          name: "hasura",
          typeDefs: hasuraSdl,
        },
        // other services
      ],
      isNewSchema: true, // we could potentially do something smarter here if the existing schema is unchanged
    };
  },
  buildService(def) {
    if (def.name === "hasura") {
      return hasuraDataSource;
    }
    // return other data sources;
  }
});

Your milage may vary, and this is clearly a hack until Hasura can support this natively, but it does work, and since it correctly implements the federation spec, it should be safe and stable.

One day maybe I'll get around to adding this to some sort of library but until then hopefully this saves someone some time..

@deathemperor
Copy link
Contributor

https://github.com/hasura/graphql-engine/releases/tag/v2.10.0-beta.1 now support Apollo Federation 1.0. Hurray to Hasura team!

@tirumaraiselvan
Copy link
Contributor

Yes! We have released support for Apollo Federation in v2.10.0-beta.1 . Please see the docs for usage: https://hasura.io/docs/latest/data-federation/apollo-federation/

Pls do open issues on this repo if you encounter any problem in your usage.

@kdawgwilk
Copy link

This is great to see! 🎉 Anywhere we can track progress on supporting Federation v2 spec?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a/collab-ci-cd k/ideas Discuss new ideas / pre-proposals / roadmap
Projects
None yet
Development

No branches or pull requests