[ANN] GraphQLClient.jl

Announcing GraphQLClient.jl, a GraphQL client which facilitates easy and Julian interaction with a GraphQL server.

Key features

  • Type stable querying , mutating and subscribing without manual writing of query strings
  • Deserializing responses directly into structs using StructTypes
  • Construction of Julia types from GraphQL objects
  • Using introspection to help with querying

For more information please see the basic usage below and the docs.

Status

This package has been in use internally for about a year and a half and we wanted to release it as we’ve found it very helpful, and hope you do to! This means that it has been used extensively with one server and whilst a lot of effort has been made to make it general and match the GraphQL specification, there may be some issues as it’s used more in the wild, plus there are some features that we’d still like to add. So please give it a go, let us know how you get on, check out the issues and get involved!

Basic Usage

Connecting to a server

A client can be instantiated by using the Client type

using GraphQLClient

client = Client("https://countries.trevorblades.com")

This will, by default, use a query to introspect the server schema.

We can also set a global client to be user by queries, mutations, subscriptions and introspection functions.

global_graphql_client(Client("https://countries.trevorblades.com"))

Querying

We can query a client without having to type a full GraphQL query by hand, with the response containing fields obtained by introspection

response = query(client, "countries")

Or we can query the global client

response = query("countries")

We can add arguments and specify fields in the response

query_args = Dict("filter" => Dict("code" => Dict("eq" => "AU")))
response = query("countries"; query_args=query_args, output_fields="name");
response.data["countries"]
# 1-element Vector{Any}:
#  Dict{String, Any}("name" => "Australia")

Or we can query with the query string directly

query_string = """
    query(
      \$eq: String
    ){
    countries(
        filter:{
            code:{
                eq:\$eq
            }
        }
    ){
        name
    }
}
"""

variables = Dict("eq" => "AU")

response = GraphQLClient.execute(query_string, variables=variables)

We can define a StructType to deserialise the result into

using StructTypes

StructTypes.@OrderedStruct struct CountryName
    name::String
end

response = query("countries", Vector{CountryName}, query_args=query_args, output_fields="name")

response.data["countries"][1]
# CountryName("Australia")

Or we can use introspection to build the type automatically

Country = GraphQLClient.introspect_object("Country")

response = query("countries", Vector{Country}, query_args=query_args, output_fields="name")

response.data["countries"][1]
# Country
#   name : Australia

Mutations

Mutations can be constructed in a similar way, except the arguments are not a keyword argument as typically a mutation is doing something with an input. For example

response = mutate(client, "mutation_name", Dict("new_id" => 1))
response = mutate("mutation_name", Dict("new_id" => 1)) # Use global client

Subscriptions

The subscriptions syntax is similar, except that we use Julia’s do notation

open_subscription(
    client,
    "subscription_name",
    sub_args=("id" => 1),
    output_fields="val"
) do response
    val = response.data["subscription_name"]["val"]
    stop_sub = val == 2
    return stop_sub # If this is true, the subscription ends
end

Acknowledgement

A huge thanks to Deloitte Australia for allowing this to be open sourced and encouraging open source contributions.

16 Likes

Thanks for releasing this package it looks really nice! We’ve just started using graphql for some things, so having more tooling for this is very timely.

How would you compare this graphql client to the one in Diana.jl?

2 Likes

How would you compare this graphql client to the one in Diana.jl?

Great question! As a quick summary, GraphQLClient.jl is a more developed client. Diana.jl only supplies simple querying functionality (pass a query string and optional dictionary of variables to an endpoint) that returns a String. GraphQLClient.jl can do a host of things for you, for example build the query string, use introspection to check a query exists, get all output fields of a query, and can output the response directly as a Julia type. It lets you still query by string if you need to/want to. GraphQLClient.jl also has more features that don’t exist in Diana.jl, for example subscriptions.

In a bit more detail:

Features

GraphQLClient has

  • subscriptions
  • deserialisation of responses into structs using StructTypes
  • build types from GraphQLSchema
  • using introspection to check operations (queries, mutations etc) without communicating with the server

Moreover, the client object contains the introspected schema so we can add more functions that make use of introspection.

GraphQLClient currently won’t work with multiple operations in a query string as it doesn’t have a way of setting the operation name. This is something we want to implement soon though, as being able to supply multiple operations is a great feature of GraphQL. In fact thinking about it we could make a quick change to enable the same functionality as Diana before a change that implements documents properly.

Querying Differences

To show the difference using one of the examples above, in Diana you would do

query_string = """
    query(
      \$eq: String
    ){
    countries(
        filter:{
            code:{
                eq:\$eq
            }
        }
    ){
        name
    }
}
"""
variables = Dict("eq" => "AU")
client = GraphQLClient("https://countries.trevorblades.com")
r = client.Query(query_string, vars=variables)
r.Data
# "{\"data\":{\"countries\":[{\"name\":\"Australia\"}]}}\n"

We can use the same query string in GraphQLClient

global_graphql_client(Client("https://countries.trevorblades.com"))
response = GraphQLClient.execute(query_string, variables=variables)
response.data
# Dict{String, Any} with 1 entry:
#  "countries" => Any[Dict{String, Any}("name"=>"Australia")]

Or we could use query to have a higher level interface where we don’t need to know how the query string should be formatted

query_args = Dict("filter" => Dict("code" => Dict("eq" => "AU")))
response = query("countries"; query_args=query_args, output_fields="name")

More Julian in style

When writing GraphQLClient.jl we’ve tried to stick to typical Julia style choices both in APIs and in code style, such as doing query(client, query_string) rather than client.Query(query_string).

We’ve also tried to make interaction with the client similar to AWS.jl by using the global_graphql_client function.

Error handling

GraphQLClient.jl has more sophisticated error handling, for example if we put a typo in the above example, the error in Diana is:

query_string = """
           query(
             \$eq: String
           ){
           countries(
               afilter:{
                   code:{
                       eq:\$eq
                   }
               }
           ){
               name
           }
       }
       """
"    query(\n      \$eq: String\n    ){\n    countries(\n        afilter:{\n            code:{\n                eq:\$eq\n            }\n        }\n    ){\n        name\n    }\n}\n"

julia> client.Query(query_string, vars=variables)
ERROR: HTTP.ExceptionRequest.StatusError(400, "POST", "/", HTTP.Messages.Response:
"""
HTTP/1.1 400 Bad Request
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 209
etag: W/"d1-pGanazRodXGuRpcvCi/4PpS9nVc"
date: Thu, 28 Oct 2021 09:49:03 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/b7bd044 (2021-10-26)
via: 1.1 fly.io
fly-request-id: 01FK34V304TK4G1MHNP9PJJVX2

{"errors":[{"message":"Unknown argument \"afilter\" on field \"countries\" of type \"Query\". Did you mean \"filter\"?","locations":[{"line":5,"column":9}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
""")
Stacktrace: ...

In GraphQLClient the same error is showed as

julia> response = GraphQLClient.execute(query_string, variables=variables)
ERROR: GraphQLError: Request to server failed.

Message:

Unknown argument "afilter" on field "countries" of type "Query". Did you mean "filter"?

Location(s):

line: 5, column: 9
Stacktrace:...

Additionally, I’m not sure what happens with execution errors (where data can still be returned but an error has occurred) with the Diana client, but with GraphQLClient the response contains an error field that can be inspected. Furthermore, an exception can be thrown in this instance by setting the throw_on_execution_error kwarg to true.

Using introspection to check args supplied

Because we have the schema locally, we can use this to catch errors without actually making a request. For example

julia> query_args = Dict("filter" => Dict("code_wrong" => Dict("eq" => "AU")))
Dict{String, Dict{String, Dict{String, String}}} with 1 entry:
  "filter" => Dict("code_wrong"=>Dict("eq"=>"AU"))

julia> response = query("countries"; query_args=query_args, output_fields="name")
ERROR: GraphQLClientException: Cannot query field "code_wrong" on type "CountryFilterInput"

and

julia> response = query("countries_wrong")
ERROR: GraphQLClientException: countries_wrong is not an existing query

Not a server

I know you asked about the clients specifically, but just worth highlighting that Diana.jl also has server functionality. Whilst it doesn’t have as many features as the Python and Java equivalents, things like the request parsing are pretty neat! I’d love to develop this further, either in Diana or in a new package, so we have a fully featured Julia GraphQL server in the future.

8 Likes