Papyrus is a type-safe HTTP client for Swift.
It turns 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.
- Turn REST APIs into Swift protocols
- Swift Concurrency or completion handler based APIs
- JSON, URLForm and Multipart encoding
- Easy to configure key mapping
- Sensible parameter defaults so you can write less code
- Automatically decode responses with
Codable
- Custom Interceptors & Builders
- Optional, automatic API mocks for testing
- Out of the box powered by
URLSession
or Alamofire - 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.
Documentation examples use Swift concurrency. Using concurrency is recommended but if you haven't yet migrated and need a closure based API, they are available.
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")
Each API you need to consume is represented by a protocol.
Individual endpoints are represented by the protocol's functions.
The function's parameters and return type represent the request content and response, respectively.
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.
@DELETE("/transfers/:id")
The Path
type replaces a named parameter in the path. Parameters are denoted with a leading :
.
@GET("/users/:username/repos/:id")
func getRepository(username: Path<String>, id: Path<Int>) async throws -> [Repository]
Note that you don't actually need to include the Path<...>
wrapper if the path parameter matches the function parameter. It will be inferred.
@GET("/users/:username/repos/:id")
func getRepository(username: String, id: Int) async throws -> [Repository]
You may set url queries with the Query
type.
@GET("/transactions")
func getTransactions(merchant: Query<String>) async throws -> [Transaction]
You can also set static queries directly in the path string.
@GET("/transactions?merchant=Apple")
For convenience, a unattributed function parameter on a @GET
, @HEAD
, or @DELETE
request is inferred to be a Query
.
@GET("/transactions")
func getTransactions(merchant: String) async throws -> [Transaction]
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 {
@GET("/user")
func getUser() async throws -> User
@PATCH("/user/:id")
func updateUser(id: Int, name: String) async throws
}
For convenience, the @Authorization
attribute can be used to set a static "Authorization"
header.
@Authorization(.basic(username: "joshuawright11", password: "P@ssw0rd"))
protocol Users {
...
}
A variable header can be set with the Header
type.
@GET("/accounts")
func getRepository(customHeader: Header<String>) async throws
Note that variable headers are automatically mapped to Capital-Kebab-Case. In this case, Custom-Header
.
The request body can be set using Body
on a Codable
parameter. A function can only have one Body
parameter.
struct Todo: Codable {
let name: String
let isDone: Bool
let tags: [String]
}
@POST("/todo")
func createTodo(todo: Body<Todo>) async throws
Alternatively, you can set individual fields on the body Field
. These are mutually exclusive with Body
.
@POST("/todo")
func createTodo(name: Field<String>, isDone: Field<Bool>, tags: Field<[String]>) async throws
For convenience, an unattributed parameter on a request that isn't a @GET
, @HEAD
, or @DELETE
is inferred to be a Field
.
@POST("/todo")
func createTodo(name: String, isDone: Bool, tags: [String]) async throws
By default, all Body
and Field
parameters are encoded as application/json
.
You may encode 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 parameters as multipart/form-data
using @Multipart
. If you do, all non-path parameter fields 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 JSON or URLForm encoder, you may pass them as arguments to @JSON
and @URLForm
.
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 represents the response of your endpoint.
Endpoint functions should return a type that conforms to Decodable
. It will automatically be decoded from the HTTP response body using JSON by default.
@GET("/user")
func getUser() async throws -> User
If you only need a response's raw body bytes, you can just return Data
from your function.
@GET("/bytes")
func getBytes() async throws -> Data
The above will throw if the request body is empty, even if the status code is successful. If you don't want to throw in that case, set the response as Data?
; it will successfully return nil if the response body is empty.
@GET("/bytes")
func getBytes() async throws -> Data?
If you don't need to decode something from the response and just want to confirm it was successful, you may leave out the return type.
@DELETE("/logout")
func logout() async throws
To just get the raw response you may set the return type to Response
.
@GET("/user")
func getUser() async throws -> Response
let res = try await users.getUser()
if res.error == nil {
print("The response was successful!")
}
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 error occurs during the request flight, such as an unsuccessful response code, PapyrusError
will be thrown. You can use it to access failed request and response (if present).
@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). 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 requests and responses by using intercept()
. Remember that you'll need 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
}
If you'd like to decouple your request modifier or interceptor logic from the Provider
, you can pass instances of the RequestModifer
and Interceptor
protocols on provider initialization.
let interceptor: Interceptor = ...
let modifier: Interceptor = ...
let provider = Provider(baseURL: "http://localhost:3000", modifiers: [modifier], interceptors: [interceptor])
Swift concurrency is the modern way of running asynchronous code in Swift.
While using it is highly recommended, if you haven't yet migrated to Swift concurrency and need access to a closure based API, you can pass an @escaping
completion handler as the last argument in an endpoint function.
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.
Note that the Path
, Header
, Field
, and Body
types are just typealiases for whever they wrap so you don't need to include them when conforming to the protocol.
@API
protocol GitHub {
@GET("/users/:username/repos")
func getRepositories(username: String, specialHeader: Header<String>) async throws -> [Repository]
}
struct GitHubMock: GitHub {
func getRepositories(username: String, specialHeader: String) async throws -> [Repository] {
return [
Repository(name: "papyrus"),
Repository(name: "alchemy")
]
}
}
You can then use your mock during tests when the protocol is required.
struct CounterService {
let github: GitHub
func countRepositories(of username: String) async throws -> Int {
try await getRepositories(username: username).count
}
}
func testCounting() {
let mock: GitHub = GitHubMock()
let service = MyService(github: mock)
let count = service.countRepositories(of: "joshuawright11")
XCTAssertEqual(count, 2)
}
A mock implementation can be automatically generated with the @Mock
attribute. Like @API
, this generates an implementation of your protocol.
A generated Mock
type has mock
functions to easily verify request parameters and mock their 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.