📜 Papyrus

Swift Version Latest Release License

Papyrus 是一个用于 Swift 的类型安全的 HTTP 客户端。

它将你的 API 转换为简洁的 Swift 协议,从而减少你的网络样板代码。

它是 Swift 版本的 Retrofit!

@API
@Authorization(.bearer("<my-auth-token>"))
protocol Users {
    @GET("/user")
    func getUser() async throws -> User

    @POST("/user")
    func createUser(email: String, password: String) async throws -> User

    @GET("/users/:username/todos")
    func getTodos(username: String) async throws -> [Todo]
}
let provider = Provider(baseURL: "https://api.example.com/")
let users: Users = UsersAPI(provider: provider)
let todos = try await users.getTodos(username: "joshuawright11")

你的 API 的每个端点都表示为协议上的一个函数。

协议、函数和参数上的注解有助于构建请求和解码响应。

目录

  1. 特性
  2. 入门指南
  3. 请求
  4. 响应
  5. 高级
  6. 测试
  7. 致谢
  8. 许可证

特性

入门指南

要求

支持 iOS 13+ / macOS 10.15+。

请记住,Papyrus 使用 macros,需要 Swift 5.9 / Xcode 15 才能编译。

安装

使用 Swift Package Manager 安装 Papyrus,并从下面选择一个后端网络库。

URLSession

URLSession

Papyrus 开箱即用地由 URLSession 提供支持。

.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "Papyrus", package: "papyrus")
Alamofire

Alamofire

如果你更喜欢使用 Alamofire,请使用 PapyrusAlamofire 产品。

.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "PapyrusAlamofire", package: "papyrus")
AsyncHTTPClient (Linux)

AsyncHTTPClient (Linux)

如果你使用的是 Linux / 服务器端 Swift,请使用单独的包 PapyrusAsyncHTTPClient。 它由 swift-nio 支持的 async-http-client 驱动。

.package(url: "https://github.com/joshuawright11/papyrus-async-http-client.git", from: "0.2.0")
.product(name: "PapyrusAsyncHTTPClient", package: "papyrus-async-http-client")

请求

你将使用一个 _协议_ 来表示你的每个 REST API。

单独的端点由该协议上的一个 _函数_ 表示。

函数的 _参数_ 帮助 Papyrus 构建请求,并且 _返回类型_ 指示如何处理响应。

方法和路径

在函数上设置请求方法和路径作为属性。 可用的方法有 GETPOSTPATCHDELETEPUTOPTIONSHEADTRACECONNECT。 如果你需要自定义方法,请使用 @HTTP(_ path:method:)

@POST("/accounts/transfers")

路径参数

路径中的参数(以 : 开头)将自动替换为函数中的匹配参数。

@GET("/users/:username/repos/:id")
func getRepository(username: String, id: Int) async throws -> [Repository]

查询参数

@GET@HEAD@DELETE 请求上的函数参数将被推断为查询参数。

@GET("/transactions") // GET /transactions?merchant=...
func getTransactions(merchant: String) async throws -> [Transaction]

如果需要将查询参数添加到其他 HTTP 动词的请求,请使用 Query<T> 标记该参数。

@POST("/cards") // POST /cards?username=...
func fetchCards(username: Query<String>) async throws -> [Card]

静态查询参数

静态查询可以直接在路径字符串中设置。

@GET("/transactions?merchant=Apple")

Headers

可以使用 Header<T> 类型设置可变的请求标头。 它的键将自动映射到 Capital-Kebab-Case。 例如,以下端点中的 Custom-Header

@GET("/accounts")
func getRepository(customHeader: Header<String>) async throws

静态标头

你可以使用函数或协议范围内的 @Headers 在请求上设置静态标头。

@Headers(["Cache-Control": "max-age=86400"])
@GET("/user")
func getUser() async throws -> User
@API
@Headers(["X-Client-Version": "1.2.3"])
protocol Users { ... }

Authorization 标头

为方便起见,可以使用 @Authorization 属性来设置静态的 "Authorization" 标头。

@Authorization(.basic(username: "joshuawright11", password: "P@ssw0rd"))
protocol Users {
    ...
}

Body

_不是_ @GET@HEAD@DELETE 的请求上的函数参数将被推断为 body 中的字段。

@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws

如果需要显式地将参数标记为 body 字段,请使用 Field<T>

@POST("/todo")
func createTodo(name: Field<String>, isDone: Field<Bool>, tags: Field<[String]>) async throws

Body<T>

或者,可以使用 Body<T> 设置整个请求 body。 一个端点只能有一个 Body<T> 参数,并且它与 Field<T> 互斥。

struct Todo: Codable {
    let name: String
    let isDone: Bool
    let tags: [String]
}

@POST("/todo")
func createTodo(todo: Body<Todo>) async throws

Body 编码

默认情况下,所有 BodyField 参数都编码为 application/json。 你可以使用 @JSON 属性使用自定义 JSONEncoder 进行编码。

extension JSONEncoder {
    static var iso8601: JSONEncoder {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }
}

@JSON(encoder: .iso8601)
@POST("/user")
func createUser(username: String, password: String) async throws
URLForm

你可以使用 @URLForm 将 body 参数编码为 application/x-www-form-urlencoded

@URLForm
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
Multipart

你还可以使用 @Multipart 将 body 参数编码为 multipart/form-data。 如果这样做,所有 body 参数必须是 Part 类型。

@Multipart
@POST("/attachments")
func uploadAttachments(file1: Part, file2: Part) async throws
全局编码

你可以使用编码属性来标注你的协议,以将所有请求编码为该类型。

@API
@URLForm
protocol Todos {
    @POST("/todo")
    func createTodo(name: String, isDone: Bool, tags: [String]) async throws

    @PATCH("/todo/:id")
    func updateTodo(id: Int, name: String, isDone: Bool, tags: [String]) async throws
}
自定义 Body 编码器

如果你想使用自定义编码器,你可以将它们作为参数传递给 @JSON@URLForm@Multipart

extension JSONEncoder {
    static var iso8601: JSONEncoder {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }
}

@JSON(encoder: .iso8601)
protocol Todos { ... }

响应

函数的返回类型告诉 Papyrus 如何处理端点响应。

Decodable

如果你的函数返回符合 Decodable 的类型,Papyrus 将使用 JSONDecoder 从响应 body 自动解码它。

@GET("/user")
func getUser() async throws -> User

Data

如果你只需要响应的原始 body 字节,你可以从你的函数返回 Data?Data

@GET("/bytes")
func getBytes() async throws -> Data?

@GET("/image")
func getImage() async throws -> Data // this will throw an error if `GET /image` returns an empty body

Void

如果你只想确认响应成功,而不需要访问 body,你可以省略返回类型。

@DELETE("/logout")
func logout() async throws

Response

如果你想要原始响应数据,例如访问标头,请将返回类型设置为 Response

@GET("/user")
func getUser() async throws -> Response

let res = try await users.getUser()
print("The response had headers \(res.headers)")

如果你想自动解码一个类型 _并且_ 访问 Response,你可以返回一个同时包含两者的元组。

@GET("/user")
func getUser() async throws -> (User, Response)

let (user, res) = try await users.getUser()
print("The response status code was: \(res.statusCode!)")

错误处理

如果在发出请求时发生任何错误,将抛出 PapyrusError。 使用它可以访问与错误关联的任何 RequestResponse

@GET("/user")
func getUser() async throws -> User

do {
    let user = try await users.getUser()
} catch {
    if let error = error as? PapyrusError {
        print("Error making request \(error.request): \(error.message). Response was: \(error.response)")
    }
}

高级

参数标签

如果你为函数参数使用两个标签,则第二个标签将被推断为相关键。

@GET("/posts/:postId")
func getPost(id postId: Int) async throws -> Post

键映射

通常,你希望使用 camelCase 以外的其他内容来编码请求字段和解码响应字段。 你可以使用函数或协议级别的 @KeyMapping,而不是为每个单独的属性设置自定义键。

请注意,这会影响请求上的 QueryBodyField 参数,以及从 Response 解码内容。

@API
@KeyMapping(.snakeCase)
protocol Todos {
    ...
}

访问控制

当你使用 @API@Mock 时,Papyrus 将生成一个名为 <protocol>API<protocol>Mock 的实现。 访问级别 将与协议的访问级别匹配。

请求修饰符

如果你想在执行 provider 上的任何请求之前手动运行自定义请求构建逻辑,你可以使用 modifyRequests() 函数。

let provider = Provider(baseURL: "https://sandbox.plaid.com")
    .modifyRequests { (req: inout RequestBuilder) in
        req.addField("client_id", value: "<client-id>")
        req.addField("secret", value: "<secret>")
    }
let plaid: Plaid = PlaidAPI(provider: provider)

拦截器

你还可以使用 intercept() 检查 Provider 的原始 RequestResponse。 如果你希望请求继续,请确保调用第二个闭包参数。

let provider = Provider(baseURL: "https://:3000")
    .intercept { req, next in
        let start = Date()
        let res = try await next(req)
        let elapsedTime = String(format: "%.2fs", Date().timeIntervalSince(start))
        // Got a 200 for GET /users after 0.45s
        print("Got a \(res.statusCode!) for \(req.method) \(req.url!.relativePath) after \(elapsedTime)")
        return res
    }

RequestModifer & Interceptor 协议

你可以使用 RequestModiferInterceptor 协议将请求修饰符和拦截器逻辑隔离到特定类型,以便在多个 Provider 中使用。 将它们传递给 Provider 的初始化程序。

struct MyRequestModifier: RequestModifier { ... }
struct MyInterceptor: Interceptor { ... }
let provider = Provider(baseURL: "https://:3000", modifiers: [MyRequestModifier()], interceptors: [MyInterceptor()])

回调 API

Swift 并发 是在 Swift 中运行异步代码的现代方式。

如果你还没有迁移到 Swift 并发并且需要访问基于回调的 API,你可以将 @escaping 完成处理程序作为端点函数的最后一个参数传递。

该函数必须没有返回类型,并且闭包必须具有 Result<T: Codable, Error>Result<Void, Error>Response 类型的单个参数。

// equivalent to `func getUser() async throws -> User`
@GET("/user")
func getUser(callback: @escaping (Result<User, Error>) -> Void)

// equivalent to `func createUser(email: String, password: String) async throws`
@POST("/user")
func createUser(email: String, password: String, completion: @escaping (Result<Void, Error>) -> Void)

// equivalent to `func getResponse() async throws -> Response`
@GET("/response")
func getResponse(completion: @escaping (Response) -> Void)

测试

由于使用 Papyrus 定义的 API 是协议,因此它们很容易在测试中进行模拟; 只需实现该协议即可。

如果使用 Path<T>Header<T>Field<T>Body<T> 类型,则无需将它们包含在协议一致性中。 它们只是用于提示 Papyrus 如何使用该参数的类型别名。

@API
protocol GitHub {
    @GET("/users/:username/repos")
    func getRepositories(username: String) async throws -> [Repository]
}

struct GitHubMock: GitHub {
    func getRepositories(username: String) async throws -> [Repository] {
        return [
            Repository(name: "papyrus"),
            Repository(name: "alchemy"),
            Repository(name: "fusion"),
        ]
    }
}

然后,你可以在需要协议时在测试期间使用你的模拟。

func testCounting() {
    let mock: GitHub = GitHubMock()
    let service = MyService(github: mock)
    let count = service.countRepositories(of: "joshuawright11")
    XCTAssertEqual(count, 3)
}

@Mock

为了方便起见,你可以利用 macros 使用 @Mock 自动生成 mocks。 像 @API 一样,这将生成协议的实现。

生成的 Mock 类型具有 mock 函数,可以轻松验证请求参数和模拟响应。

@API  // Generates `GitHubAPI: GitHub`
@Mock // Generates `GitHubMock: GitHub`
protocol GitHub {
    @GET("/users/:username/repos")
    func getRepositories(username: String) async throws -> [Repository]
}

func testCounting() {
    let mock = GitHubMock()
    mock.mockGetRepositories { username in
        XCTAssertEqual(username, "joshuawright11")
        return [
            Repository(name: "papyrus"),
            Repository(name: "alchemy")
        ]
    }

    let service = MyService(github: mock)
    let count = service.countRepositories(of: "joshuawright11")
    XCTAssertEqual(count, 2)
}

贡献

👋 感谢你查看 Papyrus!

如果你想贡献,请 提交 issue打开一个 pull request开始讨论

致谢

Papyrus 的灵感很大程度上来自 Retrofit

许可证

Papyrus 在 MIT 许可证下发布。 有关更多信息,请参阅 License.md