diff --git a/.travis.yml b/.travis.yml index b456f11..360d403 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ os: osx osx_image: xcode9.2 language: swift -before_install: -- pod repo update > /dev/null script: - set -o pipefail -- xcodebuild test -workspace Vox.xcworkspace -scheme Vox -destination 'platform=iOS Simulator,name=iPhone 8,OS=11.2' ONLY_ACTIVE_ARCH=NO | xcpretty -c -- pod lib lint \ No newline at end of file +- xcodebuild test -workspace Vox.xcworkspace -scheme Vox -destination 'platform=iOS Simulator,name=iPhone 8,OS=11.2' ONLY_ACTIVE_ARCH=NO +- pod lib lint +after_success: +- bash <(curl -s https://codecov.io/bash) -J 'Vox' \ No newline at end of file diff --git a/README.md b/README.md index 54cbea3..15d6a0b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Vox is a Swift JSONAPI standard implementation. +[![Build Status](https://travis-ci.org/aronbalog/Vox.svg?branch=master)](https://travis-ci.org/aronbalog/Vox) +[![codecov](https://codecov.io/gh/aronbalog/Vox/branch/master/graph/badge.svg)](https://codecov.io/gh/aronbalog/Vox) + - 🎩 [The magic behind](#the-magic-behind) - 💻 [Installation](#motivation-) - 🚀 [Usage](#getting-started-) @@ -13,12 +16,24 @@ Vox is a Swift JSONAPI standard implementation. - [Deserializing](#deserializing) - [Single resource](#single-resource) - [Resource collection](#resource-collection) - - [Network](#network) + - [Networking](#networking) - [Fetching single resource](#fetching-single-resource) - [Fetching resource collection](#fetching-resource-collection) - [Creating resource](#creating-resource) - [Updating resource](#updating-resource) - [Deleting resource](#deleting-resource) + - [Pagination](#pagination) + - [Pagination on initial request](#pagination-on-initial-request) + - [Custom pagination strategy](#custom-pagination-strategy) + - [Page-based pagination strategy](#page-based-pagination-strategy) + - [Offset-based pagination strategy](#offset-based-pagination-strategy) + - [Cursor-based pagination strategy](#cursor-based-pagination-strategy) + - [Appending next page to current document](#appending-next-page-to-current-document) + - [Fetching next document page](#fetching-next-document-page) + - [Fetching previous document page](#fetching-previous-document-page) + - [Fetching first document page](#fetching-first-document-page) + - [Fetching last document page](#fetching-last-document-page) + - [Reloading current document page](#reloading-current-document-page) - [Custom routing](#custom-routing) - ✅ [Tests](#tests) - [Contributing](#contributing) @@ -26,7 +41,7 @@ Vox is a Swift JSONAPI standard implementation. ## 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](https://en.wikipedia.org/wiki/Marshalling_(computer_science)) and [Unmarshalling](https://en.wikipedia.org/wiki/Unmarshalling) techniques to deal with direct memory access and performance challenges. Proxy (surrogat) design pattern gives us an opportunity to manipulate JSON's value directly through class properties and vice versa. +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](https://en.wikipedia.org/wiki/Marshalling_(computer_science)) and [Unmarshalling](https://en.wikipedia.org/wiki/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. ```swift import Vox @@ -51,7 +66,7 @@ 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 interafaces to a JSON object. +> Think about your `Resource` classes as strong typed interfaces to a JSON object. This opens up the possibility to easily handle the cases with: @@ -300,16 +315,17 @@ Deserializer can also be declared without generic parameter but in that case the | `links` | `Links` | `Links` object, e.g. can contain pagination data | `included` | `[[String: Any]]` | `included` array of dictionaries -### Network +### Networking +You can use `` and `` annotations in path strings. If possible, they'll get replaced with adequate values. #### Fetching single resource ```swift -let dataSource = DataSource(strategy: .path("persons/1"), client: client) +let dataSource = DataSource(strategy: .path("custom-path//"), client: client) dataSource - .fetch(id:"1") // when using path strategy `id` will be ignored because there is custom path defined + .fetch(id:"1") .include([ "favoriteArticle" ]) @@ -330,7 +346,7 @@ dataSource #### Fetching resource collection ```swift -let dataSource = DataSource(strategy: .path("persons"), client: client) +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) Person.dataSource(url: url) .fetch() @@ -353,7 +369,7 @@ let person = Person() person.age = 40 person.gender = "female" -let dataSource = DataSource(strategy: .path("persons"), client: client) +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) dataSource .create(person) @@ -372,7 +388,7 @@ let person = Person() person.age = 41 person.gender = .null -let dataSource = DataSource(strategy: .path("persons/1"), client: client) +let dataSource = DataSource(strategy: .path("custom-path//"), client: client) dataSource .update(resource: person) @@ -386,13 +402,10 @@ dataSource #### Deleting resource ```swift -let person = Person() - person.id = "1" - -let dataSource = DataSource(strategy: .path("persons/1"), client: client) +let dataSource = DataSource(strategy: .path("custom-path//"), client: client) -Person.dataSource - .delete(id: person.id!) // when using path strategy `id` will be ignored because there is custom path defined +dataSource + .delete(id: "1") .result({ }) { (error) in @@ -400,6 +413,142 @@ Person.dataSource } ``` +#### Pagination + +##### Pagination on initial request + +###### Custom pagination strategy + +```swift +let paginationStrategy: PaginationStrategy // -> your object conforming `PaginationStrategy` protocol + +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) + +dataSource + .fetch() + .paginate(paginationStrategy) + .result({ (document) in + + }, { (error) in + + }) +``` + +###### Page-based pagination strategy + +```swift +let paginationStrategy = Pagination.PageBased(number: 1, size: 10) + +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) + +dataSource + .fetch() + .paginate(paginationStrategy) + .result({ (document) in + + }, { (error) in + + }) +``` + +###### Offset-based pagination strategy + +```swift +let paginationStrategy = Pagination.OffsetBased(offset: 10, limit: 10) + +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) + +dataSource + .fetch() + .paginate(paginationStrategy) + .result({ (document) in + + }, { (error) in + + }) +``` + +###### Cursor-based pagination strategy + +```swift +let paginationStrategy = Pagination.CursorBased(cursor: "cursor") + +let dataSource = DataSource(strategy: .path("custom-path/"), client: client) + +dataSource + .fetch() + .paginate(paginationStrategy) + .result({ (document) in + + }, { (error) in + + }) +``` + +##### Appending next page to current document + +```swift +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 + +```swift +document.next?.result({ (nextDocument) in + // `nextDocument` is same type as `document` +}, { (error) in + +}) +``` + +##### Fetching previous document page + +```swift +document.previous?.result({ (previousDocument) in + // `previousDocument` is same type as `document` +}, { (error) in + +}) +``` + +##### Fetching first document page + +```swift +document.first?.result({ (firstDocument) in + // `firstDocument` is same type as `document` +}, { (error) in + +}) +``` + +##### Fetching last document page + +```swift +document.last?.result({ (lastDocument) in + // `lastDocument` is same type as `document` +}, { (error) in + +}) +``` + +##### Reloading current document page + +```swift +document.reload?.result({ (reloadedDocument) in + // `reloadedDocument` is same type as `document` +}, { (error) in + +}) +``` + #### Custom routing Generating URL for resources can be automated. @@ -409,29 +558,29 @@ Make a new object conforming `Router`. Simple example: ```swift class ResourceRouter: Router { func fetch(id: String, type: Resource.Type) -> String { - let type = type.typeIdentifier() + let type = type.resourceType - return type + "/" + id + return type + "/" + id // or "/" } func fetch(type: Resource.Type) -> String { - return type.typeIdentifier() + return type.resourceType // or "" } func create(resource: Resource) -> String { - return resource.type + return resource.type // or "" } func update(resource: Resource) -> String { - let type = type.typeIdentifier() + let type = type.resourceType - return type + "/" + id + return type + "/" + id // or "/" } func delete(id: String, type: Resource.Type) -> String { - let type = type.typeIdentifier() + let type = type.resourceType - return type + "/" + id + return type + "/" + id // or "/" } } ``` diff --git a/Vox.podspec b/Vox.podspec index 7fb91d9..509b62a 100644 --- a/Vox.podspec +++ b/Vox.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'Vox' - spec.version = '1.0.2' + spec.version = '1.0.3' spec.license = 'MIT' spec.summary = 'A Swift JSONAPI framework' spec.author = 'Aron Balog' diff --git a/Vox.xcodeproj/project.pbxproj b/Vox.xcodeproj/project.pbxproj index 7c6bf5a..ab8452d 100644 --- a/Vox.xcodeproj/project.pbxproj +++ b/Vox.xcodeproj/project.pbxproj @@ -27,6 +27,17 @@ 387FB929203D07AB00CE47F9 /* SerializerSingleSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3821D274203A02B200AE241E /* SerializerSingleSpec.swift */; }; 387FB92B203D08C100CE47F9 /* SerializerCollectionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3821D273203A02B200AE241E /* SerializerCollectionSpec.swift */; }; 38965BF02038B6CB00FDD64A /* NullableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38965BEF2038B6CB00FDD64A /* NullableValue.swift */; }; + 3899555920425EC2007E2DE5 /* Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899555820425EC2007E2DE5 /* Links.swift */; }; + 3899555B204269FD007E2DE5 /* QueryItemsCustomizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899555A204269FD007E2DE5 /* QueryItemsCustomizable.swift */; }; + 3899555D20430232007E2DE5 /* PaginationStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899555C20430232007E2DE5 /* PaginationStrategy.swift */; }; + 3899556020430EDA007E2DE5 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899555F20430EDA007E2DE5 /* Pagination.swift */; }; + 3899556220430F6D007E2DE5 /* Pagination_PageBased.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899556120430F6D007E2DE5 /* Pagination_PageBased.swift */; }; + 38995564204310D0007E2DE5 /* Pagination_OffsetBased.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38995563204310D0007E2DE5 /* Pagination_OffsetBased.swift */; }; + 3899556620431121007E2DE5 /* Pagination_CursorBased.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3899556520431121007E2DE5 /* Pagination_CursorBased.swift */; }; + 38C8189D2042217500B6452C /* FetchPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C8189C2042217500B6452C /* FetchPageable.swift */; }; + 38C8189F2042292600B6452C /* PaginationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C8189E2042292600B6452C /* PaginationSpec.swift */; }; + 38C818A120422A2000B6452C /* Pagination1.json in Resources */ = {isa = PBXBuildFile; fileRef = 38C818A020422A2000B6452C /* Pagination1.json */; }; + 38C818A320422AD100B6452C /* Pagination2.json in Resources */ = {isa = PBXBuildFile; fileRef = 38C818A220422AD100B6452C /* Pagination2.json */; }; 38E00CF42038CDD800B45B4C /* Player.json in Resources */ = {isa = PBXBuildFile; fileRef = 38E00CF32038CDD800B45B4C /* Player.json */; }; 38E00CF52038CDD800B45B4C /* Player.json in Resources */ = {isa = PBXBuildFile; fileRef = 38E00CF32038CDD800B45B4C /* Player.json */; }; 38E00CFB2038CEEC00B45B4C /* Document.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E00CFA2038CEEC00B45B4C /* Document.swift */; }; @@ -94,7 +105,18 @@ 3872FA4F2037A10800CC26BD /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; 3872FA512037A46700CC26BD /* PropertyAccessorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyAccessorSpec.swift; sourceTree = ""; }; 38965BEF2038B6CB00FDD64A /* NullableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullableValue.swift; sourceTree = ""; }; + 3899555820425EC2007E2DE5 /* Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Links.swift; sourceTree = ""; }; + 3899555A204269FD007E2DE5 /* QueryItemsCustomizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryItemsCustomizable.swift; sourceTree = ""; }; + 3899555C20430232007E2DE5 /* PaginationStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationStrategy.swift; sourceTree = ""; }; + 3899555F20430EDA007E2DE5 /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = ""; }; + 3899556120430F6D007E2DE5 /* Pagination_PageBased.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination_PageBased.swift; sourceTree = ""; }; + 38995563204310D0007E2DE5 /* Pagination_OffsetBased.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination_OffsetBased.swift; sourceTree = ""; }; + 3899556520431121007E2DE5 /* Pagination_CursorBased.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination_CursorBased.swift; sourceTree = ""; }; 38AF37AA203A1A10009D6112 /* Vox.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Vox.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 38C8189C2042217500B6452C /* FetchPageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchPageable.swift; sourceTree = ""; }; + 38C8189E2042292600B6452C /* PaginationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationSpec.swift; sourceTree = ""; }; + 38C818A020422A2000B6452C /* Pagination1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Pagination1.json; sourceTree = ""; }; + 38C818A220422AD100B6452C /* Pagination2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Pagination2.json; sourceTree = ""; }; 38E00CF32038CDD800B45B4C /* Player.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Player.json; sourceTree = ""; }; 38E00CF62038CE0200B45B4C /* DeserializingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeserializingSpec.swift; sourceTree = ""; }; 38E00CFA2038CEEC00B45B4C /* Document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Document.swift; sourceTree = ""; }; @@ -244,6 +266,7 @@ 38E00CFA2038CEEC00B45B4C /* Document.swift */, 38E00D40203911A100B45B4C /* ErrorObject.swift */, 3821D26B2039F64F00AE241E /* ResourcePool.swift */, + 3899555820425EC2007E2DE5 /* Links.swift */, ); path = Class; sourceTree = ""; @@ -256,6 +279,17 @@ path = Protocol; sourceTree = ""; }; + 3899555E20430EBC007E2DE5 /* Extensions */ = { + isa = PBXGroup; + children = ( + 3899555F20430EDA007E2DE5 /* Pagination.swift */, + 3899556120430F6D007E2DE5 /* Pagination_PageBased.swift */, + 38995563204310D0007E2DE5 /* Pagination_OffsetBased.swift */, + 3899556520431121007E2DE5 /* Pagination_CursorBased.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 38E00CF22038CDCA00B45B4C /* Assets */ = { isa = PBXGroup; children = ( @@ -266,6 +300,8 @@ 38E00D2E2039103400B45B4C /* ErrorsWithSource.json */, 3811C013203D888700EE52B8 /* ErrorsWithoutSource.json */, 38E00CF32038CDD800B45B4C /* Player.json */, + 38C818A020422A2000B6452C /* Pagination1.json */, + 38C818A220422AD100B6452C /* Pagination2.json */, ); path = Assets; sourceTree = ""; @@ -280,6 +316,7 @@ 38E00CFF20390C2D00B45B4C /* Networking */ = { isa = PBXGroup; children = ( + 3899555E20430EBC007E2DE5 /* Extensions */, 38E00D0020390C2D00B45B4C /* Class */, 38E00D0320390C2D00B45B4C /* Protocol */, ); @@ -312,6 +349,9 @@ 38E00D0F20390C2D00B45B4C /* FilterStrategy.swift */, 38E00D1020390C2D00B45B4C /* Router.swift */, 38E00D1120390C2D00B45B4C /* Updatable.swift */, + 3899555C20430232007E2DE5 /* PaginationStrategy.swift */, + 38C8189C2042217500B6452C /* FetchPageable.swift */, + 3899555A204269FD007E2DE5 /* QueryItemsCustomizable.swift */, ); path = Protocol; sourceTree = ""; @@ -339,6 +379,7 @@ isa = PBXGroup; children = ( 38E00D4820391D3A00B45B4C /* DataSourceSpec.swift */, + 38C8189E2042292600B6452C /* PaginationSpec.swift */, ); path = DataSource; sourceTree = ""; @@ -473,10 +514,12 @@ buildActionMask = 2147483647; files = ( 38E00D302039103400B45B4C /* Articles.json in Resources */, + 38C818A320422AD100B6452C /* Pagination2.json in Resources */, 38E00D362039103400B45B4C /* ErrorsWithSource.json in Resources */, 38E00CF52038CDD800B45B4C /* Player.json in Resources */, 38E00D4620391CE500B45B4C /* DeserializerSinglePolymorphic.json in Resources */, 38E00D342039103400B45B4C /* Big.json in Resources */, + 38C818A120422A2000B6452C /* Pagination1.json in Resources */, 3811C014203D888700EE52B8 /* ErrorsWithoutSource.json in Resources */, 38E00D322039103400B45B4C /* Article.json in Resources */, ); @@ -579,10 +622,13 @@ buildActionMask = 2147483647; files = ( 38E00D1C20390C2D00B45B4C /* FetchFilterable.swift in Sources */, + 3899555B204269FD007E2DE5 /* QueryItemsCustomizable.swift in Sources */, + 3899556620431121007E2DE5 /* Pagination_CursorBased.swift in Sources */, 38E00D41203911A100B45B4C /* ErrorObject.swift in Sources */, 38E00D1A20390C2D00B45B4C /* FetchConfigurable.swift in Sources */, 38E00D1520390C2D00B45B4C /* Creatable.swift in Sources */, 38E00D2720390D5B00B45B4C /* Deserializer_Single.swift in Sources */, + 38995564204310D0007E2DE5 /* Pagination_OffsetBased.swift in Sources */, 38E00D44203911B700B45B4C /* JSONAPIError.swift in Sources */, 3872FA502037A10800CC26BD /* Resource.swift in Sources */, 38E00D1D20390C2D00B45B4C /* FetchIncludable.swift in Sources */, @@ -597,12 +643,17 @@ 38E00D2120390C2D00B45B4C /* Updatable.swift in Sources */, 3872FA4E2037A07C00CC26BD /* BaseResource.m in Sources */, 38E00D1920390C2D00B45B4C /* Fetchable.swift in Sources */, + 3899556220430F6D007E2DE5 /* Pagination_PageBased.swift in Sources */, + 3899556020430EDA007E2DE5 /* Pagination.swift in Sources */, 3821D26A2039E51D00AE241E /* Context_Query.swift in Sources */, 38E00D1620390C2D00B45B4C /* CRUD.swift in Sources */, 38E00D2020390C2D00B45B4C /* Router.swift in Sources */, 38E00D1320390C2D00B45B4C /* Request.swift in Sources */, 38E00D1720390C2D00B45B4C /* DataSourceResultable.swift in Sources */, + 3899555920425EC2007E2DE5 /* Links.swift in Sources */, + 38C8189D2042217500B6452C /* FetchPageable.swift in Sources */, 38E00CFB2038CEEC00B45B4C /* Document.swift in Sources */, + 3899555D20430232007E2DE5 /* PaginationStrategy.swift in Sources */, 3872FA4A20379FDD00CC26BD /* Context.swift in Sources */, 3821D26C2039F64F00AE241E /* ResourcePool.swift in Sources */, 38E00D1F20390C2D00B45B4C /* FilterStrategy.swift in Sources */, @@ -616,6 +667,7 @@ files = ( 387FB928203D07A900CE47F9 /* PerformanceSpec.swift in Sources */, 3872FA3D20379F8300CC26BD /* VoxTests.swift in Sources */, + 38C8189F2042292600B6452C /* PaginationSpec.swift in Sources */, 387FB926203D033D00CE47F9 /* DeserializerErrorsSpec.swift in Sources */, 387FB923203D028D00CE47F9 /* DeserializerSinglePolymorphicSpec.swift in Sources */, 387FB929203D07AB00CE47F9 /* SerializerSingleSpec.swift in Sources */, diff --git a/Vox.xcodeproj/xcshareddata/xcschemes/Vox.xcscheme b/Vox.xcodeproj/xcshareddata/xcschemes/Vox.xcscheme index d1219d0..73f7fcc 100644 --- a/Vox.xcodeproj/xcshareddata/xcschemes/Vox.xcscheme +++ b/Vox.xcodeproj/xcshareddata/xcschemes/Vox.xcscheme @@ -39,6 +39,11 @@ BlueprintName = "VoxTests" ReferencedContainer = "container:Vox.xcodeproj"> + + + + diff --git a/Vox/Class/Context.swift b/Vox/Class/Context.swift index a9a027c..621f2ad 100644 --- a/Vox/Class/Context.swift +++ b/Vox/Class/Context.swift @@ -10,7 +10,7 @@ public class Context: NSObject { self.dictionary = dictionary } - let queue = DispatchQueue(label: "vox.contex.queue", attributes: .concurrent) + let queue = DispatchQueue(label: "vox.context.queue", attributes: .concurrent) @objc public static func registerClass(_ resourceClass: Resource.Type) { classes[resourceClass.resourceType] = resourceClass diff --git a/Vox/Class/Document.swift b/Vox/Class/Document.swift index a1cdd66..89b2354 100644 --- a/Vox/Class/Document.swift +++ b/Vox/Class/Document.swift @@ -1,17 +1,18 @@ import Foundation public class Document { - public let data: DataType? + public internal(set) var data: DataType? public let meta: [String: Any]? public let jsonapi: [String: Any]? - public let links: [String: Any]? + public let links: Links? - public let included: [[String: Any]]? + public internal(set) var included: [[String: Any]]? let context: Context + weak var client: Client? init( data: DataType?, @@ -24,6 +25,14 @@ public class Document { self.data = data self.meta = meta self.jsonapi = jsonapi + let links: Links? = { + guard + let links = links, + let data = try? JSONSerialization.data(withJSONObject: links, options: []) + else { return nil } + + return try? JSONDecoder().decode(Links.self, from: data) + }() self.links = links self.included = included self.context = context diff --git a/Vox/Class/Links.swift b/Vox/Class/Links.swift new file mode 100644 index 0000000..6a637da --- /dev/null +++ b/Vox/Class/Links.swift @@ -0,0 +1,27 @@ +import Foundation + +public class Links: Decodable { + public let _self: URL? + public let first: URL? + public let prev: URL? + public let next: URL? + public let last: URL? + + enum CodingKeys: String, CodingKey { + case _self = "self" + case first = "first" + case prev = "prev" + case next = "next" + case last = "last" + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + _self = try container.decodeIfPresent(URL.self, forKey: Links.CodingKeys._self) + first = try container.decodeIfPresent(URL.self, forKey: Links.CodingKeys.first) + prev = try container.decodeIfPresent(URL.self, forKey: Links.CodingKeys.prev) + next = try container.decodeIfPresent(URL.self, forKey: Links.CodingKeys.next) + last = try container.decodeIfPresent(URL.self, forKey: Links.CodingKeys.last) + } +} diff --git a/Vox/Networking/Class/DataSource.swift b/Vox/Networking/Class/DataSource.swift index dd99ebc..87e0765 100644 --- a/Vox/Networking/Class/DataSource.swift +++ b/Vox/Networking/Class/DataSource.swift @@ -7,6 +7,11 @@ public class DataSource: NSObject, CRUD { case router(_: Router) } + private enum AnnotationStrategy { + case resourceIdentifier(id: String?, type: String) + case resource(ResourceType) + } + public typealias DocumentType = Document public typealias ResourceSuccessBlock = (_ document: DocumentType) -> Void public typealias OptionalResourceSuccessBlock = (_ document: DocumentType?) -> Void @@ -28,7 +33,7 @@ public class DataSource: NSObject, CRUD { } public func create(_ resource: ResourceType) -> Request { - let path: String = { + var path: String = { switch strategy { case .path(let path): return path @@ -37,6 +42,8 @@ public class DataSource: NSObject, CRUD { } }() + path = replaceAnnotations(on: path, with: DataSource.AnnotationStrategy.resource(resource)) + let request = Request(path: path, httpMethod: "POST", client: client) request.resource = resource @@ -45,7 +52,7 @@ public class DataSource: NSObject, CRUD { } public func fetch() -> FetchRequest { - let path: String = { + var path: String = { switch strategy { case .path(let path): return path @@ -54,13 +61,15 @@ public class DataSource: NSObject, CRUD { } }() + path = replaceAnnotations(on: path, with: DataSource.AnnotationStrategy.resourceIdentifier(id: nil, type: ResourceType.resourceType)) + let request = FetchRequest(path: path, httpMethod: "GET", client: client) return request } public func fetch(id: String) -> FetchRequest { - let path: String = { + var path: String = { switch strategy { case .path(let path): return path @@ -69,13 +78,15 @@ public class DataSource: NSObject, CRUD { } }() + path = replaceAnnotations(on: path, with: DataSource.AnnotationStrategy.resourceIdentifier(id: id, type: ResourceType.resourceType)) + let request = FetchRequest(path: path, httpMethod: "GET", client: client) return request } public func update(_ resource: ResourceType) -> Request { - let path: String = { + var path: String = { switch strategy { case .path(let path): return path @@ -84,6 +95,8 @@ public class DataSource: NSObject, CRUD { } }() + path = replaceAnnotations(on: path, with: DataSource.AnnotationStrategy.resource(resource)) + let request = Request(path: path, httpMethod: "PATCH", client: client) request.resource = resource @@ -92,7 +105,7 @@ public class DataSource: NSObject, CRUD { } public func delete(id: String) -> Request { - let path: String = { + var path: String = { switch strategy { case .path(let path): return path @@ -101,8 +114,30 @@ public class DataSource: NSObject, CRUD { } }() + path = replaceAnnotations(on: path, with: DataSource.AnnotationStrategy.resourceIdentifier(id: id, type: ResourceType.resourceType)) + let request = Request(path: path, httpMethod: "DELETE", client: client) return request } + + private func replaceAnnotations(on path: String, with strategy: AnnotationStrategy) -> String { + var newPath = path + + switch strategy { + case .resource(let resource): + if let id = resource.id { + newPath = newPath.replacingOccurrences(of: "", with: id) + } + newPath = newPath.replacingOccurrences(of: "", with: resource.type) + case .resourceIdentifier(let id, let type): + if let id = id { + newPath = newPath.replacingOccurrences(of: "", with: id) + } + newPath = newPath.replacingOccurrences(of: "", with: type) + } + + return newPath + } } + diff --git a/Vox/Networking/Class/Request.swift b/Vox/Networking/Class/Request.swift index 9f56da3..4b9dcb0 100644 --- a/Vox/Networking/Class/Request.swift +++ b/Vox/Networking/Class/Request.swift @@ -1,6 +1,6 @@ import Foundation -public class Request: DataSourceResultable { +public class Request: DataSourceResultable, QueryItemsCustomizable { public typealias DataSourceResourceSuccessfulBlock = SuccessCallbackType private var path: String @@ -27,6 +27,12 @@ public class Request: DataSourceRes try execute() } + public func queryItems(_ queryItems: [URLQueryItem]) -> Self { + self.queryItems.append(contentsOf: queryItems) + + return self + } + func execute() throws { let parameters: [String: Any]? = try resource?.documentDictionary() @@ -65,6 +71,7 @@ public class Request: DataSourceRes do { let document: Document<[ResourceType]> = try Deserializer.Collection().deserialize(data: data) + document.client = self.client success(document) } catch let __error as JSONAPIError { self.failureBlock?(__error) @@ -92,15 +99,23 @@ public class Request: DataSourceRes } public class FetchRequest: Request, FetchConfigurable { - public var filter: FilterStrategy? { + public internal(set) var pagination: PaginationStrategy? { didSet { - self.filter?.filterURLQueryItems().forEach({ (queryItem) in - queryItems.append(queryItem) - }) + guard let queryItems = self.pagination?.paginationURLQueryItems() else { return } + + self.queryItems.append(contentsOf: queryItems) + } + } + + public internal(set) var filter: FilterStrategy? { + didSet { + guard let queryItems = self.filter?.filterURLQueryItems() else { return } + + self.queryItems.append(contentsOf: queryItems) } } - public var sort: [Sort] = [] { + public internal(set) var sort: [Sort] = [] { didSet { let value = self.sort.map { element in return element.value @@ -110,7 +125,7 @@ public class FetchRequest: Request< } } - public var fields: Fields? { + public internal(set) var fields: Fields? { didSet { var dictionary: [String: String] = [:] fields?.forEach({ (field) in @@ -123,7 +138,7 @@ public class FetchRequest: Request< queryItems.append(contentsOf: _queryItems) } } - public var include: [String] = [] { + public internal(set) var include: [String] = [] { didSet { let value = include.joined(separator: ",") let queryItem = URLQueryItem(name: "include", value: value) @@ -155,4 +170,11 @@ public class FetchRequest: Request< return self } + + + public func paginate(_ pagination: PaginationStrategy) -> FetchRequest { + self.pagination = pagination + + return self + } } diff --git a/Vox/Networking/Extensions/Pagination.swift b/Vox/Networking/Extensions/Pagination.swift new file mode 100644 index 0000000..f6b8240 --- /dev/null +++ b/Vox/Networking/Extensions/Pagination.swift @@ -0,0 +1,5 @@ +import Foundation + +struct Pagination { + +} diff --git a/Vox/Networking/Extensions/Pagination_CursorBased.swift b/Vox/Networking/Extensions/Pagination_CursorBased.swift new file mode 100644 index 0000000..a9165ff --- /dev/null +++ b/Vox/Networking/Extensions/Pagination_CursorBased.swift @@ -0,0 +1,22 @@ +import Foundation + +extension Pagination { + public class CursorBased: PaginationStrategy { + public func paginationURLQueryItems() -> [URLQueryItem] { + let name = "\(key)[cursor]" + let item = URLQueryItem(name: name, value: cursor) + + return [item] + } + + public let key: String + public let cursor: String + + public init(cursor: String, key: String = "page") { + self.key = key + self.cursor = cursor + } + } +} + + diff --git a/Vox/Networking/Extensions/Pagination_OffsetBased.swift b/Vox/Networking/Extensions/Pagination_OffsetBased.swift new file mode 100644 index 0000000..1283337 --- /dev/null +++ b/Vox/Networking/Extensions/Pagination_OffsetBased.swift @@ -0,0 +1,34 @@ +import Foundation + +extension Pagination { + public class OffsetBased: PaginationStrategy { + public func paginationURLQueryItems() -> [URLQueryItem] { + var items: [URLQueryItem] = [] + + if let offset = offset { + let name = "\(key)[offset]" + let item = URLQueryItem(name: name, value: String(offset)) + items.append(item) + } + + if let limit = limit { + let name = "\(key)[limit]" + let item = URLQueryItem(name: name, value: String(limit)) + items.append(item) + } + + return items + } + + public let key: String + public let offset: Int? + public let limit: Int? + + public init(offset: Int? = nil, limit: Int? = nil, key: String = "page") { + self.key = key + self.offset = offset + self.limit = limit + } + } +} + diff --git a/Vox/Networking/Extensions/Pagination_PageBased.swift b/Vox/Networking/Extensions/Pagination_PageBased.swift new file mode 100644 index 0000000..593e2c8 --- /dev/null +++ b/Vox/Networking/Extensions/Pagination_PageBased.swift @@ -0,0 +1,33 @@ +import Foundation + +extension Pagination { + public class PageBased: PaginationStrategy { + public func paginationURLQueryItems() -> [URLQueryItem] { + var items: [URLQueryItem] = [] + + if let number = number { + let name = "\(key)[number]" + let item = URLQueryItem(name: name, value: String(number)) + items.append(item) + } + + if let size = size { + let name = "\(key)[size]" + let item = URLQueryItem(name: name, value: String(size)) + items.append(item) + } + + return items + } + + public let key: String + public let number: Int? + public let size: Int? + + public init(number: Int? = nil, size: Int? = nil, key: String = "page") { + self.key = key + self.number = number + self.size = size + } + } +} diff --git a/Vox/Networking/Protocol/Client.swift b/Vox/Networking/Protocol/Client.swift index dd4bca5..1b24185 100644 --- a/Vox/Networking/Protocol/Client.swift +++ b/Vox/Networking/Protocol/Client.swift @@ -3,7 +3,7 @@ import Foundation public typealias ClientSuccessBlock = (_ response: HTTPURLResponse?, _ data: Data?) -> Void public typealias ClientFailureBlock = (_ error: Error, _ data: Data?) -> Void -public protocol Client { +public protocol Client: class { func executeRequest(_ path: String, method: String, queryItems: [URLQueryItem], parameters: [String: Any]?, success: @escaping ClientSuccessBlock, _ failure: @escaping ClientFailureBlock) } diff --git a/Vox/Networking/Protocol/FetchConfigurable.swift b/Vox/Networking/Protocol/FetchConfigurable.swift index 853ade9..f2f25c0 100644 --- a/Vox/Networking/Protocol/FetchConfigurable.swift +++ b/Vox/Networking/Protocol/FetchConfigurable.swift @@ -1,5 +1,8 @@ import Foundation -public protocol FetchConfigurable: FetchFieldable, FetchIncludable, FetchSortable, FetchFilterable { - -} +public typealias FetchConfigurable = FetchFieldable & + FetchIncludable & + FetchSortable & + FetchFilterable & + FetchPageable + diff --git a/Vox/Networking/Protocol/FetchPageable.swift b/Vox/Networking/Protocol/FetchPageable.swift new file mode 100644 index 0000000..a012b7b --- /dev/null +++ b/Vox/Networking/Protocol/FetchPageable.swift @@ -0,0 +1,146 @@ +import Foundation + +public protocol FetchPageable { + associatedtype FetchConfigurableType + + var pagination: PaginationStrategy? { get } + + func paginate(_ pagination: PaginationStrategy) -> FetchConfigurableType +} + + + +public struct PaginationData { + let old: [T] + let new: [T] + let all: [T] +} + +public extension Document where DataType: Collection, DataType.Element: Resource { + typealias ResourceCollectionSuccessBlock = DataSource.ResourceCollectionSuccessBlock + + public var first: FetchRequest? { + guard let client = client else { fatalError("Client not available") } + guard let first = self.links?.first else { return nil } + + let dataSource = DataSource(strategy: .path(first.path), client: client) + + return dataSource.fetch() + } + + public var next: FetchRequest? { + guard let client = client else { fatalError("Client not available") } + guard let next = self.links?.next else { return nil } + + let dataSource = DataSource(strategy: .path(next.path), client: client) + + return dataSource.fetch() + } + + public var previous: FetchRequest? { + guard let client = client else { fatalError("Client not available") } + guard let prev = self.links?.prev else { return nil } + + let dataSource = DataSource(strategy: .path(prev.path), client: client) + + return dataSource.fetch() + } + + public var last: FetchRequest? { + guard let client = client else { fatalError("Client not available") } + guard let last = self.links?.last else { return nil } + + let dataSource = DataSource(strategy: .path(last.path), client: client) + + return dataSource.fetch() + } + + public var reload: FetchRequest? { + guard let client = client else { fatalError("Client not available") } + guard let _self = self.links?._self else { return nil } + + let dataSource = DataSource(strategy: .path(_self.path), client: client) + + return dataSource.fetch() + } + + public func appendNext(_ completion: ((PaginationData) -> Void)? = nil, _ failure: ((Error) -> Void)? = nil) { + guard let next = self.links?.next else { + return + } + + guard let client = client else { + fatalError("Client not available") + } + + let path = URLComponents(url: next, resolvingAgainstBaseURL: false) + + let queryItems = path?.queryItems ?? [] + + let dataSource = DataSource(strategy: .path(next.path), client: client) + + do { + try dataSource + .fetch() + .queryItems(queryItems) + .result({ (document) in + let oldElements = self.data as? [DataType.Element] ?? [] + let newElements = document.data ?? [] + + self.mergeDocument(document) + + let allElements = self.data as? [DataType.Element] ?? [] + + let page = PaginationData.init(old: oldElements, new: newElements, all: allElements) + completion?(page) + }) { (error) in + failure?(error) + } + } catch let error { + failure?(error) + } + + } + + private func mergeDocument(_ document: Document<[DataType.Element]>) { + if let array = document.context.dictionary["data"] as? [Any] { + let selfData = self.context.dictionary["data"] as? NSMutableArray + + let resources = array.flatMap({ (resourceJson) -> Resource? in + guard let resourceJson = resourceJson as? NSMutableDictionary else { return nil } + let copy = resourceJson.mutableCopy() as! NSMutableDictionary + + selfData?.add(copy) + + guard let resource = self.context.mapResource(for: copy) else { return nil } + + return resource + }) + + + var collection = data as? [Resource] + collection?.append(contentsOf: resources) + self.data = collection as? DataType + + print(collection!.count) + } + + if let array = document.context.dictionary["included"] as? NSMutableArray { + let selfData = self.context.dictionary["included"] as? NSMutableArray + + array.forEach({ (resourceJson) in + guard let resourceJson = resourceJson as? NSMutableDictionary else { return } + let copy = resourceJson.mutableCopy() as! NSMutableDictionary + + if let selfData = selfData { + selfData.add(copy) + included?.append(copy as! [String : Any]) + } + + guard let _ = self.context.mapResource(for: copy) else { return } + + }) + + } + } +} diff --git a/Vox/Networking/Protocol/PaginationStrategy.swift b/Vox/Networking/Protocol/PaginationStrategy.swift new file mode 100644 index 0000000..06ce978 --- /dev/null +++ b/Vox/Networking/Protocol/PaginationStrategy.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol PaginationStrategy { + func paginationURLQueryItems() -> [URLQueryItem] +} diff --git a/Vox/Networking/Protocol/QueryItemsCustomizable.swift b/Vox/Networking/Protocol/QueryItemsCustomizable.swift new file mode 100644 index 0000000..508d550 --- /dev/null +++ b/Vox/Networking/Protocol/QueryItemsCustomizable.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol QueryItemsCustomizable { + func queryItems(_ queryItems: [URLQueryItem]) -> Self +} diff --git a/VoxTests/Assets/Pagination1.json b/VoxTests/Assets/Pagination1.json new file mode 100644 index 0000000..d098b8b --- /dev/null +++ b/VoxTests/Assets/Pagination1.json @@ -0,0 +1,98 @@ + +{ + "links": { + "self": "http://example.com/articles?page[number]=3&page[size]=1", + "first": "http://example.com/articles?page[number]=1&page[size]=1", + "prev": "http://example.com/articles?page[number]=2&page[size]=1", + "next": "http://example.com/articles?page[number]=4&page[size]=1", + "last": "http://example.com/articles?page[number]=13&page[size]=1" + }, + "data": [{ + "id": "id", + "type": "articles3", + "attributes": { + "title": "Title", + "description": "Desc", + "customObject": { + "value": "Hello" + }, + "keywords": [ + "key1", "key2" + ], + "customObject": { + "property": "value" + } + }, + "meta": { + "hint": "Hint" + }, + "relationships": { + "coauthors": { + "data": [ + { + "id":"35124", + "type": "persons3" + }, + { + "id":"35125", + "type": "persons3" + } + ] + }, + "author": { + "data": { + "id":"123123", + "type": "persons3" + } + } + } + }], + "included": [ + { + "id": "123123", + "type": "persons3", + "attributes": { + "name": "Aron" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + }, + { + "id": "35124", + "type": "persons3", + "attributes": { + "name": "Glupan" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + }, + { + "id": "35125", + "type": "persons3", + "attributes": { + "name": "Debil" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + } + ] +} + diff --git a/VoxTests/Assets/Pagination2.json b/VoxTests/Assets/Pagination2.json new file mode 100644 index 0000000..2c7216a --- /dev/null +++ b/VoxTests/Assets/Pagination2.json @@ -0,0 +1,90 @@ +{ + "data": [{ + "id": "id2", + "type": "articles3", + "attributes": { + "title": "Title", + "description": "Desc", + "customObject": { + "value": "Hello" + }, + "keywords": [ + "key1", "key2" + ], + "customObject": { + "property": "value" + } + }, + "meta": { + "hint": "Hint" + }, + "relationships": { + "coauthors": { + "data": [ + { + "id":"35126", + "type": "persons3" + }, + { + "id":"35127", + "type": "persons3" + } + ] + }, + "author": { + "data": { + "id":"123123", + "type": "persons3" + } + } + } + }], + "included": [ + { + "id": "123123", + "type": "persons3", + "attributes": { + "name": "Aron" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + }, + { + "id": "35126", + "type": " ", + "attributes": { + "name": "Glupan" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + }, + { + "id": "35127", + "type": "persons3", + "attributes": { + "name": "Debil" + }, + "relationships": { + "favoriteArticle": { + "data": { + "id":"id", + "type":"articles3" + } + } + } + } + ] +} + diff --git a/VoxTests/DataSource/DataSourceSpec.swift b/VoxTests/DataSource/DataSourceSpec.swift index 54c074f..4706c6d 100644 --- a/VoxTests/DataSource/DataSourceSpec.swift +++ b/VoxTests/DataSource/DataSourceSpec.swift @@ -9,9 +9,10 @@ fileprivate let fetchSinglePath = "fetch/single" fileprivate let createPath = "create" fileprivate let updatePath = "update" fileprivate let deletePath = "delete" -fileprivate let queryItem = URLQueryItem(name: "name", value: "value") +fileprivate let filterQueryItem = URLQueryItem(name: "name", value: "value") +fileprivate let paginationQueryItem = URLQueryItem(name: "page", value: "value") -fileprivate let immutablePath = "path" +fileprivate let immutablePath = "path/" fileprivate class MockRouter: Router { class Invocation { @@ -101,7 +102,14 @@ fileprivate class MockClient: Client { fileprivate class MockFilterStrategy: FilterStrategy { func filterURLQueryItems() -> [URLQueryItem] { - return [queryItem] + return [filterQueryItem] + } +} + + +fileprivate class MockPaginationStrategy: PaginationStrategy { + func paginationURLQueryItems() -> [URLQueryItem] { + return [paginationQueryItem] } } @@ -148,7 +156,7 @@ class DataSourceSpec: QuickSpec { let client = MockClient() let router = MockRouter() let filterStrategy = MockFilterStrategy() - let sut = DataSource(strategy: .router(router), client: client) + let sut = DataSource(strategy: .router(router), client: client) try! sut .fetch(id: "mock") @@ -197,8 +205,8 @@ class DataSourceSpec: QuickSpec { let filterQueryItem = queryItems[1] // http://jsonapi.org/format/#fetching-filtering - expect(filterQueryItem.name).to(equal(queryItem.name)) - expect(filterQueryItem.value).to(equal(queryItem.value)) + expect(filterQueryItem.name).to(equal(filterQueryItem.name)) + expect(filterQueryItem.value).to(equal(filterQueryItem.value)) let includeQueryItem = queryItems[2] @@ -211,16 +219,17 @@ class DataSourceSpec: QuickSpec { // http://jsonapi.org/format/#fetching-sorting expect(sortQueryItem.name).to(equal("sort")) expect(sortQueryItem.value).to(equal("value1,-value2")) - }) }) context("when fetching resource collection", { let client = MockClient() let router = MockRouter() - let sut = DataSource(strategy: .router(router), client: client) + let paginationStrategy = MockPaginationStrategy() + + let sut = DataSource(strategy: .router(router), client: client) - try! sut.fetch().result({ (document) in + try! sut.fetch().paginate(paginationStrategy).result({ (document) in }, { (error) in @@ -241,6 +250,14 @@ class DataSourceSpec: QuickSpec { it("client receives correct data from router for execution", closure: { expect(client.executeRequestInspector.path).to(equal(fetchCollectionPath)) expect(client.executeRequestInspector.path).to(equal(fetchCollectionPath)) + + let queryItems = client.executeRequestInspector.queryItems + + let _paginationQueryItem = queryItems[0] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal(paginationQueryItem.name)) + expect(_paginationQueryItem.value).to(equal(paginationQueryItem.value)) }) }) @@ -276,7 +293,7 @@ class DataSourceSpec: QuickSpec { context("when deleting resource", { let client = MockClient() let router = MockRouter() - let sut = DataSource(strategy: .router(router), client: client) + let sut = DataSource(strategy: .router(router), client: client) try! sut.delete(id: "mock").result({ @@ -320,14 +337,14 @@ class DataSourceSpec: QuickSpec { }) it("client receives correct data for execution", closure: { - expect(client.executeRequestInspector.path).to(equal(immutablePath)) + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) }) }) context("when fetching single resource", { let client = MockClient() let filterStrategy = MockFilterStrategy() - let sut = DataSource(strategy: .path(immutablePath), client: client) + let sut = DataSource(strategy: .path(immutablePath), client: client) try! sut .fetch(id: "mock") .fields([ @@ -351,7 +368,7 @@ class DataSourceSpec: QuickSpec { }) it("client receives correct data for execution", closure: { - expect(client.executeRequestInspector.path).to(equal(immutablePath)) + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) let queryItems = client.executeRequestInspector.queryItems @@ -366,8 +383,8 @@ class DataSourceSpec: QuickSpec { let filterQueryItem = queryItems[1] // http://jsonapi.org/format/#fetching-filtering - expect(filterQueryItem.name).to(equal(queryItem.name)) - expect(filterQueryItem.value).to(equal(queryItem.value)) + expect(filterQueryItem.name).to(equal(filterQueryItem.name)) + expect(filterQueryItem.value).to(equal(filterQueryItem.value)) let includeQueryItem = queryItems[2] @@ -384,11 +401,74 @@ class DataSourceSpec: QuickSpec { }) }) - context("when fetching resource collection", { + context("when fetching resource collection with custom pagination", { let client = MockClient() - let sut = DataSource(strategy: .path(immutablePath), client: client) + let paginationStrategy = MockPaginationStrategy() + let sut = DataSource(strategy: .path(immutablePath), client: client) + + try! sut.fetch().paginate(paginationStrategy).result({ (document) in + + }, { (error) in + + }) + + it("invokes execute request on client", closure: { + expect(client.invocation.executeRequest.isInvokedOnce).to(beTrue()) + }) + + it("client receives correct data for execution", closure: { + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) + + let queryItems = client.executeRequestInspector.queryItems + + let _paginationQueryItem = queryItems[0] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal(paginationQueryItem.name)) + expect(_paginationQueryItem.value).to(equal(paginationQueryItem.value)) + }) + }) + + context("when fetching resource collection with page based pagination", { + let client = MockClient() + let paginationStrategy = Pagination.PageBased(number: 1, size: 2) + let sut = DataSource(strategy: .path(immutablePath), client: client) + + try! sut.fetch().paginate(paginationStrategy).result({ (document) in + + }, { (error) in + + }) + + it("invokes execute request on client", closure: { + expect(client.invocation.executeRequest.isInvokedOnce).to(beTrue()) + }) - try! sut.fetch().result({ (document) in + it("client receives correct data for execution", closure: { + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) + + let queryItems = client.executeRequestInspector.queryItems + + var _paginationQueryItem = queryItems[0] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal("page[number]")) + expect(_paginationQueryItem.value).to(equal("1")) + + _paginationQueryItem = queryItems[1] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal("page[size]")) + expect(_paginationQueryItem.value).to(equal("2")) + }) + }) + + context("when fetching resource collection with offset based pagination", { + let client = MockClient() + let paginationStrategy = Pagination.OffsetBased(offset: 1, limit: 2) + let sut = DataSource(strategy: .path(immutablePath), client: client) + + try! sut.fetch().paginate(paginationStrategy).result({ (document) in }, { (error) in @@ -399,7 +479,49 @@ class DataSourceSpec: QuickSpec { }) it("client receives correct data for execution", closure: { - expect(client.executeRequestInspector.path).to(equal(immutablePath)) + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) + + let queryItems = client.executeRequestInspector.queryItems + + var _paginationQueryItem = queryItems[0] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal("page[offset]")) + expect(_paginationQueryItem.value).to(equal("1")) + + _paginationQueryItem = queryItems[1] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal("page[limit]")) + expect(_paginationQueryItem.value).to(equal("2")) + }) + }) + + context("when fetching resource collection with cursor based pagination", { + let client = MockClient() + let paginationStrategy = Pagination.CursorBased(cursor: "mock-cursor") + let sut = DataSource(strategy: .path(immutablePath), client: client) + + try! sut.fetch().paginate(paginationStrategy).result({ (document) in + + }, { (error) in + + }) + + it("invokes execute request on client", closure: { + expect(client.invocation.executeRequest.isInvokedOnce).to(beTrue()) + }) + + it("client receives correct data for execution", closure: { + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) + + let queryItems = client.executeRequestInspector.queryItems + + let _paginationQueryItem = queryItems[0] + + // http://jsonapi.org/format/#fetching-pagination + expect(_paginationQueryItem.name).to(equal("page[cursor]")) + expect(_paginationQueryItem.value).to(equal("mock-cursor")) }) }) @@ -419,13 +541,13 @@ class DataSourceSpec: QuickSpec { }) it("client receives correct data for execution", closure: { - expect(client.executeRequestInspector.path).to(equal(immutablePath)) + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) }) }) context("when deleting resource", { let client = MockClient() - let sut = DataSource(strategy: .path(immutablePath), client: client) + let sut = DataSource(strategy: .path(immutablePath), client: client) try! sut.delete(id: "mock").result({ @@ -439,7 +561,7 @@ class DataSourceSpec: QuickSpec { it("client receives correct data for execution", closure: { - expect(client.executeRequestInspector.path).to(equal(immutablePath)) + expect(client.executeRequestInspector.path).to(equal("path/mock-resource")) }) }) } diff --git a/VoxTests/DataSource/PaginationSpec.swift b/VoxTests/DataSource/PaginationSpec.swift new file mode 100644 index 0000000..8ae33da --- /dev/null +++ b/VoxTests/DataSource/PaginationSpec.swift @@ -0,0 +1,188 @@ +import UIKit +import Quick +import Nimble + +@testable import Vox + +fileprivate class Article3: Resource { + override class var resourceType: String { + return "articles3" + } + + override class var codingKeys: [String: String] { + return [ + "descriptionText": "description" + ] + } + + @objc dynamic var title: String? + @objc dynamic var descriptionText: String? + @objc dynamic var keywords: [String]? + @objc dynamic var coauthors: [Person3]? + @objc dynamic var author: Person3? + @objc dynamic var hint: String? + @objc dynamic var customObject: [String: Any]? +} + + +fileprivate class Person3: Resource { + override class var resourceType: String { + return "persons3" + } + + @objc dynamic var name: String? + @objc dynamic var age: NSNumber? + @objc dynamic var gender: String? + @objc dynamic var favoriteArticle: Article3? + +} + + +fileprivate class MockRouter: Router { + func fetch(id: String, type: Resource.Type) -> String { + fatalError() + } + + func fetch(type: Resource.Type) -> String { + return "mock" + } + + func create(resource: Resource) -> String { + fatalError() + } + + func update(resource: Resource) -> String { + fatalError() + } + + func delete(id: String, type: Resource.Type) -> String { + fatalError() + } +} + +fileprivate class MockClient: Client { + lazy var data1 = Data(jsonFileName: "Pagination1") + lazy var data2 = Data(jsonFileName: "Pagination2") + + var count: Int = 0 + var firstPath: String? + var nextPath: String? + var queryItems: [URLQueryItem]? + + func executeRequest(_ path: String, method: String, queryItems: [URLQueryItem], parameters: [String : Any]?, success: @escaping ClientSuccessBlock, _ failure: @escaping ClientFailureBlock) { + + if count == 0 { + count += 1 + firstPath = path + success(nil, data1) + } else { + self.queryItems = queryItems + nextPath = path + success(nil, data2) + } + } +} + +fileprivate class PageableResource: Resource { + override class var resourceType: String { + return "pageable-resource" + } +} + +class PaginationSpec: QuickSpec { + override func spec() { + + describe("Paginated DataSource") { + let router = MockRouter() + let client = MockClient() + let dataSource: DataSource = DataSource(strategy: .router(router), client: client) + + var document: Document<[Article3]>? + var nextDocument: Document<[Article3]>? + var error: Error? + + context("when fetching first page", { + try! dataSource.fetch().result({ (_document) in + document = _document + }, { (_error) in + error = _error + }) + + it("returns first page document", closure: { + expect(error).toEventually(beNil()) + expect(document).toEventuallyNot(beNil()) + }) + + context("when fetching next page", { + try! document?.next?.result({ (_nextDocument) in + nextDocument = _nextDocument + }, { (_error) in + error = _error + }) + + it("returns next page document", closure: { + expect(error).toEventually(beNil()) + expect(nextDocument).toEventuallyNot(beNil()) + }) + }) + }) + } + + describe("Paginated DataSource") { + let router = MockRouter() + let client = MockClient() + let dataSource: DataSource = DataSource(strategy: .router(router), client: client) + + var document: Document<[Article3]>? + var pagination: PaginationData? + var error: Error? + + context("when fetching first page", { + try! dataSource.fetch().result({ (_document) in + document = _document + }, { (_error) in + error = _error + }) + + it("returns first page document", closure: { + expect(document).toEventuallyNot(beNil()) + expect(document?.links).toEventuallyNot(beNil()) + expect(error).toEventually(beNil()) + expect(client.firstPath).toEventually(equal(router.fetch(type: Article3.self))) + }) + + + context("when appending next page", { + document?.appendNext({ (_pagination) in + pagination = _pagination + }, { (_error) in + error = _error + }) + + it("receives page", closure: { + expect(error).toEventually(beNil()) + expect(pagination).toEventuallyNot(beNil()) + expect(pagination?.new).toEventually(haveCount(1)) + expect(pagination?.old).toEventually(haveCount(1)) + expect(pagination?.all).toEventually(haveCount(2)) + expect(document?.data).toEventually(haveCount(2)) + expect(client.nextPath).toEventually(equal("/articles")) + expect(client.queryItems).toEventually(equal([ + URLQueryItem.init(name: "page[number]", value: "4"), + URLQueryItem.init(name: "page[size]", value: "1") + ])) + }) + + it("document is appended", closure: { + expect(document?.data).toEventually(haveCount(2)) + }) + + it("included is appended", closure: { + expect(document?.included).toEventually(haveCount(6)) + }) + }) + }) + } + } +} + diff --git a/VoxTests/Deserializer/DeserializerCollectionSpec.swift b/VoxTests/Deserializer/DeserializerCollectionSpec.swift index 01f6574..0f3e771 100644 --- a/VoxTests/Deserializer/DeserializerCollectionSpec.swift +++ b/VoxTests/Deserializer/DeserializerCollectionSpec.swift @@ -47,11 +47,11 @@ class DeserializerCollectionSpec: QuickSpec { let sut = Deserializer.Collection() context("when deserializing resource collection", { - let document = try? sut.deserialize(data: self.data) + let document = try! sut.deserialize(data: self.data) it("maps correctly", closure: { expect(document).notTo(beNil()) - let articles = document?.data + let articles = document.data expect(articles).notTo(beNil()) expect(articles?.count).to(equal(1))