From 12e3c414e6b070595675a3a9df9a914db39eb5a5 Mon Sep 17 00:00:00 2001 From: Joan Romano Date: Sat, 25 Jun 2016 20:10:52 +0200 Subject: [PATCH] Add initial support for links --- Kakapo.xcodeproj/project.pbxproj | 20 +++- README.playground/Contents.swift | 33 +++++- Source/JSONAPILinks.swift | 56 ++++++++++ Source/JSONAPISerializer.swift | 33 +++++- Tests/JSONAPILinksTests.swift | 182 +++++++++++++++++++++++++++++++ Tests/KakapoDBTests.swift | 1 - 6 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 Source/JSONAPILinks.swift create mode 100644 Tests/JSONAPILinksTests.swift diff --git a/Kakapo.xcodeproj/project.pbxproj b/Kakapo.xcodeproj/project.pbxproj index 8247185..a084a1c 100644 --- a/Kakapo.xcodeproj/project.pbxproj +++ b/Kakapo.xcodeproj/project.pbxproj @@ -8,6 +8,13 @@ /* Begin PBXBuildFile section */ 5B16AF891A00E1CEB873C5F8 /* Pods_Kakapo_iOSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1250C40FD2117CD20E6AE31F /* Pods_Kakapo_iOSTests.framework */; }; + 8BE0FC801D16BD9A00FE706A /* JSONAPILinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC7E1D16BD8300FE706A /* JSONAPILinksTests.swift */; }; + 8BE0FC811D16BD9B00FE706A /* JSONAPILinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC7E1D16BD8300FE706A /* JSONAPILinksTests.swift */; }; + 8BE0FC821D16BD9C00FE706A /* JSONAPILinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC7E1D16BD8300FE706A /* JSONAPILinksTests.swift */; }; + 8BE0FC841D172EC900FE706A /* JSONAPILinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */; }; + 8BE0FC851D172EC900FE706A /* JSONAPILinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */; }; + 8BE0FC861D172EC900FE706A /* JSONAPILinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */; }; + 8BE0FC871D172EC900FE706A /* JSONAPILinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */; }; AC9577F342F6730725CC9D72 /* Pods_Kakapo_tvOSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C67882333142806D5CF9A918 /* Pods_Kakapo_tvOSTests.framework */; }; D5363674ABF8735A0A1368BD /* Pods_Kakapo_macOSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ABE4F11D5A141DB87CD81023 /* Pods_Kakapo_macOSTests.framework */; }; DE76E1311D0DC62B009721A4 /* Kakapo.h in Headers */ = {isa = PBXBuildFile; fileRef = DE76E0FA1D0DC37E009721A4 /* Kakapo.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -103,6 +110,8 @@ /* Begin PBXFileReference section */ 1250C40FD2117CD20E6AE31F /* Pods_Kakapo_iOSTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Kakapo_iOSTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 822EBBB73B6B4117D229CDF1 /* Pods-Kakapo tvOSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Kakapo tvOSTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Kakapo tvOSTests/Pods-Kakapo tvOSTests.debug.xcconfig"; sourceTree = ""; }; + 8BE0FC7E1D16BD8300FE706A /* JSONAPILinksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPILinksTests.swift; sourceTree = ""; }; + 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPILinks.swift; sourceTree = ""; }; ABE4F11D5A141DB87CD81023 /* Pods_Kakapo_macOSTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Kakapo_macOSTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C67882333142806D5CF9A918 /* Pods_Kakapo_tvOSTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Kakapo_tvOSTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C9E9EAD776E83C0F5785A0DB /* Pods-Kakapo macOSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Kakapo macOSTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Kakapo macOSTests/Pods-Kakapo macOSTests.debug.xcconfig"; sourceTree = ""; }; @@ -261,6 +270,8 @@ DE76E0FD1D0DC38B009721A4 /* Tests */ = { isa = PBXGroup; children = ( + DE76E0FE1D0DC38B009721A4 /* Info.plist */, + 8BE0FC7E1D16BD8300FE706A /* JSONAPILinksTests.swift */, DE76E1B11D0DC857009721A4 /* JSONAPITests.swift */, DE76E1B21D0DC857009721A4 /* KakapoDBTests.swift */, DE76E1B31D0DC857009721A4 /* PropertyPolicyTests.swift */, @@ -268,7 +279,6 @@ DE76E1B51D0DC857009721A4 /* SerializerTests.swift */, DE76E1B61D0DC857009721A4 /* URLDecomposerTests.swift */, DE76E1B71D0DC857009721A4 /* XCTestCase+CustomAssertions.swift */, - DE76E0FE1D0DC38B009721A4 /* Info.plist */, ); path = Tests; sourceTree = ""; @@ -276,6 +286,7 @@ DE76E1001D0DC395009721A4 /* Source */ = { isa = PBXGroup; children = ( + 8BE0FC831D172EC900FE706A /* JSONAPILinks.swift */, DE76E1011D0DC395009721A4 /* JSONAPISerializer.swift */, DE76E1021D0DC395009721A4 /* KakapoDB.swift */, DE76E1031D0DC395009721A4 /* KakapoServer.swift */, @@ -717,6 +728,7 @@ DE76E19A1D0DC755009721A4 /* PropertyPolicy.swift in Sources */, DE76E19D1D0DC755009721A4 /* URLDecomposer.swift in Sources */, DE76E1971D0DC755009721A4 /* KakapoDB.swift in Sources */, + 8BE0FC851D172EC900FE706A /* JSONAPILinks.swift in Sources */, DE76E19C1D0DC755009721A4 /* Serializer.swift in Sources */, DE76E1961D0DC755009721A4 /* JSONAPISerializer.swift in Sources */, DE76E19B1D0DC755009721A4 /* Router.swift in Sources */, @@ -733,6 +745,7 @@ DE76E1921D0DC755009721A4 /* PropertyPolicy.swift in Sources */, DE76E1951D0DC755009721A4 /* URLDecomposer.swift in Sources */, DE76E18F1D0DC755009721A4 /* KakapoDB.swift in Sources */, + 8BE0FC861D172EC900FE706A /* JSONAPILinks.swift in Sources */, DE76E1941D0DC755009721A4 /* Serializer.swift in Sources */, DE76E18E1D0DC755009721A4 /* JSONAPISerializer.swift in Sources */, DE76E1931D0DC755009721A4 /* Router.swift in Sources */, @@ -746,6 +759,7 @@ buildActionMask = 2147483647; files = ( DE76E1BC1D0DC857009721A4 /* JSONAPITests.swift in Sources */, + 8BE0FC811D16BD9B00FE706A /* JSONAPILinksTests.swift in Sources */, DE76E1CB1D0DC857009721A4 /* URLDecomposerTests.swift in Sources */, DE76E1BF1D0DC857009721A4 /* KakapoDBTests.swift in Sources */, DE76E1C81D0DC857009721A4 /* SerializerTests.swift in Sources */, @@ -763,6 +777,7 @@ DE76E18A1D0DC754009721A4 /* PropertyPolicy.swift in Sources */, DE76E18D1D0DC754009721A4 /* URLDecomposer.swift in Sources */, DE76E1871D0DC754009721A4 /* KakapoDB.swift in Sources */, + 8BE0FC871D172EC900FE706A /* JSONAPILinks.swift in Sources */, DE76E18C1D0DC754009721A4 /* Serializer.swift in Sources */, DE76E1861D0DC754009721A4 /* JSONAPISerializer.swift in Sources */, DE76E18B1D0DC754009721A4 /* Router.swift in Sources */, @@ -776,6 +791,7 @@ buildActionMask = 2147483647; files = ( DE76E1BD1D0DC857009721A4 /* JSONAPITests.swift in Sources */, + 8BE0FC821D16BD9C00FE706A /* JSONAPILinksTests.swift in Sources */, DE76E1CC1D0DC857009721A4 /* URLDecomposerTests.swift in Sources */, DE76E1C01D0DC857009721A4 /* KakapoDBTests.swift in Sources */, DE76E1C91D0DC857009721A4 /* SerializerTests.swift in Sources */, @@ -793,6 +809,7 @@ DE76E1A21D0DC756009721A4 /* PropertyPolicy.swift in Sources */, DE76E1A51D0DC756009721A4 /* URLDecomposer.swift in Sources */, DE76E19F1D0DC756009721A4 /* KakapoDB.swift in Sources */, + 8BE0FC841D172EC900FE706A /* JSONAPILinks.swift in Sources */, DE76E1A41D0DC756009721A4 /* Serializer.swift in Sources */, DE76E19E1D0DC756009721A4 /* JSONAPISerializer.swift in Sources */, DE76E1A31D0DC756009721A4 /* Router.swift in Sources */, @@ -806,6 +823,7 @@ buildActionMask = 2147483647; files = ( DE76E1BB1D0DC857009721A4 /* JSONAPITests.swift in Sources */, + 8BE0FC801D16BD9A00FE706A /* JSONAPILinksTests.swift in Sources */, DE76E1CA1D0DC857009721A4 /* URLDecomposerTests.swift in Sources */, DE76E1BE1D0DC857009721A4 /* KakapoDBTests.swift in Sources */, DE76E1C71D0DC857009721A4 /* SerializerTests.swift in Sources */, diff --git a/README.playground/Contents.swift b/README.playground/Contents.swift index 076b311..6358d8e 100644 --- a/README.playground/Contents.swift +++ b/README.playground/Contents.swift @@ -1,4 +1,35 @@ //: Playground - noun: a place where people can play -import Kakapo +@testable import Kakapo +struct Dog: JSONAPIEntity { + let id: String + let name: String + let cat: Cat? +} + +struct Cat: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let topLinks: [String : JSONAPILink]? = ["one": JSONAPILink.Simple(value: "hello top"), "two": JSONAPILink.Simple(value: "world")] + let links: [String : JSONAPILink]? = ["one": JSONAPILink.Simple(value: "hello"), "two": JSONAPILink.Simple(value: "world")] +} + +struct User: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let dog: Dog + let cats: [Cat] + let links: [String : JSONAPILink]? = ["one": JSONAPILink.Simple(value: "hello"), "two": JSONAPILink.Object(href: "hello", meta: Meta())] +} + +struct Meta: Serializable { + let copyright = "Copyright 2015 Example Corp." + let authors = ["Yehuda Katz", "Steve Klabnik","Dan Gebhardt","Tyler Kellen"] +} + +let cats = [Cat(id: "33", name: "Stancho"), Cat(id: "44", name: "Hez")] +let dog = Dog(id: "22", name: "Joan", cat: cats[0]) +let user = User(id: "11", name: "Alex", dog: dog, cats: cats) + +print(JSONAPISerializer(user).serialize()!) diff --git a/Source/JSONAPILinks.swift b/Source/JSONAPILinks.swift new file mode 100644 index 0000000..f914566 --- /dev/null +++ b/Source/JSONAPILinks.swift @@ -0,0 +1,56 @@ +// +// JSONAPILinks.swift +// Kakapo +// +// Created by Joan Romano on 19/06/16. +// Copyright © 2016 devlucky. All rights reserved. +// + +import Foundation + +public enum JSONAPILink: CustomSerializable { + case Simple(value: String) + case Object(href: String, meta: Serializable) + + public func customSerialize() -> AnyObject? { + switch self { + case let Object(href, meta): + var serializedObject = [String: AnyObject](dictionaryLiteral: ("href", href)) + serializedObject["meta"] = meta.serialize() + + return serializedObject + case let Simple(value): + return value + } + } +} + +public protocol JSONAPILinkedEntity { + var links: [String : JSONAPILink]? { get } + var topLinks: [String : JSONAPILink]? { get } +} + +extension JSONAPILinkedEntity { + public var links: [String : JSONAPILink]? { return nil } + public var topLinks: [String : JSONAPILink]? { return nil } +} + +extension Array: JSONAPILinkedEntity { + public var topLinks: [String : JSONAPILink]? { + var returnLinks = [String : JSONAPILink]() + + for linkedEntity in self { + guard let linkedEntity = linkedEntity as? JSONAPILinkedEntity, + links = linkedEntity.topLinks else { break } + returnLinks += links + } + + return !returnLinks.isEmpty ? returnLinks : nil + } +} + +private func += (inout left: [K:V], right: [K:V]) { + for (k, v) in right { + left.updateValue(v, forKey: k) + } +} diff --git a/Source/JSONAPISerializer.swift b/Source/JSONAPISerializer.swift index 21f2487..2a34789 100644 --- a/Source/JSONAPISerializer.swift +++ b/Source/JSONAPISerializer.swift @@ -70,31 +70,41 @@ public protocol JSONAPIEntity: CustomSerializable, JSONAPISerializable { */ public struct JSONAPISerializer: Serializable { + // Top level `data` member: the document’s “primary data” private let data: AnyObject + + // Top level `included` member: an array of resource objects that are related to the primary data and/or each other (“included resources”). private let included: [AnyObject]? + + // Top level `links` member: a links object related to the primary data. + private let links: AnyObject? /** Initialize a serializer with a single `JSONAPIEntity` - - parameter object: A `JSONAPIEntities` + - parameter object: A `JSONAPIEntity` + - parameter topLinks: A top `JSONAPILink` optional object - returns: A serializable object that serializes a `JSONAPIEntity` conforming to JSON API */ - public init(_ object: T) { + public init(_ object: T, topLinks: [String: JSONAPILink]? = nil) { data = object.serialize()! // can't fail, JSONAPIEntity must always be serializable included = object.includedRelationships() + links = topLinks.serialize() } /** Initialize a serializer with an array of `JSONAPIEntity` - parameter objects: An array of `JSONAPIEntity` + - parameter topLinks: A top `JSONAPILink` optional object - returns: A serializable object that serializes an array of `JSONAPIEntity` conforming to JSON API */ - public init(_ objects: [T]) { + public init(_ objects: [T], topLinks: [String: JSONAPILink]? = nil) { data = objects.serialize()! // can't fail, JSONAPIEntity must always be serializable included = objects.includedRelationships() + links = topLinks.serialize() } } @@ -212,16 +222,27 @@ public extension JSONAPIEntity { var attributes = [String: AnyObject]() var relationships = [String: AnyObject]() + if let linkedEntity = self as? JSONAPILinkedEntity, + entityLinks = linkedEntity.links where entityLinks.count > 0 { + data["links"] = linkedEntity.links.serialize() + } + for child in mirror.children { if let label = child.label { if let value = child.value as? JSONAPISerializable, let data = value.data(includeRelationships: false, includeAttributes: false) { if includeRelationships { - relationships[label] = ["data": data] + var relationship = [String: AnyObject](dictionaryLiteral: ("data", data)) + + if let value = value as? JSONAPILinkedEntity { + relationship["links"] = value.topLinks.serialize() + } + + relationships[label] = relationship } - } else if includeAttributes { + } else if includeAttributes && !["id", "links", "topLinks"].contains(label) { if let value = child.value as? Serializable { attributes[label] = value.serialize() - } else if label != "id" { + } else { assert(child.value is AnyObject) attributes[label] = child.value as? AnyObject } diff --git a/Tests/JSONAPILinksTests.swift b/Tests/JSONAPILinksTests.swift new file mode 100644 index 0000000..3390a6c --- /dev/null +++ b/Tests/JSONAPILinksTests.swift @@ -0,0 +1,182 @@ +// +// JSONAPILinksTests.swift +// Kakapo +// +// Created by Joan Romano on 17/05/16. +// Copyright © 2016 devlucky. All rights reserved. +// + +import Quick +import Nimble +import SwiftyJSON +@testable import Kakapo + +class JSONAPILinksSpec: QuickSpec { + + struct Dog: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let cat: Cat? + let links: [String : JSONAPILink]? + let topLinks: [String : JSONAPILink]? + } + + struct DogWithNoLinks: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let cat: Cat? + } + + struct Cat: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let links: [String : JSONAPILink]? + let topLinks: [String : JSONAPILink]? + } + + struct User: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let dog: Dog + let cats: [Cat] + let links: [String : JSONAPILink]? + } + + struct NoCatsUser: JSONAPIEntity, JSONAPILinkedEntity { + let id: String + let name: String + let dog: DogWithNoLinks + let links: [String : JSONAPILink]? + } + + struct Meta: Serializable { + let copyright = "Copyright 2015 Example Corp." + let authors = ["Yehuda Katz", "Steve Klabnik","Dan Gebhardt","Tyler Kellen"] + } + + override func spec() { + + func json(object: Serializable) -> JSON { + return JSON(object.serialize()!) + } + + describe("JSON API serializer") { + let dog = Dog(id: "22", + name: "Joan", + cat: nil, + links: ["self": JSONAPILink.Simple(value: "selfish"), + "related": JSONAPILink.Simple(value: "relatedish")], + topLinks: nil) + + it("should serialize data with links") { + let object = json(JSONAPISerializer(dog, topLinks: ["test": JSONAPILink.Simple(value: "hello")])) + let data = object["data"]["links"].dictionaryValue + expect(data.count).toNot(equal(0)) + expect(data["self"]).to(equal("selfish")) + } + + it("should serialize top level links") { + let object = json(JSONAPISerializer(dog, topLinks: ["test": JSONAPILink.Simple(value: "hello"), "another": JSONAPILink.Object(href: "http://example.com/articles/1/comments", meta: Meta())])) + let data = object["links"].dictionaryValue + expect(data.count).toNot(equal(0)) + expect(data["test"]).to(equal("hello")) + expect(data["another"]!["href"]).to(equal("http://example.com/articles/1/comments")) + expect(data["another"]!["meta"]["authors"][0]).to(equal("Yehuda Katz")) + } + } + + describe("JSON API Entity links serialization") { + let cats = [Cat(id: "33", + name: "Stancho", + links: nil, + topLinks: ["prev": JSONAPILink.Simple(value: "hello"), + "next": JSONAPILink.Simple(value: "world")]), + Cat(id: "44", + name: "Hez", + links: ["test": JSONAPILink.Simple(value: "hello"), + "another": JSONAPILink.Object(href: "http://example.com/articles/1/comments", meta: Meta())], + topLinks: ["first": JSONAPILink.Simple(value: "yeah"), + "last": JSONAPILink.Simple(value: "text")])] + let dog = Dog(id: "22", + name: "Joan", + cat: cats[0], + links: nil, + topLinks: ["testDog": JSONAPILink.Simple(value: "hello"), + "anotherDog": JSONAPILink.Object(href: "http://example.com/articles/1/comments", meta: Meta())]) + let user = User(id: "11", + name: "Alex", + dog: dog, + cats: cats, + links: ["one": JSONAPILink.Simple(value: "hello"), + "two": JSONAPILink.Object(href: "hello", meta: Meta())]) + + let user2 = User(id: "39", + name: "Joro", + dog: dog, + cats: cats, + links: ["joroLinkOne": JSONAPILink.Simple(value: "hello"), + "joroLinkTwo": JSONAPILink.Object(href: "hello", meta: Meta())]) + + it("should serialize the links inside the top data object") { + let object = json(user) + let links = object["links"].dictionaryValue + expect(links.count).toNot(equal(0)) + expect(links["one"]).to(equal("hello")) + expect(links["two"]!["href"]).to(equal("hello")) + expect(links["two"]!["meta"]["copyright"]).to(equal("Copyright 2015 Example Corp.")) + } + + it("should serialize the top links inside single relationships") { + let object = json(user) + let data = object["relationships"]["dog"]["links"].dictionaryValue + expect(data.count).toNot(equal(0)) + expect(data["anotherDog"]!["href"]).to(equal("http://example.com/articles/1/comments")) + expect(data["testDog"]).to(equal("hello")) + } + + it("should serialize the top links inside multiple relationships") { + let object = json(user) + let data = object["relationships"]["cats"]["links"].dictionaryValue + expect(data.count).toNot(equal(0)) + expect(data["prev"]).to(equal("hello")) + expect(data["next"]).to(equal("world")) + expect(data["first"]).to(equal("yeah")) + expect(data["last"]).to(equal("text")) + } + + it("should serialize the links inside included objects") { + let object = json(JSONAPISerializer(user)) + var data = object["included"][1][1]["links"].dictionaryValue + expect(data).toNot(beNil()) + expect(data["test"]).to(equal("hello")) + expect(data["another"]!["href"]).to(equal("http://example.com/articles/1/comments")) + } + + it("should serialize the links inside array of data objects") { + let object = json(JSONAPISerializer([user, user2])) + let firstData = object["data"][0]["links"] + let secondData = object["data"][0]["links"] + expect(firstData).toNot(beNil()) + expect(secondData).toNot(beNil()) + } + + it("should not serialize nil links") { + let object = json(DogWithNoLinks(id: "213", name: "Hez", cat: cats[0])) + expect(object["links"].count) == 0 + } + + it("should not serialize nil top links") { + let newUser = NoCatsUser(id: "11", + name: "Alex", + dog: DogWithNoLinks(id: "213", name: "Hez", cat: cats[0]), + links: ["one": JSONAPILink.Simple(value: "hello"), + "two": JSONAPILink.Object(href: "hello", meta: Meta())]) + let object = json(newUser) + let data = object["relationships"]["dog"]["links"] + expect(data.count) == 0 + } + } + + } + +} diff --git a/Tests/KakapoDBTests.swift b/Tests/KakapoDBTests.swift index c7c95af..6fc7df7 100644 --- a/Tests/KakapoDBTests.swift +++ b/Tests/KakapoDBTests.swift @@ -333,7 +333,6 @@ class KakapoDBTests: QuickSpec { let users = sut.create(UserFactory.self, number: 100) dispatch_apply(100, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)) { i in - print(i) try! sut.delete(users[i]) }