Coder Social home page Coder Social logo

simplenetworking's Introduction

SimpleNetworking

Twitter: @gonzalezreal

SimpleNetworking is a Swift Package that helps you create scalable API clients, simple and elegantly. It uses Combine to expose API responses, making it easy to compose and transform them.

It also includes other goodies, like logging and response stubbing.

Let's explore all the features using The Movie Database API as an example.

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.

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 value:

struct Status: Decodable {
    var code: Int
    var message: String

    enum CodingKeys: String, CodingKey {
        case code = "status_code"
        case message = "status_message"
    }
}

Now, consider the GET /genre/movie/list API request. This request returns the official list of genres for movies. We could implement a GenreList type for its response:

struct Genre: Decodable {
    var id: Int
    var name: String
}

struct GenreList: Decodable {
    var genres: [Genre]
}

With these response models in place, we are ready to create the API request:

let movieGenresRequest = APIRequest<GenreList, Status>.get("/genre/movie/list")

But we can do better, and extend APIClient to provide a method to get the movie genres:

extension APIClient {
    func movieGenres() -> AnyPublisher<GenreList, APIClientError<Status>> {
        response(for: .get("/genre/movie/list"))
    }
}

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:

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]))
    }
    
    ...
}

Handling errors

Your app must be prepared to handle errors when working with an API client. SimpleNetworking provides APIClientError, which unifies URL loading errors, JSON decoding errors, and specific API error responses in a single generic type.

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 type provides access to the HTTP status code and the API error response.

Combining and transforming responses

Since our API client wraps responses in a 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 to issue three different requests.

We could model an item in that list as follows:

struct MovieItem {
    var title: String
    var posterURL: URL?
    var genres: String
    
    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 API client.

func popularItems(page: Int) -> AnyPublisher<[MovieItem], APIClientError<Status>> {
    return Publishers.Zip3(
        tmdbClient.configuration(),
        tmdbClient.movieGenres(),
        tmdbClient.popularMovies(page: page)
    )
    .map { (config, genres, page) -> [MovieItem] in
        let url = config.images.secureBaseURL
        return page.results.map {
            MovieItem(movie: $0, imageBaseURL: url, movieGenres: genres)
        }
    }
    .eraseToAnyPublisher()
}

Logging requests and responses

Each APIClient instance logs requests and responses using a SwiftLog logger.

To see requests and responses logs as they happen, you need to specify the .debug log-level when constructing the APIClient.

let tmdbClient = APIClient(
    baseURL: URL(string: "https://api.themoviedb.org/3")!,
    configuration: APIClientConfiguration(
        ...
    ),
    logLevel: .debug
)

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 request:

2019-12-15T17:18:47+0100 debug: [REQUEST] GET https://api.themoviedb.org/3/genre/movie/list?language=en
├─ Headers
│ Accept: application/json
2019-12-15T17:18:47+0100 debug: [RESPONSE] 200 https://api.themoviedb.org/3/genre/movie/list?language=en
├─ Headers
│ access-control-expose-headers: ETag, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, Content-Length, Content-Range
│ Content-Type: application/json;charset=utf-8
│ x-ratelimit-reset: 1576426582
│ Server: openresty
│ Etag: "df2617d2ab5d0c85ceff5098b8ab70c4"
│ Cache-Control: public, max-age=28800
│ access-control-allow-methods: GET, HEAD, POST, PUT, DELETE, OPTIONS
│ Access-Control-Allow-Origin: *
│ Date: Sun, 15 Dec 2019 16:16:14 GMT
│ x-ratelimit-remaining: 39
│ Content-Length: 547
│ x-ratelimit-limit: 40
├─ Content
 {
   "genres" : [
     {
       "id" : 28,
       "name" : "Action"
     },
     {
       "id" : 12,
       "name" : "Adventure"
     },
 ...

Stubbing responses for API requests

Stubbing responses can be useful when writing UI or integration tests to avoid depending on network reachability.

For this task, SimpleNetworking provides HTTPStubProtocol, a URLProtocol subclass that allows stubbing responses for specific API or URL requests.

You can stub any Encodable value as a valid response for an API request:

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")!
)

Or as an error response for the same API request:

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")!
)

To use stubbed responses, you need to pass URLSession.stubbed as a parameter when creating an APIClient instance:

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.

.package(url: "https://github.com/gonzalezreal/SimpleNetworking", from: "2.0.0")

Related projects

  • 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 if you need help, if you found a bug, or if you want to discuss a feature request.
  • Open a PR if you want to make some change to SimpleNetworking.
  • Contact @gonzalezreal on Twitter.

simplenetworking's People

Contributors

gonzalezreal avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

simplenetworking's Issues

Can't add headers to an endpoint

I can't do something like this:

extension Endpoint where Output == Void {
  static func submitChallenge(challenge: Challenge, response: String) -> Endpoint {
    let payload = ChallengePayload(code: response)
    return Endpoint(method: .patch, path: "auth/challenges/\(challenge.id)", headers: [.accept: ContentType.json.rawValue, .contentType: ContentType.json.rawValue], body: payload)
  }
}

Even though the server doesn't give a response for this particular endpoint, I still need it to have the Accept: application/json header, which isn't there when Output is Void.

I also can't construct the Endpoint and then set endpoint.headers = [.accept: ContentType.json.rawValue, .contentType: ContentType.json.rawValue], since headers is a let.

Type '()' cannot conform to 'Publisher'

I have tried to copy exactly what you recommend (as part of producing a small reproducible error):

let tmdbClient = APIClient(
	baseURL: URL(string: "https://api.themoviedb.org/3")!,
	configuration: APIClientConfiguration(
		additionalParameters: [
			"api_key": "20495f041a8caac8752afc86",
			"language": "es",
		]
	)
)

struct Status: Decodable {
	var code: Int
	var message: String

	enum CodingKeys: String, CodingKey {
		case code = "status_code"
		case message = "status_message"
	}
}

struct Genre: Decodable {
	var id: Int
	var name: String
}

struct GenreList: Decodable {
	var genres: [Genre]
}

let movieGenresRequest = APIRequest<GenreList, Status>.get("/genre/movie/list")

tmdbClient.response(for: movieGenresRequest).catch({ error in
	print(error)
})

But the errors I get are:

Instance method 'catch' requires the types 'GenreList' and '().Output' be equivalent
Type '()' cannot conform to 'Publisher'; only struct/enum/class types can conform to protocols

Do you have any ideas what I am doing wrong?

How to handle empty error responses

I am communicating with an API which doesn't always respond with with json encoded responses. When authentication is required and omitted it simply has a status code of 401 and no body. As such, I cannot model the response and the error this library throws isn't easy to work with...

2020-10-11T14:50:45+0100 debug APIClient : [REQUEST] GET https://api/users/settings
2020-10-11T14:50:46+0100 debug APIClient : [RESPONSE] 401 https://api/users/settings

error: decodingError(Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "No value." UserInfo={NSDebugDescription=No value.}))))

As you can see, the error I get is that it couldn't decode the json into my empty object:

struct Error: Decodable {
		
}

Is there any way I can catch the error status code? I appreciate I could override the error handler from the APIRequest

error: { try jsonDecoder.decode(Error.self, from: $0) }

But these convenience helpers are very useful and it would essentially mean I would have to duplicate a lot of code. When the status code is not successful the response should always be .apiError with an optional error type in my opinion.

Decode error response?

Is there an easy way to decode the response in case of an error? For example when the response' status code is 401, I know there will a challenge object in the response, which I need to re-authenticate the user (using 2FA).

APIClient throws a BadStatusError which contains the data, so I can decode that manually (inside a tryCatch operator?) but it doesn't seem ideal. Wondering if the library can make this easier?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.