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 的每个端点都表示为协议上的一个函数。
协议、函数和参数上的注解有助于构建请求和解码响应。
async
/await
_或者_ 回调 APICodable
自动解码响应URLSession
或 Alamofire 提供支持支持 iOS 13+ / macOS 10.15+。
请记住,Papyrus 使用 macros,需要 Swift 5.9 / Xcode 15 才能编译。
使用 Swift Package Manager 安装 Papyrus,并从下面选择一个后端网络库。
Papyrus 开箱即用地由 URLSession
提供支持。
.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "Papyrus", package: "papyrus")
如果你更喜欢使用 Alamofire,请使用 PapyrusAlamofire
产品。
.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "PapyrusAlamofire", package: "papyrus")
如果你使用的是 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 构建请求,并且 _返回类型_ 指示如何处理响应。
在函数上设置请求方法和路径作为属性。 可用的方法有 GET
、POST
、PATCH
、DELETE
、PUT
、OPTIONS
、HEAD
、TRACE
和 CONNECT
。 如果你需要自定义方法,请使用 @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")
可以使用 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(.basic(username: "joshuawright11", password: "P@ssw0rd"))
protocol Users {
...
}
_不是_ @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。 一个端点只能有一个 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
和 Field
参数都编码为 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
将 body 参数编码为 application/x-www-form-urlencoded
。
@URLForm
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
你还可以使用 @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
}
如果你想使用自定义编码器,你可以将它们作为参数传递给 @JSON
、@URLForm
和 @Multipart
。
extension JSONEncoder {
static var iso8601: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}
@JSON(encoder: .iso8601)
protocol Todos { ... }
函数的返回类型告诉 Papyrus 如何处理端点响应。
如果你的函数返回符合 Decodable
的类型,Papyrus 将使用 JSONDecoder
从响应 body 自动解码它。
@GET("/user")
func getUser() async throws -> User
如果你只需要响应的原始 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
如果你只想确认响应成功,而不需要访问 body,你可以省略返回类型。
@DELETE("/logout")
func logout() async throws
如果你想要原始响应数据,例如访问标头,请将返回类型设置为 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
。 使用它可以访问与错误关联的任何 Request
和 Response
。
@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
,而不是为每个单独的属性设置自定义键。
请注意,这会影响请求上的 Query
、Body
和 Field
参数,以及从 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
的原始 Request
和 Response
。 如果你希望请求继续,请确保调用第二个闭包参数。
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
协议将请求修饰符和拦截器逻辑隔离到特定类型,以便在多个 Provider
中使用。 将它们传递给 Provider
的初始化程序。
struct MyRequestModifier: RequestModifier { ... }
struct MyInterceptor: Interceptor { ... }
let provider = Provider(baseURL: "https://:3000", modifiers: [MyRequestModifier()], interceptors: [MyInterceptor()])
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)
}
为了方便起见,你可以利用 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。