PewPew 1.1.0

PewPew 1.1.0

Maintained by Jacob Sikorski.



PewPew 1.1.0

  • By
  • Jacob Sikorski

Swift 5 iOS 8+ Carthage CocoaPods GitHub Build

PewPew

Formerly known as NetworkKit, the project was renamed to support CocoaPods. PewPew adds the concept of Futures (aka: Promises) to iOS. It is intended to make netwoking calls cleaner and simpler and provides the developer with more customizability then any other networking framework.

Q: Why should I use this framework? A: Because, you like clean code.

Q: Why the stupid name? A: Because "pew pew" is the sound of lazers. And lazers are from the future.

Q: What sort of bear is best? A: False! A black bear!

Updates

1.1.0

Removed default translations.

To migrate to this you should include your own translations by extending ResponseError, RequestError and SerializationError conforming to LocalizedError and (optionally) CustomNSError

To re-introduce the previous behaviour, you should include the files found here and the localizations here

1.0.1

Fixed crash when translating

Features

  • A wrapper around network requests
  • Uses Futures (ie. Promises) to allow scalablity and dryness
  • Convenience methods for deserializing Decodable and JSON
  • Easy integration
  • Handles common http errors
  • Returns production safe error messages
  • Strongly typed and safely unwrapped responses
  • Easily extensible. Can easily work with frameworks such as Alamofire, ObjectMapper and MapCodableKit
  • Clean!

Installation

Carthage

Carthage is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks.

You can install Carthage with Homebrew using the following command:

$ brew update
$ brew install carthage

To integrate PewPew into your Xcode project using Carthage, specify it in your Cartfile:

github "cuba/PewPew" ~> 1.0

Run carthage update to build the framework and drag the built PewPew.framework into your Xcode project.

Usage

1. Import PewPew into your file

import PewPew

2. Implement a ServerProvider

The server provider gives the server url. The reason a simple URL is not used is so that you can dynamically change the url. Say for example you have an environment picker. You would have to recreate the dispatcher every time you change the environment. The simplest way to create a ServerProvider is to just implement the protocol on your ViewController.

extension ViewController: ServerProvider {
    var baseURL: URL {
        return URL(string: "https://example.com")!
    }
}

But you may chose to use a seperate object to implement the server provider or create a singleton object so you can share it througout your application. Because the reference to the server provider on the NetworkDispatcher is weak, you don't have to worry about any circular references.

3. Making a request.

Now that we have our ServerProvider established, we can start making api calls.

let dispatcher = NetworkDispatcher(serverProvider: self)
let request = BasicRequest(method: .get, path: "/posts")

dispatcher.future(from: request).response({ response in
    // Handles all responses including negative responses such as 4xx and 5xx

    // The error object is available if we get an
    // undesirable status code such as a 4xx or 5xx
    if error = response.error {
        // Throwing an error in any callback will trigger the `error` callback.
        // This allows us to pool all failures in one place.
        throw error
    }

    let post = try response.decode(Post.self)
    // Do something with our deserialized object
    // ...
}).error({ error in
    // Handles any errors during the request process,
    // including anything thrown in any of the callback (except this one).
}).completion({
    // The completion callback is guaranteed to be called once
    // for every time the `start()` or `send()` method is triggered on the future.
    // 
}).start()

NOTE: Nothing will happen if you don't call start(). NOTE: Strange things might happen if you call start() more than once. Don't do it.

4. Separating concerns and transforming the future

Pun not indended (honestly)

Now lets move the part of the future that decodes our object to another method. This way, our business logic is not mixed up with our serialization logic. One of the great thing about using futures is that we can return them!

Lets create a method similar to this:

private func getPosts() -> ResponseFuture<[Post]> {
    let dispatcher = NetworkDispatcher(serverProvider: self)
    let request = BasicRequest(method: .get, path: "/posts")

    // We create a future and tell it to transform the response using the
    // `then` callback.
    return dispatcher.future(from: request).then({ response -> [Post] in
        // This callback transforms our response to another type
        // We can still handle errors the same way as we did before.
        
        if let error = response.error {
            // The error is available when a non-2xx response comes in
            // Such as a 4xx or 5xx
            // You may also parse a custom error object here.
            throw error
        }
        
        // Return the decoded object. If an error is thrown while decoding,
        // It will be caught in the `error` callback.
        return try response.decode([Post].self)
    })
}

NOTE: We intentionally did not call start() in this case.

Then we can simply call it like this:

getPosts().response({ posts in
    // Handle the success which will give your posts.
    responseExpectation.fulfill()
}).error({ error in
    // Triggers whenever an error is thrown.
    // This includes deserialization errors, unwraping failures, and anything else that is thrown
    // in a any other throwable callback.
}).completion({
    // Always triggered at the very end to inform you this future has been satisfied.
}).send()

Future

You've already seen that a ResponseFuture allows you to chain your callbacks, transform the response object and pass it around. But besides the simple examples above, there is so much more you can do to make your code amazingly clean!

dispatcher.future(from: request).then({ response -> Post in
    // Handles any responses and transforms them to another type
    // This includes negative responses such as 4xx and 5xx

    // The error object is available if we get an
    // undesirable status code such as a 4xx or 5xx
    if let error = response.error {
        // Throwing an error in any callback will trigger the `error` callback.
        // This allows us to pool all the errors in one place.
        throw error
    }
    
    return try response.decode(Post.self)
}).replace({ post -> ResponseFuture<EnrichedPost> in
    // Perform some operation that itself uses a future
    // such as something heavy like markdown parsing.
    // Any callback can be transformed to a future.
    return self.enrich(post: post)
}).join({ enrichedPost -> ResponseFuture<User> in
    // Joins a future with another one returning both results
    return self.fetchUser(forId: post.userId)
}).response({ enrichedPost, user in
    // The final response callback includes all the transformations and
    // Joins we had previously performed.
}).error({ error in
    // Handles any errors throw in any callbacks
}).completion({
    // At the end of all the callbacks, this is triggered once. Error or no error.
}).send()

Callbacks

response callback

The response callback is triggered when the request is recieved and no errors are thrown in any chained callbacks (such as then or join). At the end of the callback sequences, this gives you exactly what your transforms "promised" to return.

dispatcher.future(from: request).response({ response in
    // Triggered when a response is recieved and all callbacks succeed.
})

NOTE: This method should ONLY be called ONCE.

error callback

Think of this as a catch on a do block. From the moment you trigger send(), the error callback is triggered whenever something is thrown during the callback sequence. This includes errors thrown in any other callback.

dispatcher.future(from: request).error({ error in
    // Any errors thrown in any other callback will be triggered here.
    // Think of this as the `catch` on a `do` block.
})

NOTE: This method should ONLY be called ONCE.

completion callback

The completion callback is always triggered at the end after all ResponseFuture callbacks once every time send() or start() is triggered.

dispatcher.future(from: request).completion({
    // The completion callback guaranteed to be called once
    // for every time the `send` or `start` method is triggered on the callback.
})

NOTE: This method should ONLY be called ONCE.

then callback

This callback transforms the response type to another type.

dispatcher.future(from: request).then({ response -> Post in
    // The `then` callback transforms a successful response to another object
    // You can return any object here and this will be reflected on the `success` callback.
    return try response.decode(Post.self)
}).response({ post in
    // Handles any success responses.
    // In this case the object returned in the `then` method.
})

replace callback

This callback transforms the future to another type using another callback. This allows us to make asyncronous calls inside our callbacks.

dispatcher.future(from: request).then({ response -> Post in
    return try response.decode(Post.self)
}).replace({ post -> ResponseFuture<EnrichedPost> in
    // Perform some operation operation that itself requires a future
    // such as something heavy like markdown parsing.
    return self.enrich(post: post)
}).response({ enrichedPost in
    // The final response callback has the enriched post.
})

join callback

This callback transforms the future to another type containing its original results plus the results of the returned callback. This allows us to make asyncronous calls in series.

dispatcher.future(from: request).then({ response -> Post in
    return try response.decode(Post.self)
}).join({ post -> ResponseFuture<User> in
    // Joins a future with another one returning both results
    return self.fetchUser(forId: post.userId)
}).response({ post, user in
    // The final response callback includes both results.
})

send or start

This will start the ResponseFuture. In other words, the action callback will be triggered and the requests will be sent to the server.

NOTE: If this method is not called, nothing will happen (no request will be made). NOTE: This method should ONLY be called AFTER declaring all of your callbacks (success, failure, error, then etc...) NOTE: This method should ONLY be called ONCE.

Creating your own ResponseFuture

You can create your own ResponseFuture for a variety of reasons. If you do, you will have all the benefits you have seen so far.

Here is an example of a response future that does decoding in another thread.

return ResponseFuture<[Post]>(action: { future in
    // This is an example of how a future is executed and
    // fulfilled.
    DispatchQueue.global(qos: .userInitiated).async {
        // lets make an expensive operation on a background thread.
        // The below is just an example of how you can parse on a seperate thread.

        do {
            // Do an expensive operation here ....
            let posts = try response.decode([Post].self)

            DispatchQueue.main.async {
                // We should syncronyze the result back to the main thread.
                future.succeed(with: posts)
            }
        } catch {
            // We can handle any errors as well.
            DispatchQueue.main.async {
                // We should syncronize the error to the main thread.
                future.fail(with: error)
            }
        }
    }
})

NOTE You should ALWAYS syncronize the results on the main thread before succeeding or failing your future.

Encoding

PewPew has some convenience methods for you to encode objects into JSON and add them to the BasicRequest object.

Encode JSON String

var request = BasicRequest(method: .post, path: "/users")
request.setJSONBody(string: jsonString, encoding: .utf8)

Encode JSON Object

let jsonObject: [String: Any?] = [
    "id": "123",
    "name": "Kevin Malone"
]

var request = BasicRequest(method: .post, path: "/users")
try request.setJSONBody(jsonObject: jsonObject)

Encode Encodable

var request = BasicRequest(method: .post, path: "/posts")
try request.setJSONBody(encodable: myCodable)

Custom Encoding (By setting the Data object)

var request = BasicRequest(method: .post, path: "/users")
request.httpBody = myData

Wrap Encoding In a ResponseFuture

It might be beneficial to wrap the Request creation in a ResponseFuture. This will allow you to:

  1. Delay the request creation at a later time when submitting the request.
  2. Combine any errors thrown while creating the request in the error callback.
dispatcher.future(from: {
    var request = BasicRequest(method: .post, path: "/posts")
    try request.setJSONBody(myCodable)
    return request
}).error({ error in
    // Any error thrown while creating the request will trigger this callback.
}).send()

Decoding

Unwrapping Data

This will unwrap the data object for you or throw a ResponseError if it not there. This is convenent so that you don't have to deal with those pesky optionals.

dispatcher.future(from: request).response({ response in
    let data = try response.unwrapData()

    // do something with data.
    print(data)
}).error({ error in 
    // Triggered when the data object is not there.
}).send()

Decode String

dispatcher.future(from: request).response({ response in
    let string = try response.decodeString(encoding: .utf8)

    // do something with string.
    print(string)
}).error({ error in
    // Triggered when decoding fails.
}).send()

Decode Decodable

dispatcher.future(from: request).response({ response in
    let posts = try response.decode([Post].self)

    // do something with the decodable object.
    print(posts)
}).error({ error in
    // Triggered when decoding fails.
}).send()

Memory Managment

The ResponseFuture may have 3 types of strong references:

  1. The system may have a strong reference to the ResponseFuture after send() is called. This reference is temporary and will be dealocated once the system returns a response. This will never create a circular reference but as the promise is held on by the system, it will not be released until AFTER a response is recieved or an error is triggered.
  2. Any callback that references self has a strong reference to self unless [weak self] is explicitly specified.
  3. The developer's own strong reference to the ResponseFuture.

Strong callbacks

When ONLY 1 and 2 applies to your case, no circular reference is created. However the object reference as self is held on stongly (temporarily) until the request returns or an error is thrown. You may wish to use [weak self] in this case but it is not necessary.

dispatcher.future(from: request).then({ response -> [Post] in
    // [weak self] not needed as `self` is not called but it doesn't hurt
    return try response.decode([Post].self)
}).response({ posts in
    self.show(posts)
}).send()

WARNING If you use [weak self] do not forcefully unwrap self and never forcefully unwrap anything on self either. Thats just asking for crashes.

!! DO NOT DO THIS. !! Never do this. Not even if you're a programming genius. It's just asking for problems.

dispatcher.future(from: request).success({ response in
    // We are foce unwrapping a text field! DO NOT DO THIS!
    let textField = self.textField!

    // If we dealocated textField by the time the 
    // response comes back, a crash will occur
    textField.text = "Success"
}).send()

You will have crashes if you force unwrap anything in your callbacks (i.e. usign a !). We suggest you ALWAYS avoid force unwrapping anything in your callbacks.

Always unwrap your objects before using them. This includes any IBOutlets that the system generates. Use a guard, Use an assert. Use anything but a !.

Strong reference to a ResponseFuture

You may be holding a reference to your ResponseFuture. This is fine as long as you make the callbacks weak in order to avoid circular references.

self.postResponseFuture = dispatcher.future(from: request).then({ response in
    // [weak self] not needed as `self` is not called
    let posts = try response.decode([Post].self)
    return SuccessResponse<[Post]>(data: posts, response: response)
}).response({ [weak self] response in
    // [weak self] needed as `self` is called
    self?.show(response.data)
}).completion({ [weak self] in
    // [weak self] needed as `self` is called
    self?.postResponseFuture = nil
})

// Perform other logic, add delay, do whatever you would do that forced you
// to store a reference to this ResponseFuture in the first place

self.postResponseFuture?.send()

WARNING If you hold strongly to your future but don't make self weak using [weak self] you are guaranteed to have a cirucular reference. The following is a bad example that should not be followed:

!! DO NOT DO THIS !!

self.strongResponseFuture = dispatcher.future(from: request).response({ response in
    // Both the `ResponseFuture` and `self` are held on by each other.
    // `self` will never be dealocated and neither will the future!
    self.show(response.data)
}).send()

if self has a strong reference to your ResponseFuture and the ResponseFuture has a strong reference to self through any callback, you have created a circular reference. Neither will be dealocated.

Weak reference to a ResponseFuture

You may chose to have a weak reference to your ResponseFuture. This is fine, as long as you do it after calling send() as your object will be dealocated before you get a chance to do this.

self.weakResponseFuture = dispatcher.future(from: request).completion({
    // Always triggered
}).send()

// This ResponseFuture may or may not be nil at this point.
// This depends on if the system is holding on to the
// ResponseFuture as it is awaiting a response.
// but the callbacks will always be triggered. 

NOTE The following is an example of where we our request will never happen because we lose the referrence to the ResponseFuture before send() is triggered:

DO NOT DO THIS:

self.weakResponseFuture = dispatcher.future(from: request).completion({
    // [weak self]
    expectation.fulfill()
})

// WHOOPS!!!!
// Our object is already nil because we have not established a strong reference to it.
// The `send()` method will do nothing and no callback will be triggered.

self.weakResponseFuture?.send()

Custom Encoding

You can extend BasicRequest to add encoding for any type of object.

ObjectMapper

ObjectMapper is not included in the framework. This is in order to make the framework much lighter for those that don't want to use it. But if you want, you can easily add encoding support for ObjectMapper. Here is an example how you can add BaseMappable (Mappable and ImmutableMappable) encoding support for objects and arrays:

extension BasicRequest {
    /// Add JSON body to the request from a `BaseMappable` object.
    ///
    /// - Parameters:
    ///   - mappable: The `BaseMappable` object to serialize into JSON.
    ///   - context: The context of the mapping object
    ///   - shouldIncludeNilValues: Wether or not we should serialize nil values into the json object
    mutating func setJSONBody<T: BaseMappable>(mappable: T, context: MapContext? = nil, shouldIncludeNilValues: Bool = false) {
        let mapper = Mapper<T>(context: context, shouldIncludeNilValues: shouldIncludeNilValues)

        guard let jsonString = mapper.toJSONString(mappable) else {
            return
        }

        self.setJSONBody(string: jsonString)
    }

    /// Add JSON body to the request from a `BaseMappable` array.
    ///
    /// - Parameters:
    ///   - mappable: The `BaseMappable` array to serialize into JSON.
    ///   - context: The context of the mapping object
    ///   - shouldIncludeNilValues: Wether or not we should serialize nil values into the json object
    mutating func setJSONBody<T: BaseMappable>(mappable: [T], context: MapContext? = nil, shouldIncludeNilValues: Bool = false) {
        let mapper = Mapper<T>(context: context, shouldIncludeNilValues: shouldIncludeNilValues)

        guard let jsonString = mapper.toJSONString(mappable) else {
            return
        }

        self.setJSONBody(string: jsonString)
    }
}

MapCodableKit

MapCodableKit is a lightweight json parsing framework.

Similarly MapCodableKit support is no longer available on this framework. But like ObjectMapper You can easily add back support for MapEncodable.

extension BasicRequest {
    /// Add body to the request from a `MapEncodable` object.
    ///
    /// - Parameters:
    ///   - mapEncodable: The `MapEncodable` object to serialize into JSON.
    ///   - options: Writing options for serializing the `MapEncodable` object.
    /// - Throws: Any serialization errors thrown by `MapCodableKit`.
    mutating public func setJSONBody<T: MapEncodable>(mapEncodable: T, options: JSONSerialization.WritingOptions = []) throws {
        ensureJSONContentType()
        self.httpBody = try mapEncodable.jsonData(options: options)
    }
}

Custom Decoding

Similar to encoding, you can also add Decoding support for whatever decoder you are using, including ObjectMapper by extending the ResponseInterface

ObjectMapper

extension ResponseInterface where T == Data? {
    /// Attempt to Decode the response data into an BaseMappable object.
    ///
    /// - Returns: The decoded object
    func decodeMappable<D: BaseMappable>(_ type: D.Type, context: MapContext? = nil) throws  -> D {
        let jsonString = try self.decodeString()
        let mapper = Mapper<D>(context: context)

        guard let result = mapper.map(JSONString: jsonString) else {
            throw SerializationError.failedToDecodeResponseData(cause: nil)
        }

        return result
    }

    /// Attempt to decode the response data into a BaseMappable array.
    ///
    /// - Returns: The decoded array
    func decodeMappable<D: BaseMappable>(_ type: [D].Type, context: MapContext? = nil) throws  -> [D] {
        let jsonString = try self.decodeString()
        let mapper = Mapper<D>(context: context)

        guard let result = mapper.mapArray(JSONString: jsonString) else {
            throw SerializationError.failedToDecodeResponseData(cause: nil)
        }

        return result
    }
}

MapCodableKit

MapCodableKit is a lightweight json parsing framework.

extension ResponseInterface where T == Data? {

    /// Attempt to deserialize the response data into a MapDecodable object.
    ///
    /// - Returns: The decoded object
    func decodeMapDecodable<D: MapDecodable>(_ type: D.Type) throws -> D {
        let data = try self.unwrapData()

        do {
            // Attempt to deserialize the object.
            return try D(jsonData: data)
        } catch {
            // Wrap this error so that we're controlling the error type and return a safe message to the user.
            throw SerializationError.failedToDecodeResponseData(cause: error)
        }
    }

    /// Attempt to decode the response data into a MapDecodable array.
    ///
    /// - Returns: The decoded array
    func decodeMapDecodable<D: MapDecodable>(_ type: [D].Type) throws  -> [D] {
        let data = try self.unwrapData()

        do {
            // Attempt to deserialize the object.
            return try D.parseArray(jsonData: data)
        } catch {
            // Wrap this error so that we're controlling the error type and return a safe message to the user.
            throw SerializationError.failedToDecodeResponseData(cause: error)
        }
    }
}

Mock Dispatcher

Testing network calls is always a pain. That's why we included the MockDispatcher. It allows you to simulate network responses without actually making network calls.

let url = URL(string: "https://jsonplaceholder.typicode.com")!
let dispatcher = MockDispatcher(baseUrl: url, mockStatusCode: .ok)
let request = BasicRequest(method: .get, path: "/posts")
try dispatcher.setMockData(codable)

/// The url specified is not actually called.
dispatcher.future(from: request).send()

Future Features

  • Parallel calls
  • Sequential calls:
  • Custom translations
  • More futuresque request creation
  • A more generic dispatcher. The response object is way too specific.
  • Better multi-threading support

Dependencies

PewPew includes...nothing. This is a light-weight library.

Credits

PewPew is owned and maintained by Jacob Sikorski.

License

PewPew is released under the MIT license. See LICENSE for details