GraphQL Request Batching
We are working on an "extension" of GraphQL at Braintree to allow clients to execute a series of GraphQL queries in a single HTTP request, and optionally share variables between them. "Extension" is in quotes because this feature is intended to maintain full compliance with the GraphQL spec, which is intentionally vague around request/response transport format. We will go straight into examples here, but feel free to jump to the end for more details on the motivation behind the feature.
Here is a basic example using the Ruby http
gem:
HTTP
.basic_auth(
user: "my_public_key",
pass: "my_private_key"
)
.headers(
content_type: "application/vnd+braintree.graphql.batch.v0+json",
braintree_version: "2020-05-06"
)
.post(
"https://payments.sandbox.braintree-api.com/graphql",
json: [
{
query: 'mutation Tokenize($input: TokenizeCreditCardInput!) {
tokenizeCreditCard(input: $input) {
paymentMethod {
id @export(as: "tokenizedId")
usage
}
}
}',
variables: {
input: {
creditCard: {
number: "4111111111111111",
expirationYear: "2020",
expirationMonth: "12"
}
}
}
},
{
query: 'mutation Vault($tokenizedId: ID!) {
vaultPaymentMethod(input: {paymentMethodId: $tokenizedId}) {
paymentMethod {
id
usage
}
}
}'
}
]
)
At a high level, this example performs two GraphQL mutations in a single HTTP request to the
Braintree API. The first mutation is tokenizeCreditCard
, which exchanges credit card
details for a single-use payment method, and the second is vaultPaymentMethod
, which
converts the single-use payment method from the previous step into a multi-use (vaulted) payment
method. Let's break down the pieces.
.basic_auth(...)
sets the authentication for the entire request. In this case, we use
our public/private key pair.
.headers(...)
is where things start to diverge from a typical GraphQL request.
Specifically, notice the content_type
header:
application/vnd+braintree.graphql.batch.v0+json
. This is a custom media type that is
required in order to opt-in to this feature. Without it, we will assume you are trying to
make a regular GraphQL request, and fail due to invalid syntax.
.post(...)
sends the request to the Braintree Sandbox GraphQL URL, with a JSON
request body represented as a Ruby array. Each object in this array corresponds to the GraphQL
request payload you are used to: i.e. a query
entry that contains the GraphQL query
as a string, and an optional variables
entry that contains a JSON object representing
variables referenced by the query.
One thing you may notice is that the second query, vaultPaymentMethod
declares a
variable $tokenizedId: ID!
which is not present in the
variables
object for that query. In fact, the second query doesn't specify any
variables at all. The reason this works is because of the @export
directive in the
first query, which propagates variables to subsequent queries behind the scenes.
@export
is a GraphQL
query directive which accepts a single
argument as
of type String!
. In this case, we've passed
"tokenizedId"
to indicate we want the result of the field id
to be
available as $tokenizedId
to subsequent queries, provided they declare the variable
first. For instance, the vaultPaymentMethod
mutation declares the variable on its
first line: mutation Vault($tokenizedId: ID!)
, and then references it with
vaultPaymentMethod(input: {paymentMethodId: $tokenizedId})
.
Responses
Just as the request is a list of regular GraphQL request payloads, the response is a list of regular GraphQL response payloads, in the same order. For instance, if everything goes well, the above request would return something like this:
[
{
"data": {
"tokenizeCreditCard": {
"paymentMethod": {
"id": "tokencc_ab_123abc_456def_789ghi_123abc_abc",
"usage": "SINGLE_USE"
}
}
},
"extensions": {
"exportedVariables": {
"tokenizedId": "tokencc_ab_123abc_456def_789ghi_123abc_abc"
},
"requestId": "0c532285-b668-4e74-9c08-44577219835f"
}
},
{
"data": {
"vaultPaymentMethod": {
"paymentMethod": {
"id": "cGF5bWVudG1ldGhvZF9jY19hYmMxMjM0Cg==",
"usage": "MULTI_USE"
}
}
},
"extensions": {
"exportedVariables": {
"tokenizedId": "tokencc_ab_123abc_456def_789ghi_123abc_abc"
},
"requestId": "0c532285-b668-4e74-9c08-44577219835f"
}
}
]
Each member of this list is a typical GraphQL response payload, which is a JSON object including
data
, extensions
, and optionally errors
. The
data
entry includes all the fields we selected in the corresponding query, in this
case paymentMethod.id
and paymentMethod.usage
. The
extensions
entry includes the Braintree standard requestId
(which is
shared between all responses in the list), but also an entry called
exportedVariables
. This entry contains a snapshot of all variables exported since the
beginning of the request until this particular query was resolved, and can be useful for debugging
purposes.
Errors follow the same pattern as data. If a query in the list results in errors, those errors
will be represented in the errors
entry in the corresponding response payload. The
important thing to note here is that, if an exported field can't be resolved due to errors, then
it is never added to the exportedVariables
object, and subsequent queries behave as
if the variable was never passed. For instance, if we had passed an invalid
expirationYear
in the tokenizeCreditCard
request, our response would
look more like this:
[
{
"errors": [
{
"message": "Expiration year is invalid",
"locations": [{"line": 2, "column": 3}],
"path": ["tokenizeCreditCard"],
"extensions": {
"errorClass": "VALIDATION",
"legacyCode": "81713",
"inputPath": ["input", "creditCard", "expirationYear"]
}
}
],
"data": {
"tokenizeCreditCard": null
},
"extensions": {
"exportedVariables": {},
"requestId": "0c532285-b668-4e74-9c08-44577219835f"
}
},
{
"errors": [
{
"message": "Variable 'tokenizedId' has coerced Null value for NonNull type 'ID!'",
"locations": [{"line": 1, "column": 29}]
}
],
"extensions": {
"exportedVariables": {},
"requestId": "0c532285-b668-4e74-9c08-44577219835f"
}
}
]
The first query failed with a VALIDATION
class error, and consequently never got a
chance to resolve the exported paymentMethod.id
field. As a result, it was never
added to the next query's variables
object, causing the generic GraphQL failure
Variable 'tokenizedId' has coerced Null value for NonNull type 'ID!'
.
When to use Request Batching
Request batching should only be used in specific scenarios, namely when you need to mix mutations and queries in a single request, and/or when you need to pass variables from one to the other. If you simply want to perform multiple independent mutations or queries in a single request, GraphQL already supports that. For instance, this is a valid query:
- GraphQL
query MultiQuery {
ping
viewer {
id
}
}
This will perform ping
and viewer
in parallel. Similarly, the
following is a valid mutation:
- Mutation
mutation MultiMutation(
$customerInput: CreateCustomerInput!
$tokenizeInput: TokenizeCreditCardInput!
) {
createCustomer(input: $customerInput) {
customer {
id
}
}
tokenizeCreditCard(input: $tokenizeInput) {
paymentMethod {
id
}
}
}
This will perform createCustomer
and tokenizeCreditCard
sequentially (top-level mutation fields always run sequentially).
In general, if your request batching can be replaced with standard GraphQL, you should prefer standard GraphQL.
Prior Art
This form of request batching has already been popularized by some GraphQL server implementations, for instance Hot Chocolate and Sangria. Our implementation is slightly different in that it only returns a single response instead of streaming each query response separately. We may add support for streaming responses in the future.
More Considerations and Limitations
This is a non-exhaustive list; it may change in future as we add functionality.
All queries will be executed, even if there are errors.
As the previous errors example shows, all queries are executed even when previous queries have "failed". This is because GraphQL doesn't really have a binary representation of failure. Individual fields can contain errors, but that doesn't necessarily affect the rest of the query, and often you will be able to proceed with the data you did get. In future iterations of this feature, we may allow clients more flexibility in specifying how they want to proceed in the event of errors.
All queries are executed sequentially.
While we do expect clients to see slight performance improvements from this feature due to less overall HTTP round-trips, the underlying queries will be executed sequentially. So every query you add to your list will introduce that much extra time to your overall response. For that reason, we have limited the number of queries you can send at the time of this writing to 5. All queries beyond this limit will return an error.
Only scalar values can be exported.
This is actually a hard requirement due to the distinction between GraphQL input and output types. Even if we supported exporting a non-scalar field, it wouldn't be usable as input in subsequent queries.
Exported variables are interoperable with passed-in variables.
There's nothing stopping you from referencing both exported variables and those in the
variables
object of your query. Just note that variables in the
variables
object will override exported ones in the event of a conflict.
Exporting fields that are under a list node will only export the last value encountered.
We haven't decided how to support accumulating values into a list variable yet (although we're exploring some options). For now it will just override the exported variable for each result in the list, which is probably not what you want.
Authentication applies to all queries.
The examples we used here conveniently all accept public/private key basic authentication. However, there may be cases where different query fields will expect different authentication, which is not currently supported. We are currently exploring some possible solutions for this.
Motivation
One of our goals designing the Braintree GraphQL API has been to break down our existing API
features into smaller, composable pieces. For instance, the Transaction.sale
function
in our client libraries accepts any combination of payment_method_token
,
payment_method_nonce
, or raw payment method details. In addition, you can pass
store_in_vault: true
to indicate that you would like to persist the payment method
after the transaction completes. 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. After all, one of the core tenets of GraphQL is client flexibility. However, previously this placed a significant burden on clients to perform multiple HTTP requests for each of these operations, which involves separate response handling for each step. With request batching, clients now only have to handle a single HTTP response.
We will continue to add to this in the future, and would like to hear your feedback. Feel free to open an issue on our GraphQL API repo if you have any questions.