Teapot 2.2.0

Teapot 2.2.0

Igor Ranieri维护。



Teapot 2.2.0

  • 作者:
  • Igor Ranieri

Teapot

HTTP状态码 418:我是一把茶壶。

一个轻量级的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 并处理以下情况:

  1. 无效请求路径:提供的路径包含无法由 URLComponents 解析的字符或格式。
  2. 无效响应状态。状态不在 200 到 299 之间,因此会被Teapot视为错误处理(但不一定是您应用程序的处理)。
  3. 图像缺失。当使用 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 有一个简单的日志记录器,会在底层记录某些内容。这可以通过每个 Teapotlogger 属性访问。

默认日志级别是 .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",
])

然后,在调用下一个方法时,将验证这两个预期的头是否都存在并具有适当值。

注意:此操作不会检查这仅仅是一些唯一包含的头,但至少包含这一些头。