Vox is a Swift JSONAPI standard implementation. 🔌
🔜 More stable version (written in Swift 5) coming soon.
Vox combines Swift with Objective-C dynamism and C selectors. During serialization and deserialization JSON is not mapped to resource object(s). Instead, it uses Marshalling and Unmarshalling techniques to deal with direct memory access and performance challenges. Proxy (surrogate) design pattern gives us an opportunity to manipulate JSON's value directly through class properties and vice versa.
import Vox
class Person: Resource {
@objc dynamic var name: String?
}
let person = Person()
person.name = "Sherlock Holmes"
print(person.attributes?["name"]) // -> "Sherlock Holmes"
Let's explain what's going on under the hood!
Setting the person's name won't assign the value to Person
object. Instead it will directly mutate the JSON behind (the one received from server).
Getting the property will actually resolve the value in JSON (it points to its actual memory address).
When values in resource's attributes
or relationship
dictionaries are directly changed, getting the property value will resolve to the one changed in JSON.
Every attribute or relationship (Resource
subclass property) must have @objc dynamic
prefix to be able to do so.
Think about your
Resource
classes as strong typed interfaces to a JSON object.
This opens up the possibility to easily handle the cases with:
Basic
pod 'Vox'
With Alamofire plugin
pod 'Vox/Alamofire'
import Vox
class Article: Resource {
/*--------------- Attributes ---------------*/
@objc dynamic
var title: String?
@objc dynamic
var descriptionText: String?
@objc dynamic
var keywords: [String]?
@objc dynamic
var viewsCount: NSNumber?
@objc dynamic
var isFeatured: NSNumber?
@objc dynamic
var customObject: [String: Any]?
/*------------- Relationships -------------*/
@objc dynamic
var authors: [Person]?
@objc dynamic
var editor: Person?
/*------------- Resource type -------------*/
// resource type must be defined
override class var resourceType: String {
return "articles"
}
/*------------- Custom coding -------------*/
override class var codingKeys: [String : String] {
return [
"descriptionText": "description"
]
}
}
import Vox
let person = Person()
person.name = "John Doe"
person.age = .null
person.gender = "male"
person.favoriteArticle = .null()
let json: [String: Any] = try! person.documentDictionary()
// or if `Data` is needed
let data: Data = try! person.documentData()
Previous example will resolve to following JSON:
{
"data": {
"attributes": {
"name": "John Doe",
"age": null,
"gender": "male"
},
"type": "persons",
"id": "id-1",
"relationships": {
"favoriteArticle": {
"data": null
}
}
}
}
In this example favorite article is unassigned from person. To do so, use .null()
on resource properties and .null
on all other properties.
import Vox
let article = Article()
article.id = "article-identifier"
let person1 = Person()
person1.id = "id-1"
person1.name = "John Doe"
person1.age = .null
person1.gender = "male"
person1.favoriteArticle = article
let person2 = Person()
person2.id = "id-2"
person2.name = "Mr. Nobody"
person2.age = 99
person2.gender = .null
person2.favoriteArticle = .null()
let json: [String: Any] = try! [person1, person2].documentDictionary()
// or if `Data` is needed
let data: Data = try! [person1, person2].documentData()
Previous example will resolve to following JSON:
{
"data": [
{
"attributes": {
"name": "John Doe",
"age": null,
"gender": "male"
},
"type": "persons",
"id": "id-1",
"relationships": {
"favoriteArticle": {
"data": {
"id": "article-identifier",
"type": "articles"
}
}
}
},
{
"attributes": {
"name": "Mr. Nobody",
"age": 99,
"gender": null
},
"type": "persons",
"id": "id-2",
"relationships": {
"favoriteArticle": {
"data": null
}
}
}
]
}
Use .null()
on Resource
type properties or .null
on any other type properties.
.null
(or .null()
) will result in JSON value being set to null
nil
will remove value from JSONimport Vox
let data: Data // -> provide data received from JSONAPI server
let deserializer = Deserializer.Single<Article>()
do {
let document = try deserializer.deserialize(data: self.data)
// `document.data` is an Article object
} catch JSONAPIError.API(let errors) {
// API response is valid JSONAPI error document
errors.forEach { error in
print(error.title, error.detail)
}
} catch JSONAPIError.serialization {
print("Given data is not valid JSONAPI document")
} catch {
print("Something went wrong. Maybe `data` does not contain valid JSON?")
}
import Vox
let data: Data // -> provide data received from JSONAPI server
let deserializer = Deserializer.Collection<Article>()
let document = try! deserializer.deserialize(data: self.data)
// `document.data` is an [Article] object
Provided data must be Data
object containing valid JSONAPI document or error. If this preconditions are not met, JSONAPIError.serialization
error will be thrown.
Deserializer can also be declared without generic parameter but in that case the resource's data
property may need an enforced casting on your side so using generics is recommended.
Document<DataType: Any>
has following properties:
Property | Type | Description |
---|---|---|
data |
DataType |
Contains the single resource or resource collection |
meta |
[String: Any] |
meta dictionary |
jsonapi |
[String: Any] |
jsonApi dictionary |
links |
Links |
Links object, e.g. can contain pagination data |
included |
[[String: Any]] |
included array of dictionaries |
<id>
and <type>
annotations can be used in path strings. If possible, they'll get replaced with adequate values.
Implement following method from Client
protocol:
func executeRequest(path: String,
method: String,
queryItems: [URLQueryItem],
bodyParameters: [String : Any]?,
success: @escaping ClientSuccessBlock,
failure: @escaping ClientFailureBlock,
userInfo: [String: Any])
where
ClientSuccessBlock
= (HTTPURLResponse?, Data?) -> Void
ClientFailureBlock
= (Error?, Data?) -> Void
Note:
userInfo
contains custom data you can pass to the client to do some custom logic: e.g. add some extra headers, add encryption etc.
If custom networking is not required, there is a plugin which wraps Alamofire and provides networking client in accordance with JSON:API specification.
Alamofire is Elegant HTTP Networking in Swift
Example:
let baseURL = URL(string: "http://demo7377577.mockable.io")!
let client = JSONAPIClient.Alamofire(baseURL: baseURL)
let dataSource = DataSource<Article>(strategy: .path("vox/articles"), client: client)
dataSource
.fetch()
...
pod 'Vox/Alamofire'
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>/<id>"), client: client)
dataSource
.fetch(id:"1")
.include([
"favoriteArticle"
])
.result({ (document: Document<Person>) in
let person = document?.data // ➜ `person` is `Person?` type
}) { (error) in
if let error = error as? JSONAPIError {
switch error {
case .API(let errors):
()
default:
()
}
}
}
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource(url: url)
.fetch()
.include([
"favoriteArticle"
])
.result({ (document: Document<[Person]>) in
let persons = document.data // ➜ `persons` is `[Person]?` type
}) { (error) in
}
let person = Person()
person.id = "1"
person.name = "Name"
person.age = 40
person.gender = "female"
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource
.create(person)
.result({ (document: Document<Person>?) in
let person = document?.data // ➜ `person` is `Person?` type
}) { (error) in
}
let person = Person()
person.id = "1"
person.age = 41
person.gender = .null
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>/<id>"), client: client)
dataSource
.update(resource: person)
.result({ (document: Document<Person>?) in
let person = document?.data // ➜ `person` is `Person?` type
}) { (error) in
}
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>/<id>"), client: client)
dataSource
.delete(id: "1")
.result({
}) { (error) in
}
let paginationStrategy: PaginationStrategy // -> your object conforming `PaginationStrategy` protocol
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource
.fetch()
.paginate(paginationStrategy)
.result({ (document) in
}, { (error) in
})
let paginationStrategy = Pagination.PageBased(number: 1, size: 10)
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource
.fetch()
.paginate(paginationStrategy)
.result({ (document) in
}, { (error) in
})
let paginationStrategy = Pagination.OffsetBased(offset: 10, limit: 10)
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource
.fetch()
.paginate(paginationStrategy)
.result({ (document) in
}, { (error) in
})
let paginationStrategy = Pagination.CursorBased(cursor: "cursor")
let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>"), client: client)
dataSource
.fetch()
.paginate(paginationStrategy)
.result({ (document) in
}, { (error) in
})
document.appendNext({ (data) in
// data.old -> Resource values before pagination
// data.new -> Resource values from pagination
// data.all -> Resource values after pagination
// document.data === data.all -> true
}, { (error) in
})
document.next?.result({ (nextDocument) in
// `nextDocument` is same type as `document`
}, { (error) in
})
document.previous?.result({ (previousDocument) in
// `previousDocument` is same type as `document`
}, { (error) in
})
document.first?.result({ (firstDocument) in
// `firstDocument` is same type as `document`
}, { (error) in
})
document.last?.result({ (lastDocument) in
// `lastDocument` is same type as `document`
}, { (error) in
})
document.reload?.result({ (reloadedDocument) in
// `reloadedDocument` is same type as `document`
}, { (error) in
})
Generating URL for resources can be automated.
Make a new object conforming Router
. Simple example:
class ResourceRouter: Router {
func fetch(id: String, type: Resource.Type) -> String {
let type = type.resourceType
return type + "/" + id // or "<type>/<id>"
}
func fetch(type: Resource.Type) -> String {
return type.resourceType // or "<type>"
}
func create(resource: Resource) -> String {
return resource.type // or "<type>"
}
func update(resource: Resource) -> String {
let type = type.resourceType
return type + "/" + id // or "<type>/<id>"
}
func delete(id: String, type: Resource.Type) -> String {
let type = type.resourceType
return type + "/" + id // or "<type>/<id>"
}
}
Then you would use:
let router = ResourceRouter()
let dataSource = DataSource<Person>(strategy: .router(router), client: client)
dataSource
.fetch()
...
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
Please make sure to update tests as appropriate.