diff --git a/README.md b/README.md index c255fb8..88a349f 100644 --- a/README.md +++ b/README.md @@ -4,154 +4,197 @@ [![Swift Package Manager](https://img.shields.io/badge/spm-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager) [![Twitter: @gonzalezreal](https://img.shields.io/badge/twitter-@gonzalezreal-blue.svg?style=flat)](https://twitter.com/gonzalezreal) -**SimpleNetworking** is a Swift Package that helps you create scalable API clients. It uses [Combine](https://developer.apple.com/documentation/combine) to expose API responses, making it easy to compose and transform them. +**SimpleNetworking** is a Swift Package that helps you create scalable API clients, simple and elegantly. It uses [Combine](https://developer.apple.com/documentation/combine) to expose API responses, making it easy to compose and transform them. -It also includes other goodies, like logging and network request stubbing. +It also includes other goodies, like logging and response stubbing. Let's explore all the features using [The Movie Database API](https://developers.themoviedb.org/3) as an example. -- [Creating Endpoints](#creating-endpoints) -- [Configuring API clients](#configuring-api-clients) +- [Configuring the API client](#configuring-the-api-client) +- [Creating API requests](#creating-api-requests) +- [Handling errors](#handling-errors) - [Combining and transforming responses](#combining-and-transforming-responses) -- [Logging](#logging) -- [Stubbing network requests](#stubbing-network-requests) +- [Logging requests and responses](#logging-requests-and-responses) +- [Stubbing responses for API requests](#stubbing-responses-for-api-requests) - [Installation](#installation) +- [Related projects](#related-projects) - [Help & Feedback](#help--feedback) -## Creating Endpoints -The `Endpoint` struct encapsulates an API request as well as the result type of the responses for that request. +## Configuring the API client +The `APIClient` is responsible for making requests to an API and handling its responses. To create an API client, you need to provide the base URL and, optionally, any additional parameters or headers that you would like to append to all requests, like an API key or an authorization header. -For example, to implement the [Configuration Endpoint](https://developers.themoviedb.org/3/configuration/get-api-configuration), we start with the response model: +```Swift +let tmdbClient = APIClient( + baseURL: URL(string: "https://api.themoviedb.org/3")!, + configuration: APIClientConfiguration( + additionalParameters: [ + "api_key": "20495f041a8caac8752afc86", + "language": "es", + ] + ) +) +``` + +## Creating API requests +The `APIRequest` type contains all the data required to make an API request, as well as the logic to decode valid and error responses from the request's endpoint. + +Before creating an API request, we need to model its valid and error responses, preferably as types conforming to `Decodable`. + +Usually, an API defines different valid response models, depending on the request, but a single error response model for all the requests. In the case of The Movie Database API, error responses take the form of a [`Status`](https://www.themoviedb.org/documentation/api/status-codes) value: ```Swift -struct Configuration: Codable { - struct Images: Codable { - let secureBaseURL: URL - ... - } - - let images: Images - let changeKeys: [String] +struct Status: Decodable { + var code: Int + var message: String enum CodingKeys: String, CodingKey { - case images - case changeKeys = "change_keys" + case code = "status_code" + case message = "status_message" } } ``` -We could create our endpoint as follows: +Now, consider the [`GET /genre/movie/list`](https://developers.themoviedb.org/3/genres/get-movie-list) API request. This request returns the official list of genres for movies. We could implement a `GenreList` type for its response: ```Swift -let endpoint = Endpoint(method: .get, path: "configuration") -``` - -But we want our endpoints to be reusable, so we can implement a factory method or a static property in an extension: +struct Genre: Decodable { + var id: Int + var name: String +} -```Swift -extension Endpoint where Output == Configuration { - static let configuration = Endpoint(method: .get, path: "configuration") +struct GenreList: Decodable { + var genres: [Genre] } ``` -This way, you could obtain responses from that endpoint as follows: +With these response models in place, we are ready to create the API request: ```Swift -let subscription = theMovieDbClient.response(for: .configuration).sink(receiveValue: { config in - print("base url for images: \(config.images.secureBaseURL)") -}) +let movieGenresRequest = APIRequest.get("/genre/movie/list") ``` -You can customize many properties of an `Endpoint`: headers, query parameters, body, etc. Here are some additional examples: +But we can do better, and extend `APIClient` to provide a method to get the movie genres: ```Swift -extension Endpoint where Output == Page { - static func popularMovies(page: Int) -> Endpoint { - return Endpoint( - method: .get, - path: "movie/popular", - queryParameters: ["page": String(page)], - dateDecodingStrategy: .formatted(.theMovieDb) - ) +extension APIClient { + func movieGenres() -> AnyPublisher> { + response(for: .get("/genre/movie/list")) } } +``` -extension Endpoint where Output == Session { - static func session(with token: Token) -> Endpoint { - return Endpoint( - method: .post, - path: "authentication/session/new", - body: token - ) +The `response(for:)` method takes an `APIRequest` and returns a publisher that wraps sending the request and decoding its response. We can implement all the API methods by relying on it: + +```Swift +extension APIClient { + func createSession(with token: Token) -> AnyPublisher> { + response(for: .post("/authentication/session/new", body: token)) + } + + func deleteSession(_ session: Session) -> AnyPublisher> { + response(for: .delete("/authentication/session", body: session)) } + + ... + + func popularMovies(page: Int) -> AnyPublisher, APIClientError> { + response(for: .get("/movie/popular", parameters: ["page": page])) + } + + func topRatedMovies(page: Int) -> AnyPublisher, APIClientError> { + response(for: .get("/movie/top_rated", parameters: ["page": page])) + } + + ... } ``` -## Configuring API clients -When creating an API client, you must specify a base URL and, optionally, the additional headers and query parameters that go with each request. +## Handling errors +Your app must be prepared to handle errors when working with an API client. SimpleNetworking provides [`APIClientError`](Sources/SimpleNetworking/APIClientError.swift), which unifies URL loading errors, JSON decoding errors, and specific API error responses in a single generic type. ```Swift -extension APIClient { - static func theMovieDb(apiKey: String, language: String) -> APIClient { - var configuration = APIClientConfiguration() - configuration.additionalQueryParameters = [ - "api_key": apiKey, - "language": language, - ] - return APIClient(baseURL: URL(string: "https://api.themoviedb.org/3")!, configuration: configuration) +let cancellable = tmdbClient.movieGenres() + .catch { error in + switch error { + case .loadingError(let loadingError): + // Handle URL loading errors + ... + case .decodingError(let decodingError): + // Handle JSON decoding errors + ... + case .apiError(let apiError): + // Handle specific API errors + ... + } + } + .sink { movieGenres in + // handle response } -} ``` +The generic [`APIError`](Sources/SimpleNetworking/APIError.swift) type provides access to the HTTP status code and the API error response. + ## Combining and transforming responses -Since `APIClient.response(from:)` method returns a [`Publisher`](https://developer.apple.com/documentation/combine/publisher), it is quite simple to combine responses and transform them for presentation. +Since our API client wraps responses in a [`Publisher`](https://developer.apple.com/documentation/combine/publisher), it is quite simple to combine responses and transform them for presentation. -Consider, for example, that we have to present a list of popular movies; including their title, genre, and cover. To build that list we need information from three different endpoints: -* `.configuration`, to obtain the image base URL. -* `.movieGenres`, to obtain the movie genres by id. -* `.popularMovies(page:)`, to obtain the list of movies sorted by popularity. +Consider, for example, that we have to present a list of popular movies, including their title, genre, and cover. To build that list, we need to issue three different requests. +* [`GET /configuration`](https://developers.themoviedb.org/3/configuration/get-api-configuration), to get the base URL for images. +* [`GET /genre/movie/list`](https://developers.themoviedb.org/3/genres/get-movie-list), to get the list of official genres for movies. +* [`GET /movie/popular`](https://developers.themoviedb.org/3/movies/get-popular-movies), to get the list of the current popular movies. We could model an item in that list as follows: ```Swift struct MovieItem { - let title: String - let genres: String - let posterURL: URL? + var title: String + var posterURL: URL? + var genres: String - init(movieResult: MovieResult, imageBaseURL: URL, movieGenres: GenreList) { - ... + init(movie: Movie, imageBaseURL: URL, movieGenres: GenreList) { + self.title = movie.title + self.posterURL = imageBaseURL + .appendingPathComponent("w300") + .appendingPathComponent(movie.posterPath) + self.genres = ... } } ``` -To build the list, we can use the `zip` operator with the publishers returned by the `APIClient`. +To build the list, we can use the `zip` operator with the publishers returned by the API client. ```Swift -func popularItems() -> AnyPublisher<[MovieItem], Error> { +func popularItems(page: Int) -> AnyPublisher<[MovieItem], APIClientError> { return Publishers.Zip3( - theMovieDbClient.response(for: .popularMovies(page: 1)), - theMovieDbClient.response(for: .configuration), - theMovieDbClient.response(for: .movieGenres) + tmdbClient.configuration(), + tmdbClient.movieGenres(), + tmdbClient.popularMovies(page: page) ) - .map { (page, config, genres) -> [MovieItem] in + .map { (config, genres, page) -> [MovieItem] in let url = config.images.secureBaseURL return page.results.map { - MovieItem(movieResult: $0, imageBaseURL: url, movieGenres: genres) + MovieItem(movie: $0, imageBaseURL: url, movieGenres: genres) } } .eraseToAnyPublisher() } ``` -## Logging -The `APIClient` class uses [SwiftLog](https://github.com/apple/swift-log) to log requests and responses. If you set its `logger.logLevel` to `.debug` you will start seeing requests and responses as they happen in your logs. +## Logging requests and responses +Each `APIClient` instance logs requests and responses using a [SwiftLog](https://github.com/apple/swift-log) logger. + +To see requests and responses logs as they happen, you need to specify the `.debug` log-level when constructing the APIClient. ```Swift -let apiClient = APIClient(baseURL: URL(string: "https://api.themoviedb.org/3")!, logLevel: .debug) +let tmdbClient = APIClient( + baseURL: URL(string: "https://api.themoviedb.org/3")!, + configuration: APIClientConfiguration( + ... + ), + logLevel: .debug +) ``` -Here is an example of the output using the default `StreamLogHandler`: +SimpleNetworking formats the headers and JSON responses, producing structured and readable logs. Here is an example of the output produced by a [`GET /genre/movie/list`](https://developers.themoviedb.org/3/genres/get-movie-list) request: ``` 2019-12-15T17:18:47+0100 debug: [REQUEST] GET https://api.themoviedb.org/3/genre/movie/list?language=en @@ -185,32 +228,50 @@ Here is an example of the output using the default `StreamLogHandler`: ... ``` -If you want to use [Apple's Unified Logging](https://developer.apple.com/documentation/os/logging) for your logs, you might want to try [UnifiedLogHandler](https://github.com/gonzalezreal/UnifiedLogging). +## Stubbing responses for API requests +Stubbing responses can be useful when writing UI or integration tests to avoid depending on network reachability. -## Stubbing network requests -Stubbing network requests can be useful when you are writing UI or integration tests and don't want to depend on the network being reachable. +For this task, SimpleNetworking provides `HTTPStubProtocol`, a `URLProtocol` subclass that allows stubbing responses for specific API or URL requests. -You can use `HTTPStubProtocol` to stub a network request as follows: +You can stub any `Encodable` value as a valid response for an API request: ```Swift -var request = URLRequest(url: Fixtures.anyURLWithPath("user", query: "api_key=test")) -request.addValue(ContentType.json.rawValue, forHTTPHeaderField: HeaderField.accept.rawValue) -request.addValue("Bearer 3xpo", forHTTPHeaderField: HeaderField.authorization.rawValue) - -HTTPStubProtocol.stubRequest(request, data: Fixtures.anyJSON, statusCode: 200) +try HTTPStubProtocol.stub( + User(name: "gonzalezreal"), + statusCode: 200, + for: APIRequest.get( + "/user", + headers: [.authorization: "Bearer 3xpo"], + parameters: ["api_key": "a9a5aac8752afc86"] + ), + baseURL: URL(string: "https://example.com/api")! +) ``` -For this to have the desired effect, you need to pass `URLSession.stubbed` as a parameter when constructing the `APIClient`. +Or as an error response for the same API request: ```Swift -override func setUp() { - super.setUp() - - sut = APIClient(baseURL: Fixtures.anyBaseURL, configuration: configuration, session: .stubbed) -} +try HTTPStubProtocol.stub( + Error(message: "The resource you requested could not be found."), + statusCode: 404, + for: APIRequest.get( + "/user", + headers: [.authorization: "Bearer 3xpo"], + parameters: ["api_key": "a9a5aac8752afc86"] + ), + baseURL: URL(string: "https://example.com/api")! +) ``` -You can check out [`APIClientTest`](Tests/SimpleNetworkingTests/APIClientTest.swift) for more information. +To use stubbed responses, you need to pass `URLSession.stubbed` as a parameter when creating an `APIClient` instance: + +```Swift +let apiClient = APIClient( + baseURL: URL(string: "https://example.com/api")!, + configuration: configuration, + session: .stubbed +) +``` ## Installation **Using the Swift Package Manager** @@ -218,12 +279,11 @@ You can check out [`APIClientTest`](Tests/SimpleNetworkingTests/APIClientTest.sw Add SimpleNetworking as a dependency to your `Package.swift` file. For more information, see the [Swift Package Manager documentation](https://github.com/apple/swift-package-manager/tree/master/Documentation). ``` -.package(url: "https://github.com/gonzalezreal/SimpleNetworking", from: "1.3.0") +.package(url: "https://github.com/gonzalezreal/SimpleNetworking", from: "2.0.0") ``` ## Related projects -- [NetworkImage](https://github.com/gonzalezreal/NetworkImage) -- [UnifiedLogHandler](https://github.com/gonzalezreal/UnifiedLogging) +- [NetworkImage](https://github.com/gonzalezreal/NetworkImage), a Swift µpackage that provides image downloading and caching for your apps. It leverages the foundation `URLCache`, providing persistent and in-memory caches. ## Help & Feedback - [Open an issue](https://github.com/gonzalezreal/SimpleNetworking/issues/new) if you need help, if you found a bug, or if you want to discuss a feature request.