apollographql / federation

🌐  Build and scale a single data graph across multiple services with Apollo's federation gateway.
https://apollographql.com/docs/federation/
Other
661 stars 248 forks source link

New semantics to avoid "prop drilling" with overloaded keys #2563

Closed lennyburdette closed 3 months ago

lennyburdette commented 1 year ago

This is inspired from a customer use case. They're doing server-driven UI and have a subgraph for returning "blocks" of user interface elements. The goal is avoiding the "data" subgraphs from knowing anything about "presentational" concerns, but this is challenging when we need to thread presentational context through a hierarchy of blocks.

Example

Notice that we have to add template { id } to entity keys in both subgraphs. This is the "leak" between the data and presentational boundaries.

Example operation ```graphql query HomePage($url: String!) { page(url: $url) { __typename ...ProductCategoryBlockFragment } } fragment ProductCategoryBlockFragment on ProductCategoryBlock { category { id products { edges { blocks { __typename ...ProductCardBlockFragment } } } } } fragment ProductCardBlockFragment on ProductCardBlock { __typename ...ProductCardTitleFragment ...ProductCardDescriptionFragment ...ProductCardImageFragment ...ProductCardPriceFragment } fragment ProductCardTitleFragment on ProductCardTitle { title } fragment ProductCardDescriptionFragment on ProductCardDescription { description } fragment ProductCardImageFragment on ProductCardImage { imageUrl } fragment ProductCardPriceFragment on ProductCardPrice { priceFormatted } ```
Example query plan ```graphql QueryPlan { Sequence { Fetch(service: "experience") { { page(url: $url) { __typename ... on ProductCategoryBlock { category { __typename id template { id } } } } } }, Flatten(path: "page.@.category") { Fetch(service: "data") { { ... on ProductCategory { __typename id template { id } } } => { ... on ProductCategory { products { edges { __typename node { id } template { id } } } } } }, }, Flatten(path: "page.@.category.products.edges.@") { Fetch(service: "experience") { { ... on ProductEdge { __typename node { id } template { id } } } => { ... on ProductEdge { blocks { __typename ... on ProductCardTitle { __typename product { __typename id } } ... on ProductCardDescription { __typename product { __typename id } } ... on ProductCardImage { __typename product { __typename id } } ... on ProductCardPrice { __typename product { __typename id } } } } } }, }, Flatten(path: "page.@.category.products.edges.@.blocks.@.product") { Fetch(service: "data") { { ... on Product { __typename id } } => { ... on Product { name description imageUrl price { value currency } } } }, }, Flatten(path: "page.@.category.products.edges.@.blocks.@") { Fetch(service: "experience") { { ... on ProductCardTitle { __typename product { name id } } ... on ProductCardDescription { __typename product { description id } } ... on ProductCardImage { __typename product { imageUrl id } } ... on ProductCardPrice { __typename product { price { value currency } id } } } => { ... on ProductCardTitle { title } ... on ProductCardDescription { description } ... on ProductCardImage { imageUrl } ... on ProductCardPrice { priceFormatted } } }, }, }, } ```

Prop drilling vs setContext

This feels conceptually similar to "prop drilling" in React components. React has a concept of "context", state that is local to a tree of components. A parent component sets the context, and a descendent component can retrieve the context without the intermediary components knowing anything about it.

A Federation version of "context" might solve this elegantly. A directive for setting the context when an entity is resolved, and another for injecting the context into descendent fields might look like:

# 1. the query planner adds the context fields to the selection set, like it does with keys
# 2. it then stores some internal state mapping the context object 
#    ({ template: { id: "template-1" } }) to the keyed entity
type ProductCategoryBlock 
  @key(fields: "category { id }") 
  @setContext(name: "template", from: "template { id }") {
  category: ProductCategory!
  template: Template! @inaccessible
}

# 3. When the query planner needs to resolve the `blocks` field, it knows which 
#    `ProductCategoryBlock` this entity descends from, and can inject the context 
#    object from the internal state
type ProductEdge @key(fields: "node { id }") {
  node: Product!
  blocks(
    template: Template @fromContext(name: "template")
  ): [ProductCardBlock!]!
}

I tried hacking this together using coprocessors and it was much harder than it seemed. The particularly nasty part is that entities are deduplicated. If I have:

The query planner flattens and batches the ProductEdge entities into a list of P1, P2, P3, P4. We've lost track of the association with the ProductCategoryBlocks, so we can't call ProductEdge.blocks with different template arguments. (This is avoided when overloading keys because the ProductEdge entities are unique by template.) The query planner must keep this mapping internally so that it can appropriately nest the edge objects within the block objects.

I bet there's another way to do this that we haven't thought of so I wanted to write this down and start the discussion!

lennyburdette commented 1 year ago

Exploring other use cases for context — this one is interesting but I'm less sure about it.

query FeaturedBookDetails {
  featuredBook {
    title
    author {
      name
      # I want a list of books that _does not_ include the featured book
      otherBooks {
        title
      }
    }
  }
}

Proposed solution using "context"

# books subgraph
type Book @key(fields: "id") @setContext(name: "bookFilter", from: "bookFilter { notEqual }") {
  id: ID!
  title: String
  bookFilter: BookFilter @inaccessible # { notEqual: [this.id] }
  author: Author
}

type Author @key(fields: "id") {
  id: ID!
}

# authors subgraph
type Author @key(fields: "id") {
  id: ID!

  # this argument is nullable. if called outside of a 
  # `Book -> author -> otherBooks` chain, it will just return the author's books.
  # (if not "inaccessible" it could also just be public API)
  otherBooks(filter: BookFilter @fromContext(name: "bookFilter")): BookConnection
}

The following alternatives are possible today, and they don't seem that bad. But they quickly become onerous if there are any intermediary layers necessitating more key stuffing or extraneous fields.

Key stuffing alternative solution ```graphql # books subgraph type Book @key(fields: "id") { id: ID! title: String author: Author } type Author @key(fields: "id relevantBook { id }") { id: ID! relevantBook: Book @inaccessible } # authors subgraph type Author @key(fields: "id relevantBook { id }") { id: ID! relevantBook: Book otherBooks: BookConnection } ```
`@requires` alternative solution ```graphql # books subgraph type Book @key(fields: "id") { id: ID! title: String author: Author } type Author @key(fields: "id") { id: ID! relevantBook: Book @inaccessible } # authors subgraph type Author @key(fields: "id") { id: ID! relevantBook: Book @external otherBooks: BookConnection @requires(fields: "relevantBook { id }") } ```
`@requires` alternative solution no. 2 (non-functional) ```graphql # books subgraph type Book @key(fields: "id") { id: ID! title: String author: Author # this doesn't work when pagination is involved! otherBooksByAuthor: BookConnection @requires(fields: "author { books { edges { nodes { id } } } }") } type Author @key(fields: "id") { id: ID! books: BookConnection @external } # authors subgraph type Author @key(fields: "id") { id: ID! books: BookBookConnection } ```
brh55 commented 1 year ago

+1

Experiencing a similar issue with a customer and their experience graph proof of concept.

The use-case involves presentational context being provided by the client determination of the constraints on the device and platform (100s of different varieties), which lines similarly to the use-case mentioned in your second post (homepage(client: clientContext) {}). However in federation world, key stuffing is leaking presentational boundaries to other domains schemas and requires this pattern to be replicated across further nested entities. Thus, we've settled on header context propagation, and will explore a Rhai script or body extension approach driven by the experience subgraph.

timbotnik commented 1 year ago

Let's also consider the idea of the @export directive which could help with some of these issues. https://github.com/graphql/graphql-spec/issues/377

smyrick commented 1 year ago

I wanted to comment that a simpler option, while not declarative and not following any spec (and terrible to manage), is to use response extensions on subgraph responses and have a Router script/coprocessor to forward the extensions.context object to all subgraph requests

gwardwell commented 1 year ago

@smyrick Part of the goal is for the context to be limited to a specific branch of resolving data cross subgraph as not all branches are guaranteed to need the same context (otherwise headers could just be used). I think that could be achieved with what you’re describing, but it would be a nightmare to manage. That being said, custom directives could probably be developed to make this extensions idea more declarative and simpler to manage at the subgraph level.

I do think this is a fairly large gap that federation can and should solve.

smyrick commented 1 year ago

@gwardwell Totally agree, this would be a perfect fit for the query planner. I wanted to just add some possible workarounds for those "blocked" today

clenfest commented 3 months ago

This feature request is resolved by the @context / @fromContext directives released in Federation 2.8.