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(@Path 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(@Field email: String, @Field 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
- 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 (currently in beta) 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", branch: "main")
]
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
attribute replaces a named parameter in the path. Parameters are denoted with a leading :
.
@GET("/users/:username/repos/:id")
func getRepository(@Path userId: Int, @Path id: Int) async throws -> [Repository]
You may set url queries with the @Query
parameter.
@GET("/transactions")
func getTransactions(@Query merchant: String) async throws -> [Transaction]
You can also set static queries directly in the path string.
@GET("/transactions?merchant=Apple")
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(@Path 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
attribute.
@GET("/accounts")
func getRepository(@Header customHeader: String) async throws
Note that variable headers are automatically mapped to Capital-Kebab-Case. In this case, Custom-Header
. If you'd like to set a different header key, see the section on Custom Keys.
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(@Body todo: Todo) async throws
Alternatively, you can set individual fields on the body @Field
. These are mutually exclusive with @Body
.
@POST("/todo")
func createTodo(@Field name: String, @Field isDone: Bool, @Field tags: [String]) async throws
For convenience, a parameter with no attribute is treated as 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 fields must be of type Part
.
@Multipart
@POST("/attachments")
func uploadAttachments(file1: Part, file2: Part) async throws
For convenience, 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(@Path 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 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
Note 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(@Path id postId: Int) async throws -> Post
If you'd like a custom key for @Path
, @Header
, @Field
or @Query
, you can add a parameter to the attribute.
@GET("/repositories/:id")
func getRepository(@Path("id") repositoryId: Int) async throws -> Repository
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 customize the name of the generated type, you may pass an argument to @API
or @Mock
.
@API("RedditAPI") // Generates `public struct RedditAPI: RedditService`
@Mock("RedditMock") // Generates `public struct RedditMock: RedditService`
public protocol RedditService {
...
}
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 you don't need to include any attributes when conforming to the protocol.
@API
protocol GitHub {
@GET("/users/:username/repos")
func getRepositories(@Path username: String) async throws -> [Repository]
}
struct GitHubMock: GitHub {
func getRepositories(username: 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)
}
For your convenience, a mock implementation can be automatically generated with the @Mock
attribute. Like @API
, this generates an implementation of your protocol.
In addition to conforming to 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(@Path 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.