Type Introspection

Overview

Because types are well defined in a GraphQL server and we can read the definition via introspection, we can build a Julia type for any GraphQL object.

julia> client = Client("https://countries.trevorblades.com")
GraphQLClient Client
       endpoint: https://countries.trevorblades.com
    ws_endpoint: wss://countries.trevorblades.com

julia> T = introspect_object(client, "Country")
GraphQLClient.var"##Country#261"

introspect_object creates a new, uniquely-named mutable type, which has a StructType of Struct() and where the type of every field is a Union of Nothing and the field type, . This makes the type flexible as it can be used for queries where not all field names are requested.

We can use this as the output_type of a query

julia> response = query(client, "country", T, query_args=Dict("code"=>"AU"), output_fields="phone")
GraphQLClient.GQLResponse{GraphQLClient.var"##Country#261"}
  data: Dict{String, Union{Nothing, GraphQLClient.var"##Country#261"}}
          country: GraphQLClient.var"##Country#261"

julia> country = response.data["country"]
Country
  phone : 61

julia> country.phone
"61"

julia> isnothing(country.continent)
true

julia> propertynames(country)
(:emoji, :currency, :states, :phone, :emojiU, :continent, :native, :capital, :name, :languages, :code)

Using and Initialising

The Type for an object can be accessed using get_introspected_type

julia> get_introspected_type(client, "Country")
GraphQLClient.var"##Country#262"

We can initialise an instance of the type with every field set to nothing

julia> country = initialise_introspected_struct(client, "Country")
Country
  All fields are nothing

julia> country.name = "Australia"
"Australia"

Or pass a dictionary of key value pairs to parameterise an instance of the type with

julia> fields = Dict("name" => "Australia", "phone" => "61");

julia> create_introspected_struct(client, "Country", fields)
Country
   name : Australia
  phone : 61

Note, the latter method will not work if the mutable keyword argument of introspect_object is set to false.

Handling Recursion and Nested Objects

GraphQL schemas can have objects as fields of other objects, and often these end up recursing. When an object is being introspected, any objects used in its fields are also introspected. For example, in the above introspection of Country, the object State is also introspected

julia> get_introspected_type(client, "State")
GraphQLClient.var"##State#259"

We can view all the introspected objects of a Client and see that four objects were actually introspected

julia> list_all_introspected_objects(client)
4-element Vector{String}:
 "Continent"
 "State"
 "Country"
 "Language"

GraphQLClient keeps track of all introspected objects so that if two fields use the same object, they use the same Julia type. This information is stored in client so that if other objects are introspected that use already-introspected objects they will use the already-introspected types. Furthermore, if the name of an already-introspected object is inputted to introspect_object, the -already introspected type will be returned.

GraphQLClient also keeps track of the object(s) that is/are currently being introspected to ensure that it doesn't attempt to introspect any of them again, which would lead to infinite recursion.

For example in the above introspection of Country, we actually get the following warning messages which were ommited above

julia> T = introspect_object(client, "Country")
┌ Warning: Cannot introspect field country on type State due to recursion of object Country
└ @ GraphQLClient ../GraphQLClient/src/type_construction.jl:75
┌ Warning: Cannot introspect field countries on type Continent due to recursion of object Country
└ @ GraphQLClient ../GraphQLClient/src/type_construction.jl:75
GraphQLClient.var"##Country#261"

They indicate that during the introspection of Country, both State and Continent are being introspected. Both of these objects have fields which use Country, and therefore GraphQLClient cannot introspect these fields, otherwise we would be using the type that we are currently in the process of definining which therefore doesn't exist (i.e., the definition of the type for State would fail as the type for Country doesn't exist yet).

The allowed_level keyword argument can be used to control how deep an object is introspected (note in this example we using the force keyword argument to force the re-introspection of the object)

julia> T = introspect_object(client, "Country", allowed_level=1, force=true)
┌ Warning: Cannot introspect field languages on type Country due to allowed_level kwarg
└ @ GraphQLClient ../GraphQLClient/src/type_construction.jl:78
┌ Warning: Cannot introspect field states on type Country due to allowed_level kwarg
└ @ GraphQLClient ../GraphQLClient/src/type_construction.jl:78
┌ Warning: Cannot introspect field continent on type Country due to allowed_level kwarg
└ @ GraphQLClient ../GraphQLClient/src/type_construction.jl:78
GraphQLClient.var"##Country#262"

When setting this to 1 in this example, introspect_object will not include any fields of Country that are objects

julia> fieldnames(T)
(:emoji, :currency, :native, :code, :name, :phone, :capital, :emojiU)

Because of the nesting and recursion of objects, it is important to be careful with the order that objects are introspected and what level is allowed as this can affect the fields of introspected types. Considering State and Country in the above example, here are three possible outcomes:

If allowed_level=3 and Country is introspected first (State is introspected during the introspection of Country)

julia> Country = introspect_object(client, "Country", allowed_level=3, force=true);

julia> sort(collect(fieldnames(Country)))
11-element Vector{Symbol}:
 :capital
 :code
 :continent
 :currency
 :emoji
 :emojiU
 :languages
 :name
 :native
 :phone
 :states

julia> sort(collect(fieldnames(get_introspected_type(client, "State"))))
2-element Vector{Symbol}:
 :code
 :name

If allowed_level=3 and State is introspected first (Client is introspected during the introspection of Country) we can see that State now has the field name and Country does not have the field states.

julia> State = introspect_object(client, "State", allowed_level=3, force=true);

julia> sort(collect(fieldnames(State)))
3-element Vector{Symbol}:
 :code
 :country
 :name

julia> sort(collect(fieldnames(get_introspected_type(client, "Country"))))
10-element Vector{Symbol}:
 :capital
 :code
 :continent
 :currency
 :emoji
 :emojiU
 :languages
 :name
 :native
 :phone

If allowed_level=1, both objects must be introspected separately and have fewer fields

julia> State = introspect_object(client, "State", allowed_level=1, force=true);

julia> sort(collect(fieldnames(State)))
2-element Vector{Symbol}:
 :code
 :name

julia> Country = introspect_object(client, "Country", allowed_level=1, force=true);

julia> sort(collect(fieldnames(Country)))
10-element Vector{Symbol}:
 :capital
 :code
 :currency
 :emoji
 :emojiU
 :name
 :native
 :phone

We can see that in each of the three scenarios, the types have different fields according to how they have been introspected.

A more sophiscated solution could potentially use parametric typing to get around this and allow full recursion within defined types, but this is not implemented currently.

Forcing Re-Introspection

Fortunately, GraphQL schemas are typically fairly static so we shouldn't need to re-introspect an object too frequently. Howeve re-intropsection of an object and any objects used by its fields (and so on for those objects) can be forced using the force keyword argument. This should be done with care, however, as the following example illustrates.

First we introspect Country, as we have done previously

julia> Country = introspect_object(client, "Country")
GraphQLClient.var"##Country#403"

Then we force re-introspect Language, which has already been introspected during the intropsection of Country

julia> Language = introspect_object(client, "Language", force=true)
GraphQLClient.var"##Language#404"

Now if we try to use them together, the type of Language will not match that of the languages field of Country

julia> language = create_introspected_struct(client, "Language", Dict("name" => "English"))
Language
  name : English

julia> country = create_introspected_struct(client, "Country", Dict("languages" => [language]))
ERROR: MethodError: Cannot `convert` an object of type GraphQLClient.var"##Language#404" to an object of type GraphQLClient.var"##Language#400"

It is safer to use the reset_all keyword argument once to delete all introspected types and start again.

julia> Language = introspect_object(client, "Language", reset_all=true);

julia> Country = introspect_object(client, "Country");

julia> language = create_introspected_struct(client, "Language", Dict("name" => "English"))
Language
  name : English

julia> country = create_introspected_struct(client, "Country", Dict("languages" => [language]))
Country
  languages : GraphQLClient.var"##Language#405"[GraphQLClient.var"##Language#405"(nothing, nothing, nothing, "English")]

Parent Types

By default, all introspected types have the parent type GraphQLClient.AbstractIntrospectedStruct, which has a defined StructType of Struct. However it may be desirable to change this for multiple dispatch or display purposes. This can be done in two ways in two ways.

The parent_type keyword argument sets the parent type of the top level object being introspected, but no nested objects.

julia> abstract type MyType end

julia> Country = introspect_object(client, "Country", parent_type=MyType, force=true);

julia>  Country <: MyType
true

julia> get_introspected_type(client, "State") <: MyType
false

Alternatively, a dictionary mapping object name to parent type can be supplied to the parent_map keyword argument. Any object that is not in the map will have the default parent type.

julia> parent_map = Dict("Country" => MyType, "State" => MyType)
julia> Country = introspect_object(client, "Country", parent_map=parent_map, force=true);

julia> Country <: MyType
true

julia> get_introspected_type(client, "State") <: MyType
true

julia> get_introspected_type(client, "Continent") <: MyType # not in parent_map
false

If both parent_type and parent_map are supplied, parent_type take precedence.

Custom Scalar Types

If the GraphQL server has custom scalar types defined and these are used by the object(s) being intropsected, then they must be mapped to Julia types in the custom_scalar_types keyword argument of introspect_object.

julia> custom_scalar_types = Dict("ScalarTypeName" => Int8)