Conduit

Release CocoaPods Compatible Platform

Conduit 是一个基于会话的 Swift HTTP 网络和身份验证库。

在每个会话中,请求在被分发到网络队列之前,会通过一个串行管道发送。 在管道内,请求通过一系列中间件进行处理,这些中间件可以修饰请求、暂停会话管道以及清空传出队列。 基于这种模式,Conduit 为 OAuth2 授权许可捆绑了预定义的中间件,支持 RFC 6749 中定义的所有主要流程,并按照 RFC 6750 中的定义自动将令牌应用于请求。

特性

需求

Conduit 版本 Swift 版本
0.4.x 3.x
0.5 - 0.7.x 4.0
0.8 - 0.13.x 4.1
0.14.0 - 0.17.x 4.2
0.18.0+ 5.0

安装

Swift Package Manager (推荐)

Conduit 添加到你的 Package.swift

// swift-tools-version:5.0
import PackageDescription

let package = Package(
    dependencies: [
        .package(url: "https://github.com/mindbody/Conduit.git", from: "1.0.0")
    ]
)

Cocoapods

Conduit 添加到你的 Podfile

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '9.0'
use_frameworks!

target 'MyApplicationTarget' do
    pod 'Conduit'
end

核心网络

URLSessionClient

URLSessionClient 是 Conduit 的核心。 每个客户端都由一个 URLSession 支持;因此,URLSessionClient 使用可选的 URLSessionConfiguration 和委托队列进行初始化。

// Creates a new URLSessionClient with no persistent cache storage and that fires events on a background queue
let mySessionClient = URLSessionClient(sessionConfiguration: URLSessionConfiguration.ephemeral, delegateQueue: OperationQueue())

URLSessionClient 是一个结构体,这意味着它使用 值语义。 初始化 URLSessionClient 后,任何副本都可以直接修改,而不会影响其他副本。 但是,单个客户端的多个副本将使用相同的网络管道;它们仍然是单个会话的一部分。 换句话说,每个网络会话应该只初始化一次 URLSessionClient

class MySessionClientManager {

    /// Lazy-loaded URLSessionClient used for interacting with the Kittn API 🐱
    static let kittnAPISessionClient: URLSessionClient = {
        return URLSessionClient()
    }()

}

/// Example usage ///

var sessionClient = MySessionClientManager.kittnAPISessionClient

// As a copy, this won't mutate the original copy or any other copies
sessionClient.middleware = [MyCustomMiddleware()]

HTTP 请求 / 响应

如果没有 URLRequest 发送到网络,URLSessionClient 将毫无用处。 为了在单个会话中扩展以适应许多不同的可能传输格式,URLSessionClient 没有序列化或反序列化的概念;相反,我们使用 HTTPRequestBuilderRequestSerializer 完全构建和序列化 URLRequest,然后使用 ResponseDeserializer 手动反序列化响应。

let requestBuilder = HTTPRequestBuilder(url: kittensRequestURL)
requestBuilder.method = .GET
// Can be serialzed via url-encoding, XML, or multipart/form-data
requestBuilder.serializer = JSONRequestSerializer()
// Powerful query string formatting options allow for complex query parameters
requestBuilder.queryStringParameters = [
    "options" : [
        "include" : [
            "fuzzy",
            "fluffy",
            "not mean"
        ],
        "2+2" : 4
    ]
]
requestBuilder.queryStringFormattingOptions.dictionaryFormat = .dotNotated
requestBuilder.queryStringFormattingOptions.arrayFormat = .commaSeparated
requestBuilder.queryStringFormattingOptions.spaceEncodingRule = .replacedWithPlus
requestBuilder.queryStringFormattingOptions.plusSymbolEncodingRule = .replacedWithDecodedPlus

let request = try requestBuilder.build()

let sessionClient = MySessionClientManager.kittnAPISessionClient
sessionClient.begin(request) { (data, response, error) in
    let deserializer = JSONResponseDeserializer()
    let responseDict = try? deserializer.deserialize(response: response, data: data) as? [String : Any]
    ...
}

MultipartFormRequestSerializer 使用预定的 MIME 类型来大大简化 multipart/form-data 请求的构建。

let serializer = MultipartFormRequestSerializer()
let kittenImage = UIImage(named: "jingles")
let kittenImageFormPart = FormPart(name: "kitten", filename: "mr-jingles.jpg", content: .image(kittenImage, .jpeg(compressionQuality: 0.8)))
let pdfFormPart = FormPart(name: "pedigree", filename: "pedigree.pdf", content: .pdf(pedigreePDFData))
let videoFormPart = FormPart(name: "cat-video", filename: "cats.mov", content: .video(catVideoData, .mov))

serializer.append(formPart: kittenImageFormPart)
serializer.append(formPart: pdfFormPart)
serializer.append(formPart: videoFormPart)

requestBuilder.serializer = serializer

XMLRequestSerializerXMLResponseDeserializer 利用项目定义的 XMLXMLNode。 XML 数据会自动解析为可索引和可下标的树。

let requestBodyXMLString = "<?xml version=\"1.0\" encoding=\"utf-8\"?><Request>give me cats</Request>"

requestBuilder.requestSerializer = XMLRequestSerializer()
requestBuilder.method = .POST
requestBuilder.bodyParameters = XML(xmlString: requestBodyXMLString)

中间件

当请求通过 URLSessionClient 发送时,它首先通过可能包含中间件的管道进行串行处理。 每个中间件组件都可以修改请求、取消请求或完全冻结管道。

Network Pipeline Architecture

这可以用于日志记录、代理、授权和实施严格的网络行为。

/// Simple middelware example that logs each outbound request
struct LoggingRequestPipelineMiddleware: RequestPipelineMiddleware {

    public func prepareForTransport(request: URLRequest, completion: @escaping Result<Void>.Block) {
        print("Outbound request: \(request)")
    }

}

mySessionClient.middleware = [LoggingRequestPipelineMiddleware()]

SSL 证书绑定

服务器信任评估内置于 URLSessionClient 中。 ServerAuthenticationPolicy 评估会话身份验证质询。 最常见的服务器身份验证请求是 TLS/SSL 连接的启动,可以使用 SSLPinningServerAuthenticationPolicy 进行验证。

由于单个会话客户端可能与断开连接的第三方主机交互,因此初始化程序需要一个谓词来确定是否应该针对信任链进行绑定。

let sslPinningPolicy = SSLPinningServerAuthenticationPolicy(certificates: CertificateBundle.certificatesInBundle) { challenge in
    // All challenges from other hosts will be ignored and will proceed through normal system evaluation
    return challenge.protectionSpace.host == "api.example.com"
}

mySessionClient.serverAuthenticationPolicies = [sslPinningPolicy]

身份验证

Conduit 实现了 RFC 6749RFC 6750 中的所有主要 OAuth2 流程和复杂性。 这使得 Conduit 成为基于 OAuth2 的 API SDK 的理想基础解决方案。

配置

每个身份验证会话都需要客户端配置,而客户端配置又需要 OAuth2 服务器环境。

guard let tokenGrantURL = URL(string: "https://api.example.com/oauth2/issue/token") else {
    return
}

let scope = "cats dogs giraffes"
let serverEnvironment = OAuth2ServerEnvironment(scope: scope, tokenGrantURL: tokenGrantURL)

let clientID = "my_oauth2_client"
let clientSecret = "shhhh"

let clientConfiguration = OAuth2ClientConfiguration(clientIdentifier: clientID, clientSecret: clientSecret, environment: serverEnvironment)

// Only for convenience for single-client applications; can be managed elsewhere
Auth.defaultClientConfiguration = clientConfiguration

令牌存储

OAuth2 令牌存储允许在令牌授权流程中自动检索/更新。

// Stores user and client tokens to the keychain
let keychainStore = OAuth2KeychainStore(serviceName: "com.company.app-name.oauth-token", accessGroup: "com.company.shared-access-group")

// Stores user and client tokens to UserDefaults or a defined storage location
let diskStore = OAuth2TokenDiskStore(storageMethod: .userDefaults)

// Stores user and client tokens to memory; useful for tests/debugging
let memoryStore = OAuth2TokenMemoryStore()

// Only for convenience for single-client applications; can be managed elsewhere
Auth.defaultTokenStore = keychainStore

令牌授权

OAuth2 令牌授权通过策略处理。 Conduit 支持 RFC 6749 中列出的所有授权类型:passwordclient_credentialsauthorization_coderefresh_token 和自定义扩展授权。

在 Conduit Auth 的许多地方,都需要 OAuth2AuthorizationOAuth2Authorization 是一个简单的结构体,它将客户端授权与用户授权、Bearer 凭据与 Basic 凭据分开。 虽然某些 OAuth2 服务器可能实际上并不将这些视为不同的角色或身份,但它可以对用户敏感数据与客户端敏感数据进行清晰的管理。

当手动创建和使用 OAuth2TokenGrantStrategy(资源所有者流程的常见情况)时,令牌也必须手动存储

// This token grant is most-likely issued on behalf of a user, so the authorization level is "user", and the authorization type is "bearer"
let tokenGrantStrategy = OAuth2PasswordTokenGrantStrategy(username: "user@example.com", password: "hunter2", clientConfiguration: Auth.defaultClientConfiguration)
tokenGrantStrategy.issueToken { result in
    guard case .value(let token) = result else {
        // Handle failure
        return
    }
    let userBearerAuthorization = OAuth2Authorization(type: .bearer, level: .user)
    Auth.defaultTokenStore.store(token: token, for: Auth.defaultClientConfiguration, with: userBearerAuthorization)
    // Handle success
}
// This token grant is issued on behalf of a client, so the authorization level is "client"
let tokenGrantStrategy = OAuth2ClientCredentialsTokenGrantStrategy(clientConfiguration: Auth.defaultClientConfiguration)
tokenGrantStrategy.issueToken { result in
    guard case .value(let token) = result else {
        // Handle failure
        return
    }
    let clientBearerAuthorization = OAuth2Authorization(type: .bearer, level: .client)
    Auth.defaultTokenStore.store(token: token, for: Auth.defaultClientConfiguration, with: clientBearerAuthorization)
    // Handle success
}

对于授权码流程,存在 OAuth2AuthorizationStrategy。 目前,仅存在 iOS Safari 的实现。

// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    OAuth2AuthorizationRedirectHandler.default.authorizationURLScheme = "x-my-custom-scheme"
    // Other setup
    return true
}

func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
    if OAuth2AuthorizationRedirectHandler.default.handleOpen(url) {
        return true
    }
    ...
}
// SampleAuthManager.swift

guard let authorizationBaseURL = URL(string: "https://api.example.com/oauth2/authorize"),
    let redirectURI = URL(string: "x-my-custom-scheme://authorize") else {
    return
}
let authorizationStrategy = OAuth2SafariAuthorizationStrategy(presentingViewController: visibleViewController, authorizationRequestEndpoint: authorizationBaseURL)

var authorizationRequest = OAuth2AuthorizationRequest(clientIdentifier: "my_oauth2_client")
authorizationRequest.redirectURI = redirectURI
authorizationRequest.scope = "cats dogs giraffes"
authorizationRequest.state = "abc123"
authorizationRequest.additionalParameters = [
    "custom_param_1" : "value"
]

authorizationStrategy.authorize(request: authorizationRequest) { result in
    guard case .value(let response) = result else {
        // Handle failure
        return
    }
    if response.state != authorizationRequest.state {
        // We've been attacked! 👽
        return
    }
    let tokenGrantStrategy = OAuth2AuthorizationCodeTokenGrantStrategy(code: response.code, redirectURI: redirectURI, clientConfiguration: Auth.defaultClientConfiguration)

    tokenGrantStrategy.issueToken { result in
        // Store token
        // Handle success/failure
    }
}

身份验证中间件

总而言之,Conduit 提供了中间件,可以处理 OAuth2 客户端涉及的大部分繁琐工作。 这简要概括了 OAuth2RequestPipelineMiddleware 的强大功能

当完全使用时,Conduit 使服务操作非常易于阅读和理解,从所需的参数/编码到所需的精确授权类型和级别

let requestBuilder = HTTPRequestBuilder(url: protectedKittensRequestURL)
requestBuilder.method = .GET
requestBuilder.serializer = JSONRequestSerializer()
requestBuilder.queryStringParameters = [
    "options" : [
        "include" : [
            "fuzzy",
            "fluffy",
            "not mean"
        ],
        "2+2" : 4
    ]
]
requestBuilder.queryStringFormattingOptions.dictionaryFormat = .dotNotated
requestBuilder.queryStringFormattingOptions.arrayFormat = .commaSeparated
requestBuilder.queryStringFormattingOptions.spaceEncodingRule = .replacedWithPlus
requestBuilder.queryStringFormattingOptions.plusSymbolEncodingRule = .replacedWithDecodedPlus

let request = try requestBuilder.build()

let bearerUserAuthorization = OAuth2Authorization(type: .bearer, level: .user)
let authMiddleware = OAuth2RequestPipelineMiddleware(clientConfiguration: Auth.defaultClientConfiguration, authorization: userBearerAuthorization, tokenStorage: Auth.defaultTokenStore)

var sessionClient = MySessionClientManager.kittnAPISessionClient
// Again, this is a copy, so we're free to mutate the middleware within the copy
sessionClient.middleware.append(authMiddleware)

sessionClient.begin(request) { (data, response, error) in
    let deserializer = JSONResponseDeserializer()
    let responseDict = try? deserializer.deserialize(response: response, data: data) as? [String : Any]
    ...
}

示例

此仓库包含一个 iOS 示例,该示例附加到 Conduit.xcworkspace

许可证

在 Apache 2.0 许可证下发布。 有关更多详细信息,请参阅 LICENSE

鸣谢

mindbody-logo

Conduit 归 MINDBODY, Inc. 所有,并由我们的贡献者持续维护。