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 使用 宏,需要 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 on Server,请使用单独的包 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
的请求上的函数参数被推断为正文中的字段。
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
如果你需要显式地将参数标记为正文字段,请使用 Field<T>
。
@POST("/todo")
func createTodo(name: Field<String>, isDone: Field<Bool>, tags: Field<[String]>) async throws
或者,可以使用 Body<T>
设置整个请求正文。一个端点只能有一个 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
将正文参数编码为 application/x-www-form-urlencoded
。
@URLForm
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
你还可以使用 @Multipart
将正文参数编码为 multipart/form-data
。如果这样做,所有正文参数都必须是 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
从响应正文自动解码它。
@GET("/user")
func getUser() async throws -> User
如果你只需要响应的原始正文字节,你可以只从你的函数返回 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
如果你只是想确认响应成功并且不需要访问正文,你可以省略返回类型。
@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
的实现。 访问级别 将匹配协议的访问级别。
如果你想在提供程序上执行任何请求之前手动运行自定义请求构建逻辑,你可以使用 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"),
]
}
}
然后,你可以在需要协议时在测试期间使用你的 mock 对象。
func testCounting() {
let mock: GitHub = GitHubMock()
let service = MyService(github: mock)
let count = service.countRepositories(of: "joshuawright11")
XCTAssertEqual(count, 3)
}
为了方便起见,你可以利用宏使用 @Mock
自动生成 mock 对象。 像 @API
一样,这会生成协议的实现。
生成的 Mock
类型具有 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。