An Endpoint object describes a network (REST) endpoint and can parse its data.
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.
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.
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.
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
.
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
.
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
.
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
.
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?
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.
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)")
}
}
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>()
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
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
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)")
}
}
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.
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"]),
See the changelog.
Endpoint is a product of Oak City Labs.