aronbalog / Vox

Swift JSON:API client framework
http://jsonapi.org
MIT License
46 stars 19 forks source link
json-api json-api-normalizer json-api-serializer jsonapi jsonapi-resources jsonapiclient swift swift4

Vox

Vox is a Swift JSONAPI standard implementation. 🔌

🔜 More stable version (written in Swift 5) coming soon.

Build Status codecov Platform CocoaPods Compatible

The magic behind

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!

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:

Installation

Requirements

Basic

pod 'Vox'

With Alamofire plugin

pod 'Vox/Alamofire'

Usage

Defining resource

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"
        ]
    }
}

Serializing

Single resource

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.

Resource collection

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
        }
      }
    }
  ]
}

Nullability

Use .null() on Resource type properties or .null on any other type properties.

Deserializing

Single resource

import 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?")
}

Resource collection

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

Description

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

Networking

<id> and <type> annotations can be used in path strings. If possible, they'll get replaced with adequate values.

Client protocol

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

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.

Alamofire client plugin

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()
    ...
Installation
pod 'Vox/Alamofire'

Fetching single resource

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:
                ()
            }
        }
    }

Fetching resource collection

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

    }

Creating resource

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

    }

Updating resource

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

    }

Deleting resource

let dataSource = DataSource<Person>(strategy: .path("custom-path/<type>/<id>"), client: client)

dataSource
    .delete(id: "1")
    .result({

    }) { (error) in

    }

Pagination

Pagination on initial request
Custom pagination strategy
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

    })
Page-based pagination strategy
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

    })
Offset-based pagination strategy
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

    })
Cursor-based pagination strategy
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

    })
Appending next page to current document
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

})
Fetching next document page
document.next?.result({ (nextDocument) in
    // `nextDocument` is same type as `document`
}, { (error) in

})
Fetching previous document page
document.previous?.result({ (previousDocument) in
    // `previousDocument` is same type as `document`
}, { (error) in

})
Fetching first document page
document.first?.result({ (firstDocument) in
    // `firstDocument` is same type as `document`
}, { (error) in

})
Fetching last document page
document.last?.result({ (lastDocument) in
    // `lastDocument` is same type as `document`
}, { (error) in

})
Reloading current document page
document.reload?.result({ (reloadedDocument) in
    // `reloadedDocument` is same type as `document`
}, { (error) in

})

Custom routing

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()
    ...

Tests

Contributing

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.

License

MIT