📜 Papyrus
Papyrus is a type-safe HTTP client for Swift.
It drastically reduces network boilerplate by turning your APIs into clean and concise Swift protocols.
@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")
Each endpoint of your API is represented as function on the protocol.
Annotations on the protocol, functions, and parameters help construct requests and decode responses.
Table of Contents
Features
- Turn REST APIs into Swift Protocols
-
async
/await
or Callback APIs - JSON, URLForm and Multipart Encoding Support
- Automatic Key Mapping
- Sensible Parameter Defaults Based on HTTP Verb
- Automatically Decode Responses with
Codable
- Custom Interceptors & Request Builders
- Advanced Error Handling
- Automatic Mocks for Testing
- Powered by
URLSession
or Alamofire Out of the Box - Linux / Swift on Server Support Powered by async-http-client
Getting Started
Requirements
Supports iOS 13+ / macOS 10.15+.
Keep in mind that Papyrus uses macros which require Swift 5.9 / Xcode 15 to compile.
Installation
Install Papyrus using the Swift Package Manager, choosing a backing networking library from below.
URLSession
URLSession
Out of the box, Papyrus is powered by URLSession
.
.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "Papyrus", package: "papyrus")
Alamofire
Alamofire
If you'd prefer to use Alamofire, use the PapyrusAlamofire
product.
.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.6.0")
.product(name: "PapyrusAlamofire", package: "papyrus")
AsyncHTTPClient (Linux)
AsyncHTTPClient (Linux)
If you're using Linux / Swift on Server, use the separate package PapyrusAsyncHTTPClient. It's driven by the swift-nio backed 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")
Requests
You'll represent each of your REST APIs with a protocol.
Individual endpoints are represented by a function on that protocol.
The function's parameters help Papyrus build the request and the return type indicates how to handle the response.
Method and Path
Set the request method and path as an attribute on the function. Available methods are GET
, POST
, PATCH
, DELETE
, PUT
, OPTIONS
, HEAD
, TRACE
, and CONNECT
. Use @HTTP(_ path:method:)
if you need a custom method.
@POST("/accounts/transfers")
Path Parameters
Parameters in the path, marked with a leading :
, will be automatically replaced by matching parameters in the function.
@GET("/users/:username/repos/:id")
func getRepository(username: String, id: Int) async throws -> [Repository]
Query Parameters
Function parameters on a @GET
, @HEAD
, or @DELETE
request are inferred to be a query.
@GET("/transactions") // GET /transactions?merchant=...
func getTransactions(merchant: String) async throws -> [Transaction]
If you need to add query paramters to requests of other HTTP Verbs, mark the parameter with Query<T>
.
@POST("/cards") // POST /cards?username=...
func fetchCards(username: Query<String>) async throws -> [Card]
Static Query Parameters
Static queries can be set directly in the path string.
@GET("/transactions?merchant=Apple")
Headers
A variable request header can be set with the Header<T>
type. It's key will be automatically mapped to Capital-Kebab-Case. e.g. Custom-Header
in the following endpoint.
@GET("/accounts")
func getRepository(customHeader: Header<String>) async throws
Static Headers
You can set static headers on a request using @Headers
at the function or protocol scope.
@Headers(["Cache-Control": "max-age=86400"])
@GET("/user")
func getUser() async throws -> User
@API
@Headers(["X-Client-Version": "1.2.3"])
protocol Users { ... }
Authorization Header
For convenience, the @Authorization
attribute can be used to set a static "Authorization"
header.
@Authorization(.basic(username: "joshuawright11", password: "P@ssw0rd"))
protocol Users {
...
}
Body
Function parameters on a request that isn't a @GET
, @HEAD
, or @DELETE
are inferred to be a field in the body.
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
If you need to explicitly mark a parameter as a body field, use Field<T>
.
@POST("/todo")
func createTodo(name: Field<String>, isDone: Field<Bool>, tags: Field<[String]>) async throws
Body<T>
Aternatively, the entire request body can be set using Body<T>
. An endpoint can only have one Body<T>
parameter and it is mutually exclusive with Field<T>
.
struct Todo: Codable {
let name: String
let isDone: Bool
let tags: [String]
}
@POST("/todo")
func createTodo(todo: Body<Todo>) async throws
Body Encoding
By default, all Body
and Field
parameters are encoded as application/json
. You can encode with a custom JSONEncoder
using the @JSON
attribute.
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
You may encode body parameters as application/x-www-form-urlencoded
using @URLForm
.
@URLForm
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
Multipart
You can also encode body parameters as multipart/form-data
using @Multipart
. If you do, all body parameters must be of type Part
.
@Multipart
@POST("/attachments")
func uploadAttachments(file1: Part, file2: Part) async throws
Global Encoding
You can attribute your protocol with an encoding attribute to encode all requests as such.
@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
}
Custom Body Encoders
If you'd like to use a custom encoder, you may pass them as arguments to @JSON
, @URLForm
and @Multipart
.
extension JSONEncoder {
static var iso8601: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}
@JSON(encoder: .iso8601)
protocol Todos { ... }
Responses
The return type of your function tells Papyrus how to handle the endpoint response.
Decodable
If your function returns a type conforming to Decodable
, Papyrus will automatically decode it from the response body using JSONDecoder
.
@GET("/user")
func getUser() async throws -> User
Data
If you only need a response's raw body bytes, you can just return Data?
or Data
from your function.
@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
If you just want to confirm the response was successful and don't need to access the body, you may leave out the return type.
@DELETE("/logout")
func logout() async throws
Response
If you want the raw response data, e.g. to access headers, set the return type to Response
.
@GET("/user")
func getUser() async throws -> Response
let res = try await users.getUser()
print("The response had headers \(res.headers)")
If you'd like to automatically decode a type AND access the Response
, you may return a tuple with both.
@GET("/user")
func getUser() async throws -> (User, Response)
let (user, res) = try await users.getUser()
print("The response status code was: \(res.statusCode!)")
Error Handling
If any errors occur while making a request, a PapyrusError
will be thrown. Use it to access any Request
and Response
associated with the error.
@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)")
}
}
Advanced
Parameter Labels
If you use two labels for a function parameter, the second one will be inferred as the relevant key.
@GET("/posts/:postId")
func getPost(id postId: Int) async throws -> Post
Key Mapping
Often, you'll want to encode request fields and decode response fields using something other than camelCase. Instead of setting a custom key for each individual attribute, you can use @KeyMapping
at the function or protocol level.
Note that this affects Query
, Body
, and Field
parameters on requests as well as decoding content from the Response
.
@API
@KeyMapping(.snakeCase)
protocol Todos {
...
}
Access Control
When you use @API
or @Mock
, Papyrus will generate an implementation named <protocol>API
or <protocol>Mock
respectively. The access level will match the access level of the protocol.
Request Modifiers
If you'd like to manually run custom request build logic before executing any request on a provider, you may use the modifyRequests()
function.
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)
Interceptors
You may also inspect a Provider
's raw Request
s and Response
s using intercept()
. Make sure to call the second closure parameter if you want the request to continue.
let provider = Provider(baseURL: "http://localhost: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
protocols
You can isolate request modifier and interceptor logic to a specific type for use across multiple Provider
s using the RequestModifer
and Interceptor
protocols. Pass them to a Provider
's initializer.
struct MyRequestModifier: RequestModifier { ... }
struct MyInterceptor: Interceptor { ... }
let provider = Provider(baseURL: "http://localhost:3000", modifiers: [MyRequestModifier()], interceptors: [MyInterceptor()])
Callback APIs
Swift concurrency is the modern way of running asynchronous code in Swift.
If you haven't yet migrated to Swift concurrency and need access to a callback based API, you can pass an @escaping
completion handler as the last argument in your endpoint functions.
The function must have no return type and the closure must have a single argument of type Result<T: Codable, Error>
, Result<Void, Error>
, or Response
argument.
// 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)
Testing
Because APIs defined with Papyrus are protocols, they're simple to mock in tests; just implement the protocol.
If you use Path<T>
, Header<T>
, Field<T>
, or Body<T>
types, you don't need to include them in your protocol conformance. They are just typealiases used to hint Papyrus how to use the parameter.
@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"),
]
}
}
You can then use your mock during tests when the protocol is required.
func testCounting() {
let mock: GitHub = GitHubMock()
let service = MyService(github: mock)
let count = service.countRepositories(of: "joshuawright11")
XCTAssertEqual(count, 3)
}
@Mock
For convenience, you can leverage macros to automatically generated mocks using @Mock
. Like @API
, this generates an implementation of your protocol.
The generated Mock
type has mock
functions to easily verify request parameters and mock responses.
@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)
}
Contribution
👋 Thanks for checking out Papyrus!
If you'd like to contribute please file an issue, open a pull request or start a discussion.
Acknowledgements
Papyrus was heavily inspired by Retrofit.
License
Papyrus is released under an MIT license. See License.md for more information.