Papyrus is a type-safe HTTP client for Swift.
It turns your APIs into clean and concise Swift protocols.
@API
protocol GitHub {
@GET("/users/:username/repos")
func getRepositories(username: String) async throws -> [Repository]
}let provider = Provider(baseURL: "https://api.github.com/")
let github: GitHub = GitHubAPI(provider: provider)
let repos = try await github.getRepositories(username: "alchemy-swift")Each function on your protocol represents an endpoint on your API.
Annotations on the protocol, functions, and parameters help construct requests and decode responses.
@API
@Authorization(.bearer("<my-auth-token>"))
protocol Users {
@GET("/user")
func getUser() async throws -> User
@URLForm
@POST("/user")
func createUser(email: String, password: String) async throws -> User
}- Turn REST APIs into Swift protocols
- Generate 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
- Generate mock APIs for testing
- iOS / macOS support powered by Alamofire
- 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.
While using concurrency is recommended, if you haven't yet migrated and need a closure based API, see the section on closure based APIs.
Out of the box, Papyrus is powered by Alamofire.
If you're using Linux / Swift on Server, use PapyrusAsyncHTTPClient which is driven by the swift-nio backed async-http-client.
You can install Papyrus using the Swift Package Manager.
dependencies: [
.package(url: "https://github.com/joshuawright11/papyrus.git", from: "0.5.2")
]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 throwsNote 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 throwsAlternatively, 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 throwsFor 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 throwsBy 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 throwsYou 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 throwsYou 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 -> UserIf 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 throwsNote that if the function return type is Codable or empty, any error that occurs during the request flight, such as an unsuccessful response code, will be thrown.
To just get the raw response you may set the return type to Response.
Note that in this case, errors that occur during the flight of the request will NOT be thrown so you should check the Response.error property before assuming it was successful.
@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 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 -> PostOften, 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.
Under the hood, Papyrus uses Alamofire to make requests. If you'd like to use a custom Alamofire Session for making requests, pass it in when initializing a Provider.
let customSession: Session = ...
let provider = Provider(baseURL: "https://api.github.com", session: customSession)
let github: GitHub = GitHubAPI(provider: provider)If needbe, you can also access the under-the-hood Alamofire and URLSession objects on a Response.
let response: Response = ...
let afResponse: DataResponse<Data, AFError> = response.alamofire
let urlResponse: HTTPURLResponse = response.request!
let urlRequest: URLRequest = response.response!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.