An experiment with a simple wrapper for accessing JSON/REST web APIs. Inspired by an episode of Swift Talk.
The main aims for this library are:
- Type-safety where appropriate
- Encoding/decoding agnostic
- HTTP client agnostic
Here is an example of it in use.
let service = Service(adapter: YourFancyAdapter(), URL: url)
let comment = Post.show(1) / Comment.show(12)
service.call(comment) { result in
print(result)
}
Yopuy handles the interaction with an API via types which conform to the Resource
protocol. The Resource
protocol, various associatedtype
declarations and other related protocols — e.g. ChildResource
, RootResource
— are used to grant capabilities to these types. Generally these are static functions and properties which return values representing full or partial paths to a resource.
For example, a RootResource
is one that has a path that exists at the root of an API. This adds a number of constraints.
- It cannot belong to a parent resource
- Any path it generates will be at the root of the API
A corresponding protocol is ChildResource
. It has an associatedtype Parent
requirement. This associates a child resource with it's parent. It then guides the type-safe construction of paths e.g. Post.show(1) / Comment.show(12)
By default a Resource
lacks any of the path static functions or properties. These are provided by conforming to protocols. Most of the protocols correspond to a HTTP method.
IsListable
; can be retrieved as a collection e.g.GET /posts
IsShowable
; can be retrieved as a single record e.g.GET /posts/1
IsCreatable
; new resources can be created e.g.POST /posts
IsReplaceable
; a resource can be updated viaPUT
e.g.PUT /posts/1
IsPatchable
; a resource can be updated viaPATCH
e.g.PATCH /posts/1
IsDeletable
; a resource can be deleted viaDELETE
e.g.DELETE /posts/1
Here is an example of a record that can be updated, but not created or deleted.
struct Foo: RootResource, IsPatchable, IsShowable {
// Details elided...
}
All requests in Yopuy are handled by an instance of the Service
. It is HTTP client agnostic by delegating all requests to an adapter, which is defined via the HTTPAdapter
protocol. Here is it's full definition.
public protocol HTTPAdapter {
func perform(_ method: HTTPMethod, request: AdapterRequest, callback: @escaping (AdapterResponse) -> Void)
}
Only one function is required and it's signature is pretty simple at that. The adapter is free to do what ever it needs in the implementation; the only hard-requirement is that it calls the callback
function.
Aside from allowing support of arbitrary HTTP clients, this approach makes testing trivial. It's easy to define a test adapter that returns the required responses.
Yopuy uses a similar approach to integrating the serialisation library of your choice. Depending on how you define your resources, you will need to conform to one or both of the parsing functions; parse(collection:)
and parse(singular:)
. Yopuy will choose the parsing function based on the type of request.
For example, if you conform your resource to the IsShowable
protocol, it must implement parse(singular:)
. Another example is the IsListable
protocol. This requires the parse(collection:)
function.
Within those functions, you are free to implement your parsing however you choose.
This is a simple example of how we can encode a type-safe hierarchy of resources. They don't have any properties defined, since they are only demonstrating how parent and child resources are related. The IsRESTFul
protocol is used to extend the structs with all the HTTP verbs used in a REST API. Also note how the Commenter
struct is extended by a subset of the protocols available.
struct Post: RootResource, IsRESTFul {
typealias ID = Int
typealias Collection = [Post]
typealias Singular = Post
static let path = "posts"
let id: Int
static func parse(collection data: Data) throws -> Collection {
return []
}
static func parse(singular data: Data) throws -> Singular {
return Post(id: 20)
}
}
struct Comment: ChildResource, IsRESTFul {
typealias ID = Int
typealias Parent = Post
typealias Collection = [Comment]
typealias Singular = Comment
static let path = "comments"
let id: Int
static func parse(collection data: Data) throws -> Collection {
return []
}
static func parse(singular data: Data) throws -> Singular {
return Comment(id: 20)
}
}
struct Commenter: ChildResource, IsShowable, IsListable, IsDeletable {
typealias ID = Int
typealias Parent = Comment
typealias Collection = [Commenter]
typealias Singular = Commenter
static let path = "commenter"
let id: Int
static func parse(collection data: Data) throws -> Collection {
return []
}
static func parse(singular data: Data) throws -> Singular {
return Commenter(id: 20)
}
}
let e = Post.show(1) / Comment.show(2) / Commenter.delete(1)
There are a few interesting things about the example above:
- Resources are either
RootResource
orChildResource
which dictates how they can be used - Constructing a path like
Post.show(1) / Commenter.show(5)
is a type-error, sinceCommenter
hasComment
as it's parent - Making
Post
a child is a type error, since it is aRootResource
- Constructing a path with a child resource at the root is also a type-error
- It's configured so that a
create
path can't be constructed for theCommenter
resource - Paths also encode the HTTP verb
- Parsing is entirely delegated to your code; use whatever parsing library you prefer
- The operations on collections and resources are encoded in separate protocols — e.g.
IsDeletable
,IsListable
— meaning you can pick and choose what you include IsRESTful
is just a shortcut for including all theIs*
protocols on a resource- Declaring a resource without the
IsRESTful
orIs*
protocols means they're useless, all methods come from optional protocols - Using typealias means the results of your requests can be arbitrary types e.g.
CustomCollection<Post>