├── .gitignore
├── .travis.yml
├── Cartfile
├── Cartfile.resolved
├── LICENSE
├── README.md
├── Spine.podspec
├── Spine.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
└── xcshareddata
│ └── xcschemes
│ ├── Spine-iOS.xcscheme
│ ├── Spine-macOS.xcscheme
│ ├── Spine-tvOS.xcscheme
│ └── Spine-watchOS.xcscheme
├── Spine
├── DeserializeOperation.swift
├── Errors.swift
├── Info.plist
├── KeyFormatter.swift
├── Logging.swift
├── Networking.swift
├── Operation.swift
├── Query.swift
├── Resource.swift
├── ResourceCollection.swift
├── ResourceFactory.swift
├── ResourceField.swift
├── Routing.swift
├── SerializeOperation.swift
├── Serializing.swift
├── Spine.h
├── Spine.swift
└── ValueFormatter.swift
└── SpineTests
├── CallbackHTTPClient.swift
├── Fixtures.swift
├── Fixtures
├── EmptyFoos.json
├── Errors.json
├── MultipleFoos.json
├── PagedFoos-1.json
├── PagedFoos-2.json
├── SingleFoo.json
├── SingleFooIncludingBars.json
└── SingleFooWithUnregisteredType.json
├── Info.plist
├── QueryTests.swift
├── ResourceCollectionTests.swift
├── ResourceTests.swift
├── RoutingTests.swift
├── SerializingTests.swift
├── SpineTests.swift
└── Utilities.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by http://www.gitignore.io
2 |
3 | ### Xcode ###
4 | build/
5 | *.pbxuser
6 | !default.pbxuser
7 | *.mode1v3
8 | !default.mode1v3
9 | *.mode2v3
10 | !default.mode2v3
11 | *.perspectivev3
12 | !default.perspectivev3
13 | xcuserdata
14 | *.xccheckout
15 | *.moved-aside
16 | DerivedData
17 | *.xcuserstate
18 |
19 | Carthage/Checkouts
20 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: objective-c
2 | osx_image: xcode8
3 |
4 | env:
5 | - PLATFORM="ios" SCHEME="Spine-iOS" DESTINATION="platform=iOS Simulator,name=iPhone 7,OS=10.0"
6 | - PLATFORM="tvos" SCHEME="Spine-tvOS" DESTINATION="platform=tvOS Simulator,name=Apple TV 1080p,OS=10.0"
7 | - PLATFORM="mac" SCHEME="Spine-macOS" DESTINATION="platform=macOS,arch=x86_64"
8 |
9 | before_script:
10 | - carthage bootstrap --platform $PLATFORM
11 | script:
12 | - xcodebuild -project Spine.xcodeproj -scheme $SCHEME -destination "$DESTINATION" build test
13 |
--------------------------------------------------------------------------------
/Cartfile:
--------------------------------------------------------------------------------
1 | github "SwiftyJSON/SwiftyJSON"
2 | github "Thomvis/BrightFutures"
3 |
--------------------------------------------------------------------------------
/Cartfile.resolved:
--------------------------------------------------------------------------------
1 | github "SwiftyJSON/SwiftyJSON" "4.0.0"
2 | github "Thomvis/BrightFutures" "6.0.0"
3 | github "antitypical/Result" "3.2.4"
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Ward van Teijlingen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/wvteijlingen/Spine) [](https://gitter.im/wvteijlingen/Spine?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
2 |
3 | # Spine
4 | Spine is a Swift library for working with APIs that adhere to the [jsonapi.org](http://jsonapi.org) standard. It supports mapping to custom model classes, fetching, advanced querying, linking and persisting.
5 |
6 | ## Mission
7 | This repo is a continuation of the dead [Spine](https://github.com/wvteijlingen/Spine) project. Our mission is to keep Spine alive and moving forward, with maintenance fixes and new features. Pull Requests are welcome!
8 |
9 | Any help is greatly appreciated, feel free to submit pull-requests or open issues.
10 |
11 | ## Stability
12 | This library was born out of a hobby project. Some things are still lacking, one of which is test coverage. Beware of this when using Spine in a production app!
13 |
14 | ## Table of Contents
15 | - [Supported features](#supported-features)
16 | - [Installation](#installation)
17 | - [Configuration](#configuration)
18 | - [Defining resource types](#defining-resource-types)
19 | - [Defining resource fields](#defining-resource-fields)
20 | - [Example resource class](#example-resource-class)
21 | - [Usage](#usage)
22 | - [Fetching resources](#fetching-resources)
23 | - [Saving resources](#saving-resources)
24 | - [Deleting resources](#deleting-resources)
25 | - [Loading and reloading resources](#loading-and-reloading-resources)
26 | - [Pagination](#pagination)
27 | - [Filtering](#filtering)
28 | - [Networking](#networking)
29 | - [Logging](#logging)
30 | - [Memory management](#memory-management)
31 | - [Using the serializer separately](#using-the-serializer-separately)
32 |
33 | ## Supported features
34 | | Feature | Supported | Note |
35 | | ------------------------------ | --------- | ----------------------------------------------- |
36 | | Fetching resources | Yes | |
37 | | Creating resources | Yes | |
38 | | Updating resources | Yes | |
39 | | Deleting resources | Yes | |
40 | | Top level metadata | Yes | |
41 | | Top level errors | Yes | |
42 | | Top level links | Yes | |
43 | | Top level JSON API Object | Yes | |
44 | | Client generated ID's | Yes | |
45 | | Resource metadata | Yes | |
46 | | Custom resource links | No | |
47 | | Relationships | Yes | |
48 | | Inclusion of related resources | Yes | |
49 | | Sparse fieldsets | Partially | Fetching only, all fields will be saved |
50 | | Sorting | Yes | |
51 | | Filtering | Yes | Supports custom filter strategies |
52 | | Pagination | Yes | Offset, cursor and custom pagination strategies |
53 | | Bulk extension | No | |
54 | | JSON Patch extension | No | |
55 |
56 | ## Installation
57 | ### Carthage
58 | Add `github "json-api-ios/Spine" "master"` to your Cartfile. See the [Carthage documentation](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) for instructions on how to integrate with your project using Xcode.
59 |
60 | ### Cocoapods
61 | Add `pod 'Spine', :git => 'https://github.com/json-api-ios/Spine.git'` to your Podfile. The spec is not yet registered with the Cocoapods repository, because the library is still in flux.
62 |
63 | ## Configuration
64 | ### Defining resource types
65 | Every resource is mapped to a class that inherits from `Resource`. A subclass should override the variables `resourceType` and `fields`. The `resourceType` should contain the type of resource in plural form. The `fields` array should contain an array of fields that must be persisted. Fields that are not in this array are ignored.
66 |
67 | Each class must be registered using the `Spine.registerResource` method.
68 |
69 | ### Defining resource fields
70 | You need to specify the fields that must be persisted using an array of `Field`s. These fields are used when turning JSON into resources instances and vice versa. The name of each field corresponds to a variable on your resource class. This variable must be specified as optional.
71 |
72 | #### Field name formatters
73 | By default, the key in the JSON will be the same as your field name or serialized field name. You can specify a different name by using serializeAs(name: String). The name or custom serialized name will be mapped to a JSON key using a `KeyFormatter`. You can configure the key formatter using the `keyFormatter` variable on a Spine instance.
74 |
75 | Spine comes with three key formatters: `AsIsKeyFormatter`, `DasherizedKeyFormatter`, `UnderscoredKeyFormatter`.
76 |
77 | ```swift
78 | // Formats a field name 'myField' to key 'MYFIELD'.
79 | public struct AllCapsKeyFormatter: KeyFormatter {
80 | public func format(field: Field) -> String {
81 | return field.serializedName.uppercaseString
82 | }
83 | }
84 |
85 | spine.keyFormatter = AllCapsKeyFormatter()
86 | ```
87 |
88 | #### Built in attribute types
89 |
90 | ##### Attribute
91 | An attribute is a regular attribute that can be serialized by NSJSONSerialization. E.g. a String or NSNumber.
92 |
93 | ##### URLAttribute
94 | An url attribute corresponds to an NSURL variable. These are represented by strings in the JSON document. You can instantiate it with a baseURL, in which case Spine will expand relative URLs from the JSON relative to the given baseURL. Absolute URLs will be left as is.
95 |
96 | ##### DateAttribute
97 | A date attribute corresponds to an NSDate variable. By default, these are represented by ISO 8601 strings in the JSON document. You can instantiate it with a custom format, in which case that format will be used when serializing and deserializing that particular attribute.
98 |
99 | ##### ToOneRelationship
100 | A to-one relationship corresponds to another resource. You must instantiate it with the type of the linked resource.
101 |
102 | ##### ToManyRelationship
103 | A to-many relationship corresponds to a collection of other resources. You must instantiate it with the type of the linked resources. If the linked types are not homogenous, they must share a common ancestor as the linked type. To many relationships are mapped to LinkedResourceCollection objects.
104 |
105 | #### Custom attribute types
106 | Custom attribute types can be created by subclassing `Attribute`. A custom attribute type must have a registered transformer that handles serialization and deserialization.
107 |
108 | Transformers are registered using the `registerTransformer` method. A transformer is a class or struct that implements the `Transformer` protocol.
109 |
110 | ```swift
111 | public class RomanNumeralAttribute: Attribute { }
112 |
113 | struct RomanNumeralValueFormatter: ValueFormatter {
114 | func unformat(value: String, attribute: RomanNumeralAttribute) -> AnyObject {
115 | let integerRepresentation: NSNumber = // Magic...
116 | return integerRepresentation
117 | }
118 |
119 | func format(value: NSNumber, attribute: RomanNumeralAttribute) -> AnyObject {
120 | let romanRepresentation: String = // Magic...
121 | return romanRepresentation
122 | }
123 | }
124 | spine.registerValueFormatter(RomanNumeralValueFormatter())
125 | ```
126 |
127 | ### Example resource class
128 |
129 | ```swift
130 | // Resource class
131 | class Post: Resource {
132 | var title: String?
133 | var body: String?
134 | var creationDate: NSDate?
135 | var author: User?
136 | var comments: LinkedResourceCollection?
137 |
138 | override class var resourceType: ResourceType {
139 | return "posts"
140 | }
141 |
142 | override class var fields: [Field] {
143 | return fieldsFromDictionary([
144 | "title": Attribute(),
145 | "body": Attribute().serializeAs("content"),
146 | "creationDate": DateAttribute(),
147 | "author": ToOneRelationship(User),
148 | "comments": ToManyRelationship(Comment)
149 | ])
150 | }
151 | }
152 |
153 | spine.registerResource(Post)
154 | ```
155 |
156 | ## Usage
157 | ### Fetching resources
158 | Resources can be fetched using find methods:
159 | ```swift
160 | // Fetch posts with ID 1 and 2
161 | spine.find(["1", "2"], ofType: Post).onSuccess { resources, meta, jsonapi in
162 | println("Fetched resource collection: \(resources)")
163 | }.onFailure { error in
164 | println("Fetching failed: \(error)")
165 | }
166 |
167 | spine.findAll(Post) // Fetch all posts
168 | spine.findOne("1", ofType: Post) // Fetch a single posts with ID 1
169 | ```
170 |
171 | Alternatively, you can use a Query to perform a more advanced find:
172 | ```swift
173 | var query = Query(resourceType: Post)
174 | query.include("author", "comments", "comments.author") // Sideload relationships
175 | query.whereProperty("upvotes", equalTo: 8) // Only with 8 upvotes
176 | query.addAscendingOrder("creationDate") // Sort on creation date
177 |
178 | spine.find(query).onSuccess { resources, meta, jsonapi in
179 | println("Fetched resource collection: \(resources)")
180 | }.onFailure { error in
181 | println("Fetching failed: \(error)")
182 | }
183 | ```
184 |
185 | All fetch methods return a Future with `onSuccess` and `onFailure` callbacks.
186 |
187 | ### Saving resources
188 | ```swift
189 | spine.save(post).onSuccess { _ in
190 | println("Saving success")
191 | }.onFailure { error in
192 | println("Saving failed: \(error)")
193 | }
194 | ```
195 | Extra care MUST be taken regarding related resources. Saving does not automatically save any related resources. You must explicitly save these yourself beforehand. If you added a new create resource to a parent resource, you must first save the child resource (to obtain an ID), before saving the parent resource.
196 |
197 | ### Deleting resources
198 | ```swift
199 | spine.delete(post).onSuccess {
200 | println("Deleting success")
201 | }.onFailure { error in
202 | println("Deleting failed: \(error)")
203 | }
204 | ```
205 | Deleting does not cascade on the client.
206 |
207 | ### Loading and reloading resources
208 | You can use the `Spine.load` methods to make sure resources are loaded. If it is already loaded, it returns the resource as is. Otherwise it loads the resource using the passed query.
209 |
210 | The `Spine.reload` method works similarly, except that it always reloads a resource. This can be used to make sure a resource contains the latest data from the server.
211 |
212 | ### Pagination
213 | You can fetch next and previous pages of collections by using: `Spine.loadNextPageOfCollection` and `Spine.loadPreviousPageOfCollection`.
214 |
215 | JSON:API is agnostic about pagination strategies. Because of this, Spine by default only supports two pagination strategies:
216 | - Page based pagination using the `page[number]` and `page[size]` parameters
217 | - Offset based pagination using the `page[offset]` and `page[limit]` parameters
218 |
219 | You can add a custom filter strategy by creating a new type that conforms to the `Pagination` protocol, and then subclassing the built in `Router` class and overriding the `queryItemsForPagination(pagination: Pagination)` method.
220 |
221 | #### Example: implementing 'cursor' based pagination
222 | In this example, cursor based pagination is added a using the `page[limit]`, and either a `page[before]` or `page[after]` parameter.
223 |
224 | ```swift
225 | public struct CursorBasedPagination: Pagination {
226 | var beforeCursor: String?
227 | var afterCursor: String?
228 | var limit: Int
229 | }
230 | ```
231 |
232 | ```swift
233 | class CustomRouter: Router {
234 | override func queryItemsForPagination(pagination: Pagination) -> [NSURLQueryItem] {
235 | if let cursorPagination = pagination as? CursorBasedPagination {
236 | var queryItems = [NSURLQueryItem(name: "page[limit]", value: String(cursorPagination.limit))]
237 |
238 | if let before = cursorPagination.beforeCursor {
239 | queryItems.append(NSURLQueryItem(name: "page[before]", value: before))
240 | } else if let after = cursorPagination.afterCursor {
241 | queryItems.append(NSURLQueryItem(name: "page[after]", value: after))
242 | }
243 |
244 | return queryItems
245 | } else {
246 | return super.queryItemsForPagination(pagination)
247 | }
248 | }
249 | }
250 | ```
251 |
252 | ### Filtering
253 | JSON:API is agnostic about filter strategies. Because of this, Spine by default only supports 'is equal to' filtering in the form of `?filter[key]=value`.
254 |
255 | You can add a custom filter strategy by subclassing the built in `Router` class and overriding the `queryItemForFilter(filter: NSComparisonPredicate)` method. This method takes a comparison predicate and returns a matching `NSURLQueryItem`.
256 |
257 | #### Example: implementing a 'not equal to' filter
258 | In this example, a switch statement is used to add a 'not equal filer in the form of `?filter[key]=!value`.
259 |
260 | ```swift
261 | class CustomRouter: Router {
262 | override func queryItemForFilter(field: Field, value: AnyObject, operatorType: NSPredicateOperatorType) -> NSURLQueryItem {
263 | switch operatorType {
264 | case .NotEqualToPredicateOperatorType:
265 | let key = keyFormatter.format(field)
266 | return NSURLQueryItem(name: "filter[\(key)]", value: "!\(value)")
267 | default:
268 | return super.queryItemForFilter(filter)
269 | }
270 | }
271 | }
272 |
273 | let baseURL = NSURL(string: "http://api.example.com/v1")
274 | let spine = Spine(baseURL: baseURL, router: CustomRouter())
275 | ```
276 |
277 | ### Networking
278 | Spine uses a `NetworkClient` to communicate with the remote API. By default it uses the `HTTPClient` class which performs request over the HTTP protocol.
279 |
280 | #### Customising HTTP headers of HTTPClient
281 | The `HTTPClient` class supports setting HTTP headers as follows:
282 | ```swift
283 | (spine.networkClient as! HTTPClient).setHeader("User-Agent", to: "My App")
284 | (spine.networkClient as! HTTPClient).removeHeader("User-Agent")
285 | ```
286 |
287 | #### Using a custom network client
288 | You can use a custom client by subclassing `HTTPClient` or by creating a class that implements the `NetworkClient` protocol. Pass an instance of this class when instantiating a Spine:
289 |
290 | ```swift
291 | var customClient = CustomNetworkClient()
292 | var spine = Spine(baseURL: NSURL(string:"http://example.com")!, networkClient: customClient)
293 | ```
294 |
295 | ### Logging
296 | Spine comes with a rudimentary logging system. Each logging domain can be configured with a certain log level:
297 |
298 | ```swift
299 | Spine.setLogLevel(.Debug, forDomain: .Spine)
300 | Spine.setLogLevel(.Info, forDomain: .Networking)
301 | Spine.setLogLevel(.Warning, forDomain: .Serializing)
302 | ```
303 |
304 | These levels are global, meaning they apply to all Spine instances.
305 |
306 | #### Log domains
307 | * Spine: The main Spine component.
308 | * Networking: The networking component, requests, responses etc.
309 | * Serializing: The (de)serializing component.
310 |
311 | #### Log levels
312 | * Debug
313 | * Info
314 | * Warning
315 | * Error
316 | * None
317 |
318 | #### Custom loggers
319 | The default `ConsoleLogger` logs to the console using the Swift built in `print` command. You can assign a custom logger that implements the `Logger` protocol to the
320 | static `Spine.logger` variable.
321 |
322 | ### Memory management
323 | Spine suffers from the same memory management issues as Core Data, namely retain cycles for recursive relationships. These cycles can be broken in two ways:
324 |
325 | 1. Declare one end of the relationship as `weak` or `unowned`.
326 | 2. Use a Resource's `unload` method to break cycles when you are done with the resource.
327 |
328 | ## Using the serializer separately
329 | You can also just use the Serializer to (de)serialize to and from JSON:
330 |
331 | ```swift
332 | let serializer = Serializer()
333 |
334 | // Register resources
335 | serializer.registerResource(Post)
336 |
337 | // Optional configuration
338 | serializer.registerValueFormatter(RomanNumeralValueFormatter())
339 | serializer.keyFormatter = DasherizedKeyFormatter()
340 |
341 | // Convert NSData to a JSONAPIDocument struct
342 | let data = fetchData()
343 | let document = try! serializer.deserializeData(data)
344 |
345 | // Convert resources to NSData
346 | let data = try! serializer.serializeResources([post])
347 |
348 | // Convert resources to link data
349 | let data = try! serializer.serializeLinkData(post)
350 | let data = try! serializer.serializeLinkData([firstPost, secondPost])
351 | ```
352 |
--------------------------------------------------------------------------------
/Spine.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'Spine'
3 | s.version = '0.4'
4 | s.license = 'MIT'
5 | s.summary = 'A Swift library for interaction with a jsonapi.org API'
6 | s.homepage = 'https://github.com/wvteijlingen/Spine'
7 | s.authors = { 'Ward van Teijlingen' => 'w.van.teijlingen@gmail.com' }
8 | s.source = { :git => 'https://github.com/wvteijlingen/Spine.git', :tag => s.version }
9 |
10 | s.ios.deployment_target = '8.0'
11 | s.tvos.deployment_target = '9.0'
12 | s.osx.deployment_target = '10.10'
13 |
14 | s.source_files = 'Spine/*.swift'
15 |
16 | s.requires_arc = true
17 |
18 | s.dependency 'SwiftyJSON'
19 | s.dependency 'BrightFutures'
20 | end
21 |
--------------------------------------------------------------------------------
/Spine.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Spine.xcodeproj/xcshareddata/xcschemes/Spine-iOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/Spine.xcodeproj/xcshareddata/xcschemes/Spine-macOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/Spine.xcodeproj/xcshareddata/xcschemes/Spine-tvOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/Spine.xcodeproj/xcshareddata/xcschemes/Spine-watchOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
35 |
36 |
47 |
48 |
54 |
55 |
56 |
57 |
58 |
59 |
65 |
66 |
72 |
73 |
74 |
75 |
77 |
78 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/Spine/DeserializeOperation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeserializeOperation.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-12-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftyJSON
11 |
12 | /**
13 | A DeserializeOperation deserializes JSON data in the form of NSData to a JSONAPIDocument.
14 | */
15 | class DeserializeOperation: Operation {
16 |
17 | // Input
18 | fileprivate let data: JSON
19 | fileprivate let valueFormatters: ValueFormatterRegistry
20 | fileprivate let resourceFactory: ResourceFactory
21 | fileprivate let keyFormatter: KeyFormatter
22 | fileprivate let skipUnknownResourceType: Bool
23 |
24 | // Extracted objects
25 | fileprivate var extractedPrimaryResources: [Resource]?
26 | fileprivate var extractedIncludedResources: [Resource] = []
27 | fileprivate var extractedErrors: [APIError]?
28 | fileprivate var extractedMeta: [String: Any]?
29 | fileprivate var extractedLinks: [String: URL]?
30 | fileprivate var extractedJSONAPI: [String: Any]?
31 | fileprivate var resourcePool: [Resource] = []
32 |
33 | // Output
34 | var result: Failable?
35 |
36 |
37 | // MARK: -
38 |
39 | init(data: Data, resourceFactory: ResourceFactory, valueFormatters: ValueFormatterRegistry, keyFormatter: KeyFormatter, skipUnknownResourceType: Bool = false) {
40 | self.data = JSON(data)
41 | self.resourceFactory = resourceFactory
42 | self.valueFormatters = valueFormatters
43 | self.keyFormatter = keyFormatter
44 | self.skipUnknownResourceType = skipUnknownResourceType
45 | }
46 |
47 |
48 | func addMappingTargets(_ targets: [Resource]) {
49 | resourcePool += targets
50 | }
51 |
52 | override func main() {
53 | // Validate document
54 | guard data.dictionary != nil else {
55 | let errorMessage = "The given JSON is not a dictionary (hash).";
56 | Spine.logError(.serializing, errorMessage)
57 | result = Failable(SerializerError.invalidDocumentStructure)
58 | return
59 | }
60 |
61 | let hasData = data["data"].error == nil
62 | let hasErrors = data["errors"].error == nil
63 | let hasMeta = data["meta"].error == nil
64 |
65 | guard hasData || hasErrors || hasMeta else {
66 | let errorMessage = "Either 'data', 'errors', or 'meta' must be present in the top level.";
67 | Spine.logError(.serializing, errorMessage)
68 | result = Failable(SerializerError.topLevelEntryMissing)
69 | return
70 | }
71 |
72 | guard hasErrors && !hasData || !hasErrors && hasData else {
73 | let errorMessage = "Top level 'data' and 'errors' must not coexist in the same document.";
74 | Spine.logError(.serializing, errorMessage)
75 | result = Failable(SerializerError.topLevelDataAndErrorsCoexist)
76 | return
77 | }
78 |
79 | // Extract resources
80 | do {
81 | if let data = data["data"].array {
82 | var resources: [Resource] = []
83 | for (index, representation) in data.enumerated() {
84 | do {
85 | try resources.append(deserializeSingleRepresentation(representation, mappingTargetIndex: index))
86 | } catch SerializerError.resourceTypeUnregistered(let resourceType) {
87 | Spine.logWarning(.serializing, "Cannot perform deserialization for resource type '\(resourceType)' because it is not registered.")
88 | if !skipUnknownResourceType {
89 | throw SerializerError.resourceTypeUnregistered(resourceType)
90 | }
91 | }
92 | }
93 | extractedPrimaryResources = resources
94 | } else if let _ = data["data"].dictionary {
95 | let resource = try deserializeSingleRepresentation(data["data"], mappingTargetIndex: resourcePool.startIndex)
96 | extractedPrimaryResources = [resource]
97 | }
98 |
99 | if let data = data["included"].array {
100 | for representation in data {
101 | do {
102 | try extractedIncludedResources.append(deserializeSingleRepresentation(representation))
103 | } catch SerializerError.resourceTypeUnregistered(let resourceType) {
104 | Spine.logWarning(.serializing, "Cannot perform deserialization for resource type '\(resourceType)' because it is not registered.")
105 | }
106 | }
107 | }
108 | } catch let error as SerializerError {
109 | result = Failable(error)
110 | return
111 | } catch {
112 | result = Failable(SerializerError.unknownError)
113 | return
114 | }
115 |
116 | // Extract errors
117 | extractedErrors = data["errors"].array?.map { error -> APIError in
118 | return APIError(
119 | id: error["id"].string,
120 | status: error["status"].string,
121 | code: error["code"].string,
122 | title: error["title"].string,
123 | detail: error["detail"].string,
124 | sourcePointer: error["source"]["pointer"].string,
125 | sourceParameter: error["source"]["source"].string,
126 | meta: error["meta"].dictionaryObject
127 | )
128 | }
129 |
130 | // Extract meta
131 | extractedMeta = data["meta"].dictionaryObject
132 |
133 | // Extract links
134 | if let links = data["links"].dictionary {
135 | extractedLinks = [:]
136 |
137 | for (key, value) in links {
138 | if let linkURL = URL(string: value.stringValue) {
139 | extractedLinks![key] = linkURL
140 | }
141 | }
142 | }
143 |
144 | // Extract jsonapi
145 | extractedJSONAPI = data["jsonapi"].dictionaryObject
146 |
147 | // Resolve relations in the store
148 | resolveRelationships()
149 |
150 | // Create a result
151 | var responseDocument = JSONAPIDocument(data: nil, included: nil, errors: extractedErrors, meta: extractedMeta, links: extractedLinks as [String : URL]?, jsonapi: extractedJSONAPI)
152 | responseDocument.data = extractedPrimaryResources
153 | if !extractedIncludedResources.isEmpty {
154 | responseDocument.included = extractedIncludedResources
155 | }
156 | result = Failable(responseDocument)
157 | }
158 |
159 |
160 | // MARK: Deserializing
161 |
162 | /// Maps a single resource representation into a resource object of the given type.
163 | ///
164 | /// - parameter representation: The JSON representation of a single resource.
165 | /// - parameter mappingTargetIndex: The index of the matching mapping target.
166 | ///
167 | /// - throws: A SerializerError when an error occurs in serializing.
168 | ///
169 | /// - returns: A Resource object with values mapped from the representation.
170 | fileprivate func deserializeSingleRepresentation(_ representation: JSON, mappingTargetIndex: Int? = nil) throws -> Resource {
171 | guard representation.dictionary != nil else {
172 | throw SerializerError.invalidResourceStructure
173 | }
174 |
175 | guard let type: ResourceType = representation["type"].string else {
176 | throw SerializerError.resourceTypeMissing
177 | }
178 |
179 | guard let id = representation["id"].string else {
180 | throw SerializerError.resourceIDMissing
181 | }
182 |
183 | // Dispense a resource
184 | let resource = try resourceFactory.dispense(type, id: id, pool: &resourcePool, index: mappingTargetIndex)
185 |
186 | // Extract data
187 | resource.id = id
188 | resource.url = representation["links"]["self"].url
189 | resource.meta = representation["meta"].dictionaryObject
190 | extractAttributes(from: representation, intoResource: resource)
191 | extractRelationships(from: representation, intoResource: resource)
192 |
193 | resource.isLoaded = true
194 |
195 | return resource
196 | }
197 |
198 |
199 | // MARK: Attributes
200 |
201 | /// Extracts the attributes from the given data into the given resource.
202 | ///
203 | /// - parameter serializedData: The data from which to extract the attributes.
204 | /// - parameter resource: The resource into which to extract the attributes.
205 | fileprivate func extractAttributes(from serializedData: JSON, intoResource resource: Resource) {
206 | for case let field as Attribute in resource.fields {
207 | let key = keyFormatter.format(field)
208 | if let extractedValue = extractAttribute(key, from: serializedData) {
209 | let formattedValue = valueFormatters.unformatValue(extractedValue, forAttribute: field)
210 | resource.setValue(formattedValue, forField: field.name)
211 | }
212 | }
213 | }
214 |
215 | /// Extracts the value for the given key from the passed serialized data.
216 | ///
217 | /// - parameter key: The data from which to extract the attribute.
218 | /// - parameter serializedData: The key for which to extract the value from the data.
219 | ///
220 | /// - returns: The extracted value or nil if no attribute with the given key was found in the data.
221 | fileprivate func extractAttribute(_ key: String, from serializedData: JSON) -> Any? {
222 | let value = serializedData["attributes"][key]
223 |
224 | if let _ = value.null {
225 | return nil
226 | } else {
227 | return value.rawValue
228 | }
229 | }
230 |
231 |
232 | // MARK: Relationships
233 |
234 | /// Extracts the relationships from the given data into the given resource.
235 | ///
236 | /// - parameter serializedData: The data from which to extract the relationships.
237 | /// - parameter resource: The resource into which to extract the relationships.
238 | fileprivate func extractRelationships(from serializedData: JSON, intoResource resource: Resource) {
239 | for field in resource.fields {
240 | let key = keyFormatter.format(field)
241 | resource.relationships[field.name] = extractRelationshipData(serializedData["relationships"][key])
242 |
243 | switch field {
244 | case let toOne as ToOneRelationship:
245 | if let linkedResource = extractToOneRelationship(key, from: serializedData, linkedType: toOne.linkedType.resourceType) {
246 | if resource.value(forField: toOne.name) == nil || (resource.value(forField: toOne.name) as? Resource)?.isLoaded == false {
247 | resource.setValue(linkedResource, forField: toOne.name)
248 | }
249 | }
250 | case let toMany as ToManyRelationship:
251 | if let linkedResourceCollection = extractToManyRelationship(key, from: serializedData) {
252 | if linkedResourceCollection.linkage != nil || resource.value(forField: toMany.name) == nil {
253 | resource.setValue(linkedResourceCollection, forField: toMany.name)
254 | }
255 | }
256 | default: ()
257 | }
258 | }
259 | }
260 |
261 | /// Extracts the to-one relationship for the given key from the passed serialized data.
262 | /// This method supports both the single ID form and the resource object forms.
263 | ///
264 | /// - parameter key: The key for which to extract the relationship from the data.
265 | /// - parameter serializedData: The data from which to extract the relationship.
266 | /// - parameter linkedType: The type of the linked resource as it is defined on the parent resource.
267 | ///
268 | /// - returns: The extracted relationship or nil if no relationship with the given key was found in the data.
269 | fileprivate func extractToOneRelationship(_ key: String, from serializedData: JSON, linkedType: ResourceType) -> Resource? {
270 | var resource: Resource? = nil
271 |
272 | if let linkData = serializedData["relationships"][key].dictionary {
273 | let type = linkData["data"]?["type"].string ?? linkedType
274 |
275 | if let id = linkData["data"]?["id"].string {
276 | do {
277 | resource = try resourceFactory.dispense(type, id: id, pool: &resourcePool)
278 | } catch {
279 | resource = try! resourceFactory.dispense(linkedType, id: id, pool: &resourcePool)
280 | }
281 | } else {
282 | do {
283 | resource = try resourceFactory.instantiate(type)
284 | } catch {
285 | resource = try! resourceFactory.instantiate(linkedType)
286 | }
287 | }
288 |
289 | if let resourceURL = linkData["links"]?["related"].url {
290 | resource!.url = resourceURL
291 | }
292 | }
293 |
294 | return resource
295 | }
296 |
297 | /// Extracts the to-many relationship for the given key from the passed serialized data.
298 | /// This method supports both the array of IDs form and the resource object forms.
299 | ///
300 | /// - parameter key: The key for which to extract the relationship from the data.
301 | /// - parameter serializedData: The data from which to extract the relationship.
302 | ///
303 | /// - returns: The extracted relationship or nil if no relationship with the given key was found in the data.
304 | fileprivate func extractToManyRelationship(_ key: String, from serializedData: JSON) -> LinkedResourceCollection? {
305 | var resourceCollection: LinkedResourceCollection? = nil
306 |
307 | if let linkData = serializedData["relationships"][key].dictionary {
308 | let resourcesURL: URL? = linkData["links"]?["related"].url
309 | let linkURL: URL? = linkData["links"]?["self"].url
310 |
311 | if let linkage = linkData["data"]?.array {
312 | let mappedLinkage = linkage.map { ResourceIdentifier(type: $0["type"].stringValue, id: $0["id"].stringValue) }
313 | resourceCollection = LinkedResourceCollection(resourcesURL: resourcesURL, linkURL: linkURL, linkage: mappedLinkage)
314 | } else {
315 | resourceCollection = LinkedResourceCollection(resourcesURL: resourcesURL, linkURL: linkURL, linkage: nil)
316 | }
317 | }
318 |
319 | return resourceCollection
320 | }
321 |
322 | /// Extract the relationship data from the given JSON.
323 | ///
324 | /// - parameter linkData: The JSON from which to extract relationship data.
325 | ///
326 | /// - returns: A RelationshipData object.
327 | fileprivate func extractRelationshipData(_ linkData: JSON) -> RelationshipData {
328 | let selfURL = linkData["links"]["self"].url
329 | let relatedURL = linkData["links"]["related"].url
330 | let data: [ResourceIdentifier]?
331 |
332 | if let toOne = linkData["data"].dictionary {
333 | data = [ResourceIdentifier(type: toOne["type"]!.stringValue, id: toOne["id"]!.stringValue)]
334 | } else if let toMany = linkData["data"].array {
335 | data = toMany.map { JSON -> ResourceIdentifier in
336 | return ResourceIdentifier(type: JSON["type"].stringValue, id: JSON["id"].stringValue)
337 | }
338 | } else {
339 | data = nil
340 | }
341 |
342 | return RelationshipData(selfURL: selfURL, relatedURL: relatedURL, data: data)
343 | }
344 |
345 | /// Resolves the relations of the fetched resources.
346 | fileprivate func resolveRelationships() {
347 | for resource in resourcePool {
348 | for case let field as ToManyRelationship in resource.fields {
349 |
350 | guard let linkedResourceCollection = resource.value(forField: field.name) as? LinkedResourceCollection else {
351 | Spine.logInfo(.serializing, "Cannot resolve relationship '\(field.name)' of \(resource.resourceType):\(resource.id!) because the JSON did not include the relationship.")
352 | continue
353 | }
354 |
355 | guard let linkage = linkedResourceCollection.linkage else {
356 | Spine.logInfo(.serializing, "Cannot resolve relationship '\(field.name)' of \(resource.resourceType):\(resource.id!) because the JSON did not include linkage.")
357 | continue
358 | }
359 |
360 | let targetResources = linkage.flatMap { (link: ResourceIdentifier) in
361 | return resourcePool.filter { $0.resourceType == link.type && $0.id == link.id }
362 | }
363 |
364 | if !targetResources.isEmpty {
365 | linkedResourceCollection.resources = targetResources
366 | linkedResourceCollection.isLoaded = true
367 | }
368 |
369 | }
370 | }
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/Spine/Errors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Errors.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 07-04-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// An error returned from the server.
12 | public struct APIError: Error, Equatable {
13 | public var id: String?
14 | public var status: String?
15 | public var code: String?
16 | public var title: String?
17 | public var detail: String?
18 | public var sourcePointer: String?
19 | public var sourceParameter: String?
20 | public var meta: [String: Any]?
21 |
22 | init(id: String?, status: String?, code: String?, title: String?, detail: String?, sourcePointer: String?, sourceParameter: String?, meta: [String: Any]?) {
23 | self.id = id
24 | self.status = status
25 | self.code = code
26 | self.title = title
27 | self.detail = detail
28 | self.sourcePointer = sourcePointer
29 | self.sourceParameter = sourceParameter
30 | self.meta = meta
31 | }
32 | }
33 |
34 | public func ==(lhs: APIError, rhs: APIError) -> Bool {
35 | return lhs.code == rhs.code
36 | }
37 |
38 | /// An error that occured in Spine.
39 | public enum SpineError: Error, Equatable {
40 | case unknownError
41 |
42 | /// The next page of a collection is not available.
43 | case nextPageNotAvailable
44 |
45 | /// The previous page of a collection is not available.
46 | case previousPageNotAvailable
47 |
48 | /// The requested resource is not found.
49 | case resourceNotFound
50 |
51 | /// An error occured during (de)serializing.
52 | case serializerError(SerializerError)
53 |
54 | /// A error response was received from the API.
55 | case serverError(statusCode: Int, apiErrors: [APIError]?)
56 |
57 | /// A network error occured.
58 | case networkError(NSError)
59 | }
60 |
61 | public enum SerializerError: Error, Equatable {
62 | case unknownError
63 |
64 | /// The given JSON is not a dictionary (hash).
65 | case invalidDocumentStructure
66 |
67 | /// None of 'data', 'errors', or 'meta' is present in the top level.
68 | case topLevelEntryMissing
69 |
70 | /// Top level 'data' and 'errors' coexist in the same document.
71 | case topLevelDataAndErrorsCoexist
72 |
73 | /// The given JSON is not a dictionary (hash).
74 | case invalidResourceStructure
75 |
76 | /// 'Type' field is missing from resource JSON.
77 | case resourceTypeMissing
78 |
79 | /// The given resource type has not been registered to Spine.
80 | case resourceTypeUnregistered(ResourceType)
81 |
82 | /// 'ID' field is missing from resource JSON.
83 | case resourceIDMissing
84 |
85 | /// Error occurred in NSJSONSerialization
86 | case jsonSerializationError(NSError)
87 | }
88 |
89 |
90 | public func ==(lhs: SpineError, rhs: SpineError) -> Bool {
91 | switch (lhs, rhs) {
92 | case (.unknownError, .unknownError):
93 | return true
94 | case (.nextPageNotAvailable, .nextPageNotAvailable):
95 | return true
96 | case (.previousPageNotAvailable, .previousPageNotAvailable):
97 | return true
98 | case (.resourceNotFound, .resourceNotFound):
99 | return true
100 | case (let .serializerError(lhsError), let .serializerError(rhsError)):
101 | return lhsError == rhsError
102 | case (let .serverError(lhsStatusCode, lhsApiErrors), let .serverError(rhsStatusCode, rhsApiErrors)):
103 | if lhsStatusCode != rhsStatusCode { return false }
104 | if lhsApiErrors == nil && rhsApiErrors == nil { return true }
105 | if let lhsErrors = lhsApiErrors, let rhsErrors = rhsApiErrors {
106 | return lhsErrors == rhsErrors
107 | }
108 | return false
109 | case (let .networkError(lhsError), let .networkError(rhsError)):
110 | return lhsError == rhsError
111 | default:
112 | return false
113 | }
114 | }
115 |
116 | public func ==(lhs: SerializerError, rhs: SerializerError) -> Bool {
117 | switch (lhs, rhs) {
118 | case (.unknownError, .unknownError):
119 | return true
120 | case (.invalidDocumentStructure, .invalidDocumentStructure):
121 | return true
122 | case (.topLevelEntryMissing, .topLevelEntryMissing):
123 | return true
124 | case (.topLevelDataAndErrorsCoexist, .topLevelDataAndErrorsCoexist):
125 | return true
126 | case (.invalidResourceStructure, .invalidResourceStructure):
127 | return true
128 | case (.resourceTypeMissing, .resourceTypeMissing):
129 | return true
130 | case (.resourceIDMissing, .resourceIDMissing):
131 | return true
132 | case (let .jsonSerializationError(lhsError), let .jsonSerializationError(rhsError)):
133 | return lhsError == rhsError
134 | default:
135 | return false
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/Spine/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | 0.5
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSPrincipalClass
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/Spine/KeyFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KeyFormatter.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 29/12/15.
6 | // Copyright © 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// The KeyFormatter protocol declares methods and properties that a key formatter must implement.
12 | /// A key formatter transforms field names as they appear in Resources to keys as they appear in a JSONAPI document.
13 | public protocol KeyFormatter {
14 | func format(_ name: String) -> String
15 | }
16 |
17 | extension KeyFormatter {
18 | func format(_ field: Field) -> String {
19 | return format(field.serializedName);
20 | }
21 | }
22 |
23 | /// AsIsKeyFormatter does not format anything, i.e. it returns the field name as it. Use this if your field names correspond to
24 | /// keys in a JSONAPI document one to one.
25 | public struct AsIsKeyFormatter: KeyFormatter {
26 | public func format(_ name: String) -> String {
27 | return name;
28 | }
29 |
30 | public init() { }
31 | }
32 |
33 | /// DasherizedKeyFormatter formats field names as dasherized keys. Eg. someFieldName -> some-field-name.
34 | public struct DasherizedKeyFormatter: KeyFormatter {
35 | let regex: NSRegularExpression
36 |
37 | public func format(_ name: String) -> String {
38 | let dashed = regex.stringByReplacingMatches(in: name, options: NSRegularExpression.MatchingOptions(), range: NSMakeRange(0, name.count), withTemplate: "-$1$2")
39 | return dashed.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "-"))
40 | }
41 |
42 | public init() {
43 | regex = try! NSRegularExpression(pattern: "(?<=[a-z])([A-Z])|([A-Z])(?=[a-z])", options: NSRegularExpression.Options())
44 | }
45 | }
46 |
47 | /// UnderscoredKeyFormatter formats field names as underscored keys. Eg. someFieldName -> some_field_name.
48 | public struct UnderscoredKeyFormatter: KeyFormatter {
49 | let regex: NSRegularExpression
50 |
51 | public func format(_ name: String) -> String {
52 | let underscored = regex.stringByReplacingMatches(in: name, options: NSRegularExpression.MatchingOptions(), range: NSMakeRange(0, name.count), withTemplate: "_$1$2")
53 | return underscored.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "_"))
54 | }
55 |
56 | public init() {
57 | regex = try! NSRegularExpression(pattern: "(?<=[a-z])([A-Z])|([A-Z])(?=[a-z])", options: NSRegularExpression.Options())
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Spine/Logging.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logging.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 05-04-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | fileprivate func < (lhs: T?, rhs: T?) -> Bool {
11 | switch (lhs, rhs) {
12 | case let (l?, r?):
13 | return l < r
14 | case (nil, _?):
15 | return true
16 | default:
17 | return false
18 | }
19 | }
20 |
21 | fileprivate func >= (lhs: T?, rhs: T?) -> Bool {
22 | switch (lhs, rhs) {
23 | case let (l?, r?):
24 | return l >= r
25 | default:
26 | return !(lhs < rhs)
27 | }
28 | }
29 |
30 |
31 | public enum LogLevel: Int {
32 | case debug = 0
33 | case info = 1
34 | case warning = 2
35 | case error = 3
36 | case none = 4
37 |
38 | var description: String {
39 | switch self {
40 | case .debug: return "Debug "
41 | case .info: return "Info "
42 | case .warning: return "Warning "
43 | case .error: return "Error "
44 | case .none: return "None "
45 | }
46 | }
47 | }
48 |
49 | /// Logging domains
50 | ///
51 | /// - spine: The main Spine component.
52 | /// - networking: The networking component, requests, responses etc.
53 | /// - serializing: The (de)serializing component.
54 | public enum LogDomain {
55 | case spine, networking, serializing
56 | }
57 |
58 | private var logLevels: [LogDomain: LogLevel] = [.spine: .none, .networking: .none, .serializing: .none]
59 |
60 | /// Extension regarding logging.
61 | extension Spine {
62 | public static var logger: Logger = ConsoleLogger()
63 |
64 | public class func setLogLevel(_ level: LogLevel, forDomain domain: LogDomain) {
65 | logLevels[domain] = level
66 | }
67 |
68 | class func shouldLog(_ level: LogLevel, domain: LogDomain) -> Bool {
69 | return (level.rawValue >= logLevels[domain]?.rawValue)
70 | }
71 |
72 | class func logDebug(_ domain: LogDomain, _ object: T) {
73 | if shouldLog(.debug, domain: domain) {
74 | logger.log(object, level: .debug)
75 | }
76 | }
77 |
78 | class func logInfo(_ domain: LogDomain, _ object: T) {
79 | if shouldLog(.info, domain: domain) {
80 | logger.log(object, level: .info)
81 | }
82 | }
83 |
84 | class func logWarning(_ domain: LogDomain, _ object: T) {
85 | if shouldLog(.warning, domain: domain) {
86 | logger.log(object, level: .warning)
87 | }
88 | }
89 |
90 | class func logError(_ domain: LogDomain, _ object: T) {
91 | if shouldLog(.error, domain: domain) {
92 | logger.log(object, level: .error)
93 | }
94 | }
95 | }
96 |
97 | public protocol Logger {
98 | /// Logs the textual representations of `object`.
99 | func log(_ object: T, level: LogLevel)
100 | }
101 |
102 | /// Logger that logs to the console using the Swift built in `print` function.
103 | struct ConsoleLogger: Logger {
104 | func log(_ object: T, level: LogLevel) {
105 | print("\(level.description) - \(object)")
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Spine/Networking.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Networking.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 05-09-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public typealias NetworkClientCallback = (_ statusCode: Int?, _ data: Data?, _ error: NSError?) -> Void
12 |
13 | /**
14 | A NetworkClient is the interface between Spine and the server. It does not impose any transport
15 | and can be used for HTTP, websockets, and any other data transport.
16 | */
17 | public protocol NetworkClient {
18 | /**
19 | Performs a network request to the given URL with the given method.
20 |
21 | - parameter method: The method to use, expressed as a HTTP verb.
22 | - parameter url: The URL to which to make the request.
23 | - parameter callback: The callback to execute when the request finishes.
24 | */
25 | func request(method: String, url: URL, callback: @escaping NetworkClientCallback)
26 |
27 | /**
28 | Performs a network request to the given URL with the given method.
29 |
30 | - parameter method: The method to use, expressed as a HTTP verb.
31 | - parameter url: The URL to which to make the request.
32 | - parameter payload: The payload the send as part of the request.
33 | - parameter callback: The callback to execute when the request finishes.
34 | */
35 | func request(method: String, url: URL, payload: Data?, callback: @escaping NetworkClientCallback)
36 | }
37 |
38 | extension NetworkClient {
39 | public func request(method: String, url: URL, callback: @escaping NetworkClientCallback) {
40 | return request(method: method, url: url, payload: nil, callback: callback)
41 | }
42 | }
43 |
44 | /**
45 | The HTTPClient implements the NetworkClient protocol to work over an HTTP connection.
46 | */
47 | open class HTTPClient: NetworkClient {
48 | open var delegate: HTTPClientDelegate?
49 | let urlSession: URLSession
50 | var headers: [String: String] = ["Content-Type": "application/vnd.api+json"]
51 |
52 | /**
53 | Initializes an HTTPClient with the given URLSession.
54 |
55 | - parameter session: The URLSession to use.
56 | */
57 | public init(session: URLSession) {
58 | urlSession = session
59 | }
60 |
61 | /**
62 | Initializes a HTTPClient with an URLSession that uses the
63 | `URLSessionConfiguration.defaultSessionConfiguration()` configuration.
64 | */
65 | public convenience init() {
66 | let sessionConfiguration = URLSessionConfiguration.default
67 | self.init(session: URLSession(configuration: sessionConfiguration))
68 | }
69 |
70 | /**
71 | Sets a HTTP header for all upcoming network requests.
72 |
73 | - parameter header: The name of header to set the value for.
74 | - parameter value: The value to set the header tp.
75 | */
76 | open func setHeader(_ header: String, to value: String) {
77 | headers[header] = value
78 | }
79 |
80 | /**
81 | Removes a HTTP header for all upcoming network requests.
82 |
83 | - parameter header: The name of header to remove.
84 | */
85 | open func removeHeader(_ header: String) {
86 | headers.removeValue(forKey: header)
87 | }
88 |
89 | open func buildRequest(_ method: String, url: URL, payload: Data?) -> URLRequest {
90 | var request = URLRequest(url: url)
91 | request.httpMethod = method
92 |
93 | for (key, value) in headers {
94 | request.setValue(value, forHTTPHeaderField: key)
95 | }
96 |
97 | if let payload = payload {
98 | request.httpBody = payload
99 | }
100 |
101 | return request
102 | }
103 |
104 | open func request(method: String, url: URL, payload: Data?, callback: @escaping NetworkClientCallback) {
105 | delegate?.httpClient(self, willPerformRequestWithMethod: method, url: url, payload: payload)
106 |
107 | let request = buildRequest(method, url: url, payload: payload)
108 |
109 | Spine.logInfo(.networking, "\(method): \(url)")
110 |
111 | if Spine.shouldLog(.debug, domain: .networking) {
112 | if let httpBody = request.httpBody, let stringRepresentation = NSString(data: httpBody, encoding: String.Encoding.utf8.rawValue) {
113 | Spine.logDebug(.networking, stringRepresentation)
114 | }
115 | }
116 |
117 | let task = urlSession.dataTask(with: request, completionHandler: { data, response, networkError in
118 | let response = (response as? HTTPURLResponse)
119 | let success: Bool
120 |
121 | if let error = networkError {
122 | // Network error
123 | Spine.logError(.networking, "\(request.url!) - \(error.localizedDescription)")
124 | success = false
125 |
126 | } else if let statusCode = response?.statusCode , 200 ... 299 ~= statusCode {
127 | // Success
128 | Spine.logInfo(.networking, "\(statusCode): \(request.url!) – (\(data!.count) bytes)")
129 | success = true
130 |
131 | } else {
132 | // API Error
133 | Spine.logWarning(.networking, "\(response!.statusCode): \(request.url!) – (\(data!.count) bytes)")
134 | success = false
135 | }
136 |
137 | if Spine.shouldLog(.debug, domain: .networking) {
138 | if let data = data, let stringRepresentation = NSString(data: data, encoding: String.Encoding.utf8.rawValue) {
139 | Spine.logDebug(.networking, stringRepresentation)
140 | }
141 | }
142 |
143 | self.delegate?.httpClient(self, didPerformRequestWithMethod: method, url: url, success: success)
144 | callback(response?.statusCode, data, networkError as NSError?)
145 | })
146 |
147 | task.resume()
148 | }
149 | }
150 |
151 | public protocol HTTPClientDelegate {
152 | /**
153 | Called before the HTTPClient will perform an HTTP request.
154 |
155 | - parameter client: The client that will perform the request.
156 | - parameter method: The HTTP method of the request.
157 | - parameter url: The URL of the request.
158 | - parameter payload: The optional payload of the request.
159 | */
160 | func httpClient(_ client: HTTPClient, willPerformRequestWithMethod method: String, url: URL, payload: Data?)
161 |
162 | /**
163 | Called after the HTTPClient performed an HTTP request. This method is called after the request finished,
164 | but before the request has been processed by the NetworkClientCallback that was initially passed.
165 |
166 | - parameter client: The client that performed the request.
167 | - parameter method: The HTTP method of the request.
168 | - parameter url: The URL of the request.
169 | - parameter success: Whether the reques was successful. Network and error responses are consided unsuccessful.
170 | */
171 | func httpClient(_ client: HTTPClient, didPerformRequestWithMethod method: String, url: URL, success: Bool)
172 | }
173 |
--------------------------------------------------------------------------------
/Spine/Operation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Operation.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 05-04-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | fileprivate func statusCodeIsSuccess(_ statusCode: Int?) -> Bool {
12 | return statusCode != nil && 200 ... 299 ~= statusCode!
13 | }
14 |
15 | fileprivate extension Error {
16 | /// Promotes the rror to a SpineError.
17 | /// Errors that cannot be represented as a SpineError will be returned as SpineError.unknownError
18 | var asSpineError: SpineError {
19 | switch self {
20 | case is SpineError:
21 | return self as! SpineError
22 | case is SerializerError:
23 | return .serializerError(self as! SerializerError)
24 | default:
25 | return .unknownError
26 | }
27 | }
28 | }
29 |
30 |
31 | // MARK: - Base operation
32 |
33 | /**
34 | The ConcurrentOperation class is an abstract class for all Spine operations.
35 | You must not create instances of this class directly, but instead create
36 | an instance of one of its concrete subclasses.
37 |
38 | Subclassing
39 | ===========
40 | To support generic subclasses, Operation adds an `execute` method.
41 | Override this method to provide the implementation for a concurrent subclass.
42 |
43 | Concurrent state
44 | ================
45 | ConcurrentOperation is concurrent by default. To update the state of the operation,
46 | update the `state` instance variable. This will fire off the needed KVO notifications.
47 |
48 | Operating against a Spine
49 | =========================
50 | The `Spine` instance variable references the Spine against which to operate.
51 | */
52 | class ConcurrentOperation: Operation {
53 | enum State: String {
54 | case ready = "isReady"
55 | case executing = "isExecuting"
56 | case finished = "isFinished"
57 | }
58 |
59 | /// The current state of the operation
60 | var state: State = .ready {
61 | willSet {
62 | willChangeValue(forKey: newValue.rawValue)
63 | willChangeValue(forKey: state.rawValue)
64 | }
65 | didSet {
66 | didChangeValue(forKey: oldValue.rawValue)
67 | didChangeValue(forKey: state.rawValue)
68 | }
69 | }
70 | override var isReady: Bool {
71 | return super.isReady && state == .ready
72 | }
73 | override var isExecuting: Bool {
74 | return state == .executing
75 | }
76 | override var isFinished: Bool {
77 | return state == .finished
78 | }
79 | override var isAsynchronous: Bool {
80 | return true
81 | }
82 |
83 | /// The Spine instance to operate against.
84 | var spine: Spine!
85 |
86 | /// Convenience variables that proxy to their spine counterpart
87 | var router: Router {
88 | return spine.router
89 | }
90 | var networkClient: NetworkClient {
91 | return spine.networkClient
92 | }
93 | var serializer: Serializer {
94 | return spine.serializer
95 | }
96 |
97 | override init() {}
98 |
99 | final override func start() {
100 | if isCancelled {
101 | state = .finished
102 | } else {
103 | state = .executing
104 | main()
105 | }
106 | }
107 |
108 | final override func main() {
109 | execute()
110 | }
111 |
112 | func execute() {}
113 | }
114 |
115 |
116 | // MARK: - Main operations
117 |
118 | /// FetchOperation fetches a JSONAPI document from a Spine, using a given Query.
119 | class FetchOperation: ConcurrentOperation {
120 | /// The query describing which resources to fetch.
121 | let query: Query
122 |
123 | /// Existing resources onto which to map the fetched resources.
124 | var mappingTargets = [Resource]()
125 |
126 | /// The result of the operation. You can safely force unwrap this in the completionBlock.
127 | var result: Failable?
128 |
129 | init(query: Query, spine: Spine) {
130 | self.query = query
131 | super.init()
132 | self.spine = spine
133 | }
134 |
135 | override func execute() {
136 | let url = spine.router.urlForQuery(query)
137 |
138 | Spine.logInfo(.spine, "Fetching document using URL: \(url)")
139 |
140 | networkClient.request(method: "GET", url: url) { statusCode, responseData, networkError in
141 | defer { self.state = .finished }
142 |
143 | guard networkError == nil else {
144 | self.result = .failure(.networkError(networkError!))
145 | return
146 | }
147 |
148 | if let data = responseData , data.count > 0 {
149 | do {
150 | let document = try self.serializer.deserializeData(data, mappingTargets: self.mappingTargets)
151 | if statusCodeIsSuccess(statusCode) {
152 | self.result = .success(document)
153 | } else {
154 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: document.errors))
155 | }
156 | } catch let error {
157 | self.result = .failure(error.asSpineError)
158 | }
159 |
160 | } else {
161 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: nil))
162 | }
163 | }
164 | }
165 | }
166 |
167 | /// DeleteOperation deletes a resource from a Spine.
168 | class DeleteOperation: ConcurrentOperation {
169 | /// The resource to delete.
170 | let resource: Resource
171 |
172 | /// The result of the operation. You can safely force unwrap this in the completionBlock.
173 | var result: Failable?
174 |
175 | init(resource: Resource, spine: Spine) {
176 | self.resource = resource
177 | super.init()
178 | self.spine = spine
179 | }
180 |
181 | override func execute() {
182 | let URL = spine.router.urlForQuery(Query(resource: resource))
183 |
184 | Spine.logInfo(.spine, "Deleting resource \(resource) using URL: \(URL)")
185 |
186 | networkClient.request(method: "DELETE", url: URL) { statusCode, responseData, networkError in
187 | defer { self.state = .finished }
188 |
189 | guard networkError == nil else {
190 | self.result = .failure(.networkError(networkError!))
191 | return
192 | }
193 |
194 | if statusCodeIsSuccess(statusCode) {
195 | self.result = .success(())
196 | } else if let data = responseData , data.count > 0 {
197 | do {
198 | let document = try self.serializer.deserializeData(data)
199 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: document.errors))
200 | } catch let error {
201 | self.result = .failure(error.asSpineError)
202 | }
203 | } else {
204 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: nil))
205 | }
206 | }
207 | }
208 | }
209 |
210 | /// A SaveOperation updates or adds a resource in a Spine.
211 | class SaveOperation: ConcurrentOperation {
212 | /// The resource to save.
213 | let resource: Resource
214 |
215 | /// The result of the operation. You can safely force unwrap this in the completionBlock.
216 | var result: Failable?
217 |
218 | /// Whether the resource is a new resource, or an existing resource.
219 | fileprivate let isNewResource: Bool
220 |
221 | fileprivate let relationshipOperationQueue = OperationQueue()
222 |
223 | init(resource: Resource, spine: Spine) {
224 | self.resource = resource
225 | self.isNewResource = (resource.id == nil)
226 | super.init()
227 | self.spine = spine
228 | self.relationshipOperationQueue.maxConcurrentOperationCount = 1
229 | self.relationshipOperationQueue.addObserver(self, forKeyPath: "operations", context: nil)
230 | }
231 |
232 | deinit {
233 | self.relationshipOperationQueue.removeObserver(self, forKeyPath: "operations")
234 | }
235 |
236 | override func execute() {
237 | // First update relationships if this is an existing resource. Otherwise the local relationships
238 | // are overwritten with data that is returned from saving the resource.
239 | if isNewResource {
240 | updateResource()
241 | } else {
242 | updateRelationships()
243 | }
244 | }
245 |
246 | fileprivate func updateResource() {
247 | let url: URL
248 | let method: String
249 | let options: SerializationOptions
250 |
251 | if isNewResource {
252 | url = router.urlForResourceType(resource.resourceType)
253 | method = "POST"
254 | if let idGenerator = spine.idGenerator {
255 | resource.id = idGenerator(resource)
256 | options = [.IncludeToOne, .IncludeToMany, .IncludeID]
257 | } else {
258 | options = [.IncludeToOne, .IncludeToMany]
259 | }
260 | } else {
261 | url = router.urlForQuery(Query(resource: resource))
262 | method = "PATCH"
263 | options = [.IncludeID]
264 | }
265 |
266 | let payload: Data
267 |
268 | do {
269 | payload = try serializer.serializeResources([resource], options: options)
270 | } catch let error {
271 | result = .failure(error.asSpineError)
272 | state = .finished
273 | return
274 | }
275 |
276 | Spine.logInfo(.spine, "Saving resource \(resource) using URL: \(url)")
277 |
278 | networkClient.request(method: method, url: url, payload: payload) { statusCode, responseData, networkError in
279 | defer { self.state = .finished }
280 |
281 | if let error = networkError {
282 | self.result = .failure(.networkError(error))
283 | return
284 | }
285 |
286 | let success = statusCodeIsSuccess(statusCode)
287 | let document: JSONAPIDocument?
288 | if let data = responseData , data.count > 0 {
289 | do {
290 | // Don't map onto the resources if the response is not in the success range.
291 | let mappingTargets: [Resource]? = success ? [self.resource] : nil
292 | document = try self.serializer.deserializeData(data, mappingTargets: mappingTargets)
293 | } catch let error {
294 | self.result = .failure(error.asSpineError)
295 | return
296 | }
297 | } else {
298 | document = nil
299 | }
300 |
301 | if success {
302 | self.result = .success(())
303 | } else {
304 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: document?.errors))
305 | }
306 | }
307 | }
308 |
309 | /// Serializes `resource` into NSData using `options`. Any error that occurs is rethrown as a SpineError.
310 | fileprivate func serializePayload(_ resource: Resource, options: SerializationOptions) throws -> Data {
311 | do {
312 | let payload = try serializer.serializeResources([resource], options: options)
313 | return payload
314 | } catch let error {
315 | throw error.asSpineError
316 | }
317 | }
318 |
319 | fileprivate func updateRelationships() {
320 | let relationships = resource.fields.filter { field in
321 | return field is Relationship && !field.isReadOnly
322 | }
323 |
324 | guard !relationships.isEmpty else {
325 | updateResource()
326 | return
327 | }
328 |
329 | let completionHandler: (_ result: Failable?) -> Void = { result in
330 | if let error = result?.error {
331 | self.relationshipOperationQueue.cancelAllOperations()
332 | self.result = .failure(error)
333 | self.state = .finished
334 | }
335 | }
336 |
337 | for relationship in relationships {
338 | switch relationship {
339 | case let toOne as ToOneRelationship:
340 | let operation = RelationshipReplaceOperation(resource: resource, relationship: toOne, spine: spine)
341 | operation.completionBlock = { [unowned operation] in completionHandler(operation.result) }
342 | relationshipOperationQueue.addOperation(operation)
343 |
344 | case let toMany as ToManyRelationship:
345 | let addOperation = RelationshipMutateOperation(resource: resource, relationship: toMany, mutation: .add, spine: spine)
346 | addOperation.completionBlock = { [unowned addOperation] in completionHandler(addOperation.result) }
347 | relationshipOperationQueue.addOperation(addOperation)
348 |
349 | let removeOperation = RelationshipMutateOperation(resource: resource, relationship: toMany, mutation: .remove, spine: spine)
350 | removeOperation.completionBlock = { [unowned removeOperation] in completionHandler(removeOperation.result) }
351 | relationshipOperationQueue.addOperation(removeOperation)
352 | default: ()
353 | }
354 | }
355 | }
356 |
357 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
358 | guard let path = keyPath, let queue = object as? OperationQueue , path == "operations" && queue == relationshipOperationQueue else {
359 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
360 | return
361 | }
362 |
363 | if queue.operationCount == 0 {
364 | // At this point, we know all relationships are updated
365 | updateResource()
366 | }
367 | }
368 | }
369 |
370 | private class RelationshipOperation: ConcurrentOperation {
371 | var result: Failable?
372 |
373 | func handleNetworkResponse(_ statusCode: Int?, responseData: Data?, networkError: NSError?) {
374 | defer { self.state = .finished }
375 |
376 | guard networkError == nil else {
377 | self.result = .failure(.networkError(networkError!))
378 | return
379 | }
380 |
381 | if statusCodeIsSuccess(statusCode) {
382 | self.result = .success(())
383 | } else if let data = responseData, data.count > 0 {
384 | do {
385 | let document = try serializer.deserializeData(data)
386 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: document.errors))
387 | } catch let error {
388 | self.result = .failure(error.asSpineError)
389 | }
390 | } else {
391 | self.result = .failure(.serverError(statusCode: statusCode!, apiErrors: nil))
392 | }
393 | }
394 | }
395 |
396 | /// A RelationshipReplaceOperation replaces the entire contents of a relationship.
397 | private class RelationshipReplaceOperation: RelationshipOperation {
398 | let resource: Resource
399 | let relationship: Relationship
400 |
401 | init(resource: Resource, relationship: Relationship, spine: Spine) {
402 | self.resource = resource
403 | self.relationship = relationship
404 | super.init()
405 | self.spine = spine
406 | }
407 |
408 | override func execute() {
409 | let url = router.urlForRelationship(relationship, ofResource: resource)
410 | let payload: Data
411 |
412 | switch relationship {
413 | case is ToOneRelationship:
414 | payload = try! serializer.serializeLinkData(resource.value(forField: relationship.name) as? Resource)
415 | case is ToManyRelationship:
416 | let relatedResources = (resource.value(forField: relationship.name) as? ResourceCollection)?.resources ?? []
417 | payload = try! serializer.serializeLinkData(relatedResources)
418 | default:
419 | assertionFailure("Cannot only replace relationship contents for ToOneRelationship and ToManyRelationship")
420 | return
421 | }
422 |
423 | Spine.logInfo(.spine, "Replacing relationship \(relationship) using URL: \(url)")
424 | networkClient.request(method: "PATCH", url: url, payload: payload, callback: handleNetworkResponse)
425 | }
426 | }
427 |
428 | /// A RelationshipMutateOperation mutates a to-many relationship by adding or removing linked resources.
429 | private class RelationshipMutateOperation: RelationshipOperation {
430 | enum Mutation {
431 | case add, remove
432 | }
433 |
434 | let resource: Resource
435 | let relationship: ToManyRelationship
436 | let mutation: Mutation
437 |
438 | init(resource: Resource, relationship: ToManyRelationship, mutation: Mutation, spine: Spine) {
439 | self.resource = resource
440 | self.relationship = relationship
441 | self.mutation = mutation
442 | super.init()
443 | self.spine = spine
444 | }
445 |
446 | override func execute() {
447 | let resourceCollection = resource.value(forField: relationship.name) as! LinkedResourceCollection
448 | let httpMethod: String
449 | let relatedResources: [Resource]
450 |
451 | switch mutation {
452 | case .add:
453 | httpMethod = "POST"
454 | relatedResources = resourceCollection.addedResources
455 | case .remove:
456 | httpMethod = "DELETE"
457 | relatedResources = resourceCollection.removedResources
458 | }
459 |
460 | guard !relatedResources.isEmpty else {
461 | result = .success(())
462 | state = .finished
463 | return
464 | }
465 |
466 | let url = router.urlForRelationship(relationship, ofResource: resource)
467 | let payload = try! serializer.serializeLinkData(relatedResources)
468 | Spine.logInfo(.spine, "Mutating relationship \(relationship) using URL: \(url)")
469 | networkClient.request(method: httpMethod, url: url, payload: payload, callback: handleNetworkResponse)
470 | }
471 | }
472 |
--------------------------------------------------------------------------------
/Spine/Query.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Query.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-08-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A Query defines search criteria used to retrieve data from an API.
12 | ///
13 | /// Custom query URL
14 | /// ================
15 | /// Usually Query objects are turned into URLs by the Router. The Router decides how the query configurations
16 | /// are translated to URL components. However, queries can also be instantiated with a custom URL.
17 | /// This is used when the API returns hrefs for example. Custom URL components will not be 'parsed'
18 | /// into their respective configuration variables, so the query configuration may not correspond to
19 | /// the actual URL generated by the Router.
20 | public struct Query {
21 | /// The type of resource to fetch. This can be nil if in case of an expected heterogenous response.
22 | public private(set) var resourceType: ResourceType?
23 |
24 | /// The specific IDs the fetch.
25 | var resourceIDs: [String]?
26 |
27 | /// The optional base URL
28 | internal var url: URL?
29 |
30 | /// Related resources that must be included in a compound document.
31 | public internal(set) var includes: [String] = []
32 |
33 | /// Comparison predicates used to filter resources.
34 | public internal(set) var filters: [NSComparisonPredicate] = []
35 |
36 | /// Serialized names of fields that will be returned, per resource type. If no fields are specified, all fields are returned.
37 | public internal(set) var fields: [ResourceType: [String]] = [:]
38 |
39 | /// Sort descriptors to sort resources.
40 | public internal(set) var sortDescriptors: [NSSortDescriptor] = []
41 |
42 | public internal(set) var pagination: Pagination?
43 |
44 |
45 | //MARK: Init
46 |
47 | /// Inits a new query for the given resource type and optional resource IDs.
48 | ///
49 | /// - parameter resourceType: The type of resource to query.
50 | /// - parameter resourceIDs: The IDs of the resources to query. Pass nil to fetch all resources of the given type.
51 |
52 | /// - returns: Query
53 | public init(resourceType: T.Type, resourceIDs: [String]? = nil) {
54 | self.resourceType = T.resourceType
55 | self.resourceIDs = resourceIDs
56 | }
57 |
58 | /// Inits a new query that fetches the given resource.
59 | ///
60 | /// - parameter resource: The resource to fetch.
61 | ///
62 | /// - returns: Query
63 | public init(resource: T) {
64 | assert(resource.id != nil, "Cannot instantiate query for resource, id is nil.")
65 | self.resourceType = resource.resourceType
66 | self.url = resource.url
67 | self.resourceIDs = [resource.id!]
68 | }
69 |
70 | /// Inits a new query that fetches resources from the given resource collection.
71 | ///
72 | /// - parameter resourceType: The type of resource to query.
73 | /// - parameter resourceCollection: The resource collection whose resources to fetch.
74 | ///
75 | /// - returns: Query
76 | public init(resourceType: T.Type, resourceCollection: ResourceCollection) {
77 | self.resourceType = T.resourceType
78 | self.url = resourceCollection.resourcesURL
79 | }
80 |
81 | /// Inits a new query that fetches resource of type `resourceType`, by using the given URL.
82 | ///
83 | /// - parameter resourceType: The type of resource to query.
84 | /// - parameter path: The URL path used to fetch the resources.
85 | ///
86 | /// - returns: Query
87 | public init(resourceType: T.Type, path: String) {
88 | self.resourceType = T.resourceType
89 | self.url = URL(string: path)
90 | }
91 |
92 | init(url: URL) {
93 | self.url = url
94 | }
95 |
96 |
97 | // MARK: Including
98 |
99 | /// Includes the given relationships in the query.
100 | ///
101 | /// - parameter relationshipNames: The names of the relationships to include.
102 | public mutating func include(_ relationshipNames: String...) {
103 | for relationshipName in relationshipNames {
104 | includes.append(relationshipName)
105 | }
106 | }
107 |
108 | /// Removes previously included relationships.
109 | ///
110 | /// - parameter relationshipNames: The names of the included relationships to remove.
111 | public mutating func removeInclude(_ relationshipNames: String...) {
112 | includes = includes.filter { !relationshipNames.contains($0) }
113 | }
114 |
115 |
116 | // MARK: Filtering
117 |
118 | /// Adds a predicate to filter on a field.
119 | ///
120 | /// - parameter fieldName: The name of the field to filter on.
121 | /// - parameter value: The value to check for.
122 | /// - parameter type: The comparison operator to use
123 | public mutating func addPredicateWithField(_ fieldName: String, value: Any, type: NSComparisonPredicate.Operator) {
124 | if let field = T.fields.filter({ $0.name == fieldName }).first {
125 | addPredicateWithKey(field.name, value: value, type: type)
126 | } else {
127 | assertionFailure("Resource of type \(T.resourceType) does not contain a field named \(fieldName)")
128 | }
129 | }
130 |
131 | /// Adds a predicate to filter on a key. The key does not have to correspond
132 | /// to a field defined on the resource.
133 | ///
134 | /// - parameter key: The key of the field to filter on.
135 | /// - parameter value: The value to check for.
136 | /// - parameter type: The comparison operator to use
137 | public mutating func addPredicateWithKey(_ key: String, value: Any, type: NSComparisonPredicate.Operator) {
138 | let predicate = NSComparisonPredicate(
139 | leftExpression: NSExpression(forKeyPath: key),
140 | rightExpression: NSExpression(forConstantValue: value),
141 | modifier: .direct,
142 | type: type,
143 | options: [])
144 |
145 | filters.append(predicate)
146 | }
147 |
148 | /// Adds a filter where the given attribute should be equal to the given value.
149 | ///
150 | /// - parameter attributeName: The name of the attribute to filter on.
151 | /// - parameter equalTo: The value to check for.
152 | public mutating func whereAttribute(_ attributeName: String, equalTo: Any) {
153 | addPredicateWithField(attributeName, value: equalTo, type: .equalTo)
154 | }
155 |
156 | /// Adds a filter where the given attribute should not be equal to the given value.
157 | ///
158 | /// - parameter attributeName: The name of the attribute to filter on.
159 | /// - parameter notEqualTo: The value to check for.
160 | public mutating func whereAttribute(_ attributeName: String, notEqualTo: Any) {
161 | addPredicateWithField(attributeName, value: notEqualTo, type: .notEqualTo)
162 | }
163 |
164 | /// Adds a filter where the given attribute should be smaller than the given value.
165 | ///
166 | /// - parameter attributeName: The name of the attribute to filter on.
167 | /// - parameter lessThan: The value to check for.
168 | public mutating func whereAttribute(_ attributeName: String, lessThan: Any) {
169 | addPredicateWithField(attributeName, value: lessThan, type: .lessThan)
170 | }
171 |
172 | /// Adds a filter where the given attribute should be less then or equal to the given value.
173 | ///
174 | /// - parameter attributeName: The name of the attribute to filter on.
175 | /// - parameter lessThanOrEqualTo: The value to check for.
176 | public mutating func whereAttribute(_ attributeName: String, lessThanOrEqualTo: Any) {
177 | addPredicateWithField(attributeName, value: lessThanOrEqualTo, type: .lessThanOrEqualTo)
178 | }
179 |
180 | /// Adds a filter where the given attribute should be greater then the given value.
181 | ///
182 | /// - parameter attributeName: The name of the attribute to filter on.
183 | /// - parameter greaterThan: The value to check for.
184 | public mutating func whereAttribute(_ attributeName: String, greaterThan: Any) {
185 | addPredicateWithField(attributeName, value: greaterThan, type: .greaterThan)
186 | }
187 |
188 | /// Adds a filter where the given attribute should be greater than or equal to the given value.
189 | ///
190 | /// - parameter attributeName: The name of the attribute to filter on.
191 | /// - parameter greaterThanOrEqualTo: The value to check for.
192 | public mutating func whereAttribute(_ attributeName: String, greaterThanOrEqualTo: Any) {
193 | addPredicateWithField(attributeName, value: greaterThanOrEqualTo, type: .greaterThanOrEqualTo)
194 | }
195 |
196 | /// Adds a filter where the given relationship should point to the given resource, or the given
197 | /// resource should be present in the related resources.
198 | ///
199 | /// - parameter relationshipName: The name of the relationship to filter on.
200 | /// - parameter resource: The resource that should be related.
201 | public mutating func whereRelationship(_ relationshipName: String, isOrContains resource: Resource) {
202 | assert(resource.id != nil, "Attempt to add a where filter on a relationship, but the target resource does not have an id.")
203 | addPredicateWithField(relationshipName, value: resource.id! as AnyObject, type: .equalTo)
204 | }
205 |
206 |
207 | // MARK: Sparse fieldsets
208 |
209 | /// Restricts the fields that should be requested. When not set, all fields will be requested.
210 | /// Note: the server may still choose to return only of a select set of fields.
211 | ///
212 | /// - parameter fieldNames: Names of fields to fetch.
213 | public mutating func restrictFieldsTo(_ fieldNames: String...) {
214 | assert(resourceType != nil, "Cannot restrict fields for query without resource type, use `restrictFieldsOfResourceType` or set a resource type.")
215 |
216 | for fieldName in fieldNames {
217 | restrictFieldsOfResourceType(T.self, to: fieldName)
218 | }
219 | }
220 |
221 | /// Restricts the fields of a specific resource type that should be requested.
222 | /// This method can be used to restrict fields of included resources. When not set, all fields will be requested.
223 | ///
224 | /// Note: the server may still choose to return only of a select set of fields.
225 | ///
226 | /// - parameter type: The resource type for which to restrict the properties.
227 | /// - parameter fieldNames: Names of fields to fetch.
228 | public mutating func restrictFieldsOfResourceType(_ type: Resource.Type, to fieldNames: String...) {
229 | for fieldName in fieldNames {
230 | guard let field = type.field(named: fieldName) else {
231 | assertionFailure("Cannot restrict to field \(fieldName) of resource \(type.resourceType). No such field has been configured.")
232 | return
233 | }
234 |
235 | if fields[type.resourceType] != nil {
236 | fields[type.resourceType]!.append(field.serializedName)
237 | } else {
238 | fields[type.resourceType] = [field.serializedName]
239 | }
240 | }
241 | }
242 |
243 |
244 | // MARK: Sorting
245 |
246 | /// Sort in ascending order by the the given field. Previously added field take precedence over this field.
247 | ///
248 | /// - parameter fieldName: The name of the field which to order by.
249 | public mutating func addAscendingOrder(_ fieldName: String) {
250 | if let _ = T.field(named: fieldName) {
251 | sortDescriptors.append(NSSortDescriptor(key: fieldName, ascending: true))
252 | } else {
253 | assertionFailure("Cannot add order on field \(fieldName) of resource \(T.resourceType). No such field has been configured.")
254 | }
255 | }
256 |
257 | /// Sort in descending order by the the given field. Previously added field take precedence over this property.
258 | ///
259 | /// - parameter property: The name of the field which to order by.
260 | public mutating func addDescendingOrder(_ fieldName: String) {
261 | if let _ = T.field(named: fieldName) {
262 | sortDescriptors.append(NSSortDescriptor(key: fieldName, ascending: false))
263 | } else {
264 | assertionFailure("Cannot add order on field \(fieldName) of resource \(T.resourceType). No such field has been configured.")
265 | }
266 | }
267 |
268 |
269 | // MARK: Pagination
270 |
271 | /// Paginate the result using the given pagination configuration. Pass nil to remove pagination.
272 | ///
273 | /// - parameter pagination: The pagination configuration to use.
274 | public mutating func paginate(_ pagination: Pagination?) {
275 | self.pagination = pagination
276 | }
277 | }
278 |
279 |
280 | // MARK: - Pagination
281 |
282 | /// The Pagination protocol is an empty protocol to which pagination configurations must adhere.
283 | public protocol Pagination { }
284 |
285 | /// Page based pagination is a pagination strategy that returns results based on pages of a fixed size.
286 | public struct PageBasedPagination: Pagination {
287 | var pageNumber: Int
288 | var pageSize: Int
289 |
290 | /// Instantiates a new PageBasedPagination struct.
291 | ///
292 | /// - parameter pageNumber: The number of the page to return.
293 | /// - parameter pageSize: The size of each page.
294 | ///
295 | /// - returns: PageBasedPagination
296 | public init(pageNumber: Int, pageSize: Int) {
297 | self.pageNumber = pageNumber
298 | self.pageSize = pageSize
299 | }
300 | }
301 |
302 | /// Offet based pagination is a pagination strategy that returns results based on an offset from the beginning of the result set.
303 | public struct OffsetBasedPagination: Pagination {
304 | var offset: Int
305 | var limit: Int
306 |
307 | /// Instantiates a new OffsetBasedPagination struct.
308 | ///
309 | /// - parameter offset: The offset from the beginning of the result set.
310 | /// - parameter limit: The number of resources to return.
311 | ///
312 | /// - returns: OffsetBasedPagination
313 | public init(offset: Int, limit: Int) {
314 | self.offset = offset
315 | self.limit = limit
316 | }
317 | }
318 |
--------------------------------------------------------------------------------
/Spine/Resource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Resource.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 25-08-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public typealias ResourceType = String
12 |
13 | /// A ResourceIdentifier uniquely identifies a resource that exists on the server.
14 | public struct ResourceIdentifier: Equatable {
15 | /// The resource type.
16 | public private(set) var type: ResourceType
17 |
18 | /// The resource ID.
19 | public private(set) var id: String
20 |
21 | /// Constructs a new ResourceIdentifier instance with given `type` and `id`.
22 | init(type: ResourceType, id: String) {
23 | self.type = type
24 | self.id = id
25 | }
26 |
27 | /// Constructs a new ResourceIdentifier instance from the given dictionary.
28 | /// The dictionary must contain values for the "type" and "id" keys.
29 | init(dictionary: NSDictionary) {
30 | type = dictionary["type"] as! ResourceType
31 | id = dictionary["id"] as! String
32 | }
33 |
34 | /// Returns a dictionary with "type" and "id" keys containing the type and id.
35 | public func toDictionary() -> NSDictionary {
36 | return ["type": type, "id": id]
37 | }
38 | }
39 |
40 | public func ==(lhs: ResourceIdentifier, rhs: ResourceIdentifier) -> Bool {
41 | return lhs.type == rhs.type && lhs.id == rhs.id
42 | }
43 |
44 | /// A RelationshipData struct holds data about a relationship.
45 | struct RelationshipData {
46 | var selfURL: URL?
47 | var relatedURL: URL?
48 | var data: [ResourceIdentifier]?
49 |
50 | init(selfURL: URL?, relatedURL: URL?, data: [ResourceIdentifier]?) {
51 | self.selfURL = selfURL
52 | self.relatedURL = relatedURL
53 | self.data = data
54 | }
55 |
56 | /// Constructs a new ResourceIdentifier instance from the given dictionary.
57 | /// The dictionary must contain values for the "type" and "id" keys.
58 | init(dictionary: NSDictionary) {
59 | selfURL = dictionary["selfURL"] as? URL
60 | relatedURL = dictionary["relatedURL"] as? URL
61 | data = (dictionary["data"] as? [NSDictionary])?.map(ResourceIdentifier.init)
62 | }
63 |
64 | /// Returns a dictionary with "type" and "id" keys containing the type and id.
65 | func toDictionary() -> NSDictionary {
66 | var dictionary = [String: Any]()
67 | if let selfURL = selfURL {
68 | dictionary["selfURL"] = selfURL as AnyObject?
69 | }
70 | if let relatedURL = relatedURL {
71 | dictionary["relatedURL"] = relatedURL as AnyObject?
72 | }
73 | if let data = data {
74 | dictionary["data"] = data.map { $0.toDictionary() }
75 | }
76 | return dictionary as NSDictionary
77 | }
78 | }
79 |
80 | /// A base recource class that provides some defaults for resources.
81 | /// You can create custom resource classes by subclassing from Resource.
82 | @objcMembers
83 | open class Resource: NSObject, NSCoding {
84 | /// The resource type in plural form.
85 | open class var resourceType: ResourceType {
86 | fatalError("Override resourceType in a subclass.")
87 | }
88 |
89 | /// All fields that must be persisted in the API.
90 | open class var fields: [Field] { return [] }
91 |
92 | /// The ID of this resource.
93 | public var id: String?
94 |
95 | /// The canonical URL of the resource.
96 | public var url: URL?
97 |
98 | /// Whether the fields of the resource are loaded.
99 | public var isLoaded: Bool = false
100 |
101 | /// The metadata for this resource.
102 | public var meta: [String: Any]?
103 |
104 | /// Raw relationship data keyed by relationship name.
105 | var relationships: [String: RelationshipData] = [:]
106 |
107 | public required override init() {
108 | super.init()
109 | }
110 |
111 | public required init(coder: NSCoder) {
112 | super.init()
113 | self.id = coder.decodeObject(forKey: "id") as? String
114 | self.url = coder.decodeObject(forKey: "url") as? URL
115 | self.isLoaded = coder.decodeBool(forKey: "isLoaded")
116 | self.meta = coder.decodeObject(forKey: "meta") as? [String: AnyObject]
117 |
118 | if let relationshipsData = coder.decodeObject(forKey: "relationships") as? [String: NSDictionary] {
119 | var relationships = [String: RelationshipData]()
120 | for (key, value) in relationshipsData {
121 | relationships[key] = RelationshipData.init(dictionary: value)
122 | }
123 | }
124 | }
125 |
126 | open func encode(with coder: NSCoder) {
127 | coder.encode(id, forKey: "id")
128 | coder.encode(url, forKey: "url")
129 | coder.encode(isLoaded, forKey: "isLoaded")
130 | coder.encode(meta, forKey: "meta")
131 |
132 | var relationshipsData = [String: NSDictionary]()
133 | for (key, value) in relationships {
134 | relationshipsData[key] = value.toDictionary()
135 | }
136 | coder.encode(relationshipsData, forKey: "relationships")
137 | }
138 |
139 | /// Returns the value for the field named `field`.
140 | func value(forField field: String) -> Any? {
141 | return value(forKey: field) as AnyObject?
142 | }
143 |
144 | /// Sets the value for the field named `field` to `value`.
145 | func setValue(_ value: Any?, forField field: String) {
146 | setValue(value, forKey: field)
147 | }
148 |
149 | /// Set the values for all fields to nil and sets `isLoaded` to false.
150 | public func unload() {
151 | for field in fields {
152 | setValue(nil, forField: field.name)
153 | }
154 |
155 | isLoaded = false
156 | }
157 |
158 | /// Returns the field named `name`, or nil if no such field exists.
159 | class func field(named name: String) -> Field? {
160 | return fields.filter { $0.name == name }.first
161 | }
162 | }
163 |
164 | extension Resource {
165 | override open var description: String {
166 | return "\(resourceType)" + String(describing: id) + "," + String(describing: url)
167 | }
168 |
169 | override open var debugDescription: String {
170 | return description
171 | }
172 | }
173 |
174 | /// Instance counterparts of class functions
175 | extension Resource {
176 | final var resourceType: ResourceType { return type(of: self).resourceType }
177 | final var fields: [Field] { return type(of: self).fields }
178 | }
179 |
180 | public func == (left: T, right: T) -> Bool {
181 | return (left.id == right.id) && (left.resourceType == right.resourceType)
182 | }
183 |
--------------------------------------------------------------------------------
/Spine/ResourceCollection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceCollection.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-12-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import BrightFutures
11 |
12 | /// A ResourceCollection represents a collection of resources.
13 | public class ResourceCollection: NSObject, NSCoding {
14 | /// Whether the resources for this collection are loaded.
15 | public var isLoaded: Bool = false
16 |
17 | /// The URL of the current page in this collection.
18 | public var resourcesURL: URL?
19 |
20 | /// The URL of the next page in this collection.
21 | public var nextURL: URL?
22 |
23 | /// The URL of the previous page in this collection.
24 | public var previousURL: URL?
25 |
26 | /// The loaded resources
27 | public internal(set) var resources: [Resource] = []
28 |
29 |
30 | // MARK: Initializers
31 |
32 | public override init() {}
33 |
34 | public init(resources: [Resource], resourcesURL: URL? = nil) {
35 | self.resources = resources
36 | self.resourcesURL = resourcesURL
37 | self.isLoaded = !resources.isEmpty
38 | }
39 |
40 | init(document: JSONAPIDocument) {
41 | self.resources = document.data ?? []
42 | self.resourcesURL = document.links?["self"] as URL?
43 | self.nextURL = document.links?["next"] as URL?
44 | self.previousURL = document.links?["previous"] as URL?
45 | self.isLoaded = true
46 | }
47 |
48 |
49 | // MARK: NSCoding
50 |
51 | public required init?(coder: NSCoder) {
52 | isLoaded = coder.decodeBool(forKey: "isLoaded")
53 | resourcesURL = coder.decodeObject(forKey: "resourcesURL") as? URL
54 | nextURL = coder.decodeObject(forKey: "nextURL") as? URL
55 | previousURL = coder.decodeObject(forKey: "previousURL") as? URL
56 | resources = coder.decodeObject(forKey: "resources") as! [Resource]
57 | }
58 |
59 | public func encode(with coder: NSCoder) {
60 | coder.encode(isLoaded, forKey: "isLoaded")
61 | coder.encode(resourcesURL, forKey: "resourcesURL")
62 | coder.encode(nextURL, forKey: "nextURL")
63 | coder.encode(previousURL, forKey: "previousURL")
64 | coder.encode(resources, forKey: "resources")
65 | }
66 |
67 |
68 | // MARK: Subscript and count
69 |
70 | /// Returns the loaded resource at the given index.
71 | public subscript (index: Int) -> Resource {
72 | return resources[index]
73 | }
74 |
75 | /// Returns how many resources are loaded.
76 | public var count: Int {
77 | return resources.count
78 | }
79 |
80 | /// Returns a resource identified by the given type and id,
81 | /// or nil if no resource was found.
82 | public func resourceWithType(_ type: ResourceType, id: String) -> Resource? {
83 | return resources.filter { $0.id == id && $0.resourceType == type }.first
84 | }
85 |
86 | // MARK: Mutators
87 |
88 | /// Append `resource` to the collection.
89 | public func appendResource(_ resource: Resource) {
90 | resources.append(resource)
91 | }
92 |
93 | /// Append `resources` to the collection.
94 | public func appendResources(_ resources: [Resource]) {
95 | for resource in resources {
96 | appendResource(resource)
97 | }
98 | }
99 |
100 | /// Remove `resource` from the collection.
101 | open func removeResource(_ resource: Resource) {
102 | resources = resources.filter { $0 !== resource }
103 | }
104 |
105 | /// Remove `resources` from the collection.
106 | open func removeResources(_ resources: [Resource]) {
107 | for resource in resources {
108 | removeResource(resource)
109 | }
110 | }
111 | }
112 |
113 | extension ResourceCollection: Sequence {
114 | public typealias Iterator = IndexingIterator<[Resource]>
115 |
116 | public func makeIterator() -> Iterator {
117 | return resources.makeIterator()
118 | }
119 | }
120 |
121 | /// `LinkedResourceCollection` represents a collection of resources that is linked from another resource.
122 | /// On top of `ResourceCollection` it offers mutability, `linkage` and a self `URL` properties.
123 | ///
124 | /// A `LinkedResourceCollection` keeps track of resources that are linked and unlinked from the collection.
125 | /// This allows Spine to make partial updates to the collection when it the parent resource is persisted.
126 | public class LinkedResourceCollection: ResourceCollection {
127 | /// The type/id pairs of resources present in this link.
128 | public var linkage: [ResourceIdentifier]?
129 |
130 | /// The URL of the link object of this collection.
131 | public var linkURL: URL?
132 |
133 | /// Resources added to this linked collection, but not yet persisted.
134 | public internal(set) var addedResources: [Resource] = []
135 |
136 | /// Resources removed from this linked collection, but not yet persisted.
137 | public internal(set) var removedResources: [Resource] = []
138 |
139 | public init(resourcesURL: URL?, linkURL: URL?, linkage: [ResourceIdentifier]?) {
140 | super.init(resources: [], resourcesURL: resourcesURL)
141 | self.linkURL = linkURL
142 | self.linkage = linkage
143 | }
144 |
145 | public required init?(coder: NSCoder) {
146 | super.init(coder: coder)
147 | linkURL = coder.decodeObject(forKey: "linkURL") as? URL
148 | addedResources = coder.decodeObject(forKey: "addedResources") as! [Resource]
149 | removedResources = coder.decodeObject(forKey: "removedResources") as! [Resource]
150 |
151 | if let encodedLinkage = coder.decodeObject(forKey: "linkage") as? [NSDictionary] {
152 | linkage = encodedLinkage.map { ResourceIdentifier(dictionary: $0) }
153 | }
154 | }
155 |
156 | public override func encode(with coder: NSCoder) {
157 | super.encode(with: coder)
158 | coder.encode(linkURL, forKey: "linkURL")
159 | coder.encode(addedResources, forKey: "addedResources")
160 | coder.encode(removedResources, forKey: "removedResources")
161 |
162 | if let linkage = linkage {
163 | let encodedLinkage = linkage.map { $0.toDictionary() }
164 | coder.encode(encodedLinkage, forKey: "linkage")
165 | }
166 | }
167 |
168 | // MARK: Mutators
169 |
170 | /// Link `resource` to the parent resource by appending it to the collection.
171 | /// This marks the resource as newly linked. The relationship will be persisted when
172 | /// the parent resource is saved.
173 | public func linkResource(_ resource: Resource) {
174 | assert(resource.id != nil, "Cannot link resource that hasn't been persisted yet.")
175 |
176 | resources.append(resource)
177 |
178 | if let index = removedResources.index(of: resource) {
179 | removedResources.remove(at: index)
180 | } else {
181 | addedResources.append(resource)
182 | }
183 | }
184 |
185 | /// Unlink `resource` from the parent resource by removing it from the collection.
186 | /// This marks the resource as unlinked. The relationship will be persisted when
187 | /// the parent resource is saved.
188 | public func unlinkResource(_ resource: Resource) {
189 | assert(resource.id != nil, "Cannot unlink resource that hasn't been persisted yet.")
190 |
191 | resources = resources.filter { $0 !== resource }
192 |
193 | if let index = addedResources.index(of: resource) {
194 | addedResources.remove(at: index)
195 | } else {
196 | removedResources.append(resource)
197 | }
198 | }
199 |
200 | /// Link `resources` to the parent resource by appending them to the collection.
201 | /// This marks the resources as newly linked. The relationship will be persisted when
202 | /// the parent resource is saved.
203 | public func linkResources(_ resources: [Resource]) {
204 | for resource in resources {
205 | linkResource(resource)
206 | }
207 | }
208 |
209 | /// Unlink `resources` from the parent resource by removing then from the collection.
210 | /// This marks the resources as unlinked. The relationship will be persisted when
211 | /// the parent resource is saved.
212 | public func unlinkResources(_ resources: [Resource]) {
213 | for resource in resources {
214 | unlinkResource(resource)
215 | }
216 | }
217 |
218 | /// Append `resource` to the collection as if it is already linked.
219 | /// If it was previously linked or unlinked, this status will be removed.
220 | public override func appendResource(_ resource: Resource) {
221 | super.appendResource(resource)
222 | removedResources = removedResources.filter { $0 !== resource }
223 | addedResources = addedResources.filter { $0 !== resource }
224 | }
225 |
226 | /// Append `resources` to the collection as if they are already linked.
227 | /// If a resource was previously linked or unlinked, this status will be removed.
228 | public override func appendResources(_ resources: [Resource]) {
229 | for resource in resources {
230 | appendResource(resource)
231 | }
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/Spine/ResourceFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceFactory.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 06/01/16.
6 | // Copyright © 2016 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 |
12 | /// A ResourceFactory creates resources from given factory funtions.
13 | struct ResourceFactory {
14 |
15 | fileprivate var resourceTypes: [ResourceType: Resource.Type] = [:]
16 |
17 | /// Registers a given resource type so it can be instantiated by the factory.
18 | /// Registering a type that was alsreay registered will override it.
19 | ///
20 | /// - parameter resourceClass: <#resourceClass description#>
21 | mutating func registerResource(_ type: Resource.Type) {
22 | resourceTypes[type.resourceType] = type
23 | }
24 |
25 | /// Instantiates a resource with the given type, by using a registered factory function.
26 | ///
27 | /// - parameter type: The resource type to instantiate.
28 | ///
29 | /// - throws: A SerializerError.resourceTypeUnregistered erro when the type is not registered.
30 | ///
31 | /// - returns: An instantiated resource.
32 | func instantiate(_ type: ResourceType) throws -> Resource {
33 | if resourceTypes[type] == nil {
34 | throw SerializerError.resourceTypeUnregistered(type)
35 | }
36 | return resourceTypes[type]!.init()
37 | }
38 |
39 |
40 | /// Dispenses a resource with the given type and id, optionally by finding it in a pool of existing resource instances.
41 | ///
42 | /// This methods tries to find a resource with the given type and id in the pool. If no matching resource is found,
43 | /// it tries to find the nth resource, indicated by `index`, of the given type from the pool. If still no resource is found,
44 | /// it instantiates a new resource with the given id and adds this to the pool.
45 | ///
46 | /// - parameter type: The resource type to dispense.
47 | /// - parameter id: The id of the resource to dispense.
48 | /// - parameter pool: An array of resources in which to find exisiting matching resources.
49 | /// - parameter index: Optional index of the resource in the pool.
50 | ///
51 | /// - throws: A SerializerError.resourceTypeUnregistered erro when the type is not registered.
52 | ///
53 | /// - returns: A resource with the given type and id.
54 | func dispense(_ type: ResourceType, id: String, pool: inout [Resource], index: Int? = nil) throws -> Resource {
55 | var resource: Resource! = pool.filter { $0.resourceType == type && $0.id == id }.first
56 |
57 | if resource == nil && index != nil && !pool.isEmpty {
58 | let applicableResources = pool.filter { $0.resourceType == type }
59 | if index! < applicableResources.count {
60 | resource = applicableResources[index!]
61 | }
62 | }
63 |
64 | if resource == nil {
65 | resource = try instantiate(type)
66 | resource.id = id
67 | pool.append(resource)
68 | }
69 |
70 | return resource
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Spine/ResourceField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceAttribute.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-12-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public func fieldsFromDictionary(_ dictionary: [String: Field]) -> [Field] {
12 | return dictionary.map { (name, field) in
13 | field.name = name
14 | return field
15 | }
16 | }
17 |
18 | /// Base field.
19 | /// Do not use this field type directly, instead use a specific subclass.
20 | open class Field {
21 | /// The name of the field as it appears in the model class.
22 | /// This is declared as an implicit optional to support the `fieldsFromDictionary` function,
23 | /// however it should *never* be nil.
24 | public internal(set) var name: String! = nil
25 |
26 | /// The name of the field that will be used for formatting to the JSON key.
27 | /// This can be nil, in which case the regular name will be used.
28 | public internal(set) var serializedName: String {
29 | get {
30 | return _serializedName ?? name
31 | }
32 | set {
33 | _serializedName = newValue
34 | }
35 | }
36 | fileprivate var _serializedName: String?
37 |
38 | var isReadOnly: Bool = false
39 |
40 | fileprivate init() {}
41 |
42 | /// Sets the serialized name.
43 | ///
44 | /// - parameter name: The serialized name to use.
45 | ///
46 | /// - returns: The field.
47 | public func serializeAs(_ name: String) -> Self {
48 | serializedName = name
49 | return self
50 | }
51 |
52 | public func readOnly() -> Self {
53 | isReadOnly = true
54 | return self
55 | }
56 | }
57 |
58 | // MARK: - Built in fields
59 |
60 | /// A basic attribute field.
61 | open class Attribute: Field {
62 | override public init() {}
63 | }
64 |
65 | /// A URL attribute that maps to an URL property.
66 | /// You can optionally specify a base URL to which relative
67 | /// URLs will be made absolute.
68 | public class URLAttribute: Attribute {
69 | let baseURL: URL?
70 |
71 | public init(baseURL: URL? = nil) {
72 | self.baseURL = baseURL
73 | }
74 | }
75 |
76 | /// A date attribute that maps to an NSDate property.
77 | /// By default, it uses ISO8601 format `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ`.
78 | /// You can specify a custom format by passing it to the initializer.
79 | public class DateAttribute: Attribute {
80 | let format: String
81 |
82 | public init(format: String = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ") {
83 | self.format = format
84 | }
85 | }
86 |
87 | /// A boolean attribute that maps to an NSNumber property.
88 | public class BooleanAttribute: Attribute {}
89 |
90 | /// A basic relationship field.
91 | /// Do not use this field type directly, instead use either `ToOneRelationship` or `ToManyRelationship`.
92 | public class Relationship: Field {
93 | let linkedType: Resource.Type
94 |
95 | public init(_ type: Resource.Type) {
96 | linkedType = type
97 | }
98 | }
99 |
100 | /// A to-one relationship field.
101 | public class ToOneRelationship: Relationship { }
102 |
103 | /// A to-many relationship field.
104 | public class ToManyRelationship: Relationship { }
105 |
--------------------------------------------------------------------------------
/Spine/Routing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Routing.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 24-09-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /**
12 | The RouterProtocol declares methods and properties that a router should implement.
13 | The router is used to build URLs for API requests.
14 | */
15 | public protocol Router: class {
16 | /// The base URL of the API.
17 | var baseURL: URL! { get set }
18 | var keyFormatter: KeyFormatter! { get set }
19 |
20 | /**
21 | Returns an URL that points to the collection of resources with a given type.
22 |
23 | - parameter type: The type of resources.
24 |
25 | - returns: The URL.
26 | */
27 | func urlForResourceType(_ type: ResourceType) -> URL
28 |
29 | /**
30 | Returns an URL that points to a relationship of a resource.
31 |
32 | - parameter relationship: The relationship to get the URL for.
33 | - parameter resource: The resource that contains the relationship.
34 |
35 | - returns: The URL.
36 | */
37 | func urlForRelationship(_ relationship: Relationship, ofResource resource: T) -> URL
38 |
39 | /**
40 | Returns an URL that represents the given query.
41 |
42 | - parameter query: The query to turn into an URL.
43 |
44 | - returns: The URL.
45 | */
46 | func urlForQuery(_ query: Query) -> URL
47 | }
48 |
49 | /**
50 | The built in JSONAPIRouter builds URLs according to the JSON:API specification.
51 |
52 | Filters
53 | =======
54 | Only 'equal to' filters are supported. You can subclass Router and override
55 | `queryItemForFilter` to add support for other filtering strategies.
56 |
57 | Pagination
58 | ==========
59 | Only PageBasedPagination and OffsetBasedPagination are supported. You can subclass Router
60 | and override `queryItemsForPagination` to add support for other pagination strategies.
61 | */
62 | open class JSONAPIRouter: Router {
63 | open var baseURL: URL!
64 | open var keyFormatter: KeyFormatter!
65 |
66 | public init() { }
67 |
68 | open func urlForResourceType(_ type: ResourceType) -> URL {
69 | return baseURL.appendingPathComponent(type)
70 | }
71 |
72 | open func urlForRelationship(_ relationship: Relationship, ofResource resource: T) -> URL {
73 | if let selfURL = resource.relationships[relationship.name]?.selfURL {
74 | return selfURL
75 | }
76 |
77 | let resourceURL = resource.url ?? urlForResourceType(resource.resourceType).appendingPathComponent("/\(resource.id!)")
78 | let key = keyFormatter.format(relationship)
79 | let urlString = resourceURL.appendingPathComponent("/relationships/\(key)").absoluteString
80 | return URL(string: urlString, relativeTo: baseURL)!
81 | }
82 |
83 | open func urlForQuery(_ query: Query) -> URL {
84 | let url: URL
85 | let preBuiltURL: Bool
86 |
87 | // Base URL
88 | if let urlString = query.url?.absoluteString {
89 | url = URL(string: urlString, relativeTo: baseURL)!
90 | preBuiltURL = true
91 | } else if let type = query.resourceType {
92 | url = urlForResourceType(type)
93 | preBuiltURL = false
94 | } else {
95 | preconditionFailure("Cannot build URL for query. Query does not have a URL, nor a resource type.")
96 | }
97 |
98 | var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
99 | var queryItems: [URLQueryItem] = urlComponents.queryItems ?? []
100 |
101 | // Resource IDs
102 | if !preBuiltURL {
103 | if let ids = query.resourceIDs {
104 | if ids.count == 1 {
105 | urlComponents.path = (urlComponents.path as NSString).appendingPathComponent(ids.first!)
106 | } else {
107 | let item = URLQueryItem(name: "filter[id]", value: ids.joined(separator: ","))
108 | appendQueryItem(item, to: &queryItems)
109 | }
110 | }
111 | }
112 |
113 | // Includes
114 | if !query.includes.isEmpty {
115 | var resolvedIncludes = [String]()
116 |
117 | for include in query.includes {
118 | var keys = [String]()
119 |
120 | var relatedResourceType: Resource.Type = T.self
121 | for part in include.components(separatedBy: ".") {
122 | if let relationship = relatedResourceType.field(named: part) as? Relationship {
123 | keys.append(keyFormatter.format(relationship))
124 | relatedResourceType = relationship.linkedType
125 | }
126 | }
127 |
128 | resolvedIncludes.append(keys.joined(separator: "."))
129 | }
130 |
131 | let item = URLQueryItem(name: "include", value: resolvedIncludes.joined(separator: ","))
132 | appendQueryItem(item, to: &queryItems)
133 | }
134 |
135 | // Filters
136 | for filter in query.filters {
137 | let fieldName = filter.leftExpression.keyPath
138 | var item: URLQueryItem?
139 | if let field = T.field(named: fieldName) {
140 | item = queryItemForFilter(on: keyFormatter.format(field), value: filter.rightExpression.constantValue, operatorType: filter.predicateOperatorType)
141 | } else {
142 | item = queryItemForFilter(on: fieldName, value: filter.rightExpression.constantValue, operatorType: filter.predicateOperatorType)
143 | }
144 | appendQueryItem(item!, to: &queryItems)
145 | }
146 |
147 | // Fields
148 | for (resourceType, fields) in query.fields {
149 | let keys = fields.map { fieldName in
150 | return keyFormatter.format(fieldName)
151 | }
152 | let item = URLQueryItem(name: "fields[\(resourceType)]", value: keys.joined(separator: ","))
153 | appendQueryItem(item, to: &queryItems)
154 | }
155 |
156 | // Sorting
157 | if !query.sortDescriptors.isEmpty {
158 | let descriptorStrings = query.sortDescriptors.map { descriptor -> String in
159 | let field = T.field(named: descriptor.key!)
160 | let key = self.keyFormatter.format(field!)
161 | if descriptor.ascending {
162 | return key
163 | } else {
164 | return "-\(key)"
165 | }
166 | }
167 |
168 | let item = URLQueryItem(name: "sort", value: descriptorStrings.joined(separator: ","))
169 | appendQueryItem(item, to: &queryItems)
170 | }
171 |
172 | // Pagination
173 | if let pagination = query.pagination {
174 | for item in queryItemsForPagination(pagination) {
175 | appendQueryItem(item, to: &queryItems)
176 | }
177 | }
178 |
179 | // Compose URL
180 | if !queryItems.isEmpty {
181 | urlComponents.queryItems = queryItems
182 | }
183 |
184 | return urlComponents.url!
185 | }
186 |
187 | /**
188 | Returns an URLQueryItem that represents a filter in a URL.
189 | By default this method only supports 'equal to' predicates. You can override this method to add support for other filtering strategies.
190 | It uses the String(describing:) method to convert values to strings. If `value` is nil, a string "null" will be used. Arrays will be
191 | represented as "firstValue,secondValue,thirdValue".
192 |
193 | - parameter key: The key that is filtered.
194 | - parameter value: The value on which is filtered.
195 | - parameter operatorType: The NSPredicateOperatorType for the filter.
196 |
197 | - returns: A URLQueryItem representing the filter.
198 | */
199 | open func queryItemForFilter(on key: String, value: Any?, operatorType: NSComparisonPredicate.Operator) -> URLQueryItem {
200 | assert(operatorType == .equalTo, "The built in router only supports Query filter expressions of type 'equalTo'")
201 | let stringValue: String
202 | if let valueArray = value as? [Any] {
203 | stringValue = valueArray.map { String(describing: $0) }.joined(separator: ",")
204 | } else if let value = value {
205 | stringValue = String(describing: value)
206 | } else {
207 | stringValue = "null"
208 | }
209 | return URLQueryItem(name: "filter[\(key)]", value: stringValue)
210 | }
211 |
212 | /**
213 | Returns an array of URLQueryItems that represent the given pagination configuration.
214 | By default this method only supports the PageBasedPagination and OffsetBasedPagination configurations.
215 | You can override this method to add support for other pagination strategies.
216 |
217 | - parameter pagination: The QueryPagination configuration.
218 |
219 | - returns: Array of URLQueryItems.
220 | */
221 | open func queryItemsForPagination(_ pagination: Pagination) -> [URLQueryItem] {
222 | var queryItems = [URLQueryItem]()
223 |
224 | switch pagination {
225 | case let pagination as PageBasedPagination:
226 | queryItems.append(URLQueryItem(name: "page[number]", value: String(pagination.pageNumber)))
227 | queryItems.append(URLQueryItem(name: "page[size]", value: String(pagination.pageSize)))
228 | case let pagination as OffsetBasedPagination:
229 | queryItems.append(URLQueryItem(name: "page[offset]", value: String(pagination.offset)))
230 | queryItems.append(URLQueryItem(name: "page[limit]", value: String(pagination.limit)))
231 | default:
232 | assertionFailure("The built in router only supports PageBasedPagination and OffsetBasedPagination")
233 | }
234 |
235 | return queryItems
236 | }
237 |
238 | fileprivate func appendQueryItem(_ queryItem: URLQueryItem, to queryItems: inout [URLQueryItem]) {
239 | queryItems = queryItems.filter { return $0.name != queryItem.name }
240 | queryItems.append(queryItem)
241 | }
242 | }
243 |
--------------------------------------------------------------------------------
/Spine/SerializeOperation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SerializeOperation.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-12-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftyJSON
11 |
12 | /// A SerializeOperation serializes a JSONAPIDocument to JSON data in the form of Data.
13 | class SerializeOperation: Operation {
14 | fileprivate let resources: [Resource]
15 | let valueFormatters: ValueFormatterRegistry
16 | let keyFormatter: KeyFormatter
17 | var options: SerializationOptions = [.IncludeID]
18 |
19 | var result: Failable?
20 |
21 |
22 | // MARK: -
23 |
24 | init(document: JSONAPIDocument, valueFormatters: ValueFormatterRegistry, keyFormatter: KeyFormatter) {
25 | self.resources = document.data ?? []
26 | self.valueFormatters = valueFormatters
27 | self.keyFormatter = keyFormatter
28 | }
29 |
30 | override func main() {
31 | let serializedData: Any
32 |
33 | if resources.count == 1 {
34 | serializedData = serializeResource(resources.first!)
35 | } else {
36 | serializedData = resources.map { resource in
37 | serializeResource(resource)
38 | }
39 | }
40 |
41 | do {
42 | let serialized = try JSONSerialization.data(withJSONObject: ["data": serializedData], options: [])
43 | result = Failable.success(serialized)
44 | } catch let error as NSError {
45 | result = Failable.failure(SerializerError.jsonSerializationError(error))
46 | }
47 | }
48 |
49 |
50 | // MARK: Serializing
51 |
52 | fileprivate func serializeResource(_ resource: Resource) -> [String: Any] {
53 | Spine.logDebug(.serializing, "Serializing resource \(resource) of type '\(resource.resourceType)' with id '\(String(describing: resource.id))'")
54 |
55 | var serializedData: [String: Any] = [:]
56 |
57 | // Serialize ID
58 | if let id = resource.id , options.contains(.IncludeID) {
59 | serializedData["id"] = id as AnyObject?
60 | }
61 |
62 | // Serialize type
63 | serializedData["type"] = resource.resourceType as AnyObject?
64 |
65 | // Serialize fields
66 | addAttributes(from: resource, to: &serializedData )
67 | addRelationships(from: resource, to: &serializedData)
68 |
69 | return serializedData
70 | }
71 |
72 |
73 | // MARK: Attributes
74 |
75 | /// Adds the attributes of the the given resource to the passed serialized data.
76 | ///
77 | /// - parameter resource: The resource whose attributes to add.
78 | /// - parameter serializedData: The data to add the attributes to.
79 | fileprivate func addAttributes(from resource: Resource, to serializedData: inout [String: Any]) {
80 | var attributes = [String: Any]();
81 |
82 | for case let field as Attribute in resource.fields where field.isReadOnly == false {
83 | let key = keyFormatter.format(field)
84 |
85 | Spine.logDebug(.serializing, "Serializing attribute \(field) as '\(key)'")
86 |
87 | if let unformattedValue = resource.value(forField: field.name) {
88 | attributes[key] = valueFormatters.formatValue(unformattedValue, forAttribute: field)
89 | } else if(!options.contains(.OmitNullValues)){
90 | attributes[key] = NSNull()
91 | }
92 | }
93 |
94 | serializedData["attributes"] = attributes
95 | }
96 |
97 |
98 | // MARK: Relationships
99 |
100 | /// Adds the relationships of the the given resource to the passed serialized data.
101 | ///
102 | /// - parameter resource: The resource whose relationships to add.
103 | /// - parameter serializedData: The data to add the relationships to.
104 | fileprivate func addRelationships(from resource: Resource, to serializedData: inout [String: Any]) {
105 | for case let field as Relationship in resource.fields where field.isReadOnly == false {
106 | let key = keyFormatter.format(field)
107 |
108 | Spine.logDebug(.serializing, "Serializing relationship \(field) as '\(key)'")
109 |
110 | switch field {
111 | case let toOne as ToOneRelationship:
112 | if options.contains(.IncludeToOne) {
113 | addToOneRelationship(resource.value(forField: field.name) as? Resource, to: &serializedData, key: key, type: toOne.linkedType.resourceType)
114 | }
115 | case let toMany as ToManyRelationship:
116 | if options.contains(.IncludeToMany) {
117 | addToManyRelationship(resource.value(forField: field.name) as? ResourceCollection, to: &serializedData, key: key, type: toMany.linkedType.resourceType)
118 | }
119 | default: ()
120 | }
121 | }
122 | }
123 |
124 | /// Adds the given resource as a to to-one relationship to the serialized data.
125 | ///
126 | /// - parameter linkedResource: The linked resource to add to the serialized data.
127 | /// - parameter serializedData: The data to add the related resource to.
128 | /// - parameter key: The key to add to the serialized data.
129 | /// - parameter type: The resource type of the linked resource as defined on the parent resource.
130 | fileprivate func addToOneRelationship(_ linkedResource: Resource?, to serializedData: inout [String: Any], key: String, type: ResourceType) {
131 | let serializedId: Any
132 | if let resourceId = linkedResource?.id {
133 | serializedId = resourceId
134 | } else {
135 | serializedId = NSNull()
136 | }
137 |
138 | let serializedRelationship = [
139 | "data": [
140 | "type": type,
141 | "id": serializedId
142 | ]
143 | ]
144 |
145 | if serializedData["relationships"] == nil {
146 | serializedData["relationships"] = [key: serializedRelationship]
147 | } else {
148 | var relationships = serializedData["relationships"] as! [String: Any]
149 | relationships[key] = serializedRelationship
150 | serializedData["relationships"] = relationships
151 | }
152 | }
153 |
154 | /// Adds the given resources as a to to-many relationship to the serialized data.
155 | ///
156 | /// - parameter linkedResources: The linked resources to add to the serialized data.
157 | /// - parameter serializedData: The data to add the related resources to.
158 | /// - parameter key: The key to add to the serialized data.
159 | /// - parameter type: The resource type of the linked resource as defined on the parent resource.
160 | fileprivate func addToManyRelationship(_ linkedResources: ResourceCollection?, to serializedData: inout [String: Any], key: String, type: ResourceType) {
161 | var resourceIdentifiers: [ResourceIdentifier] = []
162 |
163 | if let resources = linkedResources?.resources {
164 | resourceIdentifiers = resources.filter { $0.id != nil }.map { resource in
165 | return ResourceIdentifier(type: resource.resourceType, id: resource.id!)
166 | }
167 | }
168 |
169 | let serializedRelationship = [
170 | "data": resourceIdentifiers.map { $0.toDictionary() }
171 | ]
172 |
173 | if serializedData["relationships"] == nil {
174 | serializedData["relationships"] = [key: serializedRelationship]
175 | } else {
176 | var relationships = serializedData["relationships"] as! [String: Any]
177 | relationships[key] = serializedRelationship
178 | serializedData["relationships"] = relationships
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/Spine/Serializing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Serializing.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 23-08-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Serializer (de)serializes according to the JSON:API specification.
12 | public class Serializer {
13 | /// The resource factory used for dispensing resources.
14 | fileprivate var resourceFactory = ResourceFactory()
15 |
16 | /// The transformers used for transforming to and from the serialized representation.
17 | fileprivate var valueFormatters = ValueFormatterRegistry.defaultRegistry()
18 |
19 | /// The key formatter used for formatting field names to keys.
20 | public var keyFormatter: KeyFormatter = AsIsKeyFormatter()
21 |
22 | /// If true, skip parsing of a resource and logs a warning but do not throw an error.
23 | public var skipUnknownResourceType: Bool = false
24 |
25 | public init() {}
26 |
27 | /// Deserializes the given data into a JSONAPIDocument.
28 | ///
29 | /// - parameter data: The data to deserialize.
30 | /// - parameter mappingTargets: Optional resources onto which data will be deserialized.
31 | ///
32 | /// - throws: SerializerError that can occur in the deserialization.
33 | ///
34 | /// - returns: A JSONAPIDocument
35 | public func deserializeData(_ data: Data, mappingTargets: [Resource]? = nil) throws -> JSONAPIDocument {
36 | let deserializeOperation = DeserializeOperation(data: data, resourceFactory: resourceFactory, valueFormatters: valueFormatters, keyFormatter: keyFormatter, skipUnknownResourceType: skipUnknownResourceType)
37 |
38 | if let mappingTargets = mappingTargets {
39 | deserializeOperation.addMappingTargets(mappingTargets)
40 | }
41 |
42 | deserializeOperation.start()
43 |
44 | switch deserializeOperation.result! {
45 | case .failure(let error):
46 | throw error
47 | case .success(let document):
48 | return document
49 | }
50 | }
51 |
52 | /// Serializes the given JSON:API document into NSData. Only the main data is serialized.
53 | ///
54 | /// - parameter document: The JSONAPIDocument to serialize.
55 | /// - parameter options: Serialization options to use.
56 | ///
57 | /// - throws: SerializerError that can occur in the serialization.
58 | ///
59 | /// - returns: Serialized data
60 | public func serializeDocument(_ document: JSONAPIDocument, options: SerializationOptions = [.IncludeID]) throws -> Data {
61 | let serializeOperation = SerializeOperation(document: document, valueFormatters: valueFormatters, keyFormatter: keyFormatter)
62 | serializeOperation.options = options
63 |
64 | serializeOperation.start()
65 |
66 | switch serializeOperation.result! {
67 | case .failure(let error):
68 | throw error
69 | case .success(let data):
70 | return data
71 | }
72 | }
73 |
74 | /// Serializes the given Resources into NSData.
75 | ///
76 | /// - parameter resources: The resources to serialize.
77 | /// - parameter options: The serialization options to use.
78 | ///
79 | /// - throws: SerializerError that can occur in the serialization.
80 | ///
81 | /// - returns: Serialized data.
82 | public func serializeResources(_ resources: [Resource], options: SerializationOptions = [.IncludeID]) throws -> Data {
83 | let document = JSONAPIDocument(data: resources, included: nil, errors: nil, meta: nil, links: nil, jsonapi: nil)
84 | return try serializeDocument(document, options: options)
85 | }
86 |
87 |
88 | /// Converts the given resource to link data, and serializes it into NSData.
89 | /// `{"data": { "type": "people", "id": "12" }}`
90 | ///
91 | /// If no resource is passed, `null` is used:
92 | /// `{ "data": null }`
93 | ///
94 | /// - parameter resource: The resource to serialize link data for.
95 | ///
96 | /// - throws: SerializerError that can occur in the serialization.
97 | ///
98 | /// - returns: Serialized data.
99 | public func serializeLinkData(_ resource: Resource?) throws -> Data {
100 | let payloadData: Any
101 |
102 | if let resource = resource {
103 | assert(resource.id != nil, "Attempt to convert resource without id to linkage. Only resources with ids can be converted to linkage.")
104 | payloadData = ["type": resource.resourceType, "id": resource.id!]
105 | } else {
106 | payloadData = NSNull()
107 | }
108 |
109 | do {
110 | return try JSONSerialization.data(withJSONObject: ["data": payloadData], options: JSONSerialization.WritingOptions(rawValue: 0))
111 | } catch let error as NSError {
112 | throw SerializerError.jsonSerializationError(error)
113 | }
114 | }
115 |
116 | /// Converts the given resources to link data, and serializes it into NSData.
117 | /// ```json
118 | /// {
119 | /// "data": [
120 | /// { "type": "comments", "id": "12" },
121 | /// { "type": "comments", "id": "13" }
122 | /// ]
123 | /// }
124 | /// ```
125 | ///
126 | /// - parameter resources: The resource to serialize link data for.
127 | ///
128 | /// - throws: SerializerError that can occur in the serialization.
129 | ///
130 | /// - returns: Serialized data.
131 | public func serializeLinkData(_ resources: [Resource]) throws -> Data {
132 | let payloadData: Any
133 |
134 | if resources.isEmpty {
135 | payloadData = []
136 | } else {
137 | payloadData = resources.map { resource in
138 | return ["type": resource.resourceType, "id": resource.id!]
139 | }
140 | }
141 |
142 | do {
143 | return try JSONSerialization.data(withJSONObject: ["data": payloadData], options: JSONSerialization.WritingOptions(rawValue: 0))
144 | } catch let error as NSError {
145 | throw SerializerError.jsonSerializationError(error)
146 | }
147 | }
148 |
149 | /// Registers a resource class.
150 | ///
151 | /// - parameter resourceClass: The resource class to register.
152 | public func registerResource(_ resourceClass: Resource.Type) {
153 | resourceFactory.registerResource(resourceClass)
154 | }
155 |
156 |
157 | /// Registers transformer `transformer`.
158 | ///
159 | /// - parameter transformer: The Transformer to register.
160 | public func registerValueFormatter(_ formatter: T) {
161 | valueFormatters.registerFormatter(formatter)
162 | }
163 | }
164 |
165 | /// A JSONAPIDocument represents a JSON API document containing
166 | /// resources, errors, metadata, links and jsonapi data.
167 | public struct JSONAPIDocument {
168 | /// Primary resources extracted from the response.
169 | public var data: [Resource]?
170 |
171 | /// Included resources extracted from the response.
172 | public var included: [Resource]?
173 |
174 | /// Errors extracted from the response.
175 | public var errors: [APIError]?
176 |
177 | /// Metadata extracted from the reponse.
178 | public var meta: Metadata?
179 |
180 | /// Links extracted from the response.
181 | public var links: [String: URL]?
182 |
183 | /// JSONAPI information extracted from the response.
184 | public var jsonapi: JSONAPIData?
185 | }
186 |
187 | public struct SerializationOptions: OptionSet {
188 | public let rawValue: Int
189 | public init(rawValue: Int) { self.rawValue = rawValue }
190 |
191 | /// Whether to include the resource ID in the serialized representation.
192 | public static let IncludeID = SerializationOptions(rawValue: 1 << 1)
193 |
194 | /// Whether to only serialize fields that are dirty.
195 | public static let DirtyFieldsOnly = SerializationOptions(rawValue: 1 << 2)
196 |
197 | /// Whether to include to-many linked resources in the serialized representation.
198 | public static let IncludeToMany = SerializationOptions(rawValue: 1 << 3)
199 |
200 | /// Whether to include to-one linked resources in the serialized representation.
201 | public static let IncludeToOne = SerializationOptions(rawValue: 1 << 4)
202 |
203 | /// If set, then attributes with null values will not be serialized.
204 | public static let OmitNullValues = SerializationOptions(rawValue: 1 << 5)
205 | }
206 |
--------------------------------------------------------------------------------
/Spine/Spine.h:
--------------------------------------------------------------------------------
1 | //
2 | // Spine.h
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-08-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for Spine.
12 | FOUNDATION_EXPORT double SpineVersionNumber;
13 |
14 | //! Project version string for Spine.
15 | FOUNDATION_EXPORT const unsigned char SpineVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Spine/Spine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Resource.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 21-08-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import BrightFutures
11 |
12 | public typealias Metadata = [String: Any]
13 | public typealias JSONAPIData = [String: Any]
14 |
15 | /// The main class
16 | open class Spine {
17 |
18 | /// The router that builds the URLs for requests.
19 | let router: Router
20 |
21 | /// The HTTPClient that performs the HTTP requests.
22 | public let networkClient: NetworkClient
23 |
24 | /// The serializer to use for serializing and deserializing of JSON representations.
25 | public let serializer: Serializer = Serializer()
26 |
27 | /// The key formatter to use for formatting field names to keys.
28 | public var keyFormatter: KeyFormatter = DasherizedKeyFormatter() {
29 | didSet {
30 | router.keyFormatter = keyFormatter
31 | serializer.keyFormatter = keyFormatter
32 | }
33 | }
34 |
35 | /// ID generator that generates client side IDs. If this is nil, IDs will not be
36 | /// generated by Spine.
37 | public var idGenerator: ((Resource) -> String)?
38 |
39 | /// The operation queue on which all operations are queued.
40 | let operationQueue = OperationQueue()
41 |
42 |
43 | // MARK: Initializers
44 |
45 | /// Creates a new Spine instance using the given router and network client.
46 | /// Use this initializer if you want to use a custom router and network client.
47 | ///
48 | /// - parameter router: The Router to use.
49 | /// - parameter networkClient: The NetworkClient to use
50 | ///
51 | /// - returns: The Spine instance.
52 | public init(router: Router, networkClient: NetworkClient) {
53 | self.router = router
54 | self.networkClient = networkClient
55 | self.operationQueue.name = "com.wardvanteijlingen.spine"
56 |
57 | self.router.keyFormatter = keyFormatter
58 | self.serializer.keyFormatter = keyFormatter
59 | }
60 |
61 | /// Creates a new Spine instance using a JSONAPIRouter and HTTPClient.
62 | ///
63 | /// - parameter baseURL: The base URL to use for routing.
64 | ///
65 | /// - returns: Spine
66 | public convenience init(baseURL: URL) {
67 | let router = JSONAPIRouter()
68 | router.baseURL = baseURL
69 | self.init(router: router, networkClient: HTTPClient())
70 | }
71 |
72 | /// Creates a new Spine instance using a specific router and HTTPClient.
73 | /// Use this initializer if you want to use a custom router.
74 | ///
75 | /// - parameter router: The Router to use.
76 | ///
77 | /// - returns: Spine
78 | public convenience init(router: Router) {
79 | self.init(router: router, networkClient: HTTPClient())
80 | }
81 |
82 | /// Creates a new Spine instance using a specific network client and the default Router class.
83 | /// Use this initializer if you want to use a custom network client.
84 | ///
85 | /// - parameter baseURL: The base URL to use for routing.
86 | /// - parameter networkClient: The NetworkClient to use.
87 | ///
88 | /// - returns: Spine
89 | public convenience init(baseURL: URL, networkClient: NetworkClient) {
90 | let router = JSONAPIRouter()
91 | router.baseURL = baseURL
92 | self.init(router: router, networkClient: networkClient)
93 | }
94 |
95 |
96 | // MARK: Operations
97 |
98 | /// Adds the given operation to the operation queue.
99 | /// This sets the spine property of the operation to this Spine instance.
100 | ///
101 | /// - parameter operation: The operation to enqueue.
102 | func addOperation(_ operation: ConcurrentOperation) {
103 | operation.spine = self
104 | operationQueue.addOperation(operation)
105 | }
106 |
107 |
108 | // MARK: Fetching
109 |
110 | /// Fetch multiple resources using the given query.
111 | ///
112 | /// - parameter query: The query describing which resources to fetch.
113 | ///
114 | /// - returns: A future that resolves to a tuple containing the fetched ResourceCollection, the document meta, and the document jsonapi object.
115 | open func find(_ query: Query) -> Future<(resources: ResourceCollection, meta: Metadata?, jsonapi: JSONAPIData?), SpineError> {
116 | let promise = Promise<(resources: ResourceCollection, meta: Metadata?, jsonapi: JSONAPIData?), SpineError>()
117 |
118 | let operation = FetchOperation(query: query, spine: self)
119 |
120 | operation.completionBlock = { [unowned operation] in
121 |
122 | switch operation.result! {
123 | case .success(let document):
124 | let response = (ResourceCollection(document: document), document.meta, document.jsonapi)
125 | promise.success(response)
126 | case .failure(let error):
127 | promise.failure(error)
128 | }
129 | }
130 |
131 | addOperation(operation)
132 |
133 | return promise.future
134 | }
135 |
136 | /// Fetch multiple resources with the given IDs and type.
137 | ///
138 | /// - parameter ids: Array containing IDs of resources to fetch.
139 | /// - parameter type: The type of resource to fetch.
140 | ///
141 | /// - returns: A future that resolves to a tuple containing the fetched ResourceCollection, the document meta, and the document jsonapi object.
142 | open func find(_ ids: [String], ofType type: T.Type) -> Future<(resources: ResourceCollection, meta: Metadata?, jsonapi: JSONAPIData?), SpineError> {
143 | let query = Query(resourceType: type, resourceIDs: ids)
144 | return find(query)
145 | }
146 |
147 | /// Fetch one resource using the given query.
148 | /// If the response contains multiple resources, the first resource is returned.
149 | /// If the response indicates success but doesn't contain any resources, the returned future fails.
150 | ///
151 | /// - parameter query: The query describing which resource to fetch.
152 | ///
153 | /// - returns: A future that resolves to a tuple containing the fetched resource, the document meta, and the document jsonapi object.
154 | open func findOne(_ query: Query) -> Future<(resource: T, meta: Metadata?, jsonapi: JSONAPIData?), SpineError> {
155 | let promise = Promise<(resource: T, meta: Metadata?, jsonapi: JSONAPIData?), SpineError>()
156 |
157 | let operation = FetchOperation(query: query, spine: self)
158 |
159 | operation.completionBlock = { [unowned operation] in
160 | switch operation.result! {
161 | case .success(let document) where document.data?.count == 0 || document.data == nil:
162 | promise.failure(SpineError.resourceNotFound)
163 | case .success(let document):
164 | let firstResource = document.data!.first as! T
165 | let response = (resource: firstResource, meta: document.meta, jsonapi: document.jsonapi)
166 | promise.success(response)
167 | case .failure(let error):
168 | promise.failure(error)
169 | }
170 | }
171 |
172 | addOperation(operation)
173 |
174 | return promise.future
175 | }
176 |
177 | /// Fetch one resource with the given ID and type.
178 | /// If the response contains multiple resources, the first resource is returned.
179 | /// If the response indicates success but doesn't contain any resources, the returned future fails.
180 | ///
181 | /// - parameter id: ID of resource to fetch.
182 | /// - parameter type: The type of resource to fetch.
183 | ///
184 | /// - returns: A future that resolves to a tuple containing the fetched resource, the document meta, and the document jsonapi object.
185 | open func findOne(_ id: String, ofType type: T.Type) -> Future<(resource: T, meta: Metadata?, jsonapi: JSONAPIData?), SpineError> {
186 | let query = Query(resourceType: type, resourceIDs: [id])
187 | return findOne(query)
188 | }
189 |
190 | /// Fetch all resources with the given type.
191 | /// This does not explicitly impose any limit, but the server may choose to limit the response.
192 | ///
193 | /// - parameter type: The type of resource to fetch.
194 | ///
195 | /// - returns: A future that resolves to a tuple containing the fetched ResourceCollection, the document meta, and the document jsonapi object.
196 | open func findAll(_ type: T.Type) -> Future<(resources: ResourceCollection, meta: Metadata?, jsonapi: JSONAPIData?), SpineError> {
197 | let query = Query(resourceType: type)
198 | return find(query)
199 | }
200 |
201 |
202 | // MARK: Loading
203 |
204 | /// Load the given resource if needed. If its `isLoaded` property is true, it returns the resource as is.
205 | /// Otherwise it loads the resource using the passed query.
206 | ///
207 | /// The `queryCallback` parameter can be used if the resource should be loaded by using a custom query.
208 | /// For example, in case you want to include a relationship or load a sparse fieldset.
209 | ///
210 | /// Spine creates a basic query to load the resource and passes it to the queryCallback.
211 | /// From this callback you return a modified query, or a whole new query if desired. This returned
212 | /// query is then used when loading the resource.
213 | ///
214 | /// - parameter resource: The resource to ensure.
215 | /// - parameter queryCallback: A optional function that returns the query used to load the resource.
216 | ///
217 | /// - returns: A future that resolves to the loaded resource.
218 | open func load(_ resource: T, queryCallback: ((Query) -> Query)? = nil) -> Future {
219 | var query = Query(resource: resource)
220 | if let callback = queryCallback {
221 | query = callback(query)
222 | }
223 | return loadResourceByExecutingQuery(resource, query: query)
224 | }
225 |
226 | /// Reload the given resource even when it's already loaded. The returned resource will be the same
227 | /// instance as the passed resource.
228 | ///
229 | /// The `queryCallback` parameter can be used if the resource should be loaded by using a custom query.
230 | /// For example, in case you want to include a relationship or load a sparse fieldset.
231 | ///
232 | /// Spine creates a basic query to load the resource and passes it to the queryCallback.
233 | /// From this callback you return a modified query, or a whole new query if desired. This returned
234 | /// query is then used when loading the resource.
235 | ///
236 | /// - parameter resource: The resource to reload.
237 | /// - parameter queryCallback: A optional function that returns the query used to load the resource.
238 | ///
239 | /// - returns: A future that resolves to the reloaded resource.
240 | open func reload(_ resource: T, queryCallback: ((Query) -> Query)? = nil) -> Future {
241 | var query = Query(resource: resource)
242 | if let callback = queryCallback {
243 | query = callback(query)
244 | }
245 | return loadResourceByExecutingQuery(resource, query: query, skipIfLoaded: false)
246 | }
247 |
248 | func loadResourceByExecutingQuery(_ resource: T, query: Query, skipIfLoaded: Bool = true) -> Future {
249 | let promise = Promise()
250 |
251 | if skipIfLoaded && resource.isLoaded {
252 | promise.success(resource)
253 | return promise.future
254 | }
255 |
256 | let operation = FetchOperation(query: query, spine: self)
257 | operation.mappingTargets = [resource]
258 | operation.completionBlock = { [unowned operation] in
259 | if let error = operation.result?.error {
260 | promise.failure(error)
261 | } else {
262 | promise.success(resource)
263 | }
264 | }
265 |
266 | addOperation(operation)
267 |
268 | return promise.future
269 | }
270 |
271 |
272 | // MARK: Paginating
273 |
274 | /// Loads the next page of the given resource collection. The newly loaded resources are appended to the passed collection.
275 | /// When the next page is not available, the returned future will fail with a `NextPageNotAvailable` error code.
276 | ///
277 | /// - parameter collection: The collection for which to load the next page.
278 | ///
279 | /// - returns: A future that resolves to the ResourceCollection including the newly loaded resources.
280 | open func loadNextPageOfCollection(_ collection: ResourceCollection) -> Future {
281 | let promise = Promise()
282 |
283 | if let nextURL = collection.nextURL {
284 | let query = Query(url: nextURL)
285 | let operation = FetchOperation(query: query, spine: self)
286 |
287 | operation.completionBlock = { [unowned operation] in
288 | switch operation.result! {
289 | case .success(let document):
290 | let nextCollection = ResourceCollection(document: document)
291 | collection.resources += nextCollection.resources
292 | collection.resourcesURL = nextCollection.resourcesURL
293 | collection.nextURL = nextCollection.nextURL
294 | collection.previousURL = nextCollection.previousURL
295 |
296 | promise.success(collection)
297 | case .failure(let error):
298 | promise.failure(error)
299 | }
300 | }
301 |
302 | addOperation(operation)
303 |
304 | } else {
305 | promise.failure(SpineError.nextPageNotAvailable)
306 | }
307 |
308 | return promise.future
309 | }
310 |
311 | /// Loads the previous page of the given resource collection. The newly loaded resources are prepended to the passed collection.
312 | /// When the previous page is not available, the returned future will fail with a `PreviousPageNotAvailable` error code.
313 | ///
314 | /// - parameter collection: The collection for which to load the previous page.
315 | ///
316 | /// - returns: A future that resolves to the ResourceCollection including the newly loaded resources.
317 | open func loadPreviousPageOfCollection(_ collection: ResourceCollection) -> Future {
318 | let promise = Promise()
319 |
320 | if let previousURL = collection.previousURL {
321 | let query = Query(url: previousURL)
322 | let operation = FetchOperation(query: query, spine: self)
323 |
324 | operation.completionBlock = { [unowned operation] in
325 | switch operation.result! {
326 | case .success(let document):
327 | let previousCollection = ResourceCollection(document: document)
328 | collection.resources = previousCollection.resources + collection.resources
329 | collection.resourcesURL = previousCollection.resourcesURL
330 | collection.nextURL = previousCollection.nextURL
331 | collection.previousURL = previousCollection.previousURL
332 |
333 | promise.success(collection)
334 | case .failure(let error):
335 | promise.failure(error)
336 | }
337 | }
338 |
339 | addOperation(operation)
340 |
341 | } else {
342 | promise.failure(SpineError.previousPageNotAvailable)
343 | }
344 |
345 | return promise.future
346 | }
347 |
348 |
349 | // MARK: Persisting
350 |
351 | /// Saves the given resource.
352 | ///
353 | /// - parameter resource: The resource to save.
354 | ///
355 | /// - returns: A future that resolves to the saved resource.
356 | open func save(_ resource: T) -> Future {
357 | let promise = Promise()
358 | let operation = SaveOperation(resource: resource, spine: self)
359 |
360 | operation.completionBlock = { [unowned operation] in
361 | if let error = operation.result?.error {
362 | promise.failure(error)
363 | } else {
364 | promise.success(resource)
365 | }
366 | }
367 |
368 | addOperation(operation)
369 |
370 | return promise.future
371 | }
372 |
373 |
374 | /// Deletes the given resource.
375 | ///
376 | /// - parameter resource: The resource to delete.
377 | ///
378 | /// - returns: A future
379 | open func delete(_ resource: T) -> Future {
380 | let promise = Promise()
381 | let operation = DeleteOperation(resource: resource, spine: self)
382 |
383 | operation.completionBlock = { [unowned operation] in
384 | if let error = operation.result?.error {
385 | promise.failure(error)
386 | } else {
387 | promise.success(())
388 | }
389 | }
390 |
391 | addOperation(operation)
392 |
393 | return promise.future
394 | }
395 | }
396 |
397 | public extension Spine {
398 | /// Registers a resource class.
399 | ///
400 | /// - parameter resourceClass: The resource class to register.
401 | func registerResource(_ resourceClass: Resource.Type) {
402 | serializer.registerResource(resourceClass)
403 | }
404 |
405 | /// Registers a value formatter.
406 | ///
407 | /// - parameter formatter: The formatter to register.
408 | func registerValueFormatter(_ formatter: T) {
409 | serializer.registerValueFormatter(formatter)
410 | }
411 | }
412 |
413 |
414 | /// Represents the result of a failable operation.
415 | ///
416 | /// - success: The operation succeeded with the given result.
417 | /// - failure: The operation failed with the given error.
418 | enum Failable {
419 | case success(T)
420 | case failure(E)
421 |
422 | init(_ value: T) {
423 | self = .success(value)
424 | }
425 |
426 | init(_ error: E) {
427 | self = .failure(error)
428 | }
429 |
430 | var error: E? {
431 | switch self {
432 | case .failure(let error):
433 | return error
434 | default:
435 | return nil
436 | }
437 | }
438 | }
439 |
--------------------------------------------------------------------------------
/Spine/ValueFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ValueFormatter.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 30-12-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 |
12 |
13 | /// The ValueFormatter protocol declares methods and properties that a value formatter must implement.
14 | /// A value formatter transforms values between the serialized and deserialized form.
15 | public protocol ValueFormatter {
16 | /// The type as it appears in serialized form (JSON).
17 | associatedtype FormattedType
18 |
19 | /// The type as it appears in deserialized form (Swift).
20 | associatedtype UnformattedType
21 |
22 | /// The attribute type for which this formatter formats values.
23 | associatedtype AttributeType
24 |
25 |
26 | /// Returns the deserialized form of the given value for the given attribute.
27 | ///
28 | /// - parameter value: The value to deserialize.
29 | /// - parameter forAttribute: The attribute to which the value belongs.
30 | ///
31 | /// - returns: The deserialized form of `value`.
32 | func unformatValue(_ value: FormattedType, forAttribute: AttributeType) -> UnformattedType
33 |
34 | /// Returns the serialized form of the given value for the given attribute.
35 | ///
36 | /// - parameter value: The value to serialize.
37 | /// - parameter forAttribute: The attribute to which the value belongs.
38 | ///
39 | /// - returns: The serialized form of `value`.
40 | func formatValue(_ value: UnformattedType, forAttribute: AttributeType) -> FormattedType
41 | }
42 |
43 | /// A value formatter Registry keeps a list of value formatters, and chooses between these value formatters
44 | /// to transform values between the serialized and deserialized form.
45 | struct ValueFormatterRegistry {
46 | /// Registered serializer functions.
47 | fileprivate var formatters: [(Any, Attribute) -> Any?] = []
48 |
49 | /// Registered deserializer functions.
50 | fileprivate var unformatters: [(Any, Attribute) -> Any?] = []
51 |
52 | /// Returns a new value formatter directory configured with the built in default value formatters.
53 | ///
54 | /// - returns: ValueFormatterRegistry
55 | static func defaultRegistry() -> ValueFormatterRegistry {
56 | var directory = ValueFormatterRegistry()
57 | directory.registerFormatter(URLValueFormatter())
58 | directory.registerFormatter(DateValueFormatter())
59 | directory.registerFormatter(BooleanValueFormatter())
60 | return directory
61 | }
62 |
63 | /// Registers the given value formatter.
64 | ///
65 | /// - parameter formatter: The value formatter to register.
66 | mutating func registerFormatter(_ formatter: T) {
67 | formatters.append { (value: Any, attribute: Attribute) -> Any? in
68 | if let typedAttribute = attribute as? T.AttributeType {
69 | if let typedValue = value as? T.UnformattedType {
70 | return formatter.formatValue(typedValue, forAttribute: typedAttribute)
71 | }
72 | }
73 |
74 | return nil
75 | }
76 |
77 | unformatters.append { (value: Any, attribute: Attribute) -> Any? in
78 | if let typedAttribute = attribute as? T.AttributeType {
79 | if let typedValue = value as? T.FormattedType {
80 | return formatter.unformatValue(typedValue, forAttribute: typedAttribute)
81 | }
82 | }
83 |
84 | return nil
85 | }
86 | }
87 |
88 | /// Returns the deserialized form of the given value for the given attribute.
89 | ///
90 | /// The actual value formatter used is the first registered formatter that supports the given
91 | /// value type for the given attribute type.
92 | ///
93 | /// - parameter value: The value to deserialize.
94 | /// - parameter attribute: The attribute to which the value belongs.
95 | ///
96 | /// - returns: The deserialized form of `value`.
97 | func unformatValue(_ value: Any, forAttribute attribute: Attribute) -> Any {
98 | for unformatter in unformatters {
99 | if let unformatted = unformatter(value, attribute) {
100 | return unformatted
101 | }
102 | }
103 |
104 | return value
105 | }
106 |
107 | /// Returns the serialized form of the given value for the given attribute.
108 | ///
109 | /// The actual value formatter used is the first registered formatter that supports the given
110 | /// value type for the given attribute type. If no suitable value formatter is found,
111 | /// the value is returned as is.
112 | ///
113 | /// - parameter value: The value to serialize.
114 | /// - parameter forAttribute: The attribute to which the value belongs.
115 | ///
116 | /// - returns: The serialized form of `value`.
117 | func formatValue(_ value: Any, forAttribute attribute: Attribute) -> Any {
118 | for formatter in formatters {
119 | if let formatted = formatter(value, attribute) {
120 | return formatted
121 | }
122 | }
123 |
124 | Spine.logWarning(.serializing, "No value formatter found for attribute \(attribute).")
125 | return value
126 | }
127 | }
128 |
129 |
130 | // MARK: - Built in value formatters
131 |
132 | /// URLValueFormatter is a value formatter that transforms between URL and String, and vice versa.
133 | /// If a baseURL has been configured in the URLAttribute, and the given String is not an absolute URL,
134 | /// it will return an absolute URL, relative to the baseURL.
135 | private struct URLValueFormatter: ValueFormatter {
136 | func unformatValue(_ value: String, forAttribute attribute: URLAttribute) -> URL {
137 | return URL(string: value, relativeTo: attribute.baseURL as URL?)!
138 | }
139 |
140 | func formatValue(_ value: URL, forAttribute attribute: URLAttribute) -> String {
141 | return value.absoluteString
142 | }
143 | }
144 |
145 | /// DateValueFormatter is a value formatter that transforms between NSDate and String, and vice versa.
146 | /// It uses the date format configured in the DateAttribute.
147 | private struct DateValueFormatter: ValueFormatter {
148 | func formatter(_ attribute: DateAttribute) -> DateFormatter {
149 | let formatter = DateFormatter()
150 | formatter.dateFormat = attribute.format
151 | return formatter
152 | }
153 |
154 | func unformatValue(_ value: String, forAttribute attribute: DateAttribute) -> Date {
155 | guard let date = formatter(attribute).date(from: value) else {
156 | Spine.logWarning(.serializing, "Could not deserialize date string \(value) with format \(attribute.format).")
157 | return Date(timeIntervalSince1970: 0)
158 | }
159 | return date
160 | }
161 |
162 | func formatValue(_ value: Date, forAttribute attribute: DateAttribute) -> String {
163 | return formatter(attribute).string(from: value)
164 | }
165 | }
166 |
167 | /// BooleanValueformatter is a value formatter that formats NSNumber to Bool, and vice versa.
168 | private struct BooleanValueFormatter: ValueFormatter {
169 | func unformatValue(_ value: Bool, forAttribute: BooleanAttribute) -> NSNumber {
170 | return NSNumber(booleanLiteral: value)
171 | }
172 |
173 | func formatValue(_ value: NSNumber, forAttribute: BooleanAttribute) -> Bool {
174 | return value.boolValue
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/SpineTests/CallbackHTTPClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CallbackHTTPClient.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 27-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | open class CallbackHTTPClient: NetworkClient {
12 | typealias HandlerFunction = (_ request: URLRequest, _ payload: Data?) -> (responseData: Data?, statusCode: Int?, error: NSError?)
13 |
14 | var handler: HandlerFunction!
15 | var delay: TimeInterval = 0
16 | internal fileprivate(set) var lastRequest: URLRequest?
17 | let queue = DispatchQueue(label: "com.wardvanteijlingen.spine.callbackHTTPClient", attributes: [])
18 |
19 | init() {}
20 |
21 | open func request(method: String, url: URL, payload: Data?, callback: @escaping NetworkClientCallback) {
22 | var request = URLRequest(url: url)
23 | request.httpMethod = method
24 |
25 | if let payload = payload {
26 | request.httpBody = payload
27 | }
28 |
29 | lastRequest = request
30 | Spine.logInfo(.networking, "\(method): \(url)")
31 |
32 | // Perform the request
33 | queue.async {
34 | let (data, statusCode, error) = self.handler(request, payload)
35 | let startTime = DispatchTime.now() + Double(Int64(self.delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
36 |
37 | DispatchQueue.main.asyncAfter(deadline: startTime) {
38 | // Framework error
39 | if let error = error {
40 | Spine.logError(.networking, "\(request.url!) - \(error.localizedDescription)")
41 |
42 | // Success
43 | } else if let statusCode = statusCode , 200 ... 299 ~= statusCode {
44 | Spine.logInfo(.networking, "\(statusCode): \(request.url!)")
45 |
46 | // API Error
47 | } else {
48 | Spine.logWarning(.networking, "\(String(describing: statusCode)): \(request.url!)")
49 | }
50 |
51 | callback(statusCode, data, error)
52 | }
53 | }
54 | }
55 |
56 | func respondWith(_ status: Int, data: Data? = Data()) {
57 | handler = { request, payload in
58 | return (responseData: data, statusCode: status, error: nil)
59 | }
60 | }
61 |
62 | /**
63 | Simulates a network error with the given error code.
64 |
65 | - parameter code: The error code.
66 |
67 | - returns: The NSError that will be returned as the simulated network error.
68 | */
69 | @discardableResult func simulateNetworkErrorWithCode(_ code: Int) -> NSError {
70 | let error = NSError(domain: "SimulatedNetworkError", code: code, userInfo: nil)
71 | handler = { request, payload in
72 | return (responseData: nil, statusCode: nil, error: error)
73 | }
74 | return error
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/SpineTests/Fixtures.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FooResource.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 19-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | import SwiftyJSON
12 |
13 | class Foo: Resource {
14 | var stringAttribute: String?
15 | var integerAttribute: NSNumber?
16 | var floatAttribute: NSNumber?
17 | var booleanAttribute: NSNumber?
18 | var nilAttribute: AnyObject?
19 | var dateAttribute: Date?
20 | var toOneAttribute: Bar?
21 | var toManyAttribute: LinkedResourceCollection?
22 |
23 | override class var resourceType: String {
24 | return "foos"
25 | }
26 |
27 | override class var fields: [Field] {
28 | return fieldsFromDictionary([
29 | "stringAttribute": Attribute(),
30 | "integerAttribute": Attribute(),
31 | "floatAttribute": Attribute(),
32 | "booleanAttribute": BooleanAttribute(),
33 | "nilAttribute": Attribute(),
34 | "dateAttribute": DateAttribute(),
35 | "toOneAttribute": ToOneRelationship(Bar.self),
36 | "toManyAttribute": ToManyRelationship(Bar.self)
37 | ])
38 | }
39 |
40 | required init() {
41 | super.init()
42 | }
43 |
44 | init(id: String) {
45 | super.init()
46 | self.id = id
47 | }
48 |
49 | required init(coder: NSCoder) {
50 | super.init(coder: coder)
51 | }
52 | }
53 |
54 | class Bar: Resource {
55 | var barStringAttribute: String?
56 | var barIntegerAttribute: NSNumber?
57 |
58 | override class var resourceType: String {
59 | return "bars"
60 | }
61 |
62 | override class var fields: [Field] {
63 | return fieldsFromDictionary([
64 | "barStringAttribute": Attribute(),
65 | "barIntegerAttribute": Attribute()
66 | ])
67 | }
68 |
69 | required init() {
70 | super.init()
71 | }
72 |
73 | init(id: String) {
74 | super.init()
75 | self.id = id
76 | }
77 |
78 | required init(coder: NSCoder) {
79 | super.init(coder: coder)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/SpineTests/Fixtures/EmptyFoos.json:
--------------------------------------------------------------------------------
1 | {
2 | "data":[
3 |
4 | ]
5 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/Errors.json:
--------------------------------------------------------------------------------
1 | {
2 | "errors": [{
3 | "id": "Error id 1",
4 | "status": "Error status 1",
5 | "code": "1",
6 | "title": "Error title 1",
7 | "detail": "Error detail 1"
8 | }, {
9 | "id": "Error id 2",
10 | "status": "Error status 2",
11 | "code": "2",
12 | "title": "Error title 2",
13 | "detail": "Error detail 2"
14 | }]
15 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/MultipleFoos.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [{
3 | "id": "1",
4 | "type": "foos",
5 | "attributes": {
6 | "string-attribute": "stringAttributeValue",
7 | "integer-attribute": 10,
8 | "float-attribute": 5.5,
9 | "boolean-attribute": true,
10 | "nil-attribute": null,
11 | "date-attribute": "1970-01-01T01:00:00.000+01:00"
12 | },
13 | "links": {
14 | "self": "http://example.com/foos/1"
15 | },
16 | "relationships": {
17 | "to-one-attribute": {
18 | "links": {
19 | "self": "http://example.com/foos/1/relationships/to-one-attribute",
20 | "related": "http://example.com/bars/10"
21 | }
22 | },
23 | "to-many-attribute": {
24 | "links": {
25 | "self": "http://example.com/foos/1/relationships/to-many-attribute",
26 | "related": "http://example.com/bars/11,12"
27 | }
28 | }
29 | }
30 | }, {
31 | "id": "2",
32 | "type": "foos",
33 | "attributes": {
34 | "string-attribute": "stringAttributeValue",
35 | "integer-attribute": 10,
36 | "float-attribute": 5.5,
37 | "boolean-attribute": true,
38 | "nil-attribute": null,
39 | "date-attribute": "1970-01-01T01:00:00.000+01:00"
40 | },
41 | "links": {
42 | "self": "http://example.com/foos/1"
43 | },
44 | "relationships": {
45 | "to-one-attribute": {
46 | "links": {
47 | "self": "http://example.com/foos/1/relationships/to-one-attribute",
48 | "related": "http://example.com/bars/10"
49 | }
50 | },
51 | "to-many-attribute": {
52 | "links": {
53 | "self": "http://example.com/foos/1/relationships/to-many-attribute",
54 | "related": "http://example.com/bars/11,12"
55 | }
56 | }
57 | }
58 | }]
59 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/PagedFoos-1.json:
--------------------------------------------------------------------------------
1 | {
2 | "links": {
3 | "self": "http://example.com/foos?page[limit]=2",
4 | "next": "http://example.com/foos?page[limit]=2&page[number]=2"
5 | },
6 | "data": [{
7 | "id": "1",
8 | "type": "foos"
9 | }, {
10 | "id": "2",
11 | "type": "foos"
12 | }]
13 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/PagedFoos-2.json:
--------------------------------------------------------------------------------
1 | {
2 | "links": {
3 | "previous": "http://example.com/foos?page[limit]=2",
4 | "self": "http://example.com/foos?page[limit]=2&page[number]=2"
5 | },
6 | "data": [{
7 | "id": "3",
8 | "type": "foos"
9 | }, {
10 | "id": "4",
11 | "type": "foos"
12 | }]
13 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/SingleFoo.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "id": "1",
4 | "type": "foos",
5 | "attributes": {
6 | "string-attribute": "stringAttributeValue",
7 | "integer-attribute": 10,
8 | "float-attribute": 5.5,
9 | "boolean-attribute": true,
10 | "nil-attribute": null,
11 | "date-attribute": "1970-01-01T01:00:00.000+01:00"
12 | },
13 | "links": {
14 | "self": "http://example.com/foos/1"
15 | },
16 | "relationships": {
17 | "to-one-attribute": {
18 | "links": {
19 | "self": "http://example.com/foos/1/relationships/to-one-attribute",
20 | "related": "http://example.com/bars/10"
21 | },
22 | "data": { "type": "bars", "id": "10" }
23 | },
24 | "to-many-attribute": {
25 | "links": {
26 | "self": "http://example.com/foos/1/relationships/to-many-attribute",
27 | "related": "http://example.com/bars/11,12"
28 | },
29 | "data": [
30 | {"type": "bars", "id": "11" },
31 | {"type": "bars", "id": "12" }
32 | ]
33 | }
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/SingleFooIncludingBars.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "id": "1",
4 | "type": "foos",
5 | "attributes": {
6 | "string-attribute": "stringAttributeValue",
7 | "integer-attribute": 10,
8 | "float-attribute": 5.5,
9 | "boolean-attribute": true,
10 | "nil-attribute": null,
11 | "date-attribute": "1970-01-01T01:00:00.000+01:00"
12 | },
13 | "links": {
14 | "self": "http://example.com/foos/1"
15 | },
16 | "relationships": {
17 | "to-one-attribute": {
18 | "links": {
19 | "self": "http://example.com/foos/1/relationships/to-one-attribute",
20 | "related": "http://example.com/bars/10"
21 | },
22 | "data": { "type": "bars", "id": "10" }
23 | },
24 | "to-many-attribute": {
25 | "links": {
26 | "self": "http://example.com/foos/1/relationships/to-many-attribute",
27 | "related": "http://example.com/bars/11,12"
28 | },
29 | "data": [
30 | {"type": "bars", "id": "11" },
31 | {"type": "bars", "id": "12" }
32 | ]
33 | }
34 | }
35 | },
36 | "included": [{
37 | "id": "10",
38 | "type": "bars",
39 | "links": {
40 | "self": "http://example.com/bars/10"
41 | }
42 | }, {
43 | "id": "11",
44 | "type": "bars",
45 | "links": {
46 | "self": "http://example.com/bars/11"
47 | }
48 | }, {
49 | "id": "12",
50 | "type": "bars",
51 | "links": {
52 | "self": "http://example.com/bars/12"
53 | }
54 | }]
55 | }
--------------------------------------------------------------------------------
/SpineTests/Fixtures/SingleFooWithUnregisteredType.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "id": "1",
4 | "type": "foos",
5 | "attributes": {
6 | "string-attribute": "stringAttributeValue",
7 | "integer-attribute": 10,
8 | "float-attribute": 5.5,
9 | "boolean-attribute": true,
10 | "nil-attribute": null,
11 | "date-attribute": "1970-01-01T01:00:00.000+01:00"
12 | },
13 | "links": {
14 | "self": "http://example.com/foos/1"
15 | },
16 | "relationships": {
17 | "to-one-attribute": {
18 | "links": {
19 | "self": "http://example.com/foos/1/relationships/to-one-attribute",
20 | "related": "http://example.com/unregistered-types/10"
21 | },
22 | "data": { "type": "unregistered-types", "id": "10" }
23 | },
24 | "to-many-attribute": {
25 | "links": {
26 | "self": "http://example.com/foos/1/relationships/to-many-attribute",
27 | "related": "http://example.com/unregistered-types/11,12"
28 | },
29 | "data": [
30 | {"type": "unregistered-types", "id": "11" },
31 | {"type": "unregistered-types", "id": "12" }
32 | ]
33 | }
34 | }
35 | },
36 | "included": [{
37 | "id": "11",
38 | "type": "unregistered-types",
39 | "links": {
40 | "self": "http://example.com/unregistered/11"
41 | }
42 | }]
43 | }
44 |
--------------------------------------------------------------------------------
/SpineTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/SpineTests/QueryTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // QueryTests.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 20-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 |
12 | class QueryInitializationTests: XCTestCase {
13 | func testInitWithResourceTypeAndIDs() {
14 | let query = Query(resourceType: Foo.self, resourceIDs: ["1", "2", "3"])
15 | XCTAssertEqual(query.resourceType, Foo.resourceType, "Resource type not as expected")
16 | XCTAssertEqual(query.resourceIDs!, ["1", "2", "3"], "Resource IDs type not as expected")
17 | }
18 |
19 | func testInitWithResource() {
20 | let foo = Foo(id: "5")
21 | let query = Query(resource: foo)
22 |
23 | XCTAssertEqual(query.resourceType, foo.resourceType, "Resource type not as expected")
24 | XCTAssertEqual(query.resourceIDs!, [foo.id!], "Resource IDs type not as expected")
25 | }
26 |
27 | // func testInitWithResourceCollection() {
28 | // let URL = NSURL(string: "http://example.com/foos")!
29 | // let collection = ResourceCollection(resourcesURL: URL, resources: [])
30 | // let query = Query(resourceCollection: collection)
31 | //
32 | // XCTAssertEqual(query.URL!, collection.resourceURL, "URL not as expected")
33 | // }
34 |
35 | func testInitWithResourceTypeAndURLString() {
36 | let urlString = "http://example.com/foos"
37 | let query = Query(resourceType: Foo.self, path: urlString)
38 |
39 | XCTAssertEqual(query.url!, URL(string: urlString)!, "URL not as expected")
40 | XCTAssertEqual(query.resourceType, Foo.resourceType, "Resource type not as expected")
41 | }
42 | }
43 |
44 | class QueryIncludeTests: XCTestCase {
45 |
46 | func testInclude() {
47 | var query = Query(resourceType: Foo.self)
48 |
49 | query.include("toOneAttribute", "toManyAttribute")
50 | XCTAssertEqual(query.includes, ["toOneAttribute", "toManyAttribute"], "Includes not as expected")
51 | }
52 |
53 | func testRemoveInclude() {
54 | var query = Query(resourceType: Foo.self)
55 |
56 | query.include("toOneAttribute", "toManyAttribute")
57 | query.removeInclude("toManyAttribute")
58 | XCTAssertEqual(query.includes, ["toOneAttribute"], "Includes not as expected")
59 | }
60 | }
61 |
62 | class QueryFilterTests: XCTestCase {
63 |
64 | func testWherePropertyEqualTo() {
65 | var query = Query(resourceType: Foo.self)
66 | query.whereAttribute("stringAttribute", equalTo: "value")
67 |
68 | let predicate = NSComparisonPredicate(
69 | leftExpression: NSExpression(forKeyPath: "stringAttribute"),
70 | rightExpression: NSExpression(forConstantValue: "value"),
71 | modifier: .direct,
72 | type: .equalTo,
73 | options: NSComparisonPredicate.Options())
74 |
75 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
76 | }
77 |
78 | func testWherePropertyNotEqualTo() {
79 | var query = Query(resourceType: Foo.self)
80 | query.whereAttribute("stringAttribute", notEqualTo: "value")
81 |
82 | let predicate = NSComparisonPredicate(
83 | leftExpression: NSExpression(forKeyPath: "stringAttribute"),
84 | rightExpression: NSExpression(forConstantValue: "value"),
85 | modifier: .direct,
86 | type: .notEqualTo,
87 | options: NSComparisonPredicate.Options())
88 |
89 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
90 | }
91 |
92 | func testWherePropertyLessThan() {
93 | var query = Query(resourceType: Foo.self)
94 | query.whereAttribute("integerAttribute", lessThan: "10")
95 |
96 | let predicate = NSComparisonPredicate(
97 | leftExpression: NSExpression(forKeyPath: "integerAttribute"),
98 | rightExpression: NSExpression(forConstantValue: "10"),
99 | modifier: .direct,
100 | type: .lessThan,
101 | options: NSComparisonPredicate.Options())
102 |
103 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
104 | }
105 |
106 | func testWherePropertyLessThanOrEqualTo() {
107 | var query = Query(resourceType: Foo.self)
108 | query.whereAttribute("integerAttribute", lessThanOrEqualTo: "10")
109 |
110 | let predicate = NSComparisonPredicate(
111 | leftExpression: NSExpression(forKeyPath: "integerAttribute"),
112 | rightExpression: NSExpression(forConstantValue: "10"),
113 | modifier: .direct,
114 | type: .lessThanOrEqualTo,
115 | options: NSComparisonPredicate.Options())
116 |
117 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
118 | }
119 |
120 | func testWherePropertyGreaterThan() {
121 | var query = Query(resourceType: Foo.self)
122 | query.whereAttribute("integerAttribute", greaterThan: "10")
123 |
124 | let predicate = NSComparisonPredicate(
125 | leftExpression: NSExpression(forKeyPath: "integerAttribute"),
126 | rightExpression: NSExpression(forConstantValue: "10"),
127 | modifier: .direct,
128 | type: .greaterThan,
129 | options: NSComparisonPredicate.Options())
130 |
131 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
132 | }
133 |
134 | func testWherePropertyGreaterThanOrEqualTo() {
135 | var query = Query(resourceType: Foo.self)
136 | query.whereAttribute("integerAttribute", greaterThanOrEqualTo: "10")
137 |
138 | let predicate = NSComparisonPredicate(
139 | leftExpression: NSExpression(forKeyPath: "integerAttribute"),
140 | rightExpression: NSExpression(forConstantValue: "10"),
141 | modifier: .direct,
142 | type: .greaterThanOrEqualTo,
143 | options: NSComparisonPredicate.Options())
144 |
145 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
146 | }
147 |
148 | func testWhereRelationshipIsOrContains() {
149 | let bar = Bar()
150 | bar.id = "3"
151 |
152 | var query = Query(resourceType: Foo.self)
153 | query.whereRelationship("toOneAttribute", isOrContains: bar)
154 |
155 | let predicate = NSComparisonPredicate(
156 | leftExpression: NSExpression(forKeyPath: "toOneAttribute"),
157 | rightExpression: NSExpression(forConstantValue: bar.id!),
158 | modifier: .direct,
159 | type: .equalTo,
160 | options: NSComparisonPredicate.Options())
161 |
162 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
163 | }
164 |
165 | func testAddPredicateWithKey() {
166 | var query = Query(resourceType: Foo.self)
167 | query.addPredicateWithKey("notAnAttribute", value: "value", type: .equalTo)
168 |
169 | let predicate = NSComparisonPredicate(
170 | leftExpression: NSExpression(forKeyPath: "notAnAttribute"),
171 | rightExpression: NSExpression(forConstantValue: "value"),
172 | modifier: .direct,
173 | type: .equalTo,
174 | options: NSComparisonPredicate.Options())
175 |
176 | XCTAssertEqual(query.filters, [predicate], "Filters not as expected")
177 | }
178 | }
179 |
180 | class QuerySparseFieldsetsTests: XCTestCase {
181 |
182 | func testRestrictPropertiesTo() {
183 | var query = Query(resourceType: Foo.self)
184 | query.restrictFieldsTo("stringAttribute", "integerAttribute")
185 |
186 | XCTAssertNotNil(query.fields[Foo.resourceType])
187 | XCTAssertEqual(query.fields[Foo.resourceType]!, ["stringAttribute", "integerAttribute"], "Fields not as expected")
188 | }
189 |
190 | func testRestrictPropertiesOfResourceTypeTo() {
191 | var query = Query(resourceType: Foo.self)
192 | query.restrictFieldsOfResourceType(Bar.self, to: "barStringAttribute", "barIntegerAttribute")
193 |
194 | XCTAssertNotNil(query.fields["bars"])
195 | XCTAssertEqual(query.fields["bars"]!, ["barStringAttribute", "barIntegerAttribute"], "Fields not as expected")
196 | }
197 | }
198 |
199 | class QuerySortOrderTests: XCTestCase {
200 |
201 | func testAddAscendingOrder() {
202 | var query = Query(resourceType: Foo.self)
203 | query.addAscendingOrder("integerAttribute")
204 |
205 | let sortDescriptor = NSSortDescriptor(key: "integerAttribute", ascending: true)
206 |
207 | XCTAssertEqual(query.sortDescriptors, [sortDescriptor], "Sort descriptors not as expected")
208 | }
209 |
210 | func testAddDescendingOrder() {
211 | var query = Query(resourceType: Foo.self)
212 | query.addDescendingOrder("integerAttribute")
213 |
214 | let sortDescriptor = NSSortDescriptor(key: "integerAttribute", ascending: false)
215 |
216 | XCTAssertEqual(query.sortDescriptors, [sortDescriptor], "Sort descriptors not as expected")
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/SpineTests/ResourceCollectionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceCollectionTests.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 21-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class ResourceCollectionTests: XCTestCase {
12 |
13 | func testInitWithResourcesURLAndResources() {
14 | let url = URL(string: "http://example.com/foos")!
15 | let resources = [Foo(), Bar()]
16 | let collection = ResourceCollection(resources: resources, resourcesURL: url)
17 |
18 | XCTAssertNotNil(collection.resourcesURL, "Expected URL to be not nil.")
19 | XCTAssertEqual(collection.resourcesURL!, url, "Expected URL to be equal.")
20 | XCTAssertTrue(collection.isLoaded, "Expected isLoaded to be true.")
21 | XCTAssertTrue(collection.resources == resources, "Expected resources to be true.")
22 | }
23 |
24 | func testIndexSubscript() {
25 | let resources = [Foo(), Bar()]
26 | let collection = ResourceCollection(resources: resources)
27 |
28 | XCTAssert(collection[0] === resources[0], "Expected resource to be equal.")
29 | XCTAssert(collection[1] === resources[1], "Expected resource to be equal.")
30 | }
31 |
32 | func testTypeAndIDSubscript() {
33 | let resources = [Foo(id: "5"), Bar(id: "6")]
34 | let collection = ResourceCollection(resources: resources)
35 |
36 | XCTAssert(collection.resourceWithType("foos", id: "5")! === resources[0], "Expected resource to be equal.")
37 | XCTAssert(collection.resourceWithType("bars", id: "6")! === resources[1], "Expected resource to be equal.")
38 | }
39 |
40 | func testCount() {
41 | let resources = [Foo(), Bar()]
42 | let collection = ResourceCollection(resources: resources)
43 |
44 | XCTAssertEqual(collection.count, 2, "Expected count to be 2.")
45 | }
46 |
47 | func testAppendResource() {
48 | let foo = Foo(id: "1")
49 | let collection = ResourceCollection(resources: [])
50 |
51 | collection.appendResource(foo)
52 | XCTAssertEqual(collection.resources, [foo], "Expected resources to be equal.")
53 | }
54 | }
55 |
56 |
57 | class LinkedResourceCollectionTests: XCTestCase {
58 |
59 | func testInitWithResourcesURLAndURLAndLinkage() {
60 | let resourcesURL = URL(string: "http://example.com/foos")!
61 | let linkURL = URL(string: "http://example.com/bars/1/link/foos")!
62 | let linkage = [ResourceIdentifier(type: "foos", id: "1"), ResourceIdentifier(type: "bars", id: "2")]
63 | let collection = LinkedResourceCollection(resourcesURL: resourcesURL, linkURL: linkURL, linkage: linkage)
64 |
65 | XCTAssertNotNil(collection.resourcesURL, "Expected resources URL to be not nil.")
66 | XCTAssertEqual(collection.resourcesURL!, resourcesURL, "Expected resources URL to be equal.")
67 |
68 | XCTAssertNotNil(collection.linkURL, "Expected link URL to be not nil.")
69 | XCTAssertEqual(collection.linkURL!, linkURL, "Expected link URL to be equal.")
70 |
71 | XCTAssert(collection.linkage != nil, "Expected linkage to be not nil.")
72 | XCTAssertEqual(collection.linkage![0], linkage[0], "Expected first linkage item to be equal.")
73 | XCTAssertEqual(collection.linkage![1], linkage[1], "Expected second linkage item to be equal.")
74 | }
75 |
76 | func testInitWithResourcesURLAndURLAndHomogenousTypeAndLinkage() {
77 | let resourcesURL = URL(string: "http://example.com/foos")!
78 | let linkURL = URL(string: "http://example.com/bars/1/link/foos")!
79 | let collection = LinkedResourceCollection(resourcesURL: resourcesURL, linkURL: linkURL, linkage: ["1", "2"].map { ResourceIdentifier(type: "foos", id: $0) })
80 |
81 | XCTAssertNotNil(collection.resourcesURL, "Expected resources URL to be not nil.")
82 | XCTAssertEqual(collection.resourcesURL!, resourcesURL, "Expected resources URL to be equal.")
83 |
84 | XCTAssertNotNil(collection.linkURL, "Expected link URL to be not nil.")
85 | XCTAssertEqual(collection.linkURL!, linkURL, "Expected link URL to be equal.")
86 |
87 | XCTAssert(collection.linkage != nil, "Expected linkage to be not nil.")
88 | XCTAssertEqual(collection.linkage![0], ResourceIdentifier(type: "foos", id: "1"), "Expected first linkage item to be equal.")
89 | XCTAssertEqual(collection.linkage![1], ResourceIdentifier(type: "foos", id: "2"), "Expected second linkage item to be equal.")
90 | }
91 |
92 | func testAppendResource() {
93 | let collection = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
94 | let foo = Foo(id: "1")
95 |
96 | collection.appendResource(foo)
97 |
98 | XCTAssert(collection.resources == [foo], "Expected collection to contain resource.")
99 | XCTAssert(collection.addedResources.isEmpty, "Expected addedResources to be empty.")
100 | XCTAssert(collection.removedResources.isEmpty, "Expected addedResources to be empty.")
101 | }
102 |
103 | func testLinkResource() {
104 | let collection = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
105 | let foo = Foo(id: "1")
106 |
107 | collection.linkResource(foo)
108 |
109 | XCTAssert(collection.resources == [foo], "Expected collection to contain resource.")
110 | XCTAssert(collection.addedResources == [foo], "Expected addedResources to contain resource.")
111 | }
112 |
113 | func testLinkUnlinked() {
114 | let collection = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
115 | let foo = Foo(id: "1")
116 |
117 | collection.appendResource(foo)
118 | collection.unlinkResource(foo)
119 | collection.linkResource(foo)
120 |
121 | XCTAssert(collection.resources == [foo], "Expected collection to contain resource.")
122 | XCTAssert(collection.addedResources.isEmpty, "Expected addedResources to be empty.")
123 | XCTAssert(collection.removedResources.isEmpty, "Expected removedResources to be empty.")
124 | }
125 |
126 | func testUnlink() {
127 | let collection = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
128 | let foo = Foo(id: "1")
129 |
130 | collection.appendResource(foo)
131 | collection.unlinkResource(foo)
132 |
133 | XCTAssert(collection.resources.isEmpty, "Expected collection to be empty.")
134 | XCTAssert(collection.removedResources == [foo], "Expected removedResources to contain resource.")
135 | }
136 |
137 | func testUnlinkLinked() {
138 | let collection = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
139 | let foo = Foo(id: "1")
140 |
141 | collection.linkResource(foo)
142 | collection.unlinkResource(foo)
143 |
144 | XCTAssert(collection.resources.isEmpty, "Expected collection to be empty.")
145 | XCTAssert(collection.addedResources.isEmpty, "Expected addedResources to be empty.")
146 | XCTAssert(collection.removedResources.isEmpty, "Expected removedResources to be empty.")
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/SpineTests/ResourceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceTests.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 06-03-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class ResourceTests: XCTestCase {
12 |
13 | let foo: Foo = Foo(id: "1")
14 |
15 | override func setUp() {
16 | super.setUp()
17 | foo.stringAttribute = "stringAttribute"
18 | foo.nilAttribute = nil
19 | foo.toOneAttribute = Bar(id: "10")
20 | }
21 |
22 | func testGetAttributeValue() {
23 | let value = foo.value(forField: "stringAttribute")
24 |
25 | XCTAssertNotNil(value, "Expected value to be not nil")
26 |
27 | if let value = value as? String {
28 | XCTAssertEqual(value, foo.stringAttribute!, "Expected value to be equal")
29 | } else {
30 | XCTFail("Expected value to be of type 'String'")
31 | }
32 | }
33 |
34 | func testGetNilAttributeValue() {
35 | let value = foo.value(forField: "nilAttribute")
36 |
37 | XCTAssertNil(value, "Expected value to be nil")
38 | }
39 |
40 | func testGetRelationshipValue() {
41 | let value = foo.value(forField: "toOneAttribute")
42 |
43 | XCTAssertNotNil(value, "Expected value to be not nil")
44 |
45 | if let value = value as? Bar {
46 | XCTAssertEqual(value, foo.toOneAttribute!, "Expected value to be equal")
47 | } else {
48 | XCTFail("Expected value to be of type 'Bar'")
49 | }
50 | }
51 |
52 | func testSetAttributeValue() {
53 | foo.setValue("newStringValue", forField: "stringAttribute")
54 |
55 | }
56 |
57 | func testEncoding() {
58 | foo.url = URL(string: "http://example.com/api/foos/1")
59 | foo.isLoaded = true
60 |
61 | let encodedData = NSKeyedArchiver.archivedData(withRootObject: foo)
62 | let decodedFoo: AnyObject? = NSKeyedUnarchiver.unarchiveObject(with: encodedData) as AnyObject?
63 |
64 | XCTAssertNotNil(decodedFoo, "Expected decoded object to be not nil")
65 | XCTAssert(decodedFoo is Foo, "Expected decoded object to be of type 'Foo'")
66 |
67 | if let decodedFoo = decodedFoo as? Foo {
68 | XCTAssertEqual(decodedFoo.id!, foo.id!, "Expected id to be equal")
69 | XCTAssertEqual(decodedFoo.url!, foo.url!, "Expected URL to be equal")
70 | XCTAssertEqual(decodedFoo.isLoaded, foo.isLoaded, "Expected isLoaded to be equal")
71 | } else {
72 | XCTFail("Fail")
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/SpineTests/RoutingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RoutingTests.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 19-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class RoutingTests: XCTestCase {
12 | let spine = Spine(baseURL: URL(string:"http://example.com")!)
13 |
14 | func testURLForResourceType() {
15 | let url = spine.router.urlForResourceType("foos")
16 | let expectedURL = URL(string: "http://example.com/foos")!
17 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
18 | }
19 |
20 | func testURLForQuery() {
21 | var query = Query(resourceType: Foo.self, resourceIDs: ["1", "2"])
22 | query.include("toOneAttribute", "toManyAttribute")
23 | query.whereAttribute("stringAttribute", equalTo: "stringValue")
24 | query.restrictFieldsTo("stringAttribute", "integerAttribute")
25 | query.restrictFieldsOfResourceType(Bar.self, to: "barStringAttribute")
26 | query.addAscendingOrder("integerAttribute")
27 | query.addDescendingOrder("floatAttribute")
28 |
29 | let url = spine.router.urlForQuery(query)
30 | let expectedURL = URL(string: "http://example.com/foos?filter[id]=1,2&include=to-one-attribute,to-many-attribute&filter[string-attribute]=stringValue&fields[foos]=string-attribute,integer-attribute&fields[bars]=bar-string-attribute&sort=integer-attribute,-float-attribute")!
31 |
32 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
33 | }
34 |
35 | func testURLForQueryWithNonAttributeFilter() {
36 | var query = Query(resourceType: Foo.self, resourceIDs: ["1", "2"])
37 | query.addPredicateWithKey("notAnAttribute", value: "stringValue", type: .equalTo)
38 |
39 | let url = spine.router.urlForQuery(query)
40 | let expectedURL = URL(string: "http://example.com/foos?filter[id]=1,2&filter[notAnAttribute]=stringValue")!
41 |
42 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
43 | }
44 |
45 | func testURLForQueryWithArrayFilter() {
46 | var query = Query(resourceType: Foo.self)
47 | query.whereAttribute("stringAttribute", equalTo: ["stringValue1", "stringValue2"])
48 |
49 | let url = spine.router.urlForQuery(query)
50 | let expectedURL = URL(string: "http://example.com/foos?filter[string-attribute]=stringValue1,stringValue2")!
51 |
52 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
53 | }
54 |
55 | func testPagePagination() {
56 | var query = Query(resourceType: Foo.self)
57 | query.paginate(PageBasedPagination(pageNumber: 1, pageSize: 5))
58 |
59 | let url = spine.router.urlForQuery(query)
60 | let expectedURL = URL(string: "http://example.com/foos?page[number]=1&page[size]=5")!
61 |
62 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
63 | }
64 |
65 | func testOffsetPagination() {
66 | var query = Query(resourceType: Foo.self)
67 | query.paginate(OffsetBasedPagination(offset: 20, limit: 5))
68 |
69 | let url = spine.router.urlForQuery(query)
70 | let expectedURL = URL(string: "http://example.com/foos?page[offset]=20&page[limit]=5")!
71 |
72 | XCTAssertEqual(url, expectedURL, "URL not as expected.")
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/SpineTests/SerializingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SerializingTests.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 06-09-14.
6 | // Copyright (c) 2014 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | import SwiftyJSON
12 |
13 | class SerializerTests: XCTestCase {
14 | let serializer = Serializer()
15 |
16 | override func setUp() {
17 | super.setUp()
18 | serializer.keyFormatter = DasherizedKeyFormatter()
19 | serializer.registerResource(Foo.self)
20 | serializer.registerResource(Bar.self)
21 | }
22 | }
23 |
24 | class SerializingTests: SerializerTests {
25 |
26 | let foo: Foo = Foo(id: "1")
27 |
28 | override func setUp() {
29 | super.setUp()
30 | foo.stringAttribute = "stringAttribute"
31 | foo.integerAttribute = 10
32 | foo.floatAttribute = 5.5
33 | foo.booleanAttribute = true
34 | foo.nilAttribute = nil
35 | foo.dateAttribute = Date(timeIntervalSince1970: 0)
36 | foo.toOneAttribute = Bar(id: "10")
37 | foo.toManyAttribute = LinkedResourceCollection(resourcesURL: nil, linkURL: nil, linkage: nil)
38 | foo.toManyAttribute?.appendResource(Bar(id: "11"))
39 | foo.toManyAttribute?.appendResource(Bar(id: "12"))
40 | }
41 |
42 | func serializedJSONWithOptions(_ options: SerializationOptions) -> JSON {
43 | let serializedData = try! serializer.serializeResources([foo], options: options)
44 | return JSON(serializedData)
45 | }
46 |
47 | func testSerializeSingleResourceAttributes() {
48 | let json = serializedJSONWithOptions([.IncludeID])
49 |
50 | XCTAssertEqual(json["data"]["id"].stringValue, foo.id!, "Serialized id is not equal.")
51 | XCTAssertEqual(json["data"]["type"].stringValue, foo.resourceType, "Serialized type is not equal.")
52 | XCTAssertEqual(json["data"]["attributes"]["integer-attribute"].intValue, foo.integerAttribute?.intValue, "Serialized integer is not equal.")
53 | XCTAssertEqual(json["data"]["attributes"]["float-attribute"].floatValue, foo.floatAttribute?.floatValue, "Serialized float is not equal.")
54 | XCTAssertTrue(json["data"]["attributes"]["boolean-attribute"].boolValue, "Serialized boolean is not equal.")
55 | XCTAssertNotNil(json["data"]["attributes"]["nil-attribute"].null, "Serialized nil is not equal.")
56 | XCTAssertEqual(json["data"]["attributes"]["date-attribute"].stringValue, ISO8601FormattedDate(foo.dateAttribute!), "Serialized date is not equal.")
57 | }
58 |
59 | func testSerializeSingleResourceToOneRelationships() {
60 | let json = serializedJSONWithOptions([.IncludeID, .IncludeToOne])
61 |
62 | XCTAssertEqual(json["data"]["relationships"]["to-one-attribute"]["data"]["id"].stringValue, foo.toOneAttribute!.id!, "Serialized to-one id is not equal")
63 | XCTAssertEqual(json["data"]["relationships"]["to-one-attribute"]["data"]["type"].stringValue, Bar.resourceType, "Serialized to-one type is not equal")
64 | }
65 |
66 | func testSerializeSingleResourceToManyRelationships() {
67 | let json = serializedJSONWithOptions([.IncludeID, .IncludeToOne, .IncludeToMany])
68 |
69 | XCTAssertEqual(json["data"]["relationships"]["to-many-attribute"]["data"][0]["id"].stringValue, "11", "Serialized to-many id is not equal")
70 | XCTAssertEqual(json["data"]["relationships"]["to-many-attribute"]["data"][0]["type"].stringValue, Bar.resourceType, "Serialized to-many type is not equal")
71 |
72 | XCTAssertEqual(json["data"]["relationships"]["to-many-attribute"]["data"][1]["id"].stringValue, "12", "Serialized to-many id is not equal")
73 | XCTAssertEqual(json["data"]["relationships"]["to-many-attribute"]["data"][1]["type"].stringValue, Bar.resourceType, "Serialized to-many type is not equal")
74 | }
75 |
76 | func testSerializeSingleResourceWithoutID() {
77 | let json = serializedJSONWithOptions([.IncludeToOne, .IncludeToMany])
78 |
79 | XCTAssertNotNil(json["data"]["id"].error, "Expected serialized id to be absent.")
80 | }
81 |
82 | func testSerializeSingleResourceWithoutToOneRelationships() {
83 | let json = serializedJSONWithOptions([.IncludeID, .IncludeToMany])
84 |
85 | XCTAssertNotNil(json["data"]["relationships"]["to-one-attribute"].error, "Expected serialized to-one to be absent")
86 | }
87 |
88 | func testSerializeSingleResourceWithoutToManyRelationships() {
89 | let options:SerializationOptions = [.IncludeID, .IncludeToOne]
90 | let serializedData = try! serializer.serializeResources([foo], options: options)
91 | let json = JSON(serializedData)
92 |
93 | XCTAssertNotNil(json["data"]["relationships"]["to-many-attribute"].error, "Expected serialized to-many to be absent.")
94 | }
95 |
96 | func testSerializeResourceOmittingNulls() {
97 | let options: SerializationOptions = [.OmitNullValues]
98 | let serializedData = try! serializer.serializeResources([foo], options: options)
99 | let json = JSON(serializedData)
100 | XCTAssertNotNil(json["data"]["attributes"]["nil-attribute"].error, "Expected serialized nil to be absent.")
101 | }
102 | }
103 |
104 | class DeserializingTests: SerializerTests {
105 |
106 | func testDeserializeSingleResource() {
107 | let fixture = JSONFixtureWithName("SingleFoo")
108 | let json = fixture.json
109 |
110 | do {
111 | let document = try serializer.deserializeData(fixture.data)
112 | XCTAssertNotNil(document.data, "Expected data to be not nil.")
113 |
114 | if let resources = document.data {
115 | XCTAssertEqual(resources.count, 1, "Expected resources count to be 1.")
116 |
117 | XCTAssert(resources.first is Foo, "Expected resource to be of class 'Foo'.")
118 | let foo = resources.first as! Foo
119 |
120 | // Attributes
121 | assertFooResource(foo, isEqualToJSON: json["data"])
122 |
123 | // To one link
124 | XCTAssertNotNil(foo.toOneAttribute, "Expected linked resource to be not nil.")
125 | if let bar = foo.toOneAttribute {
126 |
127 | XCTAssertNotNil(bar.url, "Expected URL to not be nil")
128 | if let url = bar.url {
129 | XCTAssertEqual(url, URL(string: json["data"]["relationships"]["to-one-attribute"]["links"]["related"].stringValue)!, "Deserialized link URL is not equal.")
130 | }
131 |
132 | XCTAssertFalse(bar.isLoaded, "Expected isLoaded to be false.")
133 | }
134 |
135 | // To many link
136 | XCTAssertNotNil(foo.toManyAttribute, "Deserialized linked resources should not be nil.")
137 | if let barCollection = foo.toManyAttribute {
138 |
139 | XCTAssertNotNil(barCollection.linkURL, "Expected link URL to not be nil")
140 | if let url = barCollection.linkURL {
141 | XCTAssertEqual(url, URL(string: json["data"]["relationships"]["to-many-attribute"]["links"]["self"].stringValue)!, "Deserialized link URL is not equal.")
142 | }
143 |
144 | XCTAssertNotNil(barCollection.resourcesURL, "Expected resourcesURL to not be nil")
145 | if let resourcesURL = barCollection.resourcesURL {
146 | XCTAssertEqual(resourcesURL, URL(string: json["data"]["relationships"]["to-many-attribute"]["links"]["related"].stringValue)!, "Deserialized resource URL is not equal.")
147 | }
148 |
149 | XCTAssertFalse(barCollection.isLoaded, "Expected isLoaded to be false.")
150 | }
151 | }
152 | } catch let error as NSError {
153 | XCTFail("Deserialisation failed with error: \(error).")
154 | }
155 | }
156 |
157 | func testDeserializeSingleResourceWithUnregisteredType() {
158 | let fixture = JSONFixtureWithName("SingleFooWithUnregisteredType")
159 |
160 | do {
161 | let document = try serializer.deserializeData(fixture.data)
162 | XCTAssertNotNil(document.data, "Expected data to be not nil.")
163 |
164 | if let resources = document.data {
165 | XCTAssertEqual(resources.count, 1, "Expected resources count to be 1.")
166 |
167 | XCTAssert(resources.first is Foo, "Expected resource to be of class 'Foo'.")
168 | let foo = resources.first as! Foo
169 |
170 | // To one link
171 | XCTAssertNotNil(foo.toOneAttribute, "Expected linked resource to be not nil.")
172 |
173 | // To many link
174 | XCTAssertNotNil(foo.toManyAttribute, "Deserialized linked resources should not be nil.")
175 | if let barCollection = foo.toManyAttribute {
176 | for bar in barCollection {
177 | XCTAssert(bar is Bar, "Expected relationship resource to be of class 'Bar'.")
178 | }
179 | }
180 | }
181 | } catch let error as NSError {
182 | XCTFail("Deserialisation failed with error: \(error).")
183 | }
184 | }
185 |
186 | func testDeserializeMultipleResources() {
187 | let fixture = JSONFixtureWithName("MultipleFoos")
188 |
189 | do {
190 | let document = try serializer.deserializeData(fixture.data, mappingTargets: nil)
191 |
192 | XCTAssertNotNil(document.data, "Expected data to be not nil.")
193 | if let resources = document.data {
194 | XCTAssertEqual(resources.count, 2, "Expected resources count to be 2.")
195 |
196 | for (index, resource) in resources.enumerated() {
197 | let resourceJSON = fixture.json["data"][index]
198 |
199 | XCTAssert(resource is Foo, "Expected resource to be of class 'Foo'.")
200 | let foo = resource as! Foo
201 |
202 | // Attributes
203 | assertFooResource(foo, isEqualToJSON: resourceJSON)
204 |
205 | // To one link
206 | XCTAssertNotNil(foo.toOneAttribute, "Expected linked resource to be not nil.")
207 | let bar = foo.toOneAttribute!
208 | XCTAssertEqual(bar.url!.absoluteString, resourceJSON["relationships"]["to-one-attribute"]["links"]["related"].stringValue, "Deserialized resource URL is not equal.")
209 | XCTAssertFalse(bar.isLoaded, "Expected isLoaded to be false.")
210 |
211 | // To many link
212 | if let barCollection = foo.toManyAttribute {
213 | XCTAssertEqual(barCollection.linkURL!.absoluteString, resourceJSON["relationships"]["to-many-attribute"]["links"]["self"].stringValue, "Deserialized link URL is not equal.")
214 | XCTAssertEqual(barCollection.resourcesURL!.absoluteString, resourceJSON["relationships"]["to-many-attribute"]["links"]["related"].stringValue, "Deserialized resource URL is not equal.")
215 | XCTAssertFalse(barCollection.isLoaded, "Expected isLoaded to be false.")
216 | } else {
217 | XCTFail("Deserialized linked resources should not be nil")
218 | }
219 | }
220 | }
221 |
222 | } catch let error as NSError {
223 | XCTFail("Deserialisation failed with error: \(error).")
224 | }
225 | }
226 |
227 | func testDeserializeEmptyResources() {
228 | let fixture = JSONFixtureWithName("EmptyFoos")
229 |
230 | do {
231 | let document = try serializer.deserializeData(fixture.data, mappingTargets: nil)
232 |
233 | guard let foos = document.data else {
234 | XCTFail("Expected data to be not nil.")
235 | return
236 | }
237 |
238 | XCTAssert(foos.isEmpty, "Expected an empty array.")
239 |
240 | } catch let error as NSError {
241 | XCTFail("Deserialisation failed with error: \(error).")
242 | }
243 | }
244 |
245 | func testDeserializeCompoundDocument() {
246 | let fixture = JSONFixtureWithName("SingleFooIncludingBars")
247 | let json = fixture.json
248 |
249 | do {
250 | let document = try serializer.deserializeData(fixture.data)
251 |
252 | XCTAssertNotNil(document.data, "Expected data to be not nil.")
253 | if let resources = document.data {
254 | XCTAssertEqual(resources.count, 1, "Deserialized resources count not equal.")
255 | XCTAssert(resources.first is Foo, "Deserialized resource should be of class 'Foo'.")
256 | let foo = resources.first as! Foo
257 |
258 | // Attributes
259 | assertFooResource(foo, isEqualToJSON: json["data"])
260 |
261 | // To one link
262 | XCTAssertNotNil(foo.toOneAttribute, "Deserialized linked resource should not be nil.")
263 | if let bar = foo.toOneAttribute {
264 |
265 | XCTAssertNotNil(bar.url, "Expected URL to not be nil.")
266 | if let url = bar.url {
267 | XCTAssertEqual(url, URL(string: json["data"]["relationships"]["to-one-attribute"]["links"]["related"].stringValue)!, "Deserialized resource URL is not equal.")
268 | }
269 |
270 | XCTAssertNotNil(bar.id, "Expected id to not be nil.")
271 | if let id = bar.id {
272 | XCTAssertEqual(id, json["data"]["relationships"]["to-one-attribute"]["data"]["id"].stringValue, "Deserialized link id is not equal.")
273 | }
274 |
275 | XCTAssertTrue(bar.isLoaded, "Expected isLoaded is be true.")
276 | }
277 |
278 | // To many link
279 | XCTAssertNotNil(foo.toManyAttribute, "Deserialized linked resources should not be nil.")
280 | if let barCollection = foo.toManyAttribute {
281 |
282 | XCTAssertNotNil(barCollection.linkURL, "Expected link URL to not be nil.")
283 | if let URL = barCollection.linkURL {
284 | XCTAssertEqual(URL, Foundation.URL(string: json["data"]["relationships"]["to-many-attribute"]["links"]["self"].stringValue)!, "Deserialized link URL is not equal.")
285 | }
286 |
287 | XCTAssertNotNil(barCollection.resourcesURL, "Expected resourcesURL to not be nil.")
288 | if let URLString = barCollection.resourcesURL?.absoluteString {
289 | XCTAssertEqual(URLString, json["data"]["relationships"]["to-many-attribute"]["links"]["related"].stringValue, "Deserialized resource URL is not equal.")
290 | }
291 |
292 | XCTAssertTrue(barCollection.isLoaded, "Expected isLoaded to be true.")
293 | XCTAssertEqual(barCollection.linkage![0].type, "bars", "Expected first linkage item to be of type 'bars'.")
294 | XCTAssertEqual(barCollection.linkage![0].id, "11", "Expected first linkage item to have id '11'.")
295 | XCTAssertEqual(barCollection.linkage![1].type, "bars", "Expected second linkage item to be of type 'bars'.")
296 | XCTAssertEqual(barCollection.linkage![1].id, "12", "Expected second linkage item to have id '12'.")
297 | }
298 | }
299 |
300 |
301 | } catch let error as NSError {
302 | XCTFail("Deserialisation failed with error: \(error).")
303 | }
304 | }
305 |
306 | func testDeserializeWithInvalidDocumentStructure() {
307 | let data = Data()
308 |
309 | do {
310 | try _ = serializer.deserializeData(data)
311 | XCTFail("Expected deserialization to fail.")
312 | } catch SerializerError.invalidDocumentStructure {
313 | // All is well
314 | } catch {
315 | XCTFail("Expected error domain to be SerializerError.InvalidDocumentStructure.")
316 | }
317 | }
318 |
319 | func testDeserializeWithoutTopLevelEntry() {
320 | let data = try! JSONSerialization.data(withJSONObject: [:], options: [])
321 |
322 | do {
323 | try _ = serializer.deserializeData(data)
324 | XCTFail("Expected deserialization to fail.")
325 | } catch SerializerError.topLevelEntryMissing {
326 | // All is well
327 | } catch {
328 | XCTFail("Expected error domain to be SerializerError.TopLevelEntryMissing.")
329 | }
330 | }
331 |
332 | func testDeserializeWithCoexistingDataAndErrors() {
333 | let data = try! JSONSerialization.data(withJSONObject: ["data": [], "errors": []], options: [])
334 |
335 | do {
336 | try _ = serializer.deserializeData(data)
337 | XCTFail("Expected deserialization to fail.")
338 | } catch SerializerError.topLevelDataAndErrorsCoexist {
339 | // All is well
340 | } catch {
341 | XCTFail("Expected error domain to be SerializerError.TopLevelDataAndErrorsCoexist.")
342 | }
343 | }
344 |
345 | func testDeserializeWithNullData() {
346 | let data = try! JSONSerialization.data(withJSONObject: ["data": NSNull()], options: [])
347 |
348 | do {
349 | try _ = serializer.deserializeData(data)
350 | } catch {
351 | XCTFail("Expected deserialization to succeed.")
352 | }
353 | }
354 |
355 | func testDeserializeErrorsDocument() {
356 | let fixture = JSONFixtureWithName("Errors")
357 |
358 | do {
359 | let document = try serializer.deserializeData(fixture.data)
360 |
361 | XCTAssertNotNil(document.errors, "Expected data to be not nil.")
362 |
363 | if let errors = document.errors {
364 | XCTAssertEqual(errors.count, 2, "Deserialized errors count not equal.")
365 |
366 | for (index, error) in errors.enumerated() {
367 | let errorJSON = fixture.json["errors"][index]
368 | XCTAssertEqual(error.id, errorJSON["id"].stringValue)
369 | XCTAssertEqual(error.status, errorJSON["status"].stringValue)
370 | XCTAssertEqual(error.code, errorJSON["code"].stringValue)
371 | XCTAssertEqual(error.title, errorJSON["title"].stringValue)
372 | XCTAssertEqual(error.detail, errorJSON["detail"].stringValue)
373 | }
374 | }
375 |
376 | } catch let error {
377 | XCTFail("Deserialisation failed with error: \(error).")
378 | }
379 | }
380 | }
381 |
--------------------------------------------------------------------------------
/SpineTests/Utilities.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utilities.swift
3 | // Spine
4 | //
5 | // Created by Ward van Teijlingen on 19-02-15.
6 | // Copyright (c) 2015 Ward van Teijlingen. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | import SwiftyJSON
12 | import BrightFutures
13 |
14 | extension XCTestCase {
15 |
16 | func JSONFixtureWithName(_ name: String) -> (data: Data, json: JSON) {
17 | let path = Bundle(for: type(of: self)).url(forResource: name, withExtension: "json")!
18 | let data = try! Data(contentsOf: path)
19 | let json = JSON(data)
20 | return (data: data, json: json)
21 | }
22 | }
23 |
24 | func ISO8601FormattedDate(_ date: Date) -> String {
25 | let dateFormatter = DateFormatter()
26 | let enUSPosixLocale = Locale(identifier: "en_US_POSIX")
27 | dateFormatter.locale = enUSPosixLocale
28 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
29 |
30 | return dateFormatter.string(from: date)
31 | }
32 |
33 | // MARK: - Custom assertions
34 |
35 | func assertFutureSuccess(_ future: Future, expectation: XCTestExpectation) {
36 | future.onSuccess { resources in
37 | expectation.fulfill()
38 | }.onFailure { error in
39 | expectation.fulfill()
40 | XCTFail("Expected future to complete with success.")
41 | }
42 | }
43 |
44 | func assertFutureFailure(_ future: Future, withError expectedError: SpineError, expectation: XCTestExpectation) {
45 | future.onSuccess { resources in
46 | expectation.fulfill()
47 | XCTFail("Expected success callback to not be called.")
48 | }.onFailure { error in
49 | expectation.fulfill()
50 | XCTAssertEqual(error, expectedError, "Expected error to be be \(expectedError).")
51 | }
52 | }
53 |
54 | func assertFutureFailureWithServerError(_ future: Future, statusCode code: Int, expectation: XCTestExpectation) {
55 | future.onSuccess { resources in
56 | expectation.fulfill()
57 | XCTFail("Expected success callback to not be called.")
58 | }.onFailure { error in
59 | expectation.fulfill()
60 | switch error {
61 | case .serverError(let statusCode, _):
62 | XCTAssertEqual(statusCode, code, "Expected error to be be .ServerError with statusCode \(code)")
63 | default:
64 | XCTFail("Expected error to be be .ServerError")
65 | }
66 | }
67 | }
68 |
69 | func assertFutureFailureWithNetworkError(_ future: Future, code: Int, expectation: XCTestExpectation) {
70 | future.onSuccess { resources in
71 | expectation.fulfill()
72 | XCTFail("Expected success callback to not be called.")
73 | }.onFailure { error in
74 | expectation.fulfill()
75 | switch error {
76 | case .networkError(let error):
77 | XCTAssertEqual(error.code, code, "Expected error to be be .NetworkError with code \(code)")
78 | default:
79 | XCTFail("Expected error to be be .NetworkError")
80 | }
81 | }
82 | }
83 |
84 | func assertFooResource(_ foo: Foo, isEqualToJSON json: JSON) {
85 | XCTAssertEqual(foo.stringAttribute!, json["attributes"]["string-attribute"].stringValue, "Deserialized string attribute is not equal.")
86 | XCTAssertEqual(foo.integerAttribute?.intValue, json["attributes"]["integer-attribute"].intValue, "Deserialized integer attribute is not equal.")
87 | XCTAssertEqual(foo.floatAttribute?.floatValue, json["attributes"]["float-attribute"].floatValue, "Deserialized float attribute is not equal.")
88 | XCTAssertEqual(foo.booleanAttribute?.boolValue, json["attributes"]["integer-attribute"].boolValue, "Deserialized boolean attribute is not equal.")
89 | XCTAssertNil(foo.nilAttribute, "Deserialized nil attribute is not equal.")
90 | XCTAssertEqual(foo.dateAttribute! as Date, Date(timeIntervalSince1970: 0), "Deserialized date attribute is not equal.")
91 | }
92 |
--------------------------------------------------------------------------------