Teapot
一个轻量级的URLSession包装器,用于构建简单的API客户端。
Teapot由三个简单的结构组成:一个类似于JSON的可选容器,一个NetworkResult容器,以及Teapot本身,它作为URLSession的优雅而简洁的包装器。
安装
Teapot可以通过Carthage以及CocoaPods进行安装。
将其添加到您的Podfile
中
pod 'Teapot', '2.0.1`
或者Cartfile
github "BakkenBaeck/Teapot" >= 2.0.1
JSON
JSON
结构是一个简单的枚举,包含两种情况:字典和数组。API的设计考虑到路由模型应该知道JSON将是一个字典还是一个数组,同时也考虑到它们可能不是这种情况。
// We know this API endpoint always returns a dictionary
guard let json = json?.dictionary else { return }
// Here we can have both:
switch json {
case .dictionary(let dict):
// Handle dictionary case
case .array(let ary):
// handle array case
}
NetworkResult
NetworkResult
负责封装我们的API请求的成功/失败情况,并为两种情况提供相关的对象。
self.teapot.get("path") { result in
switch result {
case .success(let json, let response):
// handle success case, JSON is an optional
if response.status == 204 {
// no content
}
case .failure(let json, let response, let error):
// handle failure case. json is an optional.
}
}
基本认证
我们同样支持基本认证。查看 Teapot+BasicAuth.swift
获取更多有关我们提供和暴露的信息。
您可以获取基本的认证密钥字符串
// "Basic YWRtaW46dGVzdDEyMw=="
let basicAuthString = teapot.basicAuthenticationValue(username: "", password: "")
或者完整的报头
// ["Authorization": "Basic YWRtaW46dGVzdDEyMw=="]
let basicAuthHeader = teapot.basicAuthenticationHeader(username: "", password: "")
Teapot本身
我们命名为Teapot的封装是其本身。该封装通过基础URL和一个可选的队列(下面会详细介绍)进行实例化,并公开四个主要方法:一个 get
,一个 post
,一个 put
和一个 delete
方法,以及一个 downloadImage
辅助方法。
示例API客户端
class APIClient {
var teapot: Teapot
init(baseURL: URL) {
self.teapot = Teapot(baseURL: baseURL)
}
func getSomething() {
self.teapot.get("something") { result in
// handle success, failure, etc
}
}
func postSomething(params: [String: Any]) {
self.teapot.post("something", parameters: params, allowsCellular: false) { result in
// handle result
}
}
}
队列管理
默认情况下,Teapot将在主队列上返回所有内容。您可以在初始化时通过创建一个新的默认队列来覆盖每个调用。
let teapot = Teapot(baseURL: url, defaultDeliveryQueue: myBackgroundQueue)
当然,有些情况下需要更复杂的方法,比如几乎所有的调用都通过后台队列执行,但是也有一些需要与UIKit交互并在主队列上执行的调用。我们不希望在后置队列调用后立即调用 DispatchQueue.main.async {}
带来开销。对于这些情况,我们也提供了一次性覆盖。您可以通过以下方式特定的调用覆盖传递队列
// This will call the results on the main queue, regardless of what default delivery queue is; just this once.
teapot.get("/get, deliveryQueue: .main) {}
取消、暂停、恢复等等…
每个动词方法都返回一个可选的 URLSessionTask
对象(只有在请求路径无效时它才会是 nil)。
let task = teapot.get("/path/here") { }
// something changed and we need to wait
task?.suspend()
// user decided to cancel the operation completely, or resume
if cancelOperation {
task?.cancel()
} else {
task?.resume()
}
错误处理
结构体 TeapotError
符合 LocalizedError
并处理以下情况:
- 无效请求路径:提供的路径包含无法由
URLComponents
解析的字符或格式。 - 无效响应状态。状态不在 200 到 299 之间,因此会被Teapot视为错误处理(但不一定是您应用程序的处理)。
- 图像缺失。当使用 Teapot 下载图像时,如果结果为 nil。
TeapotError
还提供简单且描述性的错误描述。
本地化错误字符串
默认情况下,我们使用 Teapot 自带的 .strings
文件。
"Teapot:InvalidRequestPath" = "An error occurred: request URL path is invalid.";
"Teapot:MissingImage" = "An error occurred: image is missing.";
"Teapot:InvalidResponseStatus" = "An error occurred: request response status reported an issue. Status code: %d.";
您可以用自己的文件替换它,实现这些键并全局设置
Teapot.localizationBundle = Bundle.myAppBundle
日志记录
Teapot 有一个简单的日志记录器,会在底层记录某些内容。这可以通过每个 Teapot
的 logger
属性访问。
默认日志级别是 .none
- 即,既不会生成也不会打印日志。
其他日志级别,按它们在控制台中产生多少日志噪声的升序排列:
error
- 记录发生在Teapot
级别的任何错误。incomingData
- 记录从服务器接收到的数据incomingAndOutgoingData
- 记录从服务器接收到的数据和发送到服务器的数据。
日志级别是递增的:如果您将 logger
的日志级别设置为 incomingData
,则将打印 incomingData
级别日志和 error
级别日志。
模拟
为了模拟网络调用以进行测试,您可以使用 MockTeapot
而不是标准的 Teapot
。这允许当 MockTeapot
实例下一次使用时返回文件的內容。例如
let mockedTeapot = MockTeapot(bundle: Bundle(for: MockTests.self),
mockFilename: "get")
将在测试包中查找名为 get.json
的文件,并在下一次调用下一个方法时返回其内容
mockedTeapot.get("/get") { result in
// result will be `.success` and the contents of `get.json` are returned
}
您还可以指定希望从MockTeapot
收到的状态码。这对于测试错误处理非常有用。
let mockedFailingTeapot = MockTeapot(bundle: Bundle(for: MockTests.self),
mockFilename: "get",
statusCode: .unauthorized)
mockedFailingTeapot.get("/get") { result in
// Result will be `.failure` and the response status code will be 401 Unauthorized
}
使用模拟覆盖指定的端点
有时,在执行实际调用之前,您需要击中某个端点,例如检索时间戳或XSRF
令牌。
以下是一个使用Teapot
实例执行类似操作的API示例。
class API {
static var currentTeapot: Teapot!
private static func getTimestamp(completion: (_ timestamp: Int?, error: TeapotError?) -> Void) {
currentTeapot.get("/timestamp") { result in
switch result {
case .success(let _, response):
guard let timestamp = /* something from the response */ else {
let timestampParseError = TeapotError(type: .invalidMockFile,
description: "Error parsing timestamp",
responseStatus: response.statusCode,
underlyingError: nil)
completion(nil, timestampParseError)
return
}
completion(timestamp, nil)
case .failure(let _, _, error):
let timestampFetchError = TeapotError(type: error.type,
description: "Error fetching timestamp",
responseStatus: error.responseStatus,
underlyingError: error)
completion(nil, timestampFetchError)
}
}
}
static func fetchSecureString(completion: (_ secureString: String?, error: TeapotError?) -> Void) {
getTimestamp { timestamp, error in
guard let timestamp = timestamp else {
completion (nil, error)
return
}
let headers = [ "Timestamp" : timestamp ]
currentTeapot.get("/something_secure", headerFields: headers) { result in
switch result {
case .success(let _, response) {
guard let secureString = /* something from the response */ else {
let stringParseError = TeapotError(type: .invalidMockFile,
description: "Error parsing secure string",
responseStatus: response.statusCode,
underlyingError: nil)
completion(nil, stringParseError)
return
}
completion(secureString, nil)
case .failure(let _, _, error): {
let stringFetchError = TeapotError(type: error.type,
description: "Error fetching secure string",
responseStatus: error.responseStatus,
underlyingError: error)
completion(nil, stringFetchError)
}
}
}
}
}
}
}
如果您想对这个API进行测试,您可能想写些像这样的内容。
func testGettingSecureString() {
let mockedTeapot = MockTeapot(bundle: Bundle(for: MockTests.self),
mockFilename: "something_secure")
API.teapot = mockedTeapot
API.fetchSecureString { secureString, error in
XCTAssertNil(error)
XCTAssertNotNil(secureString)
XCTAssertEqual(secureString, "expected secure string")
}
}
然而,在不做任何更改的情况下,这将导致timestamp
端点返回something_secure.json
的内容。这不是您想要的,因为这会导致底层getTimestamp
方法中的错误,从而使您的测试失败。
这就是重写的作用 - 您可以指定为特定端点返回数据,而不是直接调用您的API中的东西。在这里,相同的测试被更新,包括了在timestamp
端点上进行的覆盖。
func testGettingSecureString() {
let mockedOverriddenTeapot = MockTeapot(bundle: Bundle(for: MockTests.self),
mockFilename: "something_secure")
// Tell the mock teapot to return a particular file for a particular endpoint
mockedOverriddenTeapot.overrideEndPoint("timestamp", withFilename: "timestamp")
API.teapot = mockedOverriddenTeapot
API.fetchSecureString { secureString, error in
XCTAssertNil(error)
XCTAssertNotNil(secureString)
XCTAssertEqual(secureString, "expected secure string")
}
}
现在,您的测试将通过getSecureString
的大部分部分来通过或失败,而不仅仅是getTimestamp
的部分。
注意:如果您指定了覆盖的端点和失败状态,失败状态将不会应用于您覆盖的端点。
func testUnauthorizedTryingToGetSecureString() {
let mockedOverriddenFailingTeapot = MockTeapot(bundle: Bundle(for: MockTests.self),
mockFilename: "something_secure",
statusCode: .unauthorized)
// Tell the mock teapot to return a particular file for a particular endpoint
mockedOverriddenTeapot.overrideEndPoint("timestamp", withFilename: "timestamp")
API.teapot = mockedOverriddenTeapot
API.fetchSecureString { secureString, error in
XCTAssertNil(secureString)
XCTAssertNotNil(error)
XCTAssertEqual(error?.description, "Error fetching secure string")
XCTAssertEqual(error?.responseStatus, 401)
}
}
这确保了失败实际上是通过fetchSecureString
中的主要错误处理发生的,而不仅仅是在击中timestamp
端点时立即结束。
您还可以验证某些所需的头是否存在并且符合预期。如果您需要在头中提供签名并想确保它们存在,而不需要击中实时API,这非常有用。
要添加检查的头
teapot.setExpectedHeaders([
"foo": "bar",
"baz": "foo2",
])
然后,在调用下一个方法时,将验证这两个预期的头是否都存在并具有适当值。
注意:此操作不会检查这仅仅是一些唯一包含的头,但至少包含这一些头。