TrailerQL

1.0.6

A GraphQL query generator and response parser in Swift
ptsochantaris/trailer-ql

What's New

Version 1.0.6

2024-02-28T19:49:42Z
  • Node object now contains a relationship field which indicates, if applicable, the field in which it was contained, to help trace dependency graphs while parsing.

Logo

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 Characters or Locations 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.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

Last updated: Sat Mar 16 2024 02:53:23 GMT-0900 (Hawaii-Aleutian Daylight Time)