Awesome
<img src="https://ptsochantaris.github.io/trailer/TrailerQLLogo.webp" alt="Logo" width=256 align="right">TrailerQL
TrailerQL is a Swift package that simplifies many of the steps involved in querying a GraphQL endpoint.
- Type-safe creation of queries using element builder syntax
- Highly optimised scanning and parsing of returned data with callbacks
- Fully implemented in async/await
- Used in production apps to query and parse GitHub endpoints.
Currently used in
Usage
See TrailerQLTests.swift
for the complete code usde here
Let's see where Rick and Morty currently are...
let url = URL(string: "https://rickandmortyapi.com/graphql")!
Let's construct our query schema. Based on the documentation from that site, we want to create this GraphQL query:
characters(filter: { name: "Rick" }) {
results {
id
name
status
location {
id
name
type
}
}
}
In TrailerQL we build GraphQL object relationships using Group
, Field
, Fragment
and BatchGroup
. We start by declaring the top-level group, and give it the name characters
. We also provide a tuple (or more, if needed) which contains parameter names and values. In this case we make the whole { name: "Rick" }
bit a parameter value for filter
.
let schema = Group("characters", ("filter", "{ name: \"Rick\" }")) {
Group("results") {
Field.id
Field("name")
Field("status")
Group("location") {
Field.id
Field("name")
Field("type")
}
}
}
We create a Query, and assign that schema as the root element. In this case we also want to disable the GitHub-style rate check, which this API server doesn't support. The Query initialiser has a lot of options, so be sure to check it out in more detail.
The initialiser takes a closure (or method) in perNode
which is called once for every item parsed by TrailerQL when it is provided with the API response data. We'll see how to do that below.
let query = Query(name: "Rick And Morty", rootElement: schema, checkRate: false, perNode: scanNode)
Each call to it takes a single parameter of type Node
. Node
info on the parsed GraphQL object, such as its type, its ID, and the ID of its parent, if that exists.
TrailerQL will not parse nodes that don't contain an ID, but it will happily "unwrap" layers to find items inside them. For example the "characters" group above is not an object but a container, whereas "results" is a list of objects that contain ids.
private func scanNode(_ output: ParseOutput) {
switch output {
case .queryComplete:
print("All nodes from the query received")
case .queryPageComplete:
print("All nodes from returned page of the query are received")
case let .node(scannedNode):
switch scannedNode.elementType {
case "Character":
if let newCharacter = Character(from: scannedNode) {
Character.all.append(newCharacter)
} else {
print("Could not parse character from: \(scannedNode.jsonPayload)")
}
case "Location":
if let newLocation = Location(from: scannedNode) {
if let parentId = scannedNode.parent?.id,
let character = Character.find(id: parentId) {
character.location = newLocation
}
} else {
print("Could not parse location from: \(scannedNode.jsonPayload)")
}
default:
print("Unknown type: \(scannedNode.elementType)")
}
}
}
We check scannedNode.elementType
to know which type this callback is about, and then instantiate each object with it. Internally those initialisers use the .jsonPayload
property of Node
to access the node's JSON and de-serialise an instance from it.
queryComplete
and queryPageComplete
can be cues to whatever parsing logic handles these nodes, which may need to know when an entire tree of a query (or page) has been delivered first.
Each Character
has a Location
associated with it in this endpoint's schema. So now that we have created a Location
, we check the .parent
property of the scanned Node
to see if we can find a Character
instance and add it there.
This Query
now produces the GraphQL query that we wanted to create as text for us, in query.queryText
let queryText = query.queryText
XCTAssert(queryText == " { characters(filter: { name: \"Rick\" }) { __typename results { __typename id name status location { __typename id name type } } } }")
Let's turn this into JSON to send to the API endpoint by encoding the query text into a JSON object whose key is "query", and turn it into bytes. (Any JSON encoder will do, in this example we're using the default Apple framework).
let dataToSend = try JSONSerialization.data(withJSONObject: ["query": queryText])
Post it to the API, making sure the API knows this is JSON
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = dataToSend
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let result = try await URLSession.shared.data(for: request).0
Let's parse the response as a JSON object...
let resultJson = try JSONSerialization.jsonObject(with: result)
...and feed it intro TrailerQL. The processResponse
method will run and invoke the callback we created above for every node it encounters.
_ = try await query.processResponse(from: resultJson)
In large queries, results may indicate that more paging is needed. If this occurs, the method will return a sequence of Query
objects, which can be run to fetch more nodes.
It's worth noting that running those queries can then return more Query
objects and so on, as long as more data is available.
TrailerQL will attempt to create as few queries as possible in this case but stay below the maximum node limit of the API endpoint.
Let's list out any Character
objects we parsed!
for character in Character.all {
print(character.id, character.description, separator: "\t")
}
Fragments
You can use GraphQL fragments with Fragment
. For instance the query above could be written as:
fragment locationFragment on Location {
id
name
type
}
fragment characterFragment on Character {
id
name
status
}
{
characters(filter: { name: "Rick" }) {
results {
... characterFragment
location {
... locationFragment
}
}
}
}
Which in TrailerQL would be expressed like this:
let characterFragment = Fragment(on: "Character") {
Field.id
Field("name")
Field("status")
}
let locationFragment = Fragment(on: "Location") {
Field.id
Field("name")
Field("type")
}
let schema = Group("characters", ("filter", "{ name: \"Rick\" }")) {
Group("results") {
characterFragment
Group("location") {
locationFragment
}
}
}
In this example this is actually more complicated and not very useful, but for cases where we need to query the same type in various places, fragments both increase the speed of the query and also make it far easier to keep the schema uniform and readable. Picture how better this would make things if there were more groups that expected Character
s or Location
s for instance.
Batches
Sometimes we don't want to query a single scema, but instead we want to query a bunch of items of the same type. A BatchGroup
lets us do this by spreading a fragment over a series of Ids.
Let's say we want to query the known IDs for a bunch of characters.
fragment characterFragment on Character {
id
name
status
location {
id
name
type
}
}
{
charactersByIds(ids: [1,2,3,4,5]) {
... characterFragment
}
}
On TrailerQL the above would be generated like this...
let queries = Query.batching("Rick And Morty", groupName: "charactersByIds", idList: ["1", "2", "3", "4", "5"], checkRate: false, perNode: scanNode) {
Fragment(on: "Character") {
Field.id
Field("name")
Field("status")
Group("location") {
Field.id
Field("name")
Field("type")
}
}
}
...and run the Query as before. In this case depending on the size of the ID list, there may be more than one query, as TrailerQL will try to evaluate the node cost of each batch and keep each below the node limit (and remember each of those queries may need more paging depending on the case)
License
Copyright (c) 2023 Paul Tsochantaris. Licensed under the MIT License, see LICENSE for details.