Navigate back to the homepage

Building an API Client with Combine

Arvind Ravi
October 6th, 2020 · 2 min read

Building an API Client with Combine would mean we need the following things in working order:

  1. Domain specific methods that return a publisher of the model we’re expecting from the response
  2. Error handling for when requests fail and propagate that back to the interested parties
  3. Manage endpoints, keys securely

Let’s take a quick look at the Unsplash API to understand how we could implement an API client that fetches image data from it by going through each of the points above —

Domain specific methods that return a publisher of the model we’re expecting from the response

The Endpoint we’ll work with is: https://api.unsplash.com/photos which simply returns a list of images like this:

1[
2 {
3 "id": "yNvVnPcurD8",
4 "created_at": "2020-07-01T18:31:27-04:00",
5 "updated_at": "2020-09-23T18:18:50-04:00",
6 "promoted_at": null,
7 "width": 9600,
8 "height": 5400,
9 "color": "#F8FAFB",
10 "blur_hash": "LKFiiZxu4m%N-;R%D%s;xu~qtSD%",
11 "description": null,
12 "alt_description": "laptop on brown wooden table",
13 "urls": {
14 "raw": "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjMxMjcxfQ",
15 "full": "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?ixlib=rb-1.2.1&q=85&fm=jpg&crop=entropy&cs=srgb&ixid=eyJhcHBfaWQiOjMxMjcxfQ",
16 "regular": "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjMxMjcxfQ",
17 "small": "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjMxMjcxfQ",
18 "thumb": "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjMxMjcxfQ"
19 },
20 "links": {
21 "self": "https://api.unsplash.com/photos/yNvVnPcurD8",
22 "html": "https://unsplash.com/photos/yNvVnPcurD8",
23 "download": "https://unsplash.com/photos/yNvVnPcurD8/download",
24 "download_location": "https://api.unsplash.com/photos/yNvVnPcurD8/download"
25 },
26 "categories": [],
27 "likes": 443,
28 "liked_by_user": false,
29 "current_user_collections": [],
30 "sponsorship": {
31 "impression_urls": [
32 "https://secure.insightexpressai.com/adServer/adServerESI.aspx?script=false&bannerID=7348942&rnd=[timestamp]&gdpr=&gdpr_consent=&redir=https://secure.insightexpressai.com/adserver/1pixel.gif",
33 "https://secure.insightexpressai.com/adServer/adServerESI.aspx?script=false&bannerID=7367766&rnd=[timestamp]&gdpr=&gdpr_consent=&redir=https://secure.insightexpressai.com/adserver/1pixel.gif"
34 ],
35 "tagline": "Designed to be the Best",
36 "tagline_url": "http://www.dell.com/xps",
37 "sponsor": {
38 "id": "2DC3GyeqWjI",
39 "updated_at": "2020-09-23T12:37:30-04:00",
40 "username": "xps",
41 "name": "XPS",
42 "first_name": "XPS",
43 "last_name": null,
44 "twitter_username": "Dell",
45 "portfolio_url": "http://www.dell.com/xps",
46 "bio": "Designed to be the best, with cutting edge technologies, exceptional build quality, unique materials and powerful features.",
47 "location": null,
48 "links": {
49 "self": "https://api.unsplash.com/users/xps",
50 "html": "https://unsplash.com/@xps",
51 "photos": "https://api.unsplash.com/users/xps/photos",
52 "likes": "https://api.unsplash.com/users/xps/likes",
53 "portfolio": "https://api.unsplash.com/users/xps/portfolio",
54 "following": "https://api.unsplash.com/users/xps/following",
55 "followers": "https://api.unsplash.com/users/xps/followers"
56 },
57 "profile_image": {
58 "small": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=32&w=32",
59 "medium": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=64&w=64",
60 "large": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=128&w=128"
61 },
62 "instagram_username": "dell",
63 "total_collections": 0,
64 "total_likes": 0,
65 "total_photos": 26,
66 "accepted_tos": true
67 }
68 },
69 "user": {
70 "id": "2DC3GyeqWjI",
71 "updated_at": "2020-09-23T12:37:30-04:00",
72 "username": "xps",
73 "name": "XPS",
74 "first_name": "XPS",
75 "last_name": null,
76 "twitter_username": "Dell",
77 "portfolio_url": "http://www.dell.com/xps",
78 "bio": "Designed to be the best, with cutting edge technologies, exceptional build quality, unique materials and powerful features.",
79 "location": null,
80 "links": {
81 "self": "https://api.unsplash.com/users/xps",
82 "html": "https://unsplash.com/@xps",
83 "photos": "https://api.unsplash.com/users/xps/photos",
84 "likes": "https://api.unsplash.com/users/xps/likes",
85 "portfolio": "https://api.unsplash.com/users/xps/portfolio",
86 "following": "https://api.unsplash.com/users/xps/following",
87 "followers": "https://api.unsplash.com/users/xps/followers"
88 },
89 "profile_image": {
90 "small": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=32&w=32",
91 "medium": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=64&w=64",
92 "large": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=128&w=128"
93 },
94 "instagram_username": "dell",
95 "total_collections": 0,
96 "total_likes": 0,
97 "total_photos": 26,
98 "accepted_tos": true
99 }
100 },
101 {},
102 {},
103 {},
104]

So, what we need from Unsplash is image data, we need a model to represent this information. I’m only interested in three properties from the response, so my ImageData model would look something like —

1struct ImageData: Codable, Identifiable {
2 let id: String
3 let color: String
4 let urls: [String: String]
5}

Note: The reason it conforms to Identifiable is so an array of this model could be passed into a view at some point to make effective use of List view to display images.

The next step is to fetch the data, with combine we might return a publisher of [ImageData]. There are a couple of things we need to do that —

A URLRequest A Decoder Building a URL request is as simple as passing in a URL to URLRequest, and a decoder, in our case would simply be a JSONDecoder. So we would have the following stream —

1func photos() -> AnyPublisher<[ImageData], Error> {
2 // 1
3 var request = URLRequest(url: Endpoint.photos.url)
4
5 // 2
6 return URLSession.shared.dataTaskPublisher(for: request)
7 // 3
8 .map(\.data)
9 // 4
10 .decode(type: [ImageData].self, decoder: decoder)
11 // 5
12 .mapError { error in
13 return .invalidResponse
14 }
15 // 6
16 .eraseToAnyPublisher()
17 }

We start by initialising a URLRequest by passing in a URL Then, we create a data task publisher by passing in the URLRequest All responses from URLSession’s dataTaskPublisher return responses of the format data and response, so by passing in the key path of data to the map operator - we simply get only the data values that we’re concerned with down the stream The decoder operator takes in a type and a decoder to translate this data object into a type that swift understand, luckily for us, we’ve already modelled a type for this purpose and all we have to do is pass this in along with a JSONDecoder which could be initialised as a constant Any error that arise from this operation can be handled within the mapError operator which catches any error in the stream and lets us handle it, for simplicity’s sake we simply return an error that we’ve defined incase there’s an error parsing the response Finally, we use eraseToAnyPublisher for type erasure — in order to not have a string of types due to this stream and this lets us work with AnyPublisher Error handling for when requests fail and propagate that back to the interested parties

To keep this as simple as possible within our client, we could have an enum that conforms to LocalizedError to define our error scenarios, which could look something like —

1enum Error: LocalizedError {
2 case invalidURL(URL)
3 case invalidResponse
4
5 var errorDescription: String? {
6 switch self {
7 case .invalidResponse: return "Invalid Response"
8 case .invalidURL(let url): return "Invalid URL - \(url)"
9 }
10 }
11 }

An error of this type could then be dealt with, within the streams using an operator like mapError or replaceError.

Manage endpoints, keys securely

Now that we understand how to work with publishers and error types, we could tidy up our code by abstracting away any authorisation keys and endpoint strings using an enum like —

1enum Endpoint {
2 static let baseURL = URL(string: "https://api.unsplash.com/")!
3 static let mock = URL(string: "http://0.0.0.0:3001/")!
4
5 case mockPhotos
6 case photos
7
8 var url: URL {
9 switch self {
10 case .photos: return Endpoint.baseURL.appendingPathComponent("photos")
11 case .mockPhotos: return Endpoint.mock.appendingPathComponent("photos")
12 }
13 }
14 }

To keep things simple, I’ve decided to hardcode my auth key while building my URLRequest. But we could easily remove this and make use of Xcode’s environment variables which passes values in during runtime or by using a config generator like [Configen].

Here’s how my final API Client looks like with all of these built into a struct —

1struct API {
2
3 private let decoder = JSONDecoder()
4
5
6 enum Error: LocalizedError {
7 case invalidURL(URL)
8 case invalidResponse
9
10 var errorDescription: String? {
11 switch self {
12 case .invalidResponse: return "Invalid Response"
13 case .invalidURL(let url): return "Invalid URL - \(url)"
14 }
15 }
16 }
17
18 enum Endpoint {
19 static let APIKey = ""
20 static let baseURL = URL(string: "https://api.unsplash.com/")!
21 static let mock = URL(string: "http://0.0.0.0:3001/")!
22
23 case mockPhotos
24 case photos
25
26 var url: URL {
27 switch self {
28 case .photos: return Endpoint.baseURL.appendingPathComponent("photos")
29 case .mockPhotos: return Endpoint.mock.appendingPathComponent("photos")
30 }
31 }
32 }
33
34 func photos() -> AnyPublisher<[ImageData], Error> {
35 var request = URLRequest(url: Endpoint.photos.url)
36 request.allHTTPHeaderFields = ["Authorization": "Client-ID \(Endpoint.APIKey)"]
37 return URLSession.shared.dataTaskPublisher(for: request)
38 .map(\.data)
39 .decode(type: [ImageData].self, decoder: decoder)
40 .mapError { error in
41 return .invalidResponse
42 }
43 .eraseToAnyPublisher()
44 }
45}

More articles from Swiftla

State Management in SwiftUI

Apps: What do they all need? Fundamentally, all apps have three types of requirements: Keep track of and mutate state. Respond to user…

May 5th, 2021 · 3 min read

Mobility Metrics: Calculating Walking Speed

Do you know you can now calculate mobility metrics like walking speed with iOS 14? Learn how.

March 7th, 2021 · 2 min read
© 2020–2021 Swiftla
Link to $https://twitter.com/arvindravi_Link to $https://github.com/arvindraviLink to $https://www.linkedin.com/in/arvindravizxc/