├── .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 | [![Build Status](https://travis-ci.org/wvteijlingen/Spine.svg?branch=swift-2.0)](https://travis-ci.org/wvteijlingen/Spine) [![Join the chat at https://gitter.im/wvteijlingen/Spine](https://badges.gitter.im/Join%20Chat.svg)](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 | --------------------------------------------------------------------------------