一个零依赖的网络解决方案,用于构建现代且安全的 iOS、watchOS、macOS 和 tvOS 应用程序。

🚀 TermiNetwork 已经在生产环境中进行了测试,经受住了大量的异步请求和每天数万独立客户端的重负载

特性

目录

安装

您可以使用以下方式之一安装 TermiNetwork...

CocoaPods

将以下行添加到您的 Podfile 中,并在您的终端运行 pod install

pod 'TermiNetwork', '~> 4.0'

Carthage

将以下行添加到您的 Carthage 文件中,并在您的终端运行 carthage update

github "billp/TermiNetwork" ~> 4.0

Swift Package Manager

转到 File > Swift Packages > Add Package Dependency 并添加以下 URL

https://github.com/billp/TermiNetwork

演示应用程序

要查看 TermiNetwork 的所有功能,请下载源代码并运行 TermiNetworkExamples 方案。

用法

简单用法 (Request)

假设您有以下 Codable 模型

struct Todo: Codable {
   let id: Int
   let title: String
}

以下示例创建一个添加新 Todo 的请求

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

Request(method: .get,
	url: "https://myweb.com/api/todos",
	headers: headers,
	params: params)
    .success(responseType: Todo.self) { todos in
	print(todos)
    }
    .failure { error in
	print(error.localizedDescription)
    }

或者使用 async await

let request = Request(method: .get, 
                      url: "https://myweb.com/api/todos", 
                      headers: headers, 
                      params: params)

do {
    let todos: [Todo] = try await request.async()
    print(todos)
} catch let error { 
    print(error.localizedDescription)
}

参数解释

method

以下支持的 HTTP 方法之一

.get, .head, .post, .put, .delete, .connect, .options, .trace or .patch
responseType

以下支持的响应类型之一

Codable.self (implementations), UIImage.self, Data.self or String.self
onSuccess

一个回调,返回给定类型的对象。(在 responseType 参数中指定)

onFailure

一个回调,返回一个 Error 和响应 Data (如果有)。

Request 的高级用法,包含 Configuration 和自定义 Queue

以下示例使用具有 maxConcurrentOperationCount 的自定义 Queue 和一个配置对象。要查看可用配置属性的完整列表,请查看文档中的 Configuration properties

let myQueue = Queue(failureMode: .continue)
myQueue.maxConcurrentOperationCount = 2

let configuration = Configuration(
    cachePolicy: .useProtocolCachePolicy,
    timeoutInterval: 30,
    requestBodyType: .JSON
)

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

Request(method: .post,
        url: "https://myweb.com/todos",
        headers: headers,
        params: params,
        configuration: configuration)
    .queue(queue)
    .success(responseType: String.self) { response in
        print(response)
    }
    .failure { error in
        print(error.localizedDescription)
    }

或者使用 async await

do {
    let response = try Request(
    	method: .post,
	url: "https://myweb.com/todos",
	headers: headers,
	params: params,
	configuration: configuration
    )
    .queue(queue)
    .async(as: String.self)
} catch let error {
    print(error.localizedDescription)
}

上面的请求使用自定义队列 myQueue,其故障模式为 .continue (默认),这意味着如果请求失败,队列将继续执行。

完整的项目设置,包含 EnvironmentsRepositories

TermiNetwork 的完整且推荐的设置包括定义 EnvironmentsRepositories

环境设置

创建一个实现 EnvironmentProtocol 的 swift enum 并定义您的环境。

示例
enum MyAppEnvironments: EnvironmentProtocol {
    case development
    case qa

    func configure() -> Environment {
        switch self {
        case .development:
            return Environment(scheme: .https,
                               host: "localhost",
                               suffix: .path(["v1"]),
                               port: 3000)
        case .qa:
            return Environment(scheme: .http,
                               host: "myqaserver.com",
                               suffix: .path(["v1"]))
        }
    }
}

您可以选择传递一个 configuration 对象,以使所有 Repositories 和 Endpoints 继承给定的配置设置。

要设置您的全局环境,请使用 Environment.set 方法

Environment.set(MyAppEnvironments.development)

仓库设置

创建一个实现 EndpointProtocol 的 swift enum 并定义您的 endpoints。

以下示例创建一个 TodosRepository,其中所有必需的 endpoints 作为 cases。

示例
enum TodosRepository: EndpointProtocol {
    // Define your endpoints
    case list
    case show(id: Int)
    case add(title: String)
    case remove(id: Int)
    case setCompleted(id: Int, completed: Bool)

    static let configuration = Configuration(requestBodyType: .JSON,
                                             headers: ["x-auth": "abcdef1234"])


    // Set method, path, params, headers for each endpoint
    func configure() -> EndpointConfiguration {

        switch self {
        case .list:
            return .init(method: .get,
                         path: .path(["todos"]), // GET /todos
                         configuration: Self.configuration)
        case .show(let id):
            return .init(method: .get,
                         path: .path(["todo", String(id)]), // GET /todos/[id]
                         configuration: Self.configuration)
        case .add(let title):
            return .init(method: .post,
                         path: .path(["todos"]), // POST /todos
                         params: ["title": title],
                         configuration: Self.configuration)
        case .remove(let id):
            return .init(method: .delete,
                         path: .path(["todo", String(id)]), // DELETE /todo/[id]
                         configuration: configuration)
        case .setCompleted(let id, let completed):
            return .init(method: .patch,
                         path: .path(["todo", String(id)]), // PATCH /todo/[id]
                         params: ["completed": completed],
                         configuration: configuration)
        }
    }
}

您可以选择为每个 case 传递一个 configuration 对象,如果您想为每个 endpoint 提供不同的配置。

发起请求

要创建请求,您必须初始化一个 Client 实例,并使用您定义的 Repository 对其进行专门化,在本例中为 TodosRepository

Client<TodosRepository>().request(for: .add(title: "Go shopping!"))
    .success(responseType: Todo.self) { todo in
        // do something with todo
    }
    .failure { error in
        // do something with error
    }

或者使用 async await

do {
    let toto: Todo = Client<TodosRepository>()
	.request(for: .add(title: "Go shopping!"))
	.async()
} catch let error {
    print(error.localizedDescription)
}

队列钩子 (Queue Hooks)

Hooks 是在队列中请求执行之前和/或之后运行的闭包。 以下钩子可用

Queue.shared.beforeAllRequestsCallback = {
    // e.g. show progress loader
}

Queue.shared.afterAllRequestsCallback = { completedWithError in
    // e.g. hide progress loader
}

Queue.shared.beforeEachRequestCallback = { request in
    // do something with request
}

Queue.shared.afterEachRequestCallback = { request, data, urlResponse, error
    // do something with request, data, urlResponse, error
}

有关更多信息,请查看文档中的 Queue

文件/数据上传

您可以使用 Request 对象的 .upload.asyncUpload 方法来启动上传操作。 通过传递 Content-Type: multipart/form-data 请求标头来执行上传。 所有参数值都应作为 MultipartFormDataPartType 传递。

示例

do {
    try await Request(method: .post,
	    url: "https://mywebsite.com/upload",
	    params: [
		"file1": MultipartFormDataPartType.url(.init(filePath: "/path/to/file.zip")),
		"file2": MultipartFormDataPartType.data(data: Data(), filename: "test.png", contentType: "zip"),
		"expiration_date": MultipartFormDataPartType.value(value: Date.now.ISO8601Format())
	    ])
    .asyncUpload(as: ResponseModel.self) { _, _, progress in
	debugPrint("\(progress * 100)% completed")
    }

    debugPrint("Upload finished)
} catch let error {
    debugPrint(error)
}

文件下载

您可以使用 Request 对象的 .download.asyncDownload 方法来启动下载操作。 您唯一需要传递的是要保存的文件的本地文件路径。

示例

guard var localFile = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first.appendPathComponent("download.zip") else {
    return
}

do {
    try await Request(method: .get, 
		      url: "https://mywebsite.com/files/download.zip")
	.asyncDownload(destinationPath: localFile.path,
		       progressUpdate: { bytesSent, totalBytes, progress in
		debugPrint("\(progress * 100)% completed")
	})
} catch let error {
    debugPrint(error.localizedDescription)
}

debugPrint("File saved to: \(localFile.path)")

错误处理

TermiNetwork 为所有可能的错误情况提供了自己的错误类型 (TNError)。 这些错误通常在 start 方法的 onFailure 回调中返回。

要查看所有可用的错误,请访问文档中的 TNError

示例

Client<TodosRepository>().request(for: .add(title: "Go shopping!"))
      .success(responseType: Todo.self) { todo in
         // do something with todo
      }
      .failure: { error in
          switch error {
          case .notSuccess(let statusCode):
               debugPrint("Status code " + String(statusCode))
               break
          case .networkError(let error):
               debugPrint("Network error: " + error.localizedDescription)
               break
          case .cancelled:
               debugPrint("Request cancelled")
               break
          default:
               debugPrint("Error: " + error.localizedDescription)
       }

或者使用 async await

do {
    let todo: Todo = Client<TodosRepository>()
	.request(for: .add(title: "Go shopping!"))
	.async()
} catch let error {
    switch error as? TNError {
    case .notSuccess(let statusCode, let data):
         let errorModel = try? data.deserializeJSONData() as MyErrorModel
	 debugPrint("Status code " + String(statusCode) + ". API Error: " + errorModel?.errorMessage)
	 break
    case .networkError(let error):
	 debugPrint("Network error: " + error.localizedDescription)
	 break
    case .cancelled:
	 debugPrint("Request cancelled")
	 break
    default:
	 debugPrint("Error: " + error.localizedDescription)
 }

取消请求

您可以通过调用 .cancel() 方法来取消正在执行的请求。

示例

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

let request = Request(method: .get, 
	      url: "https://myweb.com/api/todos", 
	      headers: headers, 
	      params: params)

	
request.success(responseType: Todo.self) { todos in
    print(todos)
}
.failure { error in
    print(error.localizedDescription)
}
	
request.cancel()

或者使用 async await

let task = Task {
    let request = Request(method: .get, 
	url: "https://myweb.com/api/todos", 
	headers: headers, 
	params: params)
    do {
        let todos: [Todo] = try await request.async()
        print(todos)
    } catch let error { 
        print(error.localizedDescription)
    }
}

task.cancel()

Reachability

使用 Reachability,您可以监视设备的网络状态,例如它是否通过 wifi 或蜂窝网络连接。

示例

let reachability = Reachability()
try? reachability.monitorState { state in
    switch state {
    case .wifi:
        // Connected through wifi
    case .cellular:
        // Connected through cellular network
    case .unavailable:
        // No connection
    }
}

Transformers

通过定义您的自定义 transform 函数,Transformers 使您能够将 Rest 模型转换为 Domain 模型。 为此,您必须创建一个继承 Transformer 类的类,并通过提供 FromType 和 ToType 泛型来专门化它。

以下示例通过覆盖 transform 函数将 RSCity (rest) 数组转换为 City (domain) 数组。

示例

final class CitiesTransformer: Transformer<[RSCity], [City]> {
    override func transform(_ object: [RSCity]) throws -> [City] {
        object.map { rsCity in
            City(id: UUID(),
                 cityID: rsCity.id,
                 name: rsCity.name,
                 description: rsCity.description,
                 countryName: rsCity.countryName,
                 thumb: rsCity.thumb,
                 image: rsCity.image)
        }
    }
}

最后,在 Request 的 start 方法中传递 CitiesTransformer

示例

Client<CitiesRepository>()
    .request(for: .cities)
    .success(transformer: CitiesTransformer.self) { cities in
        self.cities = cities
    }
    .failure { error in
        switch error {
        case .cancelled:
            break
        default:
            self.errorMessage = error.localizedDescription
        }
    }

或者使用 async await

do {
    let cities = await Client<CitiesRepository>()
        .request(for: .cities)
        .async(using: CitiesTransformer.self)
} catch let error {
    switch error as? TNError {
    case .cancelled:
        break
    default:
        self.errorMessage = error.localizedDescription
    }
}

模拟响应 (Mock responses)

模拟响应 (Mock responses) 是 TermiNetwork 的一项强大功能,使您能够提供本地资源文件作为 Request 的响应。 例如,当 API 服务尚不可用并且您需要实现应用程序的功能而又不浪费任何时间时,这非常有用。(此操作的前提是拥有 API 合同)

启用模拟响应的步骤

  1. 创建一个 Bundle 资源并将您的文件放在那里。(File > New -> File... > Settings Bundle)
  2. 在 Configuration 中指定 Bundle 路径

    示例

    let configuration = Configuration()
    if let path = Bundle.main.path(forResource: "MockData", ofType: "bundle") {
        configuration.mockDataBundle = Bundle(path: path)
    }
  3. 在 Configuration 中启用模拟响应

    示例

    configuration.mockDataEnabled = true
  4. 在您的 endpoints 中定义 mockFilePath 路径。

    示例

    enum CitiesRepository: EndpointProtocol {
        case cities
    
        func configure() -> EndpointConfiguration {
        switch self {
        case .cities:
            return EndpointConfiguration(method: .get,
                                         path: .path(["cities"]),
                                         mockFilePath: .path(["Cities", "cities.json"]))
            }
        }
    }
    上面的示例从 MockData.bundle 加载 Cities/cities.json,并将其数据作为 Request 的响应返回。

有关完整示例,请打开演示应用程序并查看 City Explorer - Offline Mode

拦截器 (Interceptors)

拦截器 (Interceptors) 提供了一种更改或扩充 Request 的通常处理周期的方法。 例如,您可以刷新过期的访问令牌(未经授权的状态代码 401),然后重试原始请求。 为此,您只需实现 InterceptorProtocol

以下 Interceptor 实现尝试刷新访问令牌,并设置重试限制 (5)。

示例

final class UnauthorizedInterceptor: InterceptorProtocol {
    let retryDelay: TimeInterval = 0.1
    let retryLimit = 5

    func requestFinished(responseData data: Data?,
                         error: TNError?,
                         request: Request,
                         proceed: @escaping (InterceptionAction) -> Void) {
        switch error {
        case .notSuccess(let statusCode):
            if statusCode == 401, request.retryCount < retryLimit {
                // Login and get a new token.
                Request(method: .post,
                        url: "https://www.myserviceapi.com/login",
                        params: ["username": "johndoe",
                                 "password": "p@44w0rd"])
                    .success(responseType: LoginResponse.self) { response in
                        let authorizationValue = String(format: "Bearer %@", response.token)

                        // Update the global header in configuration which is inherited by all requests.
                        Environment.current.configuration?.headers["Authorization"] = authorizationValue

                        // Update current request's header.
                        request.headers["Authorization"] = authorizationValue

                        // Finally retry the original request.
                        proceed(.retry(delay: retryDelay))
                    }
            } else {
	 	// Continue if the retry limit is reached
	    	proceed(.continue)
            }
        default:
            proceed(.continue)
        }
    }
}

最后,您必须将 UnauthorizedInterceptor 传递给 Configuration 中的 interceptors 属性

示例

let configuration = Configuration()
configuration.interceptors = [UnauthorizedInterceptor.self]

SwiftUI/UIKit 图像助手 (Image Helpers)

TermiNetwork 提供了两种不同的助手来设置远程图像。

SwiftUI 图像助手

示例

  1. 带有 URL 的示例

    var body: some View {
        TermiNetwork.Image(withURL: "https://example.com/path/to/image.png",
    	               defaultImage: UIImage(named: "DefaultThumbImage"))
    }
  2. 带有 Request 的示例

    var body: some View {
        TermiNetwork.Image(withRequest: Client<CitiesRepository>().request(for: .image(city: city)),
                           defaultImage: UIImage(named: "DefaultThumbImage"))
    }

UIImageView、NSImageView、WKInterfaceImage 扩展

  1. 带有 URL 的示例

    let imageView = UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)
    imageView.tn_setRemoteImage(url: sampleImageURL,
                                defaultImage: UIImage(named: "DefaultThumbImage"),
                                preprocessImage: { image in
        // Optionally pre-process image and return the new image.
        return image
    }, onFinish: { image, error in
        // Optionally handle response
    })
  2. 带有 Request 的示例

    let imageView = UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)
    imageView.tn_setRemoteImage(request: Client<CitiesRepository>().request(for: .thumb(withID: "3125")),
                                defaultImage: UIImage(named: "DefaultThumbImage"),
                                preprocessImage: { image in
        // Optionally pre-process image and return the new image.
        return image
    }, onFinish: { image, error in
        // Optionally handle response
    })

中间件

中间件 (Middleware) 使您能够在 header、params 和 response 到达 success/failure 回调之前修改它们。 您可以通过实现 RequestMiddlewareProtocol 并将其传递给 Configuration 对象来创建自己的中间件。

查看 ./Examples/Communication/Middleware/CryptoMiddleware.swift 获取一个示例,该示例为应用程序添加了额外的加密层。

调试日志

您可以通过在 Configuration 中将 verbose 属性设置为 true 来启用调试日志。

let configuration = Configuration()
configuration.verbose = true

... 您将在调试窗口中看到漂亮的格式化调试输出

测试

要运行测试,请打开 Xcode Project > TermiNetwork scheme,选择 Product -> Test 或只需在键盘上按 ⌘U。

贡献者

Alex Athanasiadis, alexanderathan@gmail.com

许可证

TermiNetwork 在 MIT 许可下可用。 有关更多信息,请参见 LICENSE 文件。