Last active
December 30, 2015 12:06
-
-
Save stefanlindbohm/7e2a7da470cbd98920bd to your computer and use it in GitHub Desktop.
Extensible JSON interface in Swift enforced by types
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// JSON.swift | |
// BonAppetit | |
// | |
// Created by Stefan Lindbohm on 17/02/15. | |
// Copyright (c) 2015 Stefan Lindbohm. All rights reserved. | |
// | |
import Foundation | |
protocol JSONValue { | |
func JSONDictionaryValue() -> AnyObject | |
} | |
protocol BuildableJSONValue: JSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Self? | |
} | |
extension String: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> String? { | |
return JSONDictionaryValue as? String | |
} | |
func JSONDictionaryValue() -> AnyObject { return self } | |
} | |
extension Int: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Int? { | |
return JSONDictionaryValue as? Int | |
} | |
func JSONDictionaryValue() -> AnyObject { return self } | |
} | |
extension Float: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Float? { | |
return JSONDictionaryValue as? Float | |
} | |
func JSONDictionaryValue() -> AnyObject { return self } | |
} | |
extension Double: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Double? { | |
return JSONDictionaryValue as? Double | |
} | |
func JSONDictionaryValue() -> AnyObject { return self } | |
} | |
extension Bool: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Bool? { | |
return JSONDictionaryValue as? Bool | |
} | |
func JSONDictionaryValue() -> AnyObject { return self } | |
} | |
extension NSUUID: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Self? { | |
guard let string = JSONDictionaryValue as? String else { | |
return nil | |
} | |
return self.init(UUIDString: string) | |
} | |
func JSONDictionaryValue() -> AnyObject { | |
return self.UUIDString | |
} | |
} | |
extension NSDate: BuildableJSONValue { | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> Self? { | |
guard let string = JSONDictionaryValue as? String else { return nil } | |
guard let date = NSDateFormatter.dateFromISO8601String(string) else { | |
fatalError("Value is not parsable as date") | |
} | |
return self.init(timeIntervalSinceReferenceDate: date.timeIntervalSinceReferenceDate) | |
} | |
func JSONDictionaryValue() -> AnyObject { | |
return NSDateFormatter.ISO8601String(date: self) | |
} | |
} | |
// TODO: Constrain this to [JSONValue] when supported in Swift. Might also remove the need for | |
// separation between JSONValue and BuildableJSONValue | |
extension Array: JSONValue { | |
func JSONDictionaryValue() -> AnyObject { | |
return self.map { element in | |
guard let element = element as? BuildableJSONValue else { | |
fatalError("Array used as JSONMember contains element not conforming to JSONMember") | |
} | |
return element.JSONDictionaryValue() | |
} as [AnyObject] | |
} | |
} | |
struct JSONObject: BuildableJSONValue { | |
private let JSONDictionary: [ String : AnyObject ] | |
// MARK: Lifecycle | |
init?(JSONData: NSData) { | |
do { | |
guard let JSONDictionary = try NSJSONSerialization.JSONObjectWithData(JSONData, options: []) as? Dictionary<String, AnyObject> else { | |
fatalError("Unknown type encountered when deserializing JSON") | |
} | |
self.JSONDictionary = JSONDictionary | |
} catch let error as NSError where error.code == NSPropertyListReadCorruptError { | |
return nil | |
} catch let error as NSError { | |
fatalError("Unknown error occurred while deserializing JSON: \(error.description)") | |
} | |
} | |
init(members: [String : JSONValue?]) { | |
self.JSONDictionary = JSONObject.buildJSONDictionaryFromMembers(members) | |
} | |
private init(JSONDictionary: Dictionary<String, AnyObject>) { | |
self.JSONDictionary = JSONDictionary | |
} | |
// MARK: Nested objects | |
subscript(name: String) -> JSONObject? { | |
return get(name) | |
} | |
// MARK: Members | |
func get<T: BuildableJSONValue>(name: String) -> T? { | |
return T.buildFromJSONDictionaryValue(concreteValue(name)) | |
} | |
func get<T: BuildableJSONValue>(name: String) -> [T]? { | |
guard let values = concreteValue(name) as? [AnyObject] else { | |
return nil | |
} | |
return values.map { element in | |
guard let value = T.buildFromJSONDictionaryValue(element) else { | |
fatalError("Array contains elements not compatible with type \(T.self)") | |
} | |
return value | |
} | |
} | |
// MARK: Serialization | |
func JSONString() -> NSData { | |
do { | |
return try NSJSONSerialization.dataWithJSONObject(JSONDictionary, options: []) | |
} catch let error as NSError { | |
fatalError("Serialization failed with error: \(error.description)") | |
} | |
} | |
// MARK: BuildableJSONValue | |
static func buildFromJSONDictionaryValue(JSONDictionaryValue: AnyObject) -> JSONObject? { | |
guard let members = JSONDictionaryValue as? Dictionary<String, AnyObject> else { | |
return nil | |
} | |
return JSONObject(JSONDictionary: members) | |
} | |
func JSONDictionaryValue() -> AnyObject { | |
return JSONDictionary | |
} | |
// MARK: Private helpers | |
private func concreteValue(name: String) -> AnyObject { | |
guard let value = JSONDictionary[name] else { | |
fatalError("Object contains no member with name \(name)") | |
} | |
return value | |
} | |
private static func buildJSONDictionaryFromMembers(members: [ String : JSONValue? ]) -> [ String : AnyObject ] { | |
var newDictionary = [ String: AnyObject ]() | |
for (name, value) in members { | |
if let value = value { | |
newDictionary[name] = value.JSONDictionaryValue() | |
} else { | |
newDictionary[name] = NSNull() | |
} | |
} | |
return newDictionary | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// JSONSpec.swift | |
// BonAppetit | |
// | |
// Created by Stefan Lindbohm on 27/12/15. | |
// Copyright © 2015 Stefan Lindbohm. All rights reserved. | |
// | |
import Quick | |
import Nimble | |
@testable import BonAppetit | |
class JSONObjectSpec: QuickSpec { | |
override func spec() { | |
describe("init?(data: NSData)") { | |
it("initializes with empty JSON object as string") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{}")) | |
expect(json).to(beTruthy()) | |
} | |
it("doesn't initialize for empty strings") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("")) | |
expect(json).to(beNil()) | |
} | |
it("initializes with string values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"string\": \"foo\" }")) | |
let actual: String? = json?.get("string") | |
expect(actual).to(equal("foo")) | |
} | |
it("initializes with integer values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"integer\": 1 }")) | |
let actual: Int? = json?.get("integer") | |
expect(actual).to(equal(1)) | |
} | |
it("initializes with double values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"double\": 3.14 }")) | |
let actual: Double? = json?.get("double") | |
expect(actual).to(equal(3.14)) | |
} | |
it("initializes with boolean values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"yes\": true, \"no\": false }")) | |
let actualYes: Bool? = json?.get("yes") | |
expect(actualYes).to(beTrue()) | |
let actualNo: Bool? = json?.get("no") | |
expect(actualNo).to(beFalse()) | |
} | |
it("initializes with null values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"nothing\": null }")) | |
expect(json).to(beTruthy()) | |
let actual: Int? = json?.get("nothing") | |
expect(actual).to(beNil()) | |
} | |
it("initializes with arrays") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"array\": [ 1, 2 ] }")) | |
let actual: [Int]? = json?.get("array") | |
expect(actual).to(equal([ 1, 2 ])) | |
} | |
it("initializes with nested objects") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"object\": { \"foo\": \"bar\" } }")) | |
let actual: String? = json?["object"]?.get("foo") | |
expect(actual).to(equal("bar")) | |
} | |
it("initializes with object arrays") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"objects\": [ { \"foo\": \"bar\" }, { \"foo\": \"baz\" } ] }")) | |
let array: [JSONObject]? = json?.get("objects") | |
expect(array?.count).to(equal(2)) | |
expect(array?[0].get("foo")).to(equal("bar")) | |
expect(array?[1].get("foo")).to(equal("baz")) | |
} | |
it("initializes with UUID values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"uuid\": \"27B1FA23-0255-42D5-BC3E-98BEED67FC8D\" }")) | |
let actual: NSUUID? = json?.get("uuid") | |
expect(actual).to(equal(NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D"))) | |
} | |
it("initializes with date values") { | |
let json = JSONObject(JSONData: self.dataUsingUnicodeEncoding("{ \"date\": \"2016-01-01T00:00:00Z\" }")) | |
let actual: NSDate? = json?.get("date") | |
expect(actual).to(equal(self.UTCDate(2016, month: 1, day: 1))) | |
} | |
} | |
describe("init(members: Dictionary<String, Any?>") { | |
it("initializes with string values") { | |
let json = JSONObject(members: [ "string": "foo" ]) | |
let actual: String? = json.get("string") | |
expect(actual).to(equal("foo")) | |
} | |
it("initializes with integer values") { | |
let json = JSONObject(members: [ "integer": 1 ]) | |
let actual: Int? = json.get("integer") | |
expect(actual).to(equal(1)) | |
} | |
it("initializes with double values") { | |
let json = JSONObject(members: [ "double": 3.14 ]) | |
let actual: Double? = json.get("double") | |
expect(actual).to(equal(3.14)) | |
} | |
it("initializes with boolean values") { | |
let json = JSONObject(members: [ "yes": true, "no": false ]) | |
let actualYes: Bool? = json.get("yes") | |
expect(actualYes).to(beTrue()) | |
let actualNo: Bool? = json.get("no") | |
expect(actualNo).to(beFalse()) | |
} | |
it("initializes with null values") { | |
let json = JSONObject(members: [ "nothing": nil ]) | |
expect(json).to(beTruthy()) | |
let actual: Int? = json.get("nothing") | |
expect(actual).to(beNil()) | |
} | |
it("initializes with UUID values") { | |
let json = JSONObject(members: [ "uuid": NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D") ]) | |
let actual: NSUUID? = json.get("uuid") | |
expect(actual).to(equal(NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D"))) | |
} | |
it("initializes with NSDate values") { | |
let json = JSONObject(members: [ "date": self.UTCDate(2016, month: 1, day: 1) ]) | |
let actual: NSDate? = json.get("date") | |
expect(actual).to(equal(self.UTCDate(2016, month: 1, day: 1))) | |
} | |
it("initializes with arrays") { | |
let json = JSONObject(members: [ "array": [ 1, 2 ] ]) | |
let actual: [Int]? = json.get("array") | |
expect(actual).to(equal([ 1, 2 ])) | |
} | |
it("initializes with nested JSON instances") { | |
let json = JSONObject(members: [ "object": JSONObject(members: [ "uuid": NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D") ]) ]) | |
let actual: NSUUID? = json["object"]?.get("uuid") | |
expect(actual).to(equal(NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D"))) | |
} | |
} | |
describe("JSONString()") { | |
it("serializes empty object") { | |
let json = JSONObject(members: [ String: JSONValue? ]()) | |
expect(self.stringUsingUnicodeEncoding(json.JSONString())).to(equal("{}")) | |
} | |
it("serializes UUID") { | |
let json = JSONObject(members: [ "uuid": NSUUID(UUIDString: "27B1FA23-0255-42D5-BC3E-98BEED67FC8D") ]) | |
expect(self.stringUsingUnicodeEncoding(json.JSONString())).to(equal("{\"uuid\":\"27B1FA23-0255-42D5-BC3E-98BEED67FC8D\"}")) | |
} | |
it("serializes date") { | |
let json = JSONObject(members: [ "date": self.UTCDate(2016, month: 1, day: 1) ]) | |
expect(self.stringUsingUnicodeEncoding(json.JSONString())).to(equal("{\"date\":\"2016-01-01T00:00:00Z\"}")) | |
} | |
it("serializes nil") { | |
let json = JSONObject(members: [ "nothing": nil ]) | |
expect(self.stringUsingUnicodeEncoding(json.JSONString())).to(equal("{\"nothing\":null}")) | |
} | |
it("serializes complex structures with strings and integers") { | |
let json = JSONObject(members: [ | |
"array": [ 1, 2 ], | |
"object": JSONObject(members: [ "foo": "bar" ]) | |
]) | |
expect(self.stringUsingUnicodeEncoding(json.JSONString())).to(equal("{\"array\":[1,2],\"object\":{\"foo\":\"bar\"}}")) | |
} | |
} | |
} | |
private func dataUsingUnicodeEncoding(string: String) -> NSData { | |
return string.dataUsingEncoding(NSUTF8StringEncoding)! | |
} | |
private func stringUsingUnicodeEncoding(data: NSData) -> String { | |
guard let string = NSString(data: data, encoding: NSUTF8StringEncoding) else { | |
fatalError("Could not read string from data") | |
} | |
return string as String | |
} | |
private func UTCDate(year: Int, month: Int, day: Int) -> NSDate { | |
let calendar = NSCalendar.currentCalendar() | |
calendar.timeZone = NSTimeZone(abbreviation: "UTC")! | |
return calendar.dateWithEra(1, year: year, month: month, day: day, hour: 0, minute: 0, second: 0, nanosecond: 0)! | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment