/papyrus

A type-safe HTTP interface for Swift.

Primary LanguageSwift

Papyrus

A type-safe HTTP interface for Swift.

It leverages Codable and Property Wrappers for creating network APIs that are easy to read, easy to consume and even easy to provide. When shared between a Swift client and server, it enforces type safety when requesting and handling HTTP requests.

Installation

You may add Papyrus via the Swift Package Manager.

.package(url: "https://github.com/alchemy-swift/alchemy", .upToNextMinor(from: "0.1.0"))

Shared Library

If you're sharing code between clients and servers with a Swift library, you can add Papyrus as a dependency to that library via SPM.

// in your Package.swift

dependencies: [
    .package(url: "https://github.com/alchemy-swift/alchemy", .upToNextMinor(from: "0.1.0"))
    ...
],
targets: [
    .target(name: "MySharedLibrary", dependencies: [
        .product(name: "Papyrus", package: "alchemy"),
    ]),
]

Client

If you want to define or request Papyrus APIs on a Swift client (iOS, macOS, etc) you'll add PapyrusAlamofire as a dependency via SPM. This is a light wrapper around Papyrus with support for requesting endpoints with Alamofire.

Since Xcode manages the Package.swift for iOS and macOS targets, you can add PapyrusAlamofire as a dependency through File -> Swift Packages -> Add Package Dependency -> paste https://github.com/alchemy-swift/papyrus-alamofire -> check PapyrusAlamofire to import.

Usage

Papyrus is used to define, request, and provide HTTP endpoints.

Defining APIs

Basics

A single endpoint is defined with the Endpoint<Request, Response> type.

Endpoint.Request represents the data needed to make this request, and Endpoint.Response represents the expected return data from this request. Note that Request must conform to some RequestConvertible and Response must conform to Codable.

Define an Endpoint on an enclosing EndpointGroup subclass, and wrap it with a property wrapper representing it's HTTP method and path, relative to a base URL.

class TodosAPI: EndpointGroup {
    @GET("/todos")
    var getAll: Endpoint<GetTodosRequest, [TodoDTO]>

    struct GetTodosRequest: RequestComponents {
        @URLQuery
        var limit: Int

        @URLQuery
        var incompleteOnly: Bool
    }

    struct TodoDTO: Codable {
        var name: String
        var isComplete: Bool
    }
}

Notice a few things about the getAll endpoint.

  1. The @GET("/todos") indicates that the endpoint is at POST {some_base_url}/todos.
  2. The endpoint expects a request object of GetUsersRequest which conforms to RequestConvertible and contains two properties, wrapped by @URLQuery. The URLQuery wrappers indicate data that's expected in the query url of the request. This lets requesters of this endpoint know that the endpoint needs two query values, limit and incompleteOnly. It also lets the providers of this endpoint know that incoming requests to GET /todo will contain two items in their query URLs; limit and incompleteOnly.
  3. The endpoint has a response type of [TodoDTO], defined below it. This lets clients know what response type to expect and lets providers know what response type to return.

This gives anyone reading or using the API all the information they would need to interact with it.

Requesting this endpoint might look like

GET {some_base_url}/todos?limit=1&incompleteOnly=0 

While a response would look like

[
    {
        "name": "Do laundry",
        "isComplete": false
    },
    {
        "name": "Learn Alchemy",
        "isComplete": true
    },
    {
        "name": "Be awesome",
        "isComplete": true
    },
]

Note: The DTO suffix of TodoDTO stands for Data Transfer Object, indicating that this type represents some data moving across the wire. It is not necesssary, but helps differentiate from local Todo model types that may exist on either client or server.

Supported Methods

Out of the box, Papyrus provides @GET, @POST, @PUT, @PATCH, @DELETE as well as a @CUSTOM("OPTIONS", "/some/path") that can take any method string for defining your Endpoints.

Empty Request or Reponse

If you're endpoint doesn't have any request or response data that needs to be parsed, you may define the Request or Response type to be Empty.

class SomeAPI: EndpointGroup {
    @GET("/foo")
    var noRequest: Endpoint<Empty, SomeResponse>

    @POST("/bar")
    var noResponse: Endpoint<SomeRequest, Empty>
}

Custom Request Data

Like @URLQuery, there are other property wrappers to define where on an HTTP request data should be.

Each wrapper denotes a value in the request at the proper location with a key of the name of the property. For example @Header var someHeader: String indicates requests to this endpoint should have a header named someHeader.

Note: @Body ignore's its property name and instead encodes it's value into the entire request body.

URLQuery

@URLQuery can wrap a Bool, String, String?, Int, Int? or [String].

Optional properties with nil values will be omitted.

class SomeAPI: EndpointGroup {
    // There will be a query1, query3 and optional query2 in the request URL.
    @GET("/foo")
    var queryRequest: Endpoint<QueryRequest, Empty>
}

struct QueryRequest: RequestComponents {
    @URLQuery var query1: String
    @URLQuery var query2: String?
    @URLQuery var query3: Int
}
Header

@Header can wrap a String. It indicates that there should be a header of name {propertyName} on the request.

class SomeAPI: EndpointGroup {
    @POST("/foo")
    var foo: Endpoint<HeaderRequest, Empty>
}

/// Defines a header "someHeader" on the request.
struct HeaderRequest: RequestComponents {
    @Header var someHeader: String
}
Path Parameters

@Path can wrap a String. It indicates a dynamic path parameter at :{propertyName} in the request path.

class SomeAPI: EndpointGroup {
    @POST("/some/:someID/value")
    var foo: Endpoint<PathRequest, Empty>
}

struct PathRequest: RequestComponents {
    @Path var someID: String
}
Body

@Body can wrap any Codable type which will be encoded to the request. By default, the body is encoded as JSON, but you may override RequestConvertible.contentEncoding to use another encoding type.

class SomeAPI: EndpointGroup {
    @POST("/json")
    var json: Endpoint<JSONBody, Empty>

    @GET("/url")
    var json: Endpoint<URLEncodedBody, Empty>
}

/// Will encode `BodyData` in the request body.
struct JSONBody: RequestComponents {
    @Body var body: BodyData
}

/// Will encode `BodyData` in the request URL.
struct URLEncodedBody: RequestComponents {
    static let contentEncoding = .url

    @Body var body: BodyData
}

struct BodyData: Codable {
    var foo: String
    var baz: Int
}
Combinations

You can combine any number of these property wrappers, except for @Body. There can only be a single @Body per request.

struct MyCustomRequest: RequestComponents {
    struct SomeCodable: Codable {
        ...
    }

    @Body var bodyData: SomeCodable

    @Header var someHeader: String

    @Path var userID: String

    @URLQuery var query1: Int
    @URLQuery var query2: String
    @URLQuery var query3: String?
    @URLQuery var query3: [String]
}

Requesting APIs

Papyrus can be used to request endpoints on client or server targets.

To request an endpoint, create the EndpointGroup with a baseURL and call request on a specific endpoint, providing the needed Request type.

Requesting the the TodosAPI.getAll endpoint from above looks similar on both client and server.

// `import PapyrusAlamofire` on client
import Alchemy

let todosAPI = TodosAPI(baseURL: "http://localhost:8888")
todosAPI.getAll
    .request(.init(limit: 50, incompleteOnly: true)) { response, todoResult in
        switch todoResult {
        case .success(let todos):
            for todo in todos {
                print("Got todo: \(todo.name)")
            }
        case .failure(let error):
            print("Got error: \(error).")
        }
    }

This would make a request that looks like:

GET http://localhost:8888/todos?limit=50&incompleteOnly=false

While the APIs are built to look similar, the client and server implementations sit on top of different HTTP libraries and are customizable in separate ways.

Client, via Alamofire

Requesting an Endpoint client side is built on top of Alamofire. By default, requests are run on Session.default, but you may provide a custom Session for any customization, interceptors, etc.

Server, via AsyncHTTPClient

Request an Endpoint in an Alchemy server is built on top of AsyncHTTPClient. By default, requests are run on Services.client, but you may provide a custom HTTPClient.

Providing APIs

Alchemy contains convenient extensions for registering your Endpoints on a Router. Use .on to register an Endpoint to a router.

let todos = TodosAPI()
router.on(todos.getAll) { (request: Request, data: GetTodosRequest) in
    // when a request to `GET /todos` is handled, the `GetTodosRequest` properties will be loaded from the `Alchemy.Request`.
}

This will automatically parse the relevant GetTodosRequest data from the right places (URL query, headers, body, path parameters) on the incoming request. In this case, "limit" & "incompleteOnly" from the request query String.

If expected data is missing, a 400 is thrown describing the missing expected fields:

400 Bad Request
{
    "message": "expected query value `limit`"
}