API Calls in Swift, why not use Combine?

Alvar Arias
7 min readApr 12, 2024

--

Combine is a powerful Apple framework, according to the documentation “The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events.” One example is a network call to an API.

The next example fetches JSON data from a mock API endpoint and decodes it into a Swift struct.

// Define your data model that conforms to Codable
struct MyData: Codable {
let userId: Int
let id: Int
let title: String
let completed: Bool
}

// Define your network error enum
enum NetworkError: Error {
case invalidURL
case unknown
}

// Define your API service
class APIService {
var cancellable: AnyCancellable?

func fetchData(from url: String) -> AnyPublisher<MyData, NetworkError> {
guard let url = URL(string: url) else {
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}

return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: MyData.self, decoder: JSONDecoder())
.mapError { _ in NetworkError.unknown } // Map all errors to your NetworkError.unknown
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

// Use the API service
let apiService = APIService()
apiService.cancellable = apiService.fetchData(from: "https://jsonplaceholder.typicode.com/todos/1")
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
switch error {
case .invalidURL:
print("Invalid URL")
case .unknown:
print("Unknown error")
}
case .finished:
print("Finished")
}
}, receiveValue: { myData in
print("Received data: \(myData)")
})

I use a mock API for testing on https://jsonplaceholder.typicode.com/

, the response is the next JSON code:

{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}

This code first defines a `MyData` struct that conforms to `Codable`, which represents the data you’re fetching from the API, then my data model is :

struct MyData: Codable {
let id: Int
let name: String
}

The `APIService` class has a `fetchData(from:)` method that takes a URL string, creates a `URLSession.DataTaskPublisher` for that URL, maps the ou to just the data, decodes the data into `MyData` objects, and delivers the results on the main queue.

class APIService {
var cancellable: AnyCancellable?

func fetchData(from url: String) -> AnyPublisher<MyData, NetworkError> {
guard let url = URL(string: url) else {
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}

return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: MyData.self, decoder: JSONDecoder())
.mapError { _ in NetworkError.unknown } // Map all errors to your NetworkError.unknown
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

The `fetchData(from:)` method returns an `AnyPublisher` that emits `MyData` objects and an error out with an `Error`.

// Define network error enum
enum NetworkError: Error {
case invalidURL
case unknown
}

This enum defines network errors.

Now I want to explain a little more:

What is AnyCancellable?

Is a type provided by the Combine framework in Swift. It represents a type-erasing cancellable object that executes a closure when canceled.

var cancellable: AnyCancellable?

When you store the “AnyCancellable” returned from a publisher chain, you are ensuring that the chain is not deallocated until you are done with it.

If you fail to store this “AnyCancellable”, the chain will be deallocated immediately.

About the function fetchData

Which is designed to make a network request and return a publisher that emits the data received from the request.

func fetchData(from url: String) -> AnyPublisher<MyData, NetworkError> {
guard let url = URL(string: url) else {
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}

The function is declared to return “AnyPublisher<MyData, NetworkError>”. This means it returns a publisher that emits “MyData” values and can fail with a “NetworkError”.

func fetchData(from url: String) -> AnyPublisher<MyData, NetworkError>

The “guard let url = URL(string: url) else { … }” statement is used to try to create a “URL” instance from the “url” string that is passed as an argument to the function.

If the “URL(string:)” initializer fails to create a “URL”, it returns “nil”, and the “else” clause of the “guard” statement is executed.

guard let url = URL(string: url) else {
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}Inside the `else` clause, `return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()` is used to create a publisher that immediately fails with a `NetworkError.invalidURL`. The `Fail` publisher is a type of publisher that never emits any values and immediately terminates with the specified failure. The `eraseToAnyPublisher()` function is used to erase the type of the publisher, converting it to `AnyPublisher`.

So, if the “url” string that is passed to the “fetchData(from:)” function is not a valid URL, the function returns a publisher that immediately fails with a “NetworkError.invalidURL”.

return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()

The return of the function

return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: MyData.self, decoder: JSONDecoder())
.mapError { _ in NetworkError.unknown } // Map all errors to your NetworkError.unknown
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

In the context of “URLSession.shared.dataTaskPublisher(for: url)”, the publisher emits “URLSession.DataTaskPublisher.Output” values. This “Output” is a tuple containing two properties: “data” and “response”.

The “data” is the “Data” object that contains the body of the response, and “response” is the “URLResponse” object that contains the response metadata, such as the HTTP headers and status code.

When you use “.map { $0.data }”, you’re transforming the “Output” tuple into just the “Data” object.

.map { $0.data }

The “$0” is a shorthand way to refer to the first (and in this case, only) argument to the closure, which is the “Output” tuple.

After this “map” operation, the downstream operations in the publisher chain will receive “Data” objects instead of “Output” tuples.

This is useful because the downstream operations, such as the “decode(type:decoder:)” operation, typically work with “Data” objects.

The “.mapError { _ in NetworkError.unknown }” is an operation in the Combine framework in Swift that transforms any errors emitted by the upstream publisher.

.mapError { _ in NetworkError.unknown }

When you use “.mapError { _ in NetworkError.unknown }”, you’re transforming any error that the upstream publisher emits into a “NetworkError.unknown”.

After this “mapError” operation, the downstream operations in the publisher chain and the subscribers will receive `NetworkError` values instead of the original error types.

If you need to handle different types of errors differently, you should not use this approach and instead handle each error type individually.

.receive(on: DispatchQueue.main)

The “.receive(on: DispatchQueue.main)” is an operation in the Combine framework in Swift that ensures any downstream operations and subscribers receive their values on the specified dispatch queue.

When you use “.receive(on: DispatchQueue.main)”, you’re ensuring that any values emitted by the publisher are received on the main dispatch queue.

This is important when the subscriber is updating the UI, because all UI updates must be done on the main thread.

After this “receive(on:)” operation, any operations that you add to the publisher chain and any subscribers that you attach will receive their values on the main thread.

If you don’t specify a dispatch queue with “receive(on:)”, the publisher may emit values on a background thread, which could lead to crashes or other issues if the subscriber is updating the UI.

How to use the API Service

The “sink(receiveCompletion:receiveValue:)” method is used to start the data task and handle the results.

It prints an error message if the data task fails, prints “Finished” when the data task completes successfully, and prints the received data when new data arrives.

// Use the API service
let apiService = APIService()
apiService.cancellable = apiService.fetchData(from: "https://jsonplaceholder.typicode.com/todos/1")
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
switch error {
case .invalidURL:
print("Invalid URL")
case .unknown:
print("Unknown error")
}
case .finished:
print("Finished")
}
}, receiveValue: { myData in
print("Received data: \(myData)")
})

In the line of code you provided, “apiService.cancellable = apiService.fetchData(from: “https://jsonplaceholder.typicode.com/todos/1")”, the “fetchData(from:)” method of the “apiService” instance is being called with the URL string “https://jsonplaceholder.typicode.com/todos/1" as an argument.

apiService.cancellable = apiService.fetchData(from: "https://jsonplaceholder.typicode.com/todos/1")

The “fetchData(from:)” method is designed to make a network request to the provided URL, and it returns a Combine “AnyPublisher” that will emit the data received from the network request and then complete, or emit an error if the network request fails.

Combine publishers don’t do anything until a subscriber is attached to them.

When a subscriber is attached to a publisher, the publisher returns a “Cancellable” that represents the subscription.

In this line of code, the “sink(receiveCompletion:receiveValue:)” method is used to attach a subscriber to the publisher.

.sink(receiveCompletion: { completion in 
.....

The “sink(receiveCompletion:receiveValue:)” method takes two closures as arguments.

The first closure is called when the publisher completes, either because it has finished emitting values or because an error occurred.

{
case .failure(let error):
switch error {
case .invalidURL:
print("Invalid URL")
case .unknown:
print("Unknown error")
}
case .finished:
print("Finished")
}
}

The second closure is called each time the publisher emits a new value. In this case, the closures print the completion status and the received data to the console.

receiveValue: { myData in
print("Received data: \(myData)")
}

The “receiveValue:” closure is a part of the “sink(receiveCompletion:receiveValue:)” method in Combine, which is used to subscribe to a publisher and handle the events it emits.

The “receiveValue:” closure is called each time the publisher emits a new value. The closure takes one argument, which is the value that the publisher emitted. In this case, the argument is named `myData`.

Inside the closure, the code `print(“Received data: \(myData)”)` is executed each time a new value is received.

This “receiveValue:” closure is where you would put any code that needs to be executed each time a new value is received from the publisher.

In a real app, instead of printing the data to the console, you might update the UI, store the data in a database, etc.

I hope this example helps us to understand how Combine works and how it manages asynchronous events.

References:

Apple documentation

--

--

Alvar Arias
0 Followers

IOS Developer, my mantra is please keep it simple and then build big.