Implement a data publisher with the Combine API and the URLSession
, and how to use it as an API client

“The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.” Apple Developer documentation.
The new Combine framework released by Apple in the new iOS 13 and Xcode 11 is quite cryptic… This article shows how to implement a data publisher using the Combine API and the URLSession
, and how to use it as an API client.
This article answers the following questions:
- How to use Combine to get API responses?
- What’s the difference between your old client API and the new one with Combine?
- What are the advantages of using Combine to retrieve data from an API?
- What could be a path to upgrade my old API to be Combine ready?
- How to manage nested API calls with Combine?
Let’s Start a Demo API With Vapor and Docker
We want an API that can be tested on the Mac with Swift Playgrounds. The first step is to get a working demo API on it.
I love Swift, so why don’t we use Vapor to run our test API with a server-side Swift app?
If you don’t know how to install it, follow the instructions here. Then, open a terminal on your Mac and type the following:
vapor new demo-api --template=auth-template
You can try to run the app by using vapor run
but at the moment, the macOS 15 beta and Xcode 11 beta are not very reliable to run it.
To be sure that the Vapor app will run with the right Swift version, we’ll use Docker to build and run it. If you don’t have it yet, install Docker on your Mac from here and then run a Docker container with the Vapor code:
cd demo-api
cp web.Dockerfile Dockerfile
docker build . -t demo-api:latest
Using the next command, you’ll create your REST API which is listening on the 8080
port:
docker run -p 8080:80 --env ENVIRONMENT=DEV demo-api:latest
If everything goes well you should see the following message: Server starting on [http://0.0.0.0:80](http://0.0.0.0:80)
Leave the terminal with Docker open and check the API works. Open a new terminal window and type:
curl -X POST \
http://localhost:8080/users \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{ "name": "user", "email": "user@example.com", "password": "password123", "verifyPassword": "password123" }'
The output should be:
{"id":1,"email":"user@example.com","name":"user"}
Great, you have done it! You’ve added your first user in the demo API.
What Does Our Old API Client Look Like?
To start, you need to create a new blank Playground for Mac with Xcode 11 (beta 4 at the moment).

Give it a name and copy/paste the following code:
import Cocoa
import Foundation
let baseURL = "http://localhost:8080"
enum APIError: Error {
case invalidBody
case invalidEndpoint
case invalidURL
case emptyData
case invalidJSON
case invalidResponse
case statusCode(Int)
}
struct User: Codable {
let name: String
let email: String
let password: String
let verifyPassword: String
}
// We'll use this extension to quickly generate some user for our requests
extension User {
init(id: Int) {
self.name = "user\(id)"
self.email = "user\(id)@example.com"
self.password = "password\(id)"
self.verifyPassword = "password\(id)"
}
}
// Basic API client using dataTask
// From now on, we'll use the suffix OLD to indicate the API Client implemented with dataTask
func createUserOLD(user: User, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
]
let encoder = JSONEncoder()
guard let postData = try? encoder.encode(user) else {
completion(nil,nil, APIError.invalidBody)
return
}
guard let url = URL(string: baseURL + "/users" ) else {
completion(nil,nil, APIError.invalidEndpoint)
return
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: completion)
dataTask.resume()
}
// Basic usage of the dataTask client
createUserOLD(user: User(id:2)) { (data, response, error) in
if let data = data,
let string = String(data: data, encoding: .utf8) {
print(string)
} else {
print(" - No data")
}
//print(response ?? "")
print(error ?? "")
}
The old client has been implemented using the dataTask
function and must be consumed within a closure.
Run the playground and check that you have now created user2
.
How To Use Combine to Get API Responses
Now, add the following lines to get the same result using Combine:
import Combine
// With Combine we return a DataTaskPublisher instead of using the completion handler of the DataTask
func postUser(user: User) throws -> URLSession.DataTaskPublisher {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
]
let encoder = JSONEncoder()
guard let postData = try? encoder.encode(user) else {
throw APIError.invalidBody
}
guard let url = URL(string: baseURL + "/users" ) else {
throw APIError.invalidEndpoint
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
return session.dataTaskPublisher(for: request)
}
// Basic usage of the API client with dataTaskPublisher
// Create a DataTaskPublisher
let postUserPublisher = try? postUser(user: User(id:3))
// Use the sink Subscriber to complete the Publisher -> Subscriber pipeline
let cancellable = postUserPublisher?.sink(receiveCompletion: { (completion) in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("DONE - postUserPublisher")
}
}, receiveValue: { (data, response) in
if let string = String(data: data, encoding: .utf8) {
print(string)
}
})
The new client has been implemented using the dataTaskPublisher
function. It returns a publisher.
To consume it, it’s required to subscribe to it. The sink function is a subscriber.
The Difference Between the Two API Calls When We Decode the Payload
A good app client provides the mapping between the raw data received and the internal class
es and struct
s. The Codable
protocol makes it easy to convert raw JSON data to a struct
/class
.
// In a real world App we want to decode our JSON data response into a Codable Object
struct CreateUserResponse: Codable {
let id: Int
let email: String
let name: String
}
// Decoding CreateUserResponse inside a DataTask API client
createUserOLD(user: User(id: 4)) { (data, response, error) in
let decoder = JSONDecoder()
do {
if let data = data {
let response = try decoder.decode(CreateUserResponse.self, from: data)
print(response)
} else {
print(APIError.emptyData)
}
} catch (let error) {
print(error)
}
}
// Decoding CreateUserResponse inside the pipeline
// Note: For simplicity, we are not considering the response.statusCode
let postUser5Publisher = try? postUser(user: User(id: 5))
let decoder = JSONDecoder()
let cancellable2 = postUser5Publisher?
.map { $0.data }
.decode(type: CreateUserResponse.self, decoder: decoder)
.sink(receiveCompletion: { (completion) in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("DONE - postUser5Publisher")
}
}, receiveValue: { user in
print(user)
})
Old client (dataTask
):
- The completion handler is quite easy to use but becomes unreadable in the case of nested calls.
New client (dataTaskPublisher
):
- It’s a bit harder to write but it’s easy to read as a pipeline. Nested calls can be pipelined.
A Path to Upgrade the Old API to Be Combine-Ready
Using Future
, we can convert our old dataTask
to a publisher.
// Refactoring of the URLRequest with dataTask avoiding Throws using fatalError
func buildCreateUserURLRequest(user: User) -> URLRequest {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
]
let encoder = JSONEncoder()
guard let postData = try? encoder.encode(user) else {
fatalError("APIError.invalidBody")
}
guard let url = URL(string: baseURL + "/users" ) else {
fatalError("APIError.invalidEndpoint")
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
return request
}
// Our old API client dataTask refactoring
func createUserOLDRefactoring(user: User, session: URLSession = URLSession.shared, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
let request = buildCreateUserURLRequest(user: user)
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: completion)
dataTask.resume()
}
// Reuse your refactored old API client to implement a Publisher
func createUserPublisher(user: User, session: URLSession = URLSession.shared) -> Future<CreateUserResponse, Error> {
let future = Future<CreateUserResponse, Error>.init { (promise) in
let completion: (Data?, URLResponse?, Error?) -> () = { (data, response, error) in
//Response Validation
guard let httpResponse = response as? HTTPURLResponse else {
promise(.failure(APIError.invalidResponse))
return
}
guard (200..<300).contains(httpResponse.statusCode) else {
promise(.failure(APIError.statusCode(httpResponse.statusCode)))
return
}
// Decoding data
let decoder = JSONDecoder()
do {
if let data = data {
let response = try decoder.decode(CreateUserResponse.self, from: data)
promise(.success(response))
} else {
promise(.failure(APIError.emptyData))
}
} catch (let error) {
promise(.failure(error))
}
}
//Execute the request
createUserOLDRefactoring(user: user, session: session, completion: completion)
}
return future
}
// Usage of the refactored old API client Publisher
let createUser6Publisher = createUserPublisher(user: User(id: 6))
let cancellable3 = createUser6Publisher
.sink(receiveCompletion: { (completion) in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("DONE - createUser6Publisher")
}
}, receiveValue: { reponse in
print(reponse)
})
Full API Client Definition With Combine
Here is the complete API, implemented with the dataTaskPublisher
. It will be used later.
//Login
func buildLoginRequest(email: String, password: String) throws -> URLRequest {
let loginString = String(format: "%@:%@", email, password)
let loginData: Data = loginString.data(using: .utf8)!
let base64LoginString = loginData.base64EncodedString()
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
"Authorization": "Basic \(base64LoginString)"
]
guard let url = URL(string: baseURL + "/login" ) else {
throw APIError.invalidEndpoint
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
return request
}
func postLogin(email: String,password: String, session: URLSession = URLSession.shared) throws -> URLSession.DataTaskPublisher {
let request = try buildLoginRequest(email: email, password: password)
return session.dataTaskPublisher(for: request)
}
//Todo
struct Todo: Codable {
let id: Int?
let title: String
}
func buildPostTodoRequest(authToken: String, body: Todo) throws -> URLRequest {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
"Authorization": "Bearer \(authToken)"
]
let encoder = JSONEncoder()
guard let postData = try? encoder.encode(body) else {
throw APIError.invalidBody
}
guard let url = URL(string: baseURL + "/todos" ) else {
throw APIError.invalidEndpoint
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData
return request
}
func postTodo(authToken: String, body: Todo, session: URLSession = URLSession.shared) throws -> URLSession.DataTaskPublisher {
let request = try buildPostTodoRequest(authToken: authToken, body: body)
return session.dataTaskPublisher(for: request)
}
func buildGetTodoRequest(authToken: String) throws -> URLRequest {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
"Authorization": "Bearer \(authToken)"
]
guard let url = URL(string: baseURL + "/todos" ) else {
throw APIError.invalidEndpoint
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
return request
}
func getTodo(authToken: String, session: URLSession = URLSession.shared) throws -> URLSession.DataTaskPublisher {
let request = try buildGetTodoRequest(authToken: authToken)
return session.dataTaskPublisher(for: request)
}
func buildDeleteTodoRequest(authToken: String, id: Int) throws -> URLRequest {
let headers = [
"Content-Type": "application/json",
"cache-control": "no-cache",
"Authorization": "Bearer \(authToken)"
]
guard let url = URL(string: baseURL + "/todos/\(id)" ) else {
throw APIError.invalidEndpoint
}
var request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "DELETE"
request.allHTTPHeaderFields = headers
return request
}
func deleteTodo(authToken: String, id: Int, session: URLSession = URLSession.shared) throws -> URLSession.DataTaskPublisher {
let request = try buildDeleteTodoRequest(authToken: authToken, id: id)
return session.dataTaskPublisher(for: request)
}
Publisher Implementation
The DataTaskPublisher
is now pipelined to obtain a publisher with a decoded struct
.
A validate
function has been defined to validate the response.statusCode
.
Pipeline:
DataTaskPublisher—(tryMap with validate)→Data-(decode)→AnyPublisher
// Use of the dataTaskPublisher API to implement Publisher with the decoded data
struct Token: Codable {
let string: String
}
// We'll use the following function to validate our dataTaskPublisher output in the pipeline
func validate(_ data: Data, _ response: URLResponse) throws -> Data {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw APIError.statusCode(httpResponse.statusCode)
}
return data
}
func create(user: User) -> AnyPublisher<CreateUserResponse, Error>? {
return try? postUser(user: user)
.tryMap{ try validate($0.data, $0.response) }
.decode(type: CreateUserResponse.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
func login(email: String, password: String) -> AnyPublisher<Token, Error> {
return postLogin(email: email, password: password)
.tryMap{ try validate($0.data, $0.response) }
.decode(type: Token.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
func postTodo(authToken: String, todo: Todo) -> AnyPublisher<Todo, Error> {
return postTodo(authToken: authToken, body: todo)
.tryMap{ try validate($0.data, $0.response) }
.decode(type: Todo.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
func getTodo(authToken: String) -> AnyPublisher<[Todo], Error> {
return getTodo(authToken: authToken)
.tryMap{ try validate($0.data, $0.response) }
.decode(type: [Todo].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
func deleteTodo(authToken: String, id: Int) -> AnyPublisher<Todo, Error> {
return deleteTodo(authToken: authToken, id: id)
.tryMap{ try validate($0.data, $0.response) }
.decode(type: Todo.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
---
Using Your Combine API
It’s time to use publish with a sink subscriber.
//Let's use our brand new Publishers
let todoList = [Todo(id: nil, title: "Learn Composite"),
Todo(id: nil, title: "Learn SwiftUI")]
// use login to get the Bearer Token
let cancellableLogin = login(email: "user2@example.com", password: "password2")
.sink(receiveCompletion: { (completion) in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("BEARER TOKEN")
}
}, receiveValue: { response in
print(response)
})
Combining Publishers
In the previous example, a Token
was received after the login.
To post a Todo
, we need to use it as a parameter of the postTodo
.
Here, I show you how the two calls are pipelined.
Pipeline:
\[AnyPublisher\]-(map)-(flatMap)->\[AnyPublisher\]
// Combining login and postTodo in a single call
func post(email: String, password: String, todo: Todo) -> AnyPublisher<Todo, Error>? {
return login(email: email, password: password)
.map { token -> String in
return token.string
}
.flatMap { (token) -> AnyPublisher<Todo, Error> in
return postTodo(authToken: token, todo: todoList[1])
}
.eraseToAnyPublisher()
}
let cancellablePost = post(email: "user2@example.com", password: "password2", todo: todoList[0])?
.sink(receiveCompletion: { (completion) in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("GET - DONE")
}
}, receiveValue: { response in
print(response)
})
That’s all! Thanks for reading the full article, I hope you liked it.
Here the full Playground project
Using Your Combine API
Cleanup
Don’t forget to stop your Docker image when you finish!
docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
16edf993e50f demo-api:latest "/bin/sh -c './Run s…" 3 hours ago Exited (255) 3 minutes ago 0.0.0.0:8080->80/tcp elastic\_poitras
The above code will give you the list of the Docker containers.
Get the container name, in my case 16edf993e50f
.
docker stop 16edf993e50f
Remove the image:
docker rm 16edf993e50f
References
Combine
The Combine framework provides a declarative Swift API for processing values over time. These values can represent many…
Combine in Practice - WWDC 2019 - Videos - Apple Developer
Expand your knowledge of Combine, Apple's new unified, declarative framework for processing values over time. Learn…
Advances in Networking, Part 1 - WWDC 2019 - Videos - Apple Developer
Keep up with new and evolving networking protocols and standards by leveraging the modern networking frameworks on all…
Using Combine
There are many parts of the systems we program that can be viewed as asynchronous streams of information - events…