Endpoint

1.0.14

Swift Package for (REST) Endpoint objects
OakCityLabs/Endpoint

What's New

Fail silently parameter on an Endpoint for avoiding unnecessary log output

2020-12-08T18:39:36Z

The parameter is part of the Endpoint and not the EndpointController so only certain endpoints fail silently and not all of them.

Endpoint

An Endpoint object describes a network (REST) endpoint and can parse its data.

TL;DR

An Endpoint object describes a network API endpoint, including REST endpoints. It contains all the information needed to request data from an endpoint and can parse the data returned by the API server.
Organizing endpoints this way makes the code more testable and more organized, separating network boiler plate from endpoint specific data handling. An EndpointController manages downloading the data for and Endpoint and handling errors that may occur.

Table of Contents

Motivation

We built the Endpoint library after watching the "Tiny Networking Library" episodes of the wonderful video series SwiftTalk from the folks at objc.io.

The main goal of the library is to represent a remote API endpoint.
The Endpoint object contains everything you need to know to make a request to an API endpoint and how to parse the results.

Separating out the endpoint definition and parsing functionality makes unit testing much easier. You can define parameters for an endpoint and check the generated URL against known values. Likewise, you can use known test data to exercise the parsing routine and validate the output. Unit testing network code can be tricky, but by moving the parsing code into a separate object, we remove the networking aspect and drastically simplify the unit testing.

Breaking endpoints out into separate objects is also great for the organization of our projects. The tendency is to put endpoint handling into a massive and ever-increasing network controller, where you add a method for each new endpoint. Even with a simple project, you quickly wind up with a giant monolithic controller that is hard to read and maintain. Moving to the endpoint model, everything becomes simple. The network controller only has to retrieve data from the network and handle communication (HTTP) errors. You can organize the endpoint objects naturally, for example, making factory methods in an extension to their associated object type.

We also include an EndpointController object that handles making network requests with Endpoints. EndpointController uses URLSession to retrieve Endpoint data and check for errors.

Usage

Endpoint Base Class

The Endpoint class wraps all the data to define a network API endpoint, usually a REST endpoint. It is generic over a Payload, the type of object returned by the endpoint. For example, if you have an endpoint that retrieves information about a restaurant, the Payload type would be a Restaurant, eg Resource<Restaurant>.

The base Endpoint class is an abstract class. It defines a common interface and some default behavior, but has an empty parse() routine. You should subclass the base class for a particular type of PayLoad object. The library includes two subclasses -- CodableEndpoint and FileDownloadEndpoint.

Initialization

To create an Endpoint, you need at least the serverUrl and the pathPrefix. The serverUrl is the root URL for the service. This is usually a prefix shared among all your REST endpoints. The pathPrefix is the next part of the URL's path. If we have a URL like

http://foo.com/api/v1.0/bar

Then we can create an Endpoint to represent the URL.

    let endpoint = Endpoint<Bar>(serverUrl: URL(string: "http://foo.com/api/v1.0")!,
                                 pathPrefix: "bar")

Like many REST services, the servers we build use a particular pattern for accessing objects.

`http://foo.com/<base_path>/<object_type>/<object_id>/<detail_path>?<query_params>

If you want all the followers for user 867 the URL would be

`http://foo.com/api/v1.0/users/867/followers?expand=1

The Endpoint initializer works with this sort of template. The mapping here is:

  • serverUrl - http://foo.com/api/v1.0
  • pathPrefix - users
  • objId - 867
  • pathSuffix - followers
  • queryParams - ["expand": "1"]`

So in code:

    let endpoint = Endpoint<Bar>(serverUrl: URL(string: "http://foo.com/api/v1.0")!,
                                 pathPrefix: "users",
                                 objId: "867",
                                 pathSuffix: "followers",
                                 queryParams: ["expand": "1"])

If your server doesn't follow this path template, you can always just use the base URL as serverUrl and the rest of the path as pathPrefix.

EndpointHttpMethod

The default HTTP method when creating an endpoint is GET, but the method parameter of init allows you specify user the EndpointHttpMethod enumerations, choosing from .get, .post, .patch or .delete.

HTTP Body Data

Endpoint supports several methods for building the body of an HTTP request, including JSON, form parameters or custom data. Any endpoint that includes data for the body of the HTTP request should be using the .post or .patch HTTP methods.

To create a JSON request, specify the dictionary of values when creating the Endpoint with the jsonParams parameter to the init method. This dictionary will be JSON encoded to a data block used as the body of URLRequest.

Supplying the formData parameter will create a body for the URLRequest with the supplied dictionary url-encoded as form data.

If neither of those is suitable, like with multi-part form data, you can supply your own data with the body attribute of init.

DateFormatter

The dateFormatter parameter holds a DateFormatter object. The base class doesn't do anything with this, but it's useful for subclasses that needs a DateFormatter for parsing date types, such as the CodableEndpoint.

URL Request

This method returns a URLRequest generated from the attributes of the Endpoint object. You can pass this request directly to URLSession.
For pageable endpoints, you can specify the page requested (with '1' based indexing).
The function also accepts any extra header information to be add to the request.

open func urlRequest(page: Int = 1, extraHeaders: [String: String] = [:]) -> URLRequest?

Paging

Endpoints that return lots of data are often paged. An Endpoint supports paging if the Payload class conforms to the EndpointPageable protocol. There is a default implementation of the protocol, so a class can just declare conformance to enable paging.

    extension MyPayloadClass: EndpointPageable {}

This is the default implementation that provides the parameters sent to the server for paging.

public extension EndpointPageable {
    static var perPage: Int {
        return 30
    }
    
    static var perPageLabel: String {
        return "per_page"
    }
    
    static var pageLabel: String {
        return "page"
    }
    
    static var pageOffset: Int {
        return 0
    }
}

The page and perPageLabel are the names of the query parameters sent to the server indicating the page requested and the number of items per page, respectively. The perPage attribute is the numerical value sent with the perPageLabel to specify the number of items per page of data. Finally pageOffset is a modifier on the page number before it's sent to the server. The Endpoint class uses 1-based indexing for the pages. If your server uses 0-based indexing, you can set pageOffset to -1 to make it match.

EndpointController

The Endpoint package contains an EndpointController class that handles the loading of data from the Endpoints. An app usually only needs one EndpointController instance. You can reuse this controller over and over to load data for various Endpoints, so the controller is a long lived object. A single Endpoint, on the other hand, is typically created, loaded and then released.

The EndpointController is built on URLSession and has two main functions, sending data to the server and receiving data from the server. The controller uses an Endpoint to create a URLRequest and makes the network request to the server. When the data arrives from the server, the controller parses the data with the Endpoint's parse method or handles any error that occurred in the exchange.

Unless explicitly flagged otherwise with the synchronous parameter, the controller's load() method is asynchronous.
The load() call will return immediately once the network request starts. When the request finishes, the main thread executes the completion block, calling it with a Result object that has an associated Payload object if successful or an Error if there's a failure.

 endpointController.load(endpoint) { (result) in
    switch result {
    case .success(let payload):
        print("Success!  Received \(payload)")
    case .failure(let error):
        print("Failure! Error: \(error)")
    }
}

Initialization

The tricky thing about the EndpointController is that it needs to interpret errors from the server. REST servers often return their errors in JSON format, but it seems every server uses a different set of keys for the error response JSON. In order for you, the consumer of the EndpointController class, to define the format of the error messages, the controller class is generic over an EndpointServerError protocol.

public protocol EndpointServerError: Codable, Equatable {
    var error: String? { get }      // Error name -- AuthorizationFailed
    var reason: String? { get }     // Reson for error -- Username not found
    var detail: String? { get }     // Further details -- http://www.server.com/error/username.html
}

The protocol is relatively simple. A conforming object needs to be Codable, Equatable and have a few getters for info strings. All three getters can return nil, which is totally valid, but that means the errors passed back from the EndpointController won't carry any useful information.

The package includes an example error struct called EndpointDefaultServerError.

public struct EndpointDefaultServerError: EndpointServerError {
    public let error: String?
    public let reason: String?
    public let detail: String?
    
    public init(error: String?, reason: String?, detail: String?) {
        self.error = error
        self.reason = reason
        self.detail = detail
    }

    public init(reason: String?) {
        self.reason = reason
        error = nil
        detail = nil
    }
}

You can instantiate an EndpointController with this error struct like this:

let endpointController = EndpointController<EndpointDefaultServerError>() 

Notifications

The EndpointController emits several notifications to alert other components in the system when important network events occur. These are all instances of Notification.Name:

  • endpointServerUnreachable
    • The controller cannot reach the server because no network is available. This can happen if a phone has no cell signal or WiFi connection. This is a client side network issue.
  • endpointServerNotResponding
    • The device has a network connection, but the server is not responding to network requests. This usually indicates that the server is down or there is a network problem on the server end.
  • endpointValidationError401Unauthorized
    • The server has responded to a REST call with a 401 'Unauthorized' error. This might be a good time to open a login dialog so the user can enter new credentials.

CodableEndpoint

CodableEndpoint is an Endpoint subclass where Payload must conform to Codable. The parse() method of CodableEndpoint uses a JSONDecoder to convert the raw data into a Payload object.

Here's an example using a CodableEndpoint to retrieve an OAuth token.

struct Token: Codable {    
    let type: String
    let duration: Int
    let value: String
}

let formParms = [
    "grant_type": "client_credentials",
    "client_id": clientId,
    "client_secret": clientSecret
]
let tokenEndpoint = CodableEndpoint<Token>(serverUrl: URL(string: "http://www.foo.com/")!,
                                           pathPrefix: "oauth2/token",
                                           method: .post,
                                           formParams: formParms)

let endpointController = EndpointController<EndpointDefaultServerError>() 

endpointController.load(tokenEndpoint) { (result) in
    switch result {
    case .success(let token):
        print("Success! Received token: \(token)")
    case .failure(let error):
        print("Failure! Error: \(error)")
    }
}

FileDownloadEndpoint

FileDownloadEndpoint is an Endpoint subclass that abuses the Endpoint idea a little bit. Instead of the parse() method converting data to an object, it writes the data to a local file and returns the URL to that file. Initialization is just like the base Endpoint class, with the addition of a destination parameter that indicates where the parse() method should write the data. The parse() method will attempt to create any parent directories in the URL that don't exist. After the parse() method finishes writing the file to local disk, the destination URL is returned through the completion block. Because parsing returns the Payload which is a URL, the FileDownloadEndpoint is subclassed from Endpoint<URL>.

Here's an example using a FileDownloadEndpoint to download a file. The CSV file at URL http://www.foo.com/data/foo.csv is downloaded to /tmp/foo.csv.

let destinationURL = URL(fileURLWithPath: "/tmp/foo.csv")
let csvEndpoint = FileDownloadEndpoint(destination: destinationURL,
                                      serverUrl: URL(string: "http://www.foo.com/")!,
                                      pathPrefix: "data/foo.csv")

let endpointController = EndpointController<EndpointDefaultServerError>() 

endpointController.load(csvEndpoint) { (result) in
    switch result {
    case .success(let url):
        print("Success! File written to: \(url)")
    case .failure(let error):
        print("Failure! Error: \(error)")
    }
}

Logging

The Endpoint package uses the standard Logging API for Swift. This allows the library to emit logging info at various levels but leaves it up to you, the consumer of the library, to decide the logging destination -- console, file, logging service, etc. See the Logging API for Swift project page for basic setup examples.

SwiftPM

Endpoint is available via the Swift Package Manager. You can include Endpoint in your project by adding this line to your Package.swift dependencies section:

    .package(url: "https://github.com/OakCityLabs/Endpoint.git", from: "1.0.5"),

Be sure to add it to your targets list as well:

    .target(name: "MyApp", dependencies: ["Endpoint"]),

Changelog

See the changelog.

License

MIT licensed.

About

Endpoint is a product of Oak City Labs.

Description

  • Swift Tools 5.1.0
View More Packages from this Author

Dependencies

Last updated: Fri Apr 12 2024 10:55:17 GMT-0900 (Hawaii-Aleutian Daylight Time)