Papyrus is a type-safe HTTP client for Swift.
It reduces your network boilerplate by turning turns your APIs into clean and concise Swift protocols.
It's Retrofit for Swift!
@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.
- 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
Supports iOS 13+ / macOS 10.15+.
Keep in mind that Papyrus uses macros which require Swift 5.9 / Xcode 15 to compile.
Install Papyrus using the Swift Package Manager, choosing a backing networking library from below.
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
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)
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")
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.
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")
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]
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 queries can be set directly in the path string.
@GET("/transactions?merchant=Apple")
A variable request header can be set with the Header<T>
type. Its 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
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 { ... }
For convenience, the @Authorization
attribute can be used to set a static "Authorization"
header.
@Authorization(.basic(username: "joshuawright11", password: "P@ssw0rd"))
protocol Users {
...
}
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
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
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
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
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
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
}
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 { ... }
The return type of your function tells Papyrus how to handle the endpoint response.
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
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
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
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!)")
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)")
}
}
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
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 {
...
}
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.
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)
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
}
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()])
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)
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)
}
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)
}
👋 Thanks for checking out Papyrus!
If you'd like to contribute please file an issue, open a pull request or start a discussion.
Papyrus was heavily inspired by Retrofit.
Papyrus is released under an MIT license. See License.md for more information.