Building an API Client with Combine would mean we need the following things in working order:
- Domain specific methods that return a publisher of the model we’re expecting from the response
- Error handling for when requests fail and propagate that back to the interested parties
- 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": true67 }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": true99 }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: String3 let color: String4 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 // 13 var request = URLRequest(url: Endpoint.photos.url)45 // 26 return URLSession.shared.dataTaskPublisher(for: request)7 // 38 .map(\.data)9 // 410 .decode(type: [ImageData].self, decoder: decoder)11 // 512 .mapError { error in13 return .invalidResponse14 }15 // 616 .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 invalidResponse45 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/")!45 case mockPhotos6 case photos78 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 {23 private let decoder = JSONDecoder()456 enum Error: LocalizedError {7 case invalidURL(URL)8 case invalidResponse910 var errorDescription: String? {11 switch self {12 case .invalidResponse: return "Invalid Response"13 case .invalidURL(let url): return "Invalid URL - \(url)"14 }15 }16 }1718 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/")!2223 case mockPhotos24 case photos2526 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 }3334 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 in41 return .invalidResponse42 }43 .eraseToAnyPublisher()44 }45}