/papyrus

A type-safe HTTP client for Swift.

Primary LanguageSwiftMIT LicenseMIT

📜 Papyrus

Swift Version Latest Release License

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.

Table of Contents

  1. Features
  2. Getting Started
  3. Requests
  4. Responses
  5. Advanced
  6. Testing
  7. Acknowledgements
  8. License

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 Requests and Responses 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 Providers 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.