diff --git a/README.md b/README.md index 4ee849c..7f90714 100644 --- a/README.md +++ b/README.md @@ -249,12 +249,14 @@ You can also use key paths (for both dictionary keys and array indexes) to unbox ``` ```swift -struct User: Unboxable { +struct User { let name: String let age: Int let runningDistance: Int let primaryDeviceName: String +} +extension User: Unboxable { init(unboxer: Unboxer) { self.name = unboxer.unbox("name") self.age = unboxer.unbox("age") @@ -264,6 +266,56 @@ struct User: Unboxable { } ``` +You can also use key paths to directly unbox nested JSON structures. This is useful when you only need to extract a specific object (or objects) out of the JSON body. + +```json +{ + "company": { + "name": "Spotify", + }, + "jobOpenings": [ + { + "title": "Swift Developer", + "salary": 120000 + }, + { + "title": "UI Designer", + "salary": 100000 + }, + ] +} +``` + +```swift +struct JobOpening { + let title: String + let salary: Int +} + +extension JobOpening: Unboxable { + init(unboxer: Unboxer) { + self.title = unboxer.unbox("title") + self.salary = unboxer.unbox("salary") + } +} + +struct Company { + let name: String +} + +extension Company: Unboxable { + init(unboxer: Unboxer) { + self.name = unboxer.unbox("name") + } +} +``` + +```swift +let company: Company = try Unbox(json, at: "company") +let jobOpenings: [JobOpening] = try Unbox(json, at: "jobOpenings") +let featuredOpening: JobOpening = try Unbox(json, at: "jobOpenings.0") +``` + ### Custom unboxing Sometimes you need more fine grained control over the decoding process, and even though Unbox was designed for simplicity, it also features a powerful custom unboxing API that enables you to take control of how an object gets unboxed. This comes very much in handy when using Unbox together with Core Data, when using dependency injection, or when aggregating data from multiple sources. Here's an example: diff --git a/Sources/Unbox.swift b/Sources/Unbox.swift index 39d0f51..07b750f 100644 --- a/Sources/Unbox.swift +++ b/Sources/Unbox.swift @@ -40,6 +40,13 @@ public func Unbox(dictionary: UnboxableDictionary, context: Any? = return try Unboxer(dictionary: dictionary, context: context).performUnboxing() } +/// Unbox a JSON dictionary into a model `T` beginning at a provided key, optionally using a contextual object. Throws `UnboxError`. +public func Unbox(dictionary: UnboxableDictionary, at key: String, isKeyPath: Bool = true) throws -> T { + let context = UnboxContainerContext(key: key, isKeyPath: isKeyPath) + let container: UnboxContainer = try Unbox(dictionary, context: context) + return container.model +} + /// Unbox an array of JSON dictionaries into an array of `T`, optionally using a contextual object and/or invalid elements. Throws `UnboxError`. public func Unbox(dictionaries: [UnboxableDictionary], context: Any? = nil, allowInvalidElements: Bool = false) throws -> [T] { return try dictionaries.mapAllowingInvalidElements(allowInvalidElements, transform: { @@ -47,14 +54,21 @@ public func Unbox(dictionaries: [UnboxableDictionary], context: An }) } +/// Unbox an array JSON dictionary into an array of model `T` beginning at a provided key, optionally using a contextual object and/or invalid elements. Throws `UnboxError`. +public func Unbox(dictionary: UnboxableDictionary, at key: String, isKeyPath: Bool = true) throws -> [T] { + let context = UnboxContainerContext(key: key, isKeyPath: isKeyPath) + let container: UnboxArrayContainer = try Unbox(dictionary, context: context) + return container.models +} + /// Unbox binary data into a model `T`, optionally using a contextual object. Throws `UnboxError`. public func Unbox(data: NSData, context: Any? = nil) throws -> T { - return try Unboxer.unboxerFromData(data, context: context).performUnboxing() + return try Unboxer.unboxer(from: data, context: context).performUnboxing() } /// Unbox binary data into an array of `T`, optionally using a contextual object and/or invalid elements. Throws `UnboxError`. public func Unbox(data: NSData, context: Any? = nil, allowInvalidElements: Bool = false) throws -> [T] { - return try Unboxer.unboxersFromData(data, context: context).mapAllowingInvalidElements(allowInvalidElements, transform: { + return try Unboxer.unboxers(from: data, context: context).mapAllowingInvalidElements(allowInvalidElements, transform: { return try $0.performUnboxing() }) } @@ -73,12 +87,12 @@ public func Unbox(dictionaries: [UnboxableDictionary], /// Unbox binary data into a model `T` using a required contextual object. Throws `UnboxError`. public func Unbox(data: NSData, context: T.ContextType) throws -> T { - return try Unboxer.unboxerFromData(data, context: context).performUnboxingWithContext(context) + return try Unboxer.unboxer(from: data, context: context).performUnboxingWithContext(context) } /// Unbox binary data into an array of `T` using a required contextual object and/or invalid elements. Throws `UnboxError`. public func Unbox(data: NSData, context: T.ContextType, allowInvalidElements: Bool = false) throws -> [T] { - return try Unboxer.unboxersFromData(data, context: context).mapAllowingInvalidElements(allowInvalidElements, transform: { + return try Unboxer.unboxers(from: data, context: context).mapAllowingInvalidElements(allowInvalidElements, transform: { return try $0.performUnboxingWithContext(context) }) } @@ -423,7 +437,7 @@ public class Unboxer { /// Perform custom unboxing using an Unboxer (created from NSData) passed to a closure, or throw an UnboxError public static func performCustomUnboxingWithData(data: NSData, context: Any? = nil, closure: Unboxer throws -> T?) throws -> T { - return try Unboxer.unboxerFromData(data, context: context).performCustomUnboxingWithClosure(closure) + return try Unboxer.unboxer(from: data, context: context).performCustomUnboxingWithClosure(closure) } // MARK: - Value accessing API @@ -787,7 +801,7 @@ private class UnboxValueResolver { var dictionary = self.unboxer.dictionary var array: [AnyObject]? var modifiedKey = key - + if isKeyPath && key.containsString(".") { let components = key.componentsSeparatedByString(".") for i in 0 ..< components.count { @@ -806,12 +820,12 @@ private class UnboxValueResolver { } } } - + if let value = dictionary[modifiedKey] as? T { if let transformed = transform(value) { return transformed } - } else if let index = Int(modifiedKey), let array = array, let value = array[index] as? T { + } else if let index = Int(modifiedKey), let array = array where index < array.count, let value = array[index] as? T { if let transformed = transform(value) { return transformed } @@ -855,6 +869,29 @@ extension UnboxValueResolver where T: CollectionType, T: DictionaryLiteralConver } } +// MARK: - UnboxContainerContext + +private struct UnboxContainerContext { + let key: String + let isKeyPath: Bool +} + +private struct UnboxContainer: UnboxableWithContext { + let model: T + + init(unboxer: Unboxer, context: UnboxContainerContext) { + self.model = unboxer.unbox(context.key, isKeyPath: context.isKeyPath) + } +} + +private struct UnboxArrayContainer: UnboxableWithContext { + let models: [T] + + init(unboxer: Unboxer, context: UnboxContainerContext) { + self.models = unboxer.unbox(context.key, isKeyPath: context.isKeyPath) + } +} + // MARK: - Private extensions private extension Unboxable { @@ -870,7 +907,7 @@ private extension UnboxableWithContext { } private extension Unboxer { - static func unboxerFromData(data: NSData, context: Any?) throws -> Unboxer { + static func unboxer(from data: NSData, context: Any?) throws -> Unboxer { do { guard let dictionary = try NSJSONSerialization.JSONObjectWithData(data, options: []) as? UnboxableDictionary else { throw UnboxError.InvalidData @@ -882,15 +919,15 @@ private extension Unboxer { } } - static func unboxersFromData(data: NSData, context: Any?) throws -> [Unboxer] { + static func unboxers(from data: NSData, context: Any?) throws -> [Unboxer] { do { guard let array = try NSJSONSerialization.JSONObjectWithData(data, options: [.AllowFragments]) as? [UnboxableDictionary] else { throw UnboxError.InvalidData } - return array.map({ + return array.map { return Unboxer(dictionary: $0, context: context) - }) + } } catch { throw UnboxError.InvalidData } diff --git a/Tests/UnboxTests.swift b/Tests/UnboxTests.swift index 81c9fa8..6b90a9c 100644 --- a/Tests/UnboxTests.swift +++ b/Tests/UnboxTests.swift @@ -1210,7 +1210,120 @@ class UnboxTests: XCTestCase { XCTFail("Unexpected error thrown: \(error)") } } + + func testUnboxingStartingAtCustomKey() { + let dictionary: UnboxableDictionary = [ + "A": [ + "int": 14 + ] + ] + + do { + let unboxed: UnboxTestSimpleMock = try Unbox(dictionary, at: "A") + XCTAssertEqual(unboxed.int, 14) + } catch { + XCTFail("Unexpected error thrown: \(error)") + } + } + + func testUnboxingStartingAtMissingCustomKey() { + let dictionary: UnboxableDictionary = [ + "A": [ + "int": 14 + ] + ] + + do { + let _ : UnboxTestSimpleMock = try Unbox(dictionary, at: "B") + XCTFail() + } catch { + // Test Passed + } + } + + func testUnboxingStartingAtCustomKeyPath() { + let dictionary: UnboxableDictionary = [ + "A": [ + "B": [ + "int": 14 + ] + ] + ] + + do { + let unboxed: UnboxTestSimpleMock = try Unbox(dictionary, at: "A.B", isKeyPath: true) + XCTAssertEqual(unboxed.int, 14) + } catch { + XCTFail("Unexpected error thrown: \(error)") + } + } + + func testUnboxingStartingAtMissingCustomKeyPath() { + let dictionary: UnboxableDictionary = [ + "A": [ + "int": 14 + ] + ] + + do { + let _: UnboxTestSimpleMock = try Unbox(dictionary, at: "A.B", isKeyPath: true) + XCTFail() + } catch { + // Test Passed + } + } + + func testUnboxingArrayStartingAtCustomKeyPath() { + let dictionary: UnboxableDictionary = [ + "A": [ + "B": [ + [ + "int": 14 + ], + [ + "int": 14 + ], + [ + "int": 14 + ] + ] + ] + ] + + do { + let unboxedArray: [UnboxTestSimpleMock] = try Unbox(dictionary, at: "A.B", isKeyPath: true) + unboxedArray.forEach { + XCTAssertEqual($0.int, 14) + } + } catch { + XCTFail("Unexpected error thrown: \(error)") + } + } + + func testUnboxingArrayIndexStartingAtCustomKeyPath() { + let dictionary: UnboxableDictionary = + ["A": ["B": [["int": 14], ["int": 14], ["int": 20]]]] + do { + let unboxed: UnboxTestSimpleMock = try Unbox(dictionary, at: "A.B.2", isKeyPath: true) + XCTAssertEqual(unboxed.int, 20) + + } catch { + XCTFail("Unexpected error thrown: \(error)") + } + } + + func testUnboxingArrayInvalidIndexStartingAtCustomKeyPath() { + let dictionary: UnboxableDictionary = + ["A": ["B": [["int": 14], ["int": 14], ["int": 20]]]] + + do { + try Unbox(dictionary, at: "A.B.3", isKeyPath: true) as UnboxTestSimpleMock + } catch { + // Test Passed + } + } + } private func UnboxTestDictionaryWithAllRequiredKeysWithValidValues(nested: Bool) -> UnboxableDictionary {