Skip to content

[RFC] Dynamic variable declaration #583

Open
@alexgenco

Description

@alexgenco

RFC: Dynamic variable declaration

This RFC is an attempt to revive #377 by decoupling it from multiple operation support, and to flesh out our specific use cases and needs. It uses the same directive name @export in examples, however we could also choose a different name if we want to keep them independent features. See "Limitations/considerations" for alternatives.

Problem

At Braintree, we've been designing our new GraphQL API with the goal of breaking up our legacy API features into small, focused, and composable queries and mutations. For instance, our legacy API has a Transaction.sale operation that accepts either a payment_method_token, a payment_method_nonce, or raw payment method details. In addition, you can pass store_in_vault to indicate that you would like to persist the payment method as well. This proliferation of options leads to ambiguity about things like parameter precedence, and makes it difficult for clients to understand the overall surface area of the endpoint.

In order to avoid this scenario in our GraphQL API, we have designed our schema to only expose the basic building blocks of these operations, allowing clients to compose them in whatever way fits their needs. Referring back to the above example, if a client wants to create a transaction from raw credit card details, they would use two GraphQL mutations. First, to tokenize the details:

mutation {
  tokenizeCreditCard(input: {
    creditCard: {
      number: "4111111111111111",
      expirationYear: "2020",
      expirationMonth: "12"
    }
  }) {
    paymentMethod {
      id
    }
  }
}

Then to charge the resulting single-use payment method:

mutation {
  chargePaymentMethod(input: {paymentMethodId: "<id-from-above>"}) {
    transaction {
      status
    }
  }
}

These two mutations are easy to understand, with little ambiguity as to what you are supposed to pass in and what you expect to get in response. However, it requires clients to make two separate requests in order to do something that was previously achievable in one. That means double the amount of time spent waiting on HTTP, and extra logic around error handling, partial failure states, etc. It also undermines one of the main benefits of GraphQL: improving the performance of your clients by combining multiple requests into one.

The goal of the @export directive is to allow clients to take the paymentMethod.id from the first query and pass it through to the paymentMethodId input on the second, all in a single request.

Proposal

Happy path

Using the @export directive, we expect the above example to turn into something like this:

mutation TokenizeAndCharge(
  $tokenizeInput: TokenizeCreditCardInput!,
  $transactionInput: TransactionInput!
) {
  tokenizeCreditCard(input: $tokenizeInput) {
    paymentMethod {
      id @export(as: "paymentMethodId")
    }
  }

  chargePaymentMethod(input: {
    paymentMethodId: $paymentMethodId,
    transaction: $transactionInput
  ) {
    transaction {
      id
      status
    }
  }
}

The as argument takes a String and creates a reference to a variable that would be accessible as an argument to other fields.

This covers the case where we want to declare a single variable, but it doesn't really make sense when used inside a list type. For that, we would need a way to append results into a list variable. That could be achieved with another argument like into:

query {
  user(id: "user-id") {
    comments {
      id @export(into: "commentIds")
    }
  }

  node(ids: $commentIds) {
    ... on Comment {
      # ...
    }
  }
}

Errors

A failure to resolve an exported field should result in an error on any field that attempts to use it as input. For instance, if the above tokenizeCreditCard request failed because of an invalid credit card number, the response could be something like:

{
  "data": {
    "tokenizeCreditCard": null,
    "chargePaymentMethod": null
  },
  "errors": [
    {
      "message": "Credit card number is invalid.",
      "path": ["tokenizeCreditCard"]
    },
    {
      "message": "Failed to resolve exported variable '$paymentMethodId'.",
      "path": ["chargePaymentMethod"]
    }
  ]
}

The error would be similar to what you would get for referencing an undeclared variable as an input parameter, except with messaging that communicates the variable was expected to be exported.

Limitations/considerations

Here are some of the ones I've come up with so far:

  • Until multiple-operation support makes it to spec, there is no way to export variables between queries, mutations, or subscriptions. Those details would have to be hashed out in the multiple-operation RFC.
  • Parallel execution strategies will probably have to establish execution ordering between fields, such that a field using an exported variable from another field would wait for the other field to finish before resolving.
  • Serial execution stretegies might be able to just rely on top-to-bottom ordering of fields, such that an exported variable can't be referenced "above" its @export declaration.
  • Probably only scalar types can be exported, because an exported object type wouldn't be usable elsewhere as an input, and it introduces some edge cases where a sub-field argument could refer to an exported parent field, which would result in a deadlock (or something).
  • In the above examples, types are inferred from the field they are declared on (as implies the exact type of the field, into implies the exact type wrapped in a list). In my mind we shouldn't need to declare the variable types explicitly, since this inference seems unambiguous. But if I'm wrong about that, or if it somehow eases the directive's implementation, I think we could work out a way of declaring the types as well. Something like @export(as: "userId", type: ID!), or similar.
  • An alternative name that comes to mind is @declare, e.g. @declare(as: "fooId"), @declare(as: "fooIds", accumulate: true)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions