Lift

2.5.4

Lift is a Swift library for generating and extracting values into and out of JSON-like data structures.
iZettle/Lift

What's New

2.5.4

2022-09-30T11:07:32Z
  • Update ci to use the new Xcode version

Build Status Platforms Carthage Compatible Swift Package Manager Compatible Xcode version

Lift is a Swift library for generating and extracting values into and out of JSON-like data structures. Lift was carefully designed to meet the following requirements:

  • Use easy and intuitive syntax using subscripting.
  • Be extendable for use with your custom types.
  • Support of retroactive modeling/conformance.
  • Do not enforce how to structure your data models.
  • Be type safe and explicit about errors.
  • Work with any key value structured data such as p-lists and user defaults.
  • Provide detailed errors and support custom validation.
  • Use value semantics for the Jar container.

Example usage

Lift is simple, yet powerful. Let us see how to use it with a custom type:

struct User {
  let name: String
  let age: Int
}

Just conform to JarElement to let Lift know how to transform your type:

extension User: JarElement {
  init(jar: Jar) throws {
    name = try jar["name"]^
    age = try jar["age"]^
  }
 
  var jar: Jar {
    return ["name": name, "age": age]
  }
}

Then given some JSON, you can now construct a Jar and extract users from it using the lift operator ^:

let json = "[{\"name\": \"Adam\", \"age\": 25}, {\"name\": \"Eve\", \"age\": 20}]"
let jar = try Jar(json: json)
var users: [User] = try jar^

And it is as easy to move your model values back to JSON:

users.append(User(name: "Junior", age: 2))
let newJson = try String(json: Jar(users), prettyPrinted: true)

Lift will even work with other JSON-like structured data such as p-lists and UserDefaults:

let users: [User] = try UserDefaults.standard["users"]^

Check the Usage section for more information and examples.

Contents:

Requirements

  • Xcode 9.3+
  • Swift 4.1
  • Platforms:
    • iOS 9.0+
    • macOS 10.11+
    • tvOS 9.0+
    • watchOS 2.0+
    • Linux

Installation

github "iZettle/Lift" >= 2.3
platform :ios, '9.0'
use_frameworks!

target 'Your App Target' do
  pod 'Lift', '~> 2.3'
end
import PackageDescription

let package = Package(
  name: "Your Package Name",
  dependencies: [
      .Package(url: "https://github.com/iZettle/Lift.git",
               majorVersion: 2)
  ]
)

Note on Codable

Swift 4 introduced Codable with the "promise" to have solved working with JSON once and for all. And yes, many of the examples shown are just close to magic. But when your models start to diverge from the simple ones to ones mapping between model and JSON, the magic seems to go away. Now you are back to implementing everything yourself and this using a quite verbose API. The current version of Swift also lacks APIs for building and parsing JSON on the fly (not going through model objects) which is common when e.g. building and parsing network requests. Hence, we believe the demand for third party JSON libraries will still be there for some time to come.

Usage

Introduction

Let us start out with a simple example of how to extract data from some key value structured data such as JSON:

let jar: Jar = ["name": "Lift", "version": 1.0]
let name: String = try jar["name"]^
let version: Double = try jar["version"]^

Jar is Lift's container of heterogenous values. In this example it holds a dictionary. The operator ^ (called the lift operator) is used to extract values out of the jar container. Because the jar typically holds values that are not known at compile time, extracting them might fail. This might happen if the value is missing, if the value is not of the expected type, or if some other validation is failing. This is why you will always see a try in the presence of the lift operator ^.

As mentioned, JSON does not always come in the form of a dictionary (key-values), but could also be simple primitive types or arrays of other JSON objects:

let i: Int = try Jar(1)^
let b: Bool = try Jar(true)^
let a: [Int] = try Jar([1, 2, 3])^
let jar: Jar = ["value": "lift"]
let s: String = try jar["value"]^

The ^ operator is overloaded to allow conforming types to either be extracted as the type itself or as an optional version of it. You can also extract an array or optional array of conforming types:

let i: Int = try jar^
let i: Int? = try jar^
let i: [Int] = try jar^
let i: [Int]? = try jar^

Jar implements subscripting for keys and indices and also allows them to be nested:

let date: Date = try jar["payments"][3]["date"]^
jar["payments"][2]["date"] = Date()

JSON serialization

Lift adds convenience initializers to construct a Jar from JSON and back:

let json = "{ \"val\": 3, \"vals\": [ 1, 2 ] }"
let jar = try Jar(json: json)
let jsonString = try String(json: jar, prettyPrinted: true)
let jsonData = try Data(json: jar, prettyPrinted: false)

You could also handle the serialization yourself and just pass an Any value:

let json: Any = ...
let jar = try Jar(checked: json) // Will validate when constructed - slower
let jar = Jar(unchecked: json) // Will lazily validate at access - faster

let any: Any = try jar.asAny()

Generating JSON

To help creating JSON, Jar implements several expressible by literal protocols so you can write code like:

func send(_ jar: Jar) { ... }

send(true)
send(1)
send(3.14)
send("Hello")
send(["val": 5])
send([1, 2, 3])

When Jar can't be inferred by the complier, you can explicitly specify the type:

let jar: Jar = ["val": 5]
let jar: Jar = [1, 2, 3]

You can also build nested hierarchies:

let jar: Jar = [5, ["val": [1, 2]]]

And of course you can also build JSON from your custom types:

let jar: Jar = ["payment": payment, "date": date]

Modifying JSON

Because Jar is a value type with value semantics, you can modify your Jar value when declared as var.

var jar = Jar()
jar["payment"] = payment
jar["date"] = Date()
var jar = Jar()
jar.append(payment)
jar.append(Date())

And if you need to modify your JSON before passing it on just make a copy:

func receive(jar: Jar) {
  var jar = jar
  jar["timeReceived"] = Date()
  // ...
}

A Jar is never bound to a specific type or value, hence it is always ok to change it:

var jar: Jar = true // jar holds a boolean
jar = [1, 2] // holds an array
jar["val"] = 1 // holds a dictionary
jar = 4711 // holds an integer
jar = ["val2": 2] // holds a dictionary
jar = "Hello" // holds a string

Arrays

Lift supports working with arrays of primitive types:

var jar: Jar = [1, 2, 3]

jar[1] = 4
jar.append(5)

let val: Int = try jar[2]^

As well as arrays of your custom types:

var jar: Jar = [Payment(...), Payment(...), ...]

let payments: [Payment] = try jar^
jar[2] = Payment(...)

Missing values and JSON null

Sometimes the existence of a value is a requirement, sometimes it is optional.

let i: Int = try jar["val"]^ // Will throw if val is missing or not an Int
let i: Int? = try jar["val"]^ // Will return nil if val is missing or null, else throw if not an Int
let i: Int = try jar["val"]^ ?? 4711 // Will throw if val is present and is not an Int

For your convenience Lift treats a value set to JSON null the same as a missing value. But if you need to check for the presence of the actual null value itself you can write:

if let _: Null = try? jar["val"]^ {
  //...
}

When building your JSON it is quite common that some values are optional:

let optional: Int? = nil
var jar: Jar = ["val": 1]
jar["optional"] = optional // -> {"val": 1}

It is also possible to add your optional inline:

var jar: Jar = ["val": 1, "optional": optional] // -> {"val": 1}

If you actually want a JSON null value you can use the constant null:

var jar: Jar = ["val": 1, "optional": optional ?? null] // -> {"val": 1, "optional": null}

Heterogenous values

Sometimes parts of your JSON could hold the union of different kinds of valid types. Then you could test between the different variations you support:

let any: Any? = NSJSONSerialization...
let jar = try Jar(any) // any could be a dictionary, array or a primitive type

if let int: Int = try? jar^ {
  // ...
} else if let ints: [Int] = try? jar^ {
  // ...
} else if let int: Int = try? jar["value"]^ {
  // ...
}

JSON also supports arrays of mixed types:

let jar = try Jar(any) // [ 1, [1, 2], { "val" : 3 } ] -- [Int, Array, Dictionary]

let int: Int = try jar[0]^
let array: [Int] = try jar[1]^
let dict: Jar = try jar[2]^

Transformation of values

You sometimes need to transform the values extracted from a Jar before using them. This might happen when you are working with types that cannot conform to JarRepresentable, such as when using tuples:

typealias User = (name: String, age: Int)
let users: [User] = try (jar^ as [Jar]).map { jar in 
  try (jar["name"]^, jar["age"]^) 
}

Or when your type does not conform to JarRepresentable, as it might need some additional initialization data:

let account: Account = ...

let payments: [Payment] = try (jar["payments"]^ as Array).map {
  Payment(jar: $0, account: account)
}

Even though you can manually transform values to add additional initialization data, it is often more convenient to add this data to the jar's context instead. Jar contexts will be described further down.

Beyond JSON

Setting and getting values of unknown types is not unique to JSON. Many Cocoa APIs use dictionaries and many of them are based on similar principles as JSON, such as p-lists. Lift provides protocols for extending those types to grant them access the power of Lift. E.g. Lift already extends UserDefaults:

// extension UserDefaults: MutatingValueForKey { }

let userDefaults = UserDefaults.standard

let date: Date? = try userDefaults["lastLaunched"]^
userDefaults["lastLaunched"] = Date()

let payments: [Payments] = try userDefaults["payments"]^ ?? []

JarConvertible & JarRepresentable

Out of the box, the Lift library supports JSON dictionaries, arrays and its primitive types: string, number, bool and null. But it is easy to extend your own types to work with the Lift library as well.

To be able extract values out of a Jar using the lift operator ^, you conform your type to the JarConvertible protocol:

protocol JarConvertible {
  init(jar: Jar) throws
}

And to be able to convert your type to a Jar, you conform your type to JarRepresentable:

protocol JarRepresentable {
  var jar: Jar { get }
}

It is common to implement both these protocols hence the convenience JarElement type alias:

typealias JarElement = JarConvertible & JarRepresentable

The Lift library includes extensions for the most common primitive types such as Int, Bool, String, etc., by conforming them to JarElement.

Handling custom types

Your custom types are typically either simple types such as:

struct Money {
  let fractionized: Int
}

extension Money: JarElement {
  init(jar: Jar) throws {
    fractionized = try jar^
  }

  var jar: Jar { return Jar(fractionized) }
}

let jar: Jar = ["amount": Money(fractionized: 2000)]
let amount: Money = try jar["amount"]^

Or perhaps more common, more complex and record like types such as:

struct Payment {
  let amount: Money
  let date: Date
}

extension Payment: JarElement {
  init(jar: Jar) throws {
    amount = try jar["amount"]^
    date = try jar["date"]^
  }

  var jar: Jar {
    return ["amount": amount, "date": date]
  }
}

let jar: Jar = ["payment": Payment(...)]
let payment: Payment = try jar["payment"]^

To make it easier to conform your custom enums with raw values, Lift comes with some default implementations. All you have to do is to conform the enum to JarElement to be able to use it with Jars:

enum MyEnum: String, JarElement {
  case one, two, three
}

let jar: Jar = ["enum": MyEnum.two]
let str: String = try jar["enum"]^ // -> "two"
let myEnum: MyEnum = try jar["enum"]^ // -> .two

JarConvertible requires you to implement a required init. This can be problematic if you work with non-final classes where you cannot update the source itself, such as when the class originates from Objective-C or another external source. In those cases you have to use the Liftable protocol instead:

extension MyClass: Liftable {
  static func lift(from jar: Jar) throws -> MyClass {
    // Implementation
  }
}

Model structure

The Lift library does not enforce the structure of you custom types and also allows retroactive modeling. It is up to you how you decide to map between your types and JSON. For example you might have enums with associative values (in this example a recursive one):

// [ { "type": "Product", "uuid": ”3b0bb980-2c…” },
//   { "type": "Folder", "name": "Coffee", "items": [
//        { "type": "Product", "uuid": ”3e493140-2c…” },
//        { "type": "Product", ”uuid": ”3e623780-2c…” }] },
//   ... ]

indirect enum FlowLayout {
  case product(uuid: UUID)
  case folder(name: String, items: [FlowLayout])
}

Because the JSON format has a weaker type-system than Swift, stricter validation becomes more important:

extension FlowLayout: JarConvertible  {
  init(jar: Jar) throws {
    switch try jar["type"]^ as String {
    case "Product":
      self = try .product(uuid: jar["uuid"]^)
    case "Folder":
      self = try .folder(name: jar["name"]^, items: jar["items"]^)
    case let type:
      throw jar.assertionFailure("Unknown layout type: \(type)")
    }
  }
}

Even for these more complex types, generation of JSON is still quite straightforward:

extension FlowLayout: JarRepresentable {
  var jar: Jar {
      switch self {
      case let .product(uuid):
        return ["type": "Product", "uuid": uuid]
      case let .folder(name, items):
        return ["type": "Folder", "name": name, "items": items]
    }
  }
}

Handling errors

Because JSON is typically nested, it is useful to extend errors with some positioning and context. Lift tries to keep track of the closest context and "key-path" into your data and will expose those in LiftErrors:

struct LiftError: Error {
  let message: String
  let key: String
  let context: String
}

Because the context and key-path are really valuable during debugging, it is important to not lose those when throwing validation errors. Hence, Lift has added special assert helper methods to Jar that you are encouraged to use:

init(jar: Jar) throws {
  // ...
  try jar.assert(i > 0, "Must greater than zero")

  guard validate(...) else {
    throw jar.assertionFailure("Not a business nor a person")
  }
  
  url = try jar.assertNotNil(URL(string: jar^), "Invalid URL")
  // ...
}

Jar context

Sometimes your type's initializer needs access to more data than what is included in the JSON itself. E.g. perhaps your Money type needs a currency as well, but your JSON does not provide that or provides it far away from the actual amount value itself. This is where you can pass the currency in the jar's context instead:

struct Money {
  let fractionized: Int
  let currency: Currency
}

extension Money: JarElement {
  init(jar: Jar) throws {
    fractionized = try jar^
    currency = try jar.context.get() // will extract the currency
  }

  var jar: Jar { return Jar(fractionized) }
}

let amount: Money = try jar.union(context: currency)["amount"]^

The jar's context is also useful for customizing the encoding and decoding of your types. E.g. Date will by default use the ISO8601 date format, but by providing another DateFormatter in the jar's context you could customize the date format:

extension Date: JarConvertible, JarRepresentableWithContext {
  init(jar: Jar) throws {
    let formatter: DateFormatter = jar.context.get() ?? .iso8601
    self = try jar.assertNotNil(formatter.date(from: jar^), "Date failed to convert using formatter with dateFormat: \(formatter.dateFormat)")
  }

  func asJar(using context: Jar.Context) -> Jar {
    let formatter: DateFormatter = context.get() ?? .iso8601
    return Jar(formatter.string(from: self))
  }
}

As JarRepresentable does not provide any context, you will instead conform to JarRepresentableWithContext that passes the context in asJar:

protocol JarRepresentableWithContext: JarRepresentable {
  func asJar(using context: Jar.Context) -> Jar
}

The context could either be set externally or as part of some other type's encoding/decoding such as:

struct Payment {
  let amount: Money
  let date: Date
}

extension Payment: JarElement {
  init(jar: Jar) throws {
    let jar = jar.union(context: DateFormatter.custom)
    amount = try jar["amount"]^ // a currency must be provided in the jar's context
    date = try jar["date"]^ // date will format using DateFormatter.custom
  }

  var jar: Jar {
    let jar: Jar = ["amount": amount, "date": date]
    return jar.union(context: DateFormatter.custom)
  }
}

let payment: Payment = try jar.union(context: currency)["payment"]^

Field tested

Lift was developed, evolved and field-tested over the course of several years, and is pervasively used in iZettle's highly acclaimed point of sales app for communicating with iZettle's comprehensive set of backend services.

Collaborate

You can collaborate with us on our Slack workspace. Ask questions, share ideas or maybe just participate in ongoing discussions. To get an invitation, write to us at ios-oss@izettle.com

Learn more

To learn more about how Lift's APIs turned the way they did, we recommend reading the article:

Description

  • Swift Tools 5.3.0
View More Packages from this Author

Dependencies

  • None
Last updated: Thu Apr 04 2024 11:57:04 GMT-0900 (Hawaii-Aleutian Daylight Time)