端点 (Endpoint)

Endpoint 对象描述一个网络 (REST) 端点,并且可以解析它的数据。

概要

Endpoint 对象描述一个网络 API 端点,包括 REST 端点。它包含请求端点数据所需的所有信息,并且可以解析 API 服务器返回的数据。
以这种方式组织端点使代码更具可测试性和组织性,将网络样板代码与端点特定的数据处理分离开来。 EndpointController 管理下载 Endpoint 的数据,以及处理可能发生的错误。

目录

动机

我们构建 Endpoint 库,源于观看了来自 SwiftTalk 团队的精彩视频系列“Tiny Networking Library”。

该库的主要目标是表示一个远程 API 端点。
Endpoint 对象包含向 API 端点发出请求以及如何解析结果所需的一切信息。

分离端点定义和解析功能使得单元测试更加容易。您可以为端点定义参数,并根据已知值检查生成的 URL。 同样,您可以使用已知的测试数据来执行解析例程并验证输出。 单元测试网络代码可能很棘手,但通过将解析代码移动到一个单独的对象中,我们消除了网络方面的复杂性,并大大简化了单元测试。

将端点分解为单独的对象对于我们项目的组织也很有帮助。 趋势是将端点处理放入一个庞大且不断增长的网络控制器中,您需要为每个新端点添加一个方法。 即使是一个简单的项目,您也会很快得到一个难以阅读和维护的巨大的单片控制器。 迁移到端点模型后,一切变得简单。 网络控制器只需要从网络检索数据并处理通信 (HTTP) 错误。 您可以自然地组织端点对象,例如,在其关联的对象类型的扩展中创建工厂方法。

我们还包含一个 EndpointController 对象,用于处理使用 Endpoints 发出网络请求。 EndpointController 使用 URLSession 来检索 Endpoint 数据并检查错误。

用法

Endpoint 基类

Endpoint 类封装了定义网络 API 端点(通常是 REST 端点)的所有数据。 它是关于 Payload 的泛型,Payload 是端点返回的对象类型。 例如,如果您有一个检索有关餐厅信息的端点,则 Payload 类型将是 Restaurant,例如 Resource<Restaurant>

基础 Endpoint 类是一个抽象类。 它定义了一个公共接口和一些默认行为,但有一个空的 parse() 例程。 您应该为特定类型的 PayLoad 对象继承该基类。 该库包含两个子类—— CodableEndpointFileDownloadEndpoint

初始化

要创建一个 Endpoint,您至少需要 serverUrlpathPrefixserverUrl 是服务的根 URL。 这通常是所有 REST 端点共享的前缀。 pathPrefix 是 URL 路径的下一部分。 如果我们有一个像这样的 URL

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

然后我们可以创建一个 Endpoint 来表示该 URL。

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

像许多 REST 服务一样,我们构建的服务器使用一种特定的模式来访问对象。

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

如果您想要用户 867 的所有关注者,则 URL 将是

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

Endpoint 初始化程序使用这种模板。 这里的映射是

所以在代码中

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

如果您的服务器不遵循此路径模板,您可以始终只使用基本 URL 作为 serverUrl,并将路径的其余部分用作 pathPrefix

EndpointHttpMethod

创建端点时的默认 HTTP 方法是 GET,但 initmethod 参数允许您指定使用 EndpointHttpMethod 枚举,从 .get.post.patch.delete 中选择。

HTTP Body 数据

Endpoint 支持多种构建 HTTP 请求正文的方法,包括 JSON、表单参数或自定义数据。 任何包含 HTTP 请求正文数据的端点都应使用 .post.patch HTTP 方法。

要创建 JSON 请求,请在使用 init 方法的 jsonParams 参数创建 Endpoint 时,指定值的字典。 此字典将被 JSON 编码为用作 URLRequest 正文的数据块。

提供 formData 参数将为 URLRequest 创建一个正文,其中提供的字典 url 编码为表单数据。

如果两者都不合适,例如多部分表单数据,您可以使用 initbody 属性提供您自己的数据。

DateFormatter

dateFormatter 参数保存一个 DateFormatter 对象。 基类不对此做任何事情,但它对于需要 DateFormatter 来解析日期类型的子类很有用,例如 CodableEndpoint

URL Request

此方法返回从 Endpoint 对象的属性生成的 URLRequest。 您可以直接将此请求传递给 URLSession。
对于可分页的端点,您可以指定请求的页面(使用基于“1”的索引)。
该函数还接受要添加到请求的任何额外的标头信息。

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

分页

返回大量数据的端点通常是分页的。 如果 Payload 类符合 EndpointPageable 协议,则 Endpoint 支持分页。 该协议有一个默认实现,因此一个类只需声明符合性即可启用分页。

    extension MyPayloadClass: EndpointPageable {}

这是提供发送到服务器进行分页的参数的默认实现。

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
    }
}

pageperPageLabel 是发送到服务器的查询参数的名称,分别指示请求的页面和每页的项目数。 perPage 属性是与 perPageLabel 一起发送的数值,用于指定每页数据的项目数。 最后,pageOffset 是在发送到服务器之前对页码的修饰符。 Endpoint 类使用基于 1 的索引作为页面。 如果您的服务器使用基于 0 的索引,您可以将 pageOffset 设置为 -1 以使其匹配。

EndpointController

Endpoint 包包含一个 EndpointController 类,用于处理从 Endpoints 加载数据。 一个应用程序通常只需要一个 EndpointController 实例。 您可以一次又一次地重用此控制器来加载各种 Endpoints 的数据,因此该控制器是一个长期存在的对象。 另一方面,单个 Endpoint 通常是创建、加载然后释放。

EndpointController 构建于 URLSession 之上,有两个主要功能:向服务器发送数据和从服务器接收数据。 控制器使用 Endpoint 创建 URLRequest 并向服务器发出网络请求。 当数据从服务器到达时,控制器使用 Endpoint 的 parse 方法解析数据,或处理交换中发生的任何错误。

除非使用 synchronous 参数明确标记,否则控制器的 load() 方法是异步的。
一旦网络请求开始,load() 调用将立即返回。 请求完成后,主线程执行完成块,如果成功则使用关联的 Payload 对象或如果失败则使用 Error 调用它。

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

初始化

EndpointController 的棘手之处在于它需要解释来自服务器的错误。 REST 服务器通常以 JSON 格式返回其错误,但似乎每个服务器都对错误响应 JSON 使用不同的键集。 为了让您(EndpointController 类的使用者)定义错误消息的格式,控制器类是关于 EndpointServerError 协议的泛型。

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
}

该协议相对简单。 符合要求的对象需要是 CodableEquatable 并且有一些 info 字符串的 getter。 所有三个 getter 都可以返回 nil,这完全有效,但这意味着从 EndpointController 传回的错误不会携带任何有用的信息。

该包包含一个名为 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
    }
}

您可以像这样使用此错误结构体实例化一个 EndpointController

let endpointController = EndpointController<EndpointDefaultServerError>() 

通知

当发生重要的网络事件时,EndpointController 会发出多个通知以提醒系统中的其他组件。 这些都是 Notification.Name 的实例

CodableEndpoint

CodableEndpoint 是一个 Endpoint 子类,其中 Payload 必须符合 CodableCodableEndpointparse() 方法使用 JSONDecoder 将原始数据转换为 Payload 对象。

这是一个使用 CodableEndpoint 检索 OAuth 令牌的示例。

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 是一个 Endpoint 子类,它稍微滥用了 Endpoint 的概念。它不是使用 parse() 方法将数据转换为对象,而是将数据写入本地文件并返回该文件的 URL。初始化与基本 Endpoint 类相似,但增加了一个 destination 参数,指示 parse() 方法应将数据写入何处。parse() 方法将尝试创建 URL 中不存在的任何父目录。parse() 方法完成将文件写入本地磁盘后,destination URL 将通过完成块返回。由于解析返回的 Payload 是一个 URL,因此 FileDownloadEndpoint 是从 Endpoint<URL> 继承的。

这是一个使用 FileDownloadEndpoint 下载文件的示例。URL 为 http://www.foo.com/data/foo.csv 的 CSV 文件将被下载到 /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)")
    }
}

日志

Endpoint 包使用标准的 Swift Logging API。这允许库在不同级别发出日志信息,但由您(库的使用者)来决定日志的目标位置 - 控制台、文件、日志服务等。有关基本设置示例,请参阅 Swift Logging API 项目页面。

SwiftPM

Endpoint 可以通过 Swift Package Manager 获得。您可以通过将以下行添加到您的 Package.swift 文件的 dependencies 部分来将 Endpoint 包含到您的项目中

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

请务必也将其添加到您的 targets 列表中

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

更新日志

请参阅 更新日志。

许可证

MIT 许可。

关于

Endpoint 是 Oak City Labs 的产品。