Integrating Hasura GraphQL in your iOS/Mac app (Swift)

REST API is one of the most important application programming interfaces in the computing world since almost every application that uses the internet also uses it today. REST (Representational state transfer) was first introduced and defined in 2000; before that, we used to have SOAP (Simple Object Access Protocol). REST grew in popularity, became king, and remained without competing API protocols for the next 12 years.

In 2012, Facebook started internally developing GraphQL for use in its products. Eventually, it was open-sourced in 2015. GraphQL is a data query manipulation language for APIs and a runtime for fulfilling queries with existing data. If I were to explain it to someone, I would say that it is like SQL, but for APIs. If you do not have experience working with GraphQL yet, feel free to watch a few videos before coming back here.

In this post, I will be showing you how to integrate GraphQL into your native iOS/Mac app. Although I am focusing on Hasura here, you can do the same with any GraphQL server.

Without any further ado, let’s begin!

Download the schema from your GraphQL server

For the purposes of this post, I am going to assume that you already have an active GraphQL server or Hasura project with tables and data in it. Let us assume that the following are the tables that you would be querying and mutating:

Firstly you would need the GraphQL schema to set up GraphQL in your iOS/Mac app. There are many ways to download the GraphQL schema. For Hasura, I used the graphqurl npm package to download the schema using the URL and the Hasura access key. The following would be the commands to execute:

npm install -g graphqurl
gq https://my-graphql-engine.com/v1alpha1/graphql -H 'X-Hasura-Access-Key: secretaccesskey' --introspect --format json > schema.json

The steps are also provided on this page.

And that is all you have to do on the GraphQL server side. The other steps are to be done on the iOS/Mac side.

Add Apollo as a dependency to your XCode project

This is where we start integrating GraphQL in our iOS/Mac app. We will be using the Apollo iOS client, which will let us implement GraphQL easier than if we were to construct queries and mutations ourselves and send them as HTTP requests. This is simply for our convenience and ease of use.

Next, add github.com/apollographql/apollo-ios as a dependency through Swift Package Manager. You could also use Cocoapods or Carthage, but with Swift Package Manager being adopted by all the packages, it is better to go with that. Especially since it is Apple’s first-party implementation.

Add the GraphQL schema to the XCode project

Simple copy and paste the schema.json that you had previously downloaded using the graphqurl command. Make sure to check the copy files if needed checkbox.

This can be anywhere in the project structure. The simplest place would be at the root of the project. But wherever you choose, you would need to make sure to update the build phase script that will be covered in the next step as per the path.

Add a build phase script to generate Swift types from the schema

The next step is to add a build phase script that will generate the Swift types from the schema and the operations (queries and mutations) that we will create in the future. Go to Build Phases and add a new Run Script Phase that is run before Compile Sources.

Now copy and paste the following script:

# Don't run this during index builds
if [ $ACTION = "indexbuild" ]; then exit 0; fi

# Go to the build root and search up the chain to find the Derived Data Path where the source packages are checked out.
DERIVED_DATA_CANDIDATE="${BUILD_ROOT}"

while ! [ -d "${DERIVED_DATA_CANDIDATE}/SourcePackages" ]; do
  if [ "${DERIVED_DATA_CANDIDATE}" = / ]; then
    echo >&2 "error: Unable to locate SourcePackages directory from BUILD_ROOT: '${BUILD_ROOT}'"
    exit 1
  fi

  DERIVED_DATA_CANDIDATE="$(dirname "${DERIVED_DATA_CANDIDATE}")"
done

# Grab a reference to the directory where scripts are checked out
SCRIPT_PATH="${DERIVED_DATA_CANDIDATE}/SourcePackages/checkouts/apollo-ios/scripts"

if [ -z "${SCRIPT_PATH}" ]; then
    echo >&2 "error: Couldn't find the CLI script in your checked out SPM packages; make sure to add the framework to your project."
    exit 1
fi

cd "${SRCROOT}/Shared/Data/GraphQL" "${SCRIPT_PATH}"/run-bundled-codegen.sh codegen:generate --target=swift --includes=./**/*.graphql --localSchemaFile="../../SupportFiles/schema.json" GraphQLAPI.swift

Be sure to update the localSchemaFile option to denote the path where your schema.json lies. Also, switch off “Based on dependency analysis.”

We are not done yet, however. We would have to have at least one .graphql file with at least one operation for the build phase script to have any effect.

Create a GraphQL operation for Swift types to be generated for

Create a .graphql file anywhere in the project and define a query or mutation in it.

Create a GraphQL service class to implement Apollo Client

The GraphQL service can be built on Apollo, and that is what we are using here, where we will be setting defining methods to execute queries and mutations. Here I am using a protocol to hide the implementation details of the underlying GraphQLService.

import Foundation
import Apollo

/**
 Used to perform GraphQL operations with Hasura
 */
class HasuraGraphQLService {
    private let apolloClient: ApolloClient?
    private let graphQLUrl: URL! = URL(string: "https://my-graphql-engine.com/v1alpha1/graphql")
    
    init() {
        let cache = InMemoryNormalizedCache()
        let store = ApolloStore(cache: cache)
        let sessionConfiguration = URLSessionConfiguration.default
        let urlSessionClient = URLSessionClient(
            sessionConfiguration: sessionConfiguration,
            callbackQueue: nil
        )
        let interceptorProvider = GraphQLInterceptorProvider(
            client: urlSessionClient,
            shouldInvalidateClientOnDeinit: true,
            store: store
        )
        let requestChainTransport = RequestChainNetworkTransport(
            interceptorProvider: interceptorProvider,
            endpointURL: graphQLUrl
        )
        apolloClient = ApolloClient(networkTransport: requestChainTransport, store: store)
    }
}

// MARK: - GraphQLService

extension HasuraGraphQLService: GraphQLService {
    func executeQuery<Query: GraphQLQuery>(
        query: Query,
        cachePolicy: CachePolicy = .fetchIgnoringCacheCompletely,
        contextIdentifier: UUID? = nil,
        queue: DispatchQueue = .global()
    ) async throws -> Query.Data {
        return try await withCheckedThrowingContinuation({ continuation in
            apolloClient?.fetch(
                query: query,
                cachePolicy: cachePolicy,
                contextIdentifier: contextIdentifier,
                queue: queue
            ) { result in
                switch result {
                case let .success(graphQLResult):
                    if let errors = graphQLResult.errors {
                        AppLogger.error(String(describing: errors))
                        DispatchQueue.main.async {
                            continuation.resume(throwing: BusinessErrors.serverError())
                        }
                    } else if let data = graphQLResult.data {
                        DispatchQueue.main.async {
                            continuation.resume(returning: data)
                        }
                    } else {
                        DispatchQueue.main.async {
                            continuation.resume(throwing: BusinessErrors.noContent())
                        }
                    }
                case let .failure(error):
                    AppLogger.error(String(describing: error))
                    DispatchQueue.main.async {
                        continuation.resume(throwing: BusinessErrors.serverError())
                    }
                }
            }
        })
    }
    
    func executeMutation<Mutation: GraphQLMutation>(
        mutation: Mutation,
        publishResultToStore: Bool = true,
        queue: DispatchQueue = .global()
    ) async throws -> Mutation.Data {
        return try await withCheckedThrowingContinuation({ continuation in
            apolloClient?.perform(
                mutation: mutation,
                publishResultToStore: publishResultToStore,
                queue: queue
            ) { result in
                switch result {
                case let .success(graphQLResult):
                    if let errors = graphQLResult.errors {
                        AppLogger.error(String(describing: errors))
                        DispatchQueue.main.async {
                            continuation.resume(throwing: BusinessErrors.serverError())
                        }
                    } else if let data = graphQLResult.data {
                        DispatchQueue.main.async {
                            continuation.resume(returning: data)
                        }
                    } else {
                        DispatchQueue.main.async {
                            continuation.resume(throwing: BusinessErrors.noContent())
                        }
                    }
                case let .failure(error):
                    AppLogger.error(String(describing: error))
                    DispatchQueue.main.async {
                        continuation.resume(throwing: BusinessErrors.serverError())
                    }
                }
            }
        })
    }
}

Use the GraphQLService in your repositories

The next step is to use the generated Swift types that we have to pass a query or mutation to the GraphQLService. You could see here that I am only using GraphQLService here and not HasuraGraphQLService, and this is to stay true to the principle of data abstraction. Through constructor injection, we could pass the concrete HasuraGraphQLService later from the parent class/struct.

import Foundation

class HasuraChannelRepository: ChannelRepository {
    let graphQLService: GraphQLService
    
    init(graphQLService: GraphQLService) {
        self.graphQLService = graphQLService
    }
    
    func getAllChannels() async -> Result<[ChannelData], BusinessError> {
        do {
            let data = try await graphQLService.executeQuery(query: GetAllChannelsQuery())
            let channels = try data.channels.map { try $0.toEntity() }
            return .success(channels)
        } catch {
            AppLogger.error("Error in getAllChannels: \(error)")
            if error is BusinessErrors.parsingError {
                return .failure(error)
            } else {
                return .failure(BusinessErrors.serverError())
            }
        }
    }
}

Update the build phase script to automatically download the latest schema before generating types (Optional)

This is totally an optional step, but it makes things really easy since you don’t have to manually download the GraphQL schema every time there is a change in the server. This, however, makes the script a little more complicated. Also, this script may be slightly different between Storyboard projects and SwiftUI projects since paths change slightly. I have used a SwiftUI project here.

What I have done here is basically add a command to download the latest schema from the graphQL server before running the codegen script. However, in order to download the schema, we would be needing the HASURA_ACCESS_KEY. This is an insanely powerful private key, and hence I’ve externalized it to a .plist file that would be gitignored.

// Same as before
.
.
.

# Grab a reference to the directory where scripts are checked out
SCRIPT_PATH="${DERIVED_DATA_CANDIDATE}/SourcePackages/checkouts/apollo-ios/scripts"

if [ -z "${SCRIPT_PATH}" ]; then
    echo >&2 "error: Couldn't find the CLI script in your checked out SPM packages; make sure to add the framework to your project."
    exit 1
fi

user_input_plist_url="TestApp.xcodeproj/xcuserdata/$USER.xcuserdatad/UserEnvironmentVariables.plist"
user_input_hasura_access_key=$(/usr/libexec/PlistBuddy -c 'print:HASURA_ACCESS_KEY' ${user_input_plist_url})
if [ -n "$user_input_hasura_access_key" ]; then
    # Download schema from Hasura
    cd "${SRCROOT}/Shared/SupportFiles"
    "${SCRIPT_PATH}"/run-bundled-codegen.sh schema:download --endpoint="https://my-graphql-engine.com/v1alpha1/graphql" --header="X-Hasura-Access-Key: $user_input_hasura_access_key"

    # Generate Swift types from GraphQL schema
    cd "${SRCROOT}/Shared/Data/GraphQL"
    "${SCRIPT_PATH}"/run-bundled-codegen.sh codegen:generate --target=swift --includes=./**/*.graphql --localSchemaFile="../../SupportFiles/schema.json" GraphQLAPI.swift
else
    echo "Skipping downloading GraphQL schema and generating Swift types"
fi


And that is it!

I know this has been a long post, but I truly believe that this would add value to you, especially if you are trying to use Hasura in your project.

If you found this post useful, please let me know in the comments below. And until then, bis bald!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.