Skip to content

Commit

Permalink
Update README (#19)
Browse files Browse the repository at this point in the history
* Update the first three sections of the README

* Update "Combining and transforming responses"

* Update logging section

* Update README

* Fix README TOC
  • Loading branch information
gonzalezreal committed Jul 22, 2020
1 parent 79373ae commit 4e87c1f
Showing 1 changed file with 158 additions and 98 deletions.
256 changes: 158 additions & 98 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Configuration>(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<GenreList, Status>.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<MovieResult> {
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<GenreList, APIClientError<Status>> {
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<Session, APIClientError<Status>> {
response(for: .post("/authentication/session/new", body: token))
}

func deleteSession(_ session: Session) -> AnyPublisher<Void, APIClientError<Status>> {
response(for: .delete("/authentication/session", body: session))
}

...

func popularMovies(page: Int) -> AnyPublisher<Page<Movie>, APIClientError<Status>> {
response(for: .get("/movie/popular", parameters: ["page": page]))
}

func topRatedMovies(page: Int) -> AnyPublisher<Page<Movie>, APIClientError<Status>> {
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<Status>> {
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
Expand Down Expand Up @@ -185,45 +228,62 @@ 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<User, Error>.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<User, Error>.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**

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.
Expand Down

0 comments on commit 4e87c1f

Please sign in to comment.