To GraphQL through the s̶t̶a̶r̶s̶ REST API

Lipatova Liza
MobilePeople
Published in
12 min readAug 23, 2023

Embracing GraphQL: A Practical Approach to REST API Interaction

Sure, all of you have likely heard of a creature called GraphQL at least once. Some of you may have even had the wonderful experience of working with it. If not, don’t worry, anyway, the primary goal of this article is to demonstrate how to work with GraphQL without relying on the Apollo GraphQL client. Instead, in your Swift code, you will interact with the GraphQL server using the tried-and-tested traditional networking API provided by Apple — URLSession (to interact with RESTful APIs) and formatted to JSON queries.

But before we start, let’s go through some theory.

GraphQL? Apollo iOS? Whaaat?

GraphQL is an open-source query language, designed to define how a client can request information from an API. It empowers clients to make precise data requests. All you need to do is define the structure of the data you desire, and the server will provide information in the exact structure you specify. This capability to customize the data retrieval process is GraphQL’s primary advantage, which serves as the main ace up its sleeve.

The traditional way of working with GraphQL in iOS, as it was already mentioned, is an open-source GraphQL client — Apollo iOS.

It simplifies the execution of GraphQL queries and mutations using a GraphQL server, returning results as pre-generated and operation-specific Swift types. With Apollo, you don’t have to worry about forming spec-compliant GraphQL requests, parsing JSON responses, or manually validating and type-casting data. It takes care of all those tasks for you!

Moreover, Apollo iOS offers the added benefit of caching mechanisms designed specifically for GraphQL data. This means you can execute GraphQL queries directly against your locally cached data, which enhances performance and reduces the necessity for unnecessary network requests.

Isn’t it great? — Absolutely!

But as with everything in our lives it has its drawbacks ((, and you should think twice before getting it into your codebase.

Apollo iOS pitfalls

  1. Apollo iOS, as a large third-party library, can be not the best option when you are limited in resources.

It’s important to acknowledge that the GraphQL ecosystem is expansive and diverse, featuring numerous community-contributed packages that cater to various use cases. Each serves a unique purpose and can be used to tailor GraphQL-based applications according to specific needs. As the GraphQL ecosystem evolves, more packages and tools are introduced to address various challenges and facilitate different aspects of GraphQL development. Consequently, the Apollo iOS library carries a size of approximately 123 MB.

Including a third-party library like Apollo iOS, with its substantial size of 123 MB, can significantly inflate the binary size. In many cases, such a large library might not be desirable, especially for mobile apps where size is a crucial consideration. For smaller or simpler projects that involve minimal GraphQL interaction, the use of Apollo iOS could introduce unnecessary complexity and impact the overall app size. In such scenarios, opting to implement GraphQL without additional libraries could lead to a more straightforward approach.

2. Apollo iOS, as a third-party library, has some customization limitations.

Apollo iOS offers a predefined method for managing GraphQL queries and mutations. However, if your project demands a high degree of customized handling for GraphQL requests or responses, relying on Apollo iOS might limit your flexibility. Apollo iOS can be complex at the start.

Apollo iOS comes with its own concepts and workflow. If your team is not familiar with Apollo and your project is on fire, may it is better to consider some other options.

3. Apollo iOS can be not compatible with your existing codebase.

Apollo iOS has specific platform requirements and if your project has some legacy systems and REST is used everywhere, it can lead to inconsistency in your codebase and would require a lot of rewriting.

4. Integrating Apollo iOS when you know that GraphQL is a temporary decision, is not the most advisable course of action.

This, incidentally, was one of the factors that led to a decision not to integrate Apollo GraphQL into my previous project. We were informed that it would eventually be replaced with an alternative service, and the sole method of retrieving data from it would be through the REST API.

Let’s code: Get familiar with Rick and Morty GraphQL API

To show you how to do it, I am using the free Rick and Morty GraphQL API.

As you can see in this Apollo Studio GraphQL platform this API is based on the television show Rick and Morty. We can access data about hundreds of characters, images, and episodes.

Explore available objects in the Schema.
In the project, we will be using only one Query — Characters.
The query returns a list of characters that will be displayed within a UITableView. It includes two arguments: filter and page, which allow you to retrieve the desired data.

You can delve into the schema, experiment with queries, implement filters, and interact with the GraphQL API through a user-friendly interface. Is it working? Perfect!

Exploring Characters Query in Apollo Studio GraphQL platform

Let’s code: Delve into the starter project

Now we can start our project, it will be called — RickAndMorty, unexpectedly) the link to the start project you can find here (if you encounter any difficulties, the final project is also available for reference).

We already have here CharactersListViewController with configured UITableView, the only thing left is to fill it with data (character images and names, please, look through CharactersQueryResponse model).

Let’s code: Networking framework creation

First, let’s integrate the Networking framework, which will facilitate GraphQL requests and the parsing of corresponding responses. This framework offers a high level of flexibility and can be reused in your forthcoming projects.

Networking framework creation

Do not forget to add the Framework to your existing workspace*

Networking framework created

Let’s code: GraphQLRequest

Now, let’s move on to introducing the GraphQLRequest class, which plays an essential role in the implementation of interacting with GraphQL APIs using REST-like principles. Its main purpose is to encapsulate the logic required to construct a valid URLRequest with the necessary headers and body to send a GraphQL query to the server.

To achieve this, we define the NetworkRequest protocol, which outlines the fundamental structure for engaging with network requests and parsing their corresponding responses. This protocol includes two crucial methods: create(), for creating the URLRequest, and parse(data:), for handling the parsed response data.

public protocol NetworkRequest {
associatedtype ResponseDataType

func create() -> URLRequest
func parse(data: Data?) throws -> ResponseDataType
}

We introduce a GraphQLRequest struct that conforms to the NetworkRequest protocol. This is a generic struct, enabling us to interact with diverse variable types and response models.

Within this structure, we establish a nested Body struct responsible for composing the GraphQL query and its associated variables.

public struct GraphQLRequest<Variables: Codable, Response: Decodable>: NetworkRequest {

public struct Body<Variables: Codable>: Codable {
public let query: String
public let variables: Variables?

public init(query: String, variables: Variables?) {
self.query = query
self.variables = variables
}
}

let url: URL
let body: Body<Variables>

public init(url: URL, body: Body<Variables>) {
self.url = url
self.body = body
}
}

And since we are implementing NetworkRequest protocol, let’s add two methods:

· create()

public func create() -> URLRequest {
let encoder: JSONEncoder = .init()
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

do {
let data = try encoder.encode(body)
request.httpBody = data
} catch {
print("Encoding error: \(error)")
}

return request
}

Take note of this step: ensure that you set the HTTP Method to POST even if you have no intentions of changing data. This is essential because, for effective communication with a GraphQL server, both the query and variables must be supplied within the body. And we remember that GET method must not have a body.

Additionally, remember to configure the Content-Type Header as 𝐚𝐩𝐩𝐥𝐢𝐜𝐚𝐭𝐢𝐨𝐧/𝐣𝐬𝐨𝐧. This serves as a signal to the server that the content within the request body follows the JSON format. Failing to do so could result in an error.

Once this request is properly configured, it stands ready to initiate the actual network communication.

· parse(data: Data?)

public func parse(data: Data?) throws -> Response {
let decoder: JSONDecoder = .init()
decoder.keyDecodingStrategy = .convertFromSnakeCase

guard let data = data else {
throw NetworkError.message("Parsing failed")
}

return try decoder.decode(Response.self, from: data)
}

The purpose of this function is to process the raw response data received from the server, often in JSON format, and transform it into a Swift model that accurately represents the expected data structure. This is achieved by utilizing a JSONDecoder, which is configured with a key decoding strategy to ensure seamless correspondence between JSON keys and Swift property names. When the parsing procedure succeeds, the decoded model is returned. Otherwise, if parsing encounters an issue, an error is thrown to signify the failure of the parsing process. This error message is designed to provide clarity regarding the nature of the problem.

Now, let’s proceed by incorporating the NetworkService. In this component, we will carry out asynchronous network requests and handle responses.

public struct NetworkService {

public init() {}

public func perform<Request: NetworkRequest>(request: Request) async throws -> Request.ResponseDataType {
do {
let urlRequest = request.create()
let (data, response) = try await URLSession.shared.data(for: urlRequest)

guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.message("InvalidResponse")
}

guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
throw NetworkError.message("InvalidStatusCode - \(httpResponse.statusCode)")
}

do {
let parsedData = try request.parse(data: data)
return parsedData
} catch {
throw NetworkError.message("Data parsing failed with message - \(error)")
}
} catch {
throw NetworkError.message("NetworkError - \(error)")
}
}
}

By integrating the NetworkRequest as a fundamental component within the NetworkService, the architecture establishes modularity and clear separation of concerns. The NetworkService handles the execution of network requests and response handling, while the NetworkRequest, as we have seen, encapsulates the intricacies of constructing GraphQL-specific requests and parsing GraphQL responses. This division of roles contributes to a more organized and comprehensible codebase.

Let’s code: GraphQLResponse

In the context of GraphQL interactions within a REST API, it’s crucial to understand how GraphQL responses are structured and how errors and data are handled. GraphQL responses can contain both successful data and potential errors, and the provided code plays a crucial role in parsing and managing these aspects.

Upon sending a request to a GraphQL API, the server replies with a JSON-based response, comprising two principal elements:

· Data: This component holds the requested data, organized according to the structure of the GraphQL query. It corresponds directly to the fields explicitly specified in your query.

· Errors: GraphQL servers have the ability to return detailed error information alongside the data. If any part of the query encounters an issue, the server can include an array of error objects. Each error object contains information about the specific problem encountered during the query execution.

Based on this understanding, our GraphQLResponse struct will take the following form:

public struct GraphQLResponse<Model: Decodable>: Decodable {

public let data: Model?
public var errors: [GraphQLError] = []

enum CodingKeys: String, CodingKey {
case data, errors
}

public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<GraphQLResponse<Model>.CodingKeys> = try decoder.container(keyedBy: GraphQLResponse<Model>.CodingKeys.self)

self.data = try? container.decode(Model.self, forKey: GraphQLResponse<Model>.CodingKeys.data)
self.errors = (try? container.decode([GraphQLError].self, forKey: GraphQLResponse<Model>.CodingKeys.errors)) ?? []
}
}

Our GraphQLError structure will encapsulate both the error message and error code.

public struct GraphQLError: Swift.Error, Decodable {

enum CodingKeys: CodingKey {
case message
case code
case extensions
}

struct Extensions: Decodable {
let code: String
}

let message: String?
let code: String

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.message = try container.decodeIfPresent(String.self, forKey: .message)
let extensions = try container.decode(Extensions.self, forKey: .extensions)
self.code = extensions.code
}
}

Congrats, we have finished with the Networking framework.

Final look of the Networking framework

Now we can add it to our RickAndMorty project and, finally, use it.

Networking framework added to the RickAndMorty project

Let’s code: Using Created Framework in RickAndMorty project

To align with GraphQL philosophy of requesting only needed data, it is crucial to add Query, that specifies the fields and properties we want to receive in the response. You can take the Query from the Apollo Studio GraphQL platform and put it into a .json file.

Character Query

Add a QueryFactory that enables you to retrieve the GraphQL query as a string from a designated file.

Next, create a file named APIService and import the Networking framework. The APIService class serves as a crucial link between the GraphQL API and the application’s codebase. By utilizing the capabilities provided by our Networking framework, this class handles GraphQL requests to fetch character information. This approach enables the rest of the application to focus on working with the retrieved data.

class APIService {

private struct Configuration {
let url: URL
let charsQuery: String
}

private let networkService: NetworkService = .init()
private let configuration: Configuration = .init(url: URL(string: "https://rickandmortyapi.com/graphql") ?? URL(filePath: ""),
charsQuery: (try? QueryFactory.getQuery(from: "CharactersQuery")) ?? "")

func loadCharacters() async throws -> CharactersQueryResponse.QueryChars {
typealias Request = GraphQLRequest<CharactersListVariables, GraphQLResponse<CharactersQueryResponse>>
let variables: CharactersListVariables = .init(filter: .init(gender: nil, name: "Morty", species: nil, status: nil, type: nil), page: nil)

let request = Request(url: configuration.url,
body: Request.Body(query: configuration.charsQuery, variables: variables))

let result = try await networkService.perform(request: request)

if let data = result.data {
return data.characters
} else {
throw AppError.message("Data is nil")
}
}
}

The Configuration struct stores the URL of the GraphQL API and the character query.

If the response contains valid data, it extracts and returns the character data. However, if the data is found to be nil, the method throws an error to signify the absence of data.

With these components in place, we are nearly at the conclusion of our implementation. The final step involves injecting the APIService into your CharactersListViewModel and invoking the loadCharacters method to initiate the retrieval of character information.

class CharactersListViewModel {

let charactersListLoadedSubject = PassthroughSubject<Void, Never>()

private(set) var characters: [CharactersQueryResponse.Character] = []
private let api = APIService()

init() {
Task { @MainActor in
self.characters = await loadData()
self.charactersListLoadedSubject.send()
}
}

private func loadData() async -> [CharactersQueryResponse.Character] {
do {
let characters = try await api.loadCharacters()
return characters.results

} catch {
print(error.localizedDescription)
return []
}
}
}

That is all, you are great!
Build and enjoy the different facets of the character Morty.

Final RickAndMorty app look

Ohhh pitfalls again:

The suggested approach is not a one-size-fits-all solution. After some time working with that, we have found some drawbacks and I must warm you about them:

1. Different caching mechanisms

In traditional URL-based requests, such as REST, caching is done by identifying unique URL endpoints. However, Apollo Client takes a different approach by utilizing “__typename” for data caching on the client side. The choice between Apollo GraphQL and REST caching depends on your application’s specific needs, use cases, and complexity. And if you have decided to follow the option without Apollo GraphQL, you can’t use its caching mechanisms.

2. In case of concurrent changes of GraphQL queries it would require some time for constant updating your codebase

Apollo Client simplifies schema changes by automatically updating the schema.json and generating a Swift file called API.swift during the build phase. This file contains types and functions, which you can use without any additional setup. If you’re not using Apollo iOS, you’ll need to manage these updates manually. This involves updating queries, Codable models, methods, and more, which can be time-consuming and error-prone.

3. Potential Increase in Network Traffic

When you use REST to fetch data from a GraphQL server, you might find yourself making multiple requests to various endpoints to retrieve the same data that could have been obtained through a single GraphQL query. This can lead to higher network traffic and potentially impact performance.

It’s crucial to emphasize that the assertion that “Apollo iOS is not a capable or valuable library” is not accurate. In reality, Apollo iOS is indeed a powerful and valuable library that offers significant advantages for integrating GraphQL into iOS projects. It provides a streamlined and efficient approach to working with GraphQL, simplifying tasks such as query creation, response handling, and caching. However, it’s important not to rush this decision. The choice between utilizing Apollo iOS or exploring other options should be approached with careful consideration. It should be based on a thorough assessment of your project’s distinct requirements, your team’s expertise, and a comprehensive evaluation of alternative solutions. Rushing into a decision without weighing these factors can lead to missed opportunities or suboptimal outcomes.

Happy coding!

--

--