diff --git a/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift index eabd561..280a614 100644 --- a/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift @@ -37,7 +37,7 @@ typealias ToManyRelationship = JSONAPI.ToManyRelationship = JSONAPI.Document> +typealias Document = JSONAPI.Document> // MARK: Entity Definitions diff --git a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift index e5434e5..5917bef 100644 --- a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift @@ -64,11 +64,11 @@ if case let .data(bodyData) = peopleResponse.body { // MARK: - Work in the abstract print("-----") -func process(document: T) { - guard case let .data(body) = document.body else { +func process(document: T) { + guard let body = document.body.data else { return } - let x: T.Body.Data = body + let x: T.BodyData = body } process(document: peopleResponse) diff --git a/Package.resolved b/Package.resolved index b1970b8..add4b9a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/mattpolzin/Poly.git", "state": { "branch": null, - "revision": "b24fd3b41bf3126d4c6dede3708135182172af60", - "version": "2.2.0" + "revision": "0c9c08204142babc480938d704a23513d11420e5", + "version": "2.3.1" } } ] diff --git a/Package.swift b/Package.swift index 2093673..9b3e038 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( targets: ["JSONAPITesting"]) ], dependencies: [ - .package(url: "https://github.com/mattpolzin/Poly.git", .upToNextMajor(from: "2.2.0")), + .package(url: "https://github.com/mattpolzin/Poly.git", .upToNextMajor(from: "2.3.1")), ], targets: [ .target( diff --git a/README.md b/README.md index 688b5fa..7a1c6cd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Swift package for encoding to- and decoding from **JSON API** compliant reques See the JSON API Spec here: https://jsonapi.org/format/ -:warning: This library provides well-tested type safety when working with JSON:API 1.0. However, the Swift compiler can sometimes have difficulty tracking down small typos when initializing `ResourceObjects`. Once the code is written correctly, it will compile, but tracking down the source of programmer errors can be an annoyance. This is mostly a concern when creating resource objects in-code (servers and test cases must do this). Writing a client that uses this framework to ingest JSON API Compliant API responses is much less painful. +:warning: This library provides well-tested type safety when working with JSON:API 1.0. However, the Swift compiler can sometimes have difficulty tracking down small typos when initializing `ResourceObjects`. Correct code will always compile, but tracking down the source of programmer errors can be an annoyance. This is mostly a concern when creating resource objects in-code (i.e. declaratively) like you might for unit testing. Writing a client that uses this framework to ingest and decode JSON API Compliant API responses is much less painful. ## Quick Start @@ -26,69 +26,21 @@ See the JSON API Spec here: https://jsonapi.org/format/ This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](#example) further down in this README. ## Table of Contents - -- [JSONAPI](#jsonapi) +- JSONAPI - [Primary Goals](#primary-goals) - - [Caveat](#caveat) - [Dev Environment](#dev-environment) - [Prerequisites](#prerequisites) - [Swift Package Manager](#swift-package-manager) - - [CocoaPods](#cocoapods) - [Xcode project](#xcode-project) + - [CocoaPods](#cocoapods) - [Running the Playground](#running-the-playground) - [Project Status](#project-status) - - [JSON:API](#jsonapi) - - [Document](#document) - - [Resource Object](#resource-object) - - [Relationship Object](#relationship-object) - - [Links Object](#links-object) - - [Misc](#misc) - - [Testing](#testing) - - [Resource Object Validator](#resource-object-validator) - - [Potential Improvements](#potential-improvements) - - [Usage](#usage) - - [`JSONAPI.ResourceObjectDescription`](#jsonapiresourceobjectdescription) - - [`JSONAPI.ResourceObject`](#jsonapiresourceobject) - - [`Meta`](#meta) - - [`Links`](#links) - - [`MaybeRawId`](#mayberawid) - - [`RawIdType`](#rawidtype) - - [Convenient `typealiases`](#convenient-typealiases) - - [`JSONAPI.Relationships`](#jsonapirelationships) - - [`JSONAPI.Attributes`](#jsonapiattributes) - - [`Transformer`](#transformer) - - [`Validator`](#validator) - - [Computed `Attribute`](#computed-attribute) - - [Copying/Mutating `ResourceObjects`](#copyingmutating-resourceobjects) - - [`JSONAPI.Document`](#jsonapidocument) - - [`ResourceBody`](#resourcebody) - - [nullable `PrimaryResource`](#nullable-primaryresource) - - [`MetaType`](#metatype) - - [`LinksType`](#linkstype) - - [`IncludeType`](#includetype) - - [`APIDescriptionType`](#apidescriptiontype) - - [`Error`](#error) - - [`UnknownJSONAPIError`](#unknownjsonapierror) - - [`BasicJSONAPIError`](#basicjsonapierror) - - [`GenericJSONAPIError`](#genericjsonapierror) - - [`JSONAPI.Meta`](#jsonapimeta) - - [`JSONAPI.Links`](#jsonapilinks) - - [`JSONAPI.RawIdType`](#jsonapirawidtype) - - [Sparse Fieldsets](#sparse-fieldsets) - - [Supporting Sparse Fieldset Encoding](#supporting-sparse-fieldset-encoding) - - [Sparse Fieldset `typealias` comparisons](#sparse-fieldset-typealias-comparisons) - - [Replacing and Tapping Attributes/Relationships](#replacing-and-tapping-attributesrelationships) - - [Tapping](#tapping) - - [Replacing](#replacing) - - [Custom Attribute or Relationship Key Mapping](#custom-attribute-or-relationship-key-mapping) - - [Custom Attribute Encode/Decode](#custom-attribute-encodedecode) - - [Meta-Attributes](#meta-attributes) - - [Meta-Relationships](#meta-relationships) - [Example](#example) - - [Preamble (Setup shared by server and client)](#preamble-setup-shared-by-server-and-client) - - [Server Pseudo-example](#server-pseudo-example) - - [Client Pseudo-example](#client-pseudo-example) + - [Usage](./documentation/usage.md) - [JSONAPI+Testing](#jsonapitesting) + - [Literal Expressibility](#literal-expressibility) + - [Resource Object `check()`](#resource-object-check) + - [Comparisons](#comparisons) - [JSONAPI+Arbitrary](#jsonapiarbitrary) - [JSONAPI+OpenAPI](#jsonapiopenapi) @@ -99,6 +51,7 @@ The primary goals of this framework are: 2. Leverage `Codable` to avoid additional outside dependencies and get operability with non-JSON encoders/decoders for free. 3. Do not sacrifice type safety. 4. Be platform agnostic so that Swift code can be written once and used by both the client and the server. +5. Provide _human readable_ error output. The errors thrown when decoding an API response and the results of the `JSONAPITesting` framework's `compare(to:)` functions all have digestible human readable descriptions (just use `String(describing:)`). ### Caveat The big caveat is that, although the aim is to support the JSON API spec, this framework ends up being _naturally_ opinionated about certain things that the API Spec does not specify. These caveats are largely a side effect of attempting to write the library in a "Swifty" way. @@ -108,7 +61,7 @@ If you find something wrong with this library and it isn't already mentioned und ## Dev Environment ### Prerequisites 1. Swift 5.1+ -2. Swift Package Manager *OR* Cocoapods +2. Swift Package Manager, Xcode 11+, or Cocoapods ### Swift Package Manager Just include the following in your package's dependencies and add `JSONAPI` to the dependencies for any of your targets. @@ -116,6 +69,12 @@ Just include the following in your package's dependencies and add `JSONAPI` to t .package(url: "https://github.com/mattpolzin/JSONAPI.git", .upToNextMajor(from: "2.2.0")) ``` +### Xcode project +To create an Xcode project for JSONAPI, run +`swift package generate-xcodeproj` + +With Xcode 11+ you can also just open the folder containing your clone of this repository and begin working. + ### CocoaPods To use this framework in your project via Cocoapods, add the following dependencies to your Podfile. ``` @@ -123,12 +82,6 @@ To use this framework in your project via Cocoapods, add the following dependenc pod 'MP-JSONAPI', :git => 'https://github.com/mattpolzin/JSONAPI.git' ``` -### Xcode project -To create an Xcode project for JSONAPI, run -`swift package generate-xcodeproj` - -With Xcode 11+ you can also just open the folder containing your clone of this repository and begin working. - ### Running the Playground To run the included Playground files, create an Xcode project using Swift Package Manager, then create an Xcode Workspace in the root of the repository and add both the generated Xcode project and the playground to the Workspace. @@ -181,577 +134,6 @@ These ideas could be implemented in future versions. - [ ] Support sideposting. JSONAPI spec might become opinionated in the future (https://github.com/json-api/json-api/pull/1197, https://github.com/json-api/json-api/issues/1215, https://github.com/json-api/json-api/issues/1216) but there is also an existing implementation to consider (https://jsonapi-suite.github.io/jsonapi_suite/ruby/writes/nested-writes). At this time, any sidepost implementation would be an awesome tertiary library to be used alongside the primary JSONAPI library. Maybe `JSONAPISideloading`. - [ ] Error or warning if an included resource object is not related to a primary resource object or another included resource object (Turned off or at least not throwing by default). -## Usage - -In this documentation, in order to draw attention to the difference between the `JSONAPI` framework (this Swift library) and the **JSON API Spec** (the specification this library helps you follow), the specification will consistently be referred to below as simply the **SPEC**. - -### `JSONAPI.ResourceObjectDescription` - -A `ResourceObjectDescription` is the `JSONAPI` framework's representation of what the **SPEC** calls a *Resource Object*. You might create the following `ResourceObjectDescription` to represent a person in a network of friends: - -```swift -enum PersonDescription: IdentifiedResourceObjectDescription { - static var jsonType: String { return "people" } - - struct Attributes: JSONAPI.Attributes { - let name: Attribute<[String]> - let favoriteColor: Attribute - } - - struct Relationships: JSONAPI.Relationships { - let friends: ToManyRelationship - } -} -``` - -The requirements of a `ResourceObjectDescription` are: -1. A static `var` "jsonType" that matches the JSON type; The **SPEC** requires every *Resource Object* to have a "type". -2. A `struct` of `Attributes` **- OR -** `typealias Attributes = NoAttributes` -3. A `struct` of `Relationships` **- OR -** `typealias Relationships = NoRelationships` - -Note that an `enum` type is used here for the `ResourceObjectDescription`; it could have been a `struct`, but `ResourceObjectDescription`s do not ever need to be created so an `enum` with no `case`s is a nice fit for the job. - -This readme doesn't go into detail on the **SPEC**, but the following *Resource Object* would be described by the above `PersonDescription`: - -```json -{ - "type": "people", - "id": "9", - "attributes": { - "name": [ - "Jane", - "Doe" - ], - "favoriteColor": "Green" - }, - "relationships": { - "friends": { - "data": [ - { - "id": "7", - "type": "people" - }, - { - "id": "8", - "type": "people" - } - ] - } - } -} -``` - -### `JSONAPI.ResourceObject` - -Once you have a `ResourceObjectDescription`, you _create_, _encode_, and _decode_ `ResourceObjects` that "fit the description". If you have a `CreatableRawIdType` (see the section on `RawIdType`s below) then you can create new `ResourceObjects` that will automatically be given unique Ids, but even without a `CreatableRawIdType` you can encode, decode and work with resource objects. - -The `ResourceObject` and `ResourceObjectDescription` together with a `JSONAPI.Meta` type and a `JSONAPI.Links` type embody the rules and properties of a JSON API *Resource Object*. - -A `ResourceObject` needs to be specialized on four generic types. The first is the `ResourceObjectDescription` described above. The others are a `Meta`, `Links`, and `MaybeRawId`. - -#### `Meta` - -The second generic specialization on `ResourceObject` is `Meta`. This is described in its own section [below](#jsonapimeta). All `Meta` at any level of a JSON API Document follow the same rules. You can use `NoMetadata` if you do not need to package any metadata with the `ResourceObject`. - -#### `Links` - -The third generic specialization on `ResourceObject` is `Links`. This is described in its own section [below](#jsonnapilinks). All `Links` at any level of a JSON API Document follow the same rules, although the **SPEC** makes different suggestions as to what types of links might live on which parts of the Document. You can use `NoLinks` if you do not need to package any links with the `ResourceObject`. - -#### `MaybeRawId` - -The last generic specialization on `ResourceObject` is `MaybeRawId`. This is either a `RawIdType` that can be used to uniquely identify `ResourceObjects` or it is `Unidentified` which is used to indicate a `ResourceObject` does not have an `Id` (which is useful when a client is requesting that the server create a `ResourceObject` and assign it a new `Id`). - -##### `RawIdType` - -The raw type of `Id` to use for the `ResourceObject`. The actual `Id` of the `ResourceObject` will not be a `RawIdType`, though. The `Id` will package a value of `RawIdType` with a specialized reference back to the `ResourceObject` type it identifies. This just looks like `Id>`. - -Having the `ResourceObject` type associated with the `Id` makes it easy to store all of your resource objects in a hash broken out by `ResourceObject` type; You can pass `Ids` around and always know where to look for the `ResourceObject` to which the `Id` refers. This encapsulation provides some type safety because the Ids of two `ResourceObjects` with the "raw ID" of `"1"` but different types will not compare as equal. - -A `RawIdType` is the underlying type that uniquely identifies a `ResourceObject`. This is often a `String` or a `UUID`. - -#### Convenient `typealiases` - -Often you can use one `RawIdType` for many if not all of your `ResourceObjects`. That means you can save yourself some boilerplate by using `typealias`es like the following: -```swift -public typealias ResourceObject = JSONAPI.ResourceObject - -public typealias NewResourceObject = JSONAPI.ResourceObject -``` - -It can also be nice to create a `typealias` for each type of resource object you want to work with: -```swift -typealias Person = ResourceObject - -typealias NewPerson = NewResourceObject -``` - -Note that I am assuming an unidentified person is a "new" person. I suspect that is generally an acceptable conflation because the only time the **SPEC** allows a *Resource Object* to be encoded without an `Id` is when a client is requesting the given *Resource Object* be created by the server and the client wants the server to create the `Id` for that object. - -### `JSONAPI.Relationships` - -There are two types of `Relationships`: `ToOneRelationship` and `ToManyRelationship`. A `ResourceObjectDescription`'s `Relationships` type can contain any number of `Relationship` properties of either of these types. Do not store anything other than `Relationship` properties in the `Relationships` struct of a `ResourceObjectDescription`. - -In addition to identifying resource objects by Id and type, `Relationships` can contain `Meta` or `Links` that follow the same rules as [`Meta`](#jsonapimeta) and [`Links`](#jsonapilinks) elsewhere in the JSON API Document. - -To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire `ToOneRelationship` or `ToManyRelationship` optional. However, this is not recommended because you can also represent optional relationships as nullable which means the key is always present. A `ToManyRelationship` can naturally represent the absence of related values with an empty array, so `ToManyRelationship` does not support nullability at all. A `ToOneRelationship` can be marked as nullable (i.e. the value could be either `null` or a resource identifier) like this: -```swift -let nullableRelative: ToOneRelationship -``` - -A `ResourceObject` that does not have relationships can be described by adding the following to a `ResourceObjectDescription`: -```swift -typealias Relationships = NoRelationships -``` - -`Relationship` values boil down to `Ids` of other resource objects. To access the `Id` of a related `ResourceObject`, you can use the custom `~>` operator with the `KeyPath` of the `Relationship` from which you want the `Id`. The friends of the above `Person` `ResourceObject` can be accessed as follows (type annotations for clarity): -```swift -let friendIds: [Person.Identifier] = person ~> \.friends -``` - -### `JSONAPI.Attributes` - -The `Attributes` of a `ResourceObjectDescription` can contain any JSON encodable/decodable types as long as they are wrapped in an `Attribute`, `ValidatedAttribute`, or `TransformedAttribute` `struct`. - -To describe an attribute that may be omitted (i.e. the key might not even be in the JSON object), you make the entire `Attribute` optional: -```swift -let optionalAttribute: Attribute? -``` - -To describe an attribute that is expected to exist but might have a `null` value, you make the value within the `Attribute` optional: -```swift -let nullableAttribute: Attribute -``` - -A resource object that does not have attributes can be described by adding the following to an `ResourceObjectDescription`: -```swift -typealias Attributes = NoAttributes -``` - -As of Swift 5.1, `Attributes` can be accessed via dynamic member keypath lookup as follows: -```swift -let favoriteColor: String = person.favoriteColor -``` - -🗒 `Attributes` can also be accessed via the older `subscript` operator as follows: -```swift -let favoriteColor: String = person[\.favoriteColor] -``` - -In both cases you retain type-safety. It is best practice to pick an attribute access syntax and stick with it. At some point in the future the syntax deemed less desirable may be deprecated. - -#### `Transformer` - -Sometimes you need to use a type that does not encode or decode itself in the way you need to represent it as a serialized JSON object. For example, the Swift `Foundation` type `Date` can encode/decode itself to `Double` out of the box, but you might want to represent dates as ISO 8601 compliant `String`s instead. The Foundation library `JSONDecoder` has a setting to make this adjustment, but for the sake of an example, you could create a `Transformer`. - -A `Transformer` just provides one static function that transforms one type to another. You might define one for an ISO 8601 compliant `Date` like this: -```swift -enum ISODateTransformer: Transformer { - public static func transform(_ value: String) throws -> Date { - // parse Date out of input and return - } -} -``` - -Then you define the attribute as a `TransformedAttribute` instead of an `Attribute`: -```swift -let date: TransformedAttribute -``` - -Note that the first generic parameter of `TransformAttribute` is the type you expect to decode from JSON, not the type you want to end up with after transformation. - -If you make your `Transformer` a `ReversibleTransformer` then your life will be a bit easier when you construct `TransformedAttributes` because you have access to initializers for both the pre- and post-transformed value types. Continuing with the above example of a `ISODateTransformer`: -```swift -extension ISODateTransformer: ReversibleTransformer { - public static func reverse(_ value: Date) throws -> String { - // serialize Date to a String - } -} - -let exampleAttribute = try? TransformedAttribute(transformedValue: Date()) -let otherAttribute = try? TransformedAttribute(rawValue: "2018-12-01 09:06:41 +0000") -``` - -#### `Validator` - -You can also creator `Validators` and `ValidatedAttribute`s. A `Validator` is just a `Transformer` that by convention does not perform a transformation. It simply `throws` if an attribute value is invalid. - -#### Computed `Attribute` - -You can add computed properties to your `ResourceObjectDescription.Attributes` struct if you would like to expose attributes that are not explicitly represented by the JSON. These computed properties do not have to be wrapped in `Attribute`, `ValidatedAttribute`, or `TransformedAttribute`. This allows computed attributes to be of types that are not `Codable`. Here's an example of how you might take the `person.name` attribute from the example above and create a `fullName` computed property. - -```swift -public var fullName: Attribute { - return name.map { $0.joined(separator: " ") } -} -``` - -If your computed property is wrapped in a `AttributeType` then you can still use the default subscript operator to access it (as would be the case with the `person.fullName` example above). However, if you add a property to the `Attributes` `struct` that is not wrapped in an `AttributeType`, you must either access it from its full path (`person.attributes.newThing`) or with the "direct" subscript accessor (`person[direct: \.newThing]`). This keeps the subscript access unambiguous enough for the compiler to be helpful prior to explicitly casting, comparing, or storing the result. - -### Copying/Mutating `ResourceObjects` -`ResourceObject` is a value type, so copying is its default behavior. There are two common mutations you might want to make when copying a `ResourceObject`: -1. Assigning a new `Identifier` to the copy of an identified `ResourceObject`. -2. Assigning a new `Identifier` to the copy of an unidentified `ResourceObject`. - -The above can be accomplished with code like the following: - -```swift -// use case 1 -let person1 = person.withNewIdentifier() - -// use case 2 -let newlyIdentifiedPerson1 = unidentifiedPerson.identified(byType: String.self) - -let newlyIdentifiedPerson2 = unidentifiedPerson.identified(by: "2232") -``` - -### `JSONAPI.Document` - -The entirety of a JSON API request or response is encoded or decoded from- or to a `Document`. As an example, a JSON API response containing one `Person` and no included resource objects could be decoded as follows: -```swift -let decoder = JSONDecoder() - -let responseStructure = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError>.self - -let document = try decoder.decode(responseStructure, from: data) -``` - -A JSON API Document is guaranteed by the **SPEC** to be "data", "metadata", or "errors." If it is "data", it may also contain "metadata" and/or other "included" resources. If it is "errors," it may also contain "metadata." - -#### `ResourceBody` - -The first generic type of a `JSONAPIDocument` is a `ResourceBody`. This can either be a `SingleResourceBody` or a `ManyResourceBody`. You will find zero or one `PrimaryResource` values in a JSON API document that has a `SingleResourceBody` and you will find zero or more `PrimaryResource` values in a JSON API document that has a `ManyResourceBody`. You can use the `Poly` types (`Poly1` through `Poly6`) to specify that a `ResourceBody` will be one of a few different types of `ResourceObject`. These `Poly` types work in the same way as the `Include` types described below. - -If you expect a response to not have a "data" top-level key at all, then use `NoResourceBody` instead. - -##### nullable `PrimaryResource` - -If you expect a `SingleResourceBody` to sometimes come back `null`, you should make your `PrimaryResource` optional. If you do not make your `PrimaryResource` optional then a `null` primary resource will be considered an error when parsing the JSON. - -You cannot, however, use an optional `PrimaryResource` with a `ManyResourceBody` because the **SPEC** requires that an empty document in that case be represented by an empty array rather than `null`. - -#### `MetaType` - -The second generic type of a `JSONAPIDocument` is a `Meta`. This `Meta` follows the same rules as `Meta` at any other part of a JSON API Document. It is described below in its own section, but as an example, the JSON API document could contain the following pagination info in its meta entry: -```json -{ - "meta": { - "total": 100, - "limit": 50, - "offset": 50 - } -} -``` - -You would then create the following `Meta` type: -```swift -struct PageMetadata: JSONAPI.Meta { - let total: Int - let limit: Int - let offset: Int -} -``` - -You can always use `NoMetadata` if this JSON API feature is not needed. - -#### `LinksType` - -The third generic type of a `JSONAPIDocument` is a `Links` struct. `Links` are described in their own section [below](#jsonapilinks). - -#### `IncludeType` - -The fourth generic type of a `JSONAPIDocument` is an `Include`. This type controls which types of `ResourceObject` are looked for when decoding the "included" part of the JSON API document. If you do not expect any included resource objects to be in the document, `NoIncludes` is the way to go. The `JSONAPI` framework provides `Include`s for up to 10 types of included resource objects. These are named `Include1`, `Include2`, `Include3`, and so on. - -**IMPORTANT**: The number trailing "Include" in these type names does not indicate a number of included resource objects, it indicates a number of _types_ of included resource objects. `Include1` can be used to decode any number of included resource objects as long as all the resource objects are of the same _type_. - -To specify that we expect friends of a person to be included in the above example `JSONAPIDocument`, we would use `Include1` instead of `NoIncludes`. - -#### `APIDescriptionType` - -The fifth generic type of a `JSONAPIDocument` is an `APIDescription`. The type represents the "JSON:API Object" described by the **SPEC**. This type describes the highest version of the **SPEC** supported and can carry additional metadata to describe the API. - -You can specify this is not part of the document by using the `NoAPIDescription` type. - -You can describe the API by a version with no metadata by using `APIDescription`. - -You can supply any `JSONAPI.Meta` type as the metadata type of the API description. - -#### `Error` - -The final generic type of a `JSONAPIDocument` is the `Error`. - -You can either create an error type that can handle all the errors you expect your `JSONAPIDocument` to be able to encode/decode or use an out-of-box error type described here. As prescribed by the **SPEC**, these errors will be found under the root document key `errors`. - -##### `UnknownJSONAPIError` -The `UnknownJSONAPIError` type will always succeed in parsing errors but it will not give you any information about what error occurred. You will generally get more bang for your buck out of the next error type described. - -##### `BasicJSONAPIError` -The `BasicJSONAPIError` type will always succeed unless it is faced with an `id` field of an unexpected type, although it still "succeeds" in falling back to its `.unknown` case when that happens. This type extracts _most_ of the fields the **SPEC** describes [here](https://jsonapi.org/format/#error-objects). Because all of these fields are optional in the **SPEC**, they are optional on the `BasicJSONAPIError` type. You will have to create your own error type if you want to define certain fields as non-optional or parse metadata or links out of error objects. - -🗒Metadata and links are supported at the Document level for error responses, the are just not supported hanging off of the individual errors in the `errors` array of the response when using this error type. - -The `BasicJSONAPIError` type is generic on one thing: The type it expects for the `id` field. If you expect integer `ids` back, you use `BasicJSONAPIError`. The same can be done for `String` or any other type that is both `Codable` and `Equatable`. You can even employ something like `AnyCodable` from *Flight-School* as your id field type. If you only need to handle a small subset of possible `id` field types, you can also use the `Poly` library that is already a dependency of `JSONAPI`. For example, you might expect a mix of `String` and `Int` ids for some reason: `BasicJSONAPIError>`. - -The two easiest ways to access the available properties of an error response are under the `payload` property of the error (this property is `nil` if the error was parsed as `.unknown`) or by asking the error for its `definedFields` dictionary. - -As an example, let's say you have the following `Document` type that is destined for errors: -```swift -typealias ErrorDoc = JSONAPI.Document> -``` -And you've parsed an error response -```swift -let errorResponse = try! JSONDecoder().decode(ErrorDoc.self, from: mockErrorData) -``` -You can get at the `Document` body and errors in a couple of different ways, but for one you can switch on the body: -```swift -switch errorResponse.body { -case .data: - print("cool, data!") - -case .errors(let errors, let meta, let links): - let errorDetails = errors.compactMap { $0.payload?.detail } - - print("error details: \(errorDetails)") -} -``` - -##### `GenericJSONAPIError` -This type makes it simple to use your own error payload structures as `JSONAPIError` types. Simply define a `Codable` and `Equatable` struct and then use `GenericJSONAPIError` as the error type for a `Document`. - -### `JSONAPI.Meta` - -A `Meta` struct is totally open-ended. It is described by the **SPEC** as a place to put any information that does not fit into the standard JSON API Document structure anywhere else. - -You can specify `NoMetadata` if the part of the document being described should not contain any `Meta`. - -If you need to support metadata with structure that is not pre-determined, consider an "Any Codable" type such as that found at https://github.com/Flight-School/AnyCodable. - -### `JSONAPI.Links` - -A `Links` struct must contain only `Link` properties. Each `Link` property can either be a `URL` or a `URL` and some `Meta`. Each part of the document has some suggested common `Links` to include but generally any link can be included. - -You can specify `NoLinks` if the part of the document being described should not contain any `Links`. - -### `JSONAPI.RawIdType` - -If you want to create new `JSONAPI.ResourceObject` values and assign them Ids then you will need to conform at least one type to `CreatableRawIdType`. Doing so is easy; here are two example conformances for `UUID` and `String` (via `UUID`): -```swift -extension UUID: CreatableRawIdType { - public static func unique() -> UUID { - return UUID() - } -} - -extension String: CreatableRawIdType { - public static func unique() -> String { - return UUID().uuidString - } -} -``` - -### Sparse Fieldsets -Sparse Fieldsets are currently supported when encoding only. When decoding, Sparse Fieldsets become tricker to support under the current types this library uses and it is assumed that clients will request one or maybe two sparse fieldset combinations for any given model at most so it can simply define the `JSONAPI` models needed to decode those subsets of all possible fields. A server, on the other hand, likely needs to support arbitrary combinations of sparse fieldsets and this library provides a mechanism for encoding those sparse fieldsets without too much extra footwork. - -You can use sparse fieldsets on the primary resources(s) _and_ includes of a `JSONAPI.Document`. - -There is a sparse fieldsets example included with this repository as a Playground page. - -#### Supporting Sparse Fieldset Encoding -1. The `JSONAPI` `ResourceObjectDescription`'s `Attributes` struct must conform to `JSONAPI.SparsableAttributes` rather than `JSONAPI.Attributes`. -2. The `JSONAPI` `ResourceObjectDescription`'s `Attributes` struct must contain a `CodingKeys` enum that conforms to `JSONAPI.SparsableCodingKey` instead of `Swift.CodingKey`. -3. `typealiases` you may have created for `JSONAPI.Document` that allow you to decode Documents will not support the "encode-only" nature of sparse fieldsets. See the next section for `typealias` comparisons. -4. To create a sparse fieldset from a `ResourceObject` just call its `sparse(with: fields)` method and pass an array of `Attributes.CodingKeys` values you would like included in the encoding. -5. Initialize and encode a `Document` containing one or more sparse or full primary resource(s) and any number of sparse or full includes. - -#### Sparse Fieldset `typealias` comparisons -You might have found a `typealias` like the following for encoding/decoding `JSONAPI.Document`s (note the primary resource body is a `JSONAPI.ResourceBody`): -```swift -typealias Document = JSONAPI.Document> -``` - -In order to support sparse fieldsets (which are encode-only), the following companion `typealias` would be useful (note the primary resource body is a `JSONAPI.EncodableResourceBody`): -```swift -typealias SparseDocument = JSONAPI.Document> -``` - -### Replacing and Tapping Attributes/Relationships -When you are working with an immutable Resource Object, it can be useful to replace its attributes or relationships. As a client, you might receive a resource from the server, update something, and then send the server a PATCH request. - -`ResourceObject` is immutable, but you can create a new copy of a `ResourceObject` having updated attributes or relationships. - -#### Tapping -If your `Attributes` or `Relationships` struct is mutable (i.e. its properties are `var`s) then you may find `ResourceObject`'s `tappingAttributes()` and `tappingRelationships()` functions useful. For both, you pass a function that takes an `inout` copy of the respective object or value that you can mutate. The mutated value is then used to create a new `ResourceObject`. - -For example, to take a hypothetical `Dog` resource object and change the name attribute: -```swift -let resourceObject = Dog(...) - -let newResourceObject = resourceObject - .tappingAttributes { $0.name = .init(value: "Charlie") } -``` - -#### Replacing -If your `Attributes` or `Relationships` struct is immutable (i.e. its properties are `let`s) then you may find `ResourceObject`'s `replacingAttributes()` and `replacingRelationships()` functions useful. For both, you pass a function that takes the current attributes or relationships and you return a new value. The new value is then used to create a new `ResourceObject`. - -For example, to take a hypothetical `Dog` resource object and change the name attribute: -```swift -let resourceObject = Dog(...) - -let newResourceObject = resourceObject - .replacingAttributes { _ in - return Dog.Attributes(name: .init(value: "Charlie")) -} -``` - -### Custom Attribute or Relationship Key Mapping -There is not anything special going on at the `JSONAPI.Attributes` and `JSONAPI.Relationships` levels, so you can easily provide custom key mappings by taking advantage of `Codable`'s `CodingKeys` pattern. Here are two models that will encode/decode equivalently but offer different naming in your codebase: -```swift -public enum ResourceObjectDescription1: JSONAPI.ResourceObjectDescription { - public static var jsonType: String { return "entity" } - - public struct Attributes: JSONAPI.Attributes { - public let coolProperty: Attribute - } - - public typealias Relationships = NoRelationships -} - -public enum ResourceObjectDescription2: JSONAPI.ResourceObjectDescription { - public static var jsonType: String { return "entity" } - - public struct Attributes: JSONAPI.Attributes { - public let wholeOtherThing: Attribute - - enum CodingKeys: String, CodingKey { - case wholeOtherThing = "coolProperty" - } - } - - public typealias Relationships = NoRelationships -} -``` - -### Custom Attribute Encode/Decode -You can safely provide your own encoding or decoding functions for your Attributes struct if you need to as long as you are careful that your encode operation correctly reverses your decode operation. Although this is generally not necessary, `AttributeType` provides a convenience method to make your decoding a bit less boilerplate ridden. This is what it looks like: -```swift -public enum ResourceObjectDescription1: JSONAPI.ResourceObjectDescription { - public static var jsonType: String { return "entity" } - - public struct Attributes: JSONAPI.Attributes { - public let property1: Attribute - public let property2: Attribute - public let property3: Attribute - - public let weirdThing: Attribute - - enum CodingKeys: String, CodingKey { - case property1 - case property2 - case property3 - } - } - - public typealias Relationships = NoRelationships -} - -extension ResourceObjectDescription1.Attributes { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - property1 = try .defaultDecoding(from: container, forKey: .property1) - property2 = try .defaultDecoding(from: container, forKey: .property2) - property3 = try .defaultDecoding(from: container, forKey: .property3) - - weirdThing = .init(value: "hello world") - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(property1, forKey: .property1) - try container.encode(property2, forKey: .property2) - try container.encode(property3, forKey: .property3) - } -} -``` - -### Meta-Attributes -This advanced feature may not ever be useful, but if you find yourself in the situation of dealing with an API that does not 100% follow the **SPEC** then you might find meta-attributes are just the thing to make your resource objects more natural to work with. - -Suppose, for example, you are presented with the unfortunate situation where a piece of information you need is only available as part of the `Id` of a resource object. Perhaps a user's `Id` is formatted "{integer}-{createdAt}" where "createdAt" is the unix timestamp when the user account was created. The following `UserDescription` will expose what you need as an attribute. Realistically, the following example code is still terrible for its error handling. Using a `Result` type and/or invariants would clean things up substantially. - -```swift -enum UserDescription: ResourceObjectDescription { - public static var jsonType: String { return "users" } - - struct Attributes: JSONAPI.Attributes { - var createdAt: (User) -> Date { - return { user in - let components = user.id.rawValue.split(separator: "-") - - guard components.count == 2 else { - assertionFailure() - return Date() - } - - let timestamp = TimeInterval(components[1]) - - guard let date = timestamp.map(Date.init(timeIntervalSince1970:)) else { - assertionFailure() - return Date() - } - - return date - } - } - } - - typealias Relationships = NoRelationships -} - -typealias User = JSONAPI.ResourceObject -``` - -Given a value `user` of the above resource object type, you can access the `createdAt` attribute just like you would any other: - -```swift -let createdAt = user.createdAt -``` - -This works because `createdAt` is defined in the form: `var {name}: ({ResourceObject}) -> {Value}` where `{ResourceObject}` is the `JSONAPI.ResourceObject` described by the `ResourceObjectDescription` containing the meta-attribute. - -### Meta-Relationships -This advanced feature may not ever be useful, but if you find yourself in the situation of dealing with an API that does not 100% follow the **SPEC** then you might find meta-relationships are just the thing to make your resource objects more natural to work with. - -Similarly to Meta-Attributes, Meta-Relationships allow you to represent non-compliant relationships as computed relationship properties. In the following example, a relationship is created from some attributes on the JSON model. - -```swift -enum UserDescription: ResourceObjectDescription { - public static var jsonType: String { return "users" } - - struct Attributes: JSONAPI.Attributes { - let friend_id: Attribute - } - - struct Relationships: JSONAPI.Relationships { - public var friend: (User) -> User.Identifier { - return { user in - return User.Identifier(rawValue: user.friend_id) - } - } - } -} - -typealias User = JSONAPI.ResourceObject -``` - -Given a value `user` of the above resource object type, you can access the `friend` relationship just like you would any other: - -```swift -let friendId = user ~> \.friend -``` - -This works because `friend` is defined in the form: `var {name}: ({ResourceObject}) -> {Identifier}` where `{ResourceObject}` is the `JSONAPI.ResourceObject` described by the `ResourceObjectDescription` containing the meta-relationship. - ## Example The following serves as a sort of pseudo-example. It skips server/client implementation details not related to JSON:API but still gives a more complete picture of what an implementation using this framework might look like. You can play with this example code in the Playground provided with this repo. @@ -787,7 +169,7 @@ typealias ToManyRelationship = JSONAPI.ToManyRelationship = JSONAPI.Document> +typealias Document = JSONAPI.Document> // MARK: Entity Definitions @@ -928,14 +310,50 @@ print(response.author) ``` # JSONAPI+Testing -The `JSONAPI` framework is packaged with a test library to help you test your `JSONAPI` integration. +The `JSONAPI` framework is packaged with a test library to help you test your `JSONAPI` integration. The test library is called `JSONAPITesting`. You can see `JSONAPITesting` in action in the Playground included with the `JSONAPI` repository. -The test library is called `JSONAPITesting`. It provides literal expressibility for `Attribute`, `ToOneRelationship`, and `Id` in many situations so that you can easily write test `ResourceObject` values into your unit tests. It also provides a `check()` function for each `ResourceObject` type that can be used to catch problems with your `JSONAPI` structures that are not caught by Swift's type system. +## Literal Expressibility +Literal expressibility for `Attribute`, `ToOneRelationship`, and `Id` are provided so that you can easily write test `ResourceObject` values into your unit tests. -You can see the `JSONAPITesting` in action in the Playground included with the `JSONAPI` repository. +For example, you could create a mock `Author` (from the above example) as follows +```swift +let author = Author(id: "1234", // You can just use a String directly as an Id + attributes: .init(name: "Janice Bluff"), // The name Attribute does not need to be initialized, you just use a String directly. + relationships: .none, + meta: .none, + links: .none) +``` + +## Resource Object `check()` +The `ResourceObject` gets a `check()` function that can be used to catch problems with your `JSONAPI` structures that are not caught by Swift's type system. + +To catch malformed `JSONAPI.Attributes` and `JSONAPI.Relationships`, just call `check()` in your unit test functions: +```swift +func test_initAuthor() { + let author = Author(...) + Author.check(author) +} +``` + +## Comparisons +You can compare `Documents`, `ResourceObjects`, `Attributes`, etc. and get human-readable output using the `compare(to:)` methods included with `JSONAPITesting`. + +```swift +func test_articleResponse() { + let endToEndAPITestResponse: SingleArticleDocumentWithIncludes = ... + + let expectedResponse: SingleArticleDocumentWithIncludes = ... + + let comparison = endToEndAPITestResponse.compare(to: expectedResponse) + + XCTAssert(comparison.isSame, String(describing: comparison)) +} +``` # JSONAPI+Arbitrary -This library has moved into its own Package. See https://github.com/mattpolzin/JSONAPI-Arbitrary for more information. +The `JSONAPI+Arbitrary` library provides `SwiftCheck` `Arbitrary` conformance for many of teh `JSONAPI` types. + +See https://github.com/mattpolzin/JSONAPI-Arbitrary for more information. # JSONAPI+OpenAPI The `JSONAPI+OpenAPI` library generates OpenAPI compliant JSON Schema for models built with the `JSONAPI` library. If your Swift code is your preferred source of truth for API information, this is an easy way to document the response schemas of your API. diff --git a/Sources/JSONAPI/Document/APIDescription.swift b/Sources/JSONAPI/Document/APIDescription.swift index a0ba2ff..cb417a6 100644 --- a/Sources/JSONAPI/Document/APIDescription.swift +++ b/Sources/JSONAPI/Document/APIDescription.swift @@ -7,58 +7,58 @@ /// This is what the JSON API Spec calls the "JSON:API Object" public protocol APIDescriptionType: Codable, Equatable { - associatedtype Meta + associatedtype Meta } /// This is what the JSON API Spec calls the "JSON:API Object" public struct APIDescription: APIDescriptionType { - public let version: String - public let meta: Meta + public let version: String + public let meta: Meta - public init(version: String, meta: Meta) { - self.version = version - self.meta = meta - } + public init(version: String, meta: Meta) { + self.version = version + self.meta = meta + } } /// Can be used as `APIDescriptionType` for Documents that do not /// have any API Description (a.k.a. "JSON:API Object"). public struct NoAPIDescription: APIDescriptionType, CustomStringConvertible { - public typealias Meta = NoMetadata + public typealias Meta = NoMetadata - public init() {} + public init() {} - public static var none: NoAPIDescription { return .init() } + public static var none: NoAPIDescription { return .init() } - public var description: String { return "No JSON:API Object" } + public var description: String { return "No JSON:API Object" } } extension APIDescription { - private enum CodingKeys: String, CodingKey { - case version - case meta - } + private enum CodingKeys: String, CodingKey { + case version + case meta + } - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) - // The spec says that if a version is not specified, it should be assumed to be at least 1.0 - version = (try? container.decode(String.self, forKey: .version)) ?? "1.0" + // The spec says that if a version is not specified, it should be assumed to be at least 1.0 + version = (try? container.decode(String.self, forKey: .version)) ?? "1.0" - if let metaVal = NoMetadata() as? Meta { - meta = metaVal - } else { - meta = try container.decode(Meta.self, forKey: .meta) - } - } + if let metaVal = NoMetadata() as? Meta { + meta = metaVal + } else { + meta = try container.decode(Meta.self, forKey: .meta) + } + } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(version, forKey: .version) + try container.encode(version, forKey: .version) - if Meta.self != NoMetadata.self { - try container.encode(meta, forKey: .meta) - } - } + if Meta.self != NoMetadata.self { + try container.encode(meta, forKey: .meta) + } + } } diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index 5c8160b..a266216 100644 --- a/Sources/JSONAPI/Document/Document.swift +++ b/Sources/JSONAPI/Document/Document.swift @@ -1,5 +1,5 @@ // -// JSONAPIDocument.swift +// Document.swift // JSONAPI // // Created by Mathew Polzin on 11/5/18. @@ -7,333 +7,350 @@ import Poly -/// An `EncodableJSONAPIDocument` supports encoding but not decoding. -/// It is actually more restrictive than `JSONAPIDocument` which supports both -/// encoding and decoding. -public protocol EncodableJSONAPIDocument: Equatable, Encodable { +public protocol DocumentBodyDataContext { associatedtype PrimaryResourceBody: JSONAPI.EncodableResourceBody associatedtype MetaType: JSONAPI.Meta associatedtype LinksType: JSONAPI.Links associatedtype IncludeType: JSONAPI.Include - associatedtype APIDescription: APIDescriptionType - associatedtype Error: JSONAPIError - - typealias Body = Document.Body - - var body: Body { get } } -/// A `JSONAPIDocument` supports encoding and decoding of a JSON:API +public protocol DocumentBodyContext: DocumentBodyDataContext { + associatedtype Error: JSONAPIError + associatedtype BodyData: DocumentBodyData + where + BodyData.PrimaryResourceBody == PrimaryResourceBody, + BodyData.MetaType == MetaType, + BodyData.LinksType == LinksType, + BodyData.IncludeType == IncludeType +} + +public protocol DocumentBodyData: DocumentBodyDataContext { + /// The document's primary resource body + /// (contains one or many resource objects) + var primary: PrimaryResourceBody { get } + + /// The document's included objects + var includes: Includes { get } + var meta: MetaType { get } + var links: LinksType { get } +} + +public protocol DocumentBody: DocumentBodyContext { + /// `true` if the document represents one or more errors. `false` if the + /// document represents JSON:API data and/or metadata. + var isError: Bool { get } + + /// Get all errors in the document, if any. + /// + /// `nil` if the Document is _not_ an error response. Otherwise, + /// an array containing all errors. + var errors: [Error]? { get } + + /// Get the document data + /// + /// `nil` if the Document is an error response. Otherwise, + /// a structure containing the primary resource, any included + /// resources, metadata, and links. + var data: BodyData? { get } + + /// Quick access to the `data`'s primary resource. + /// + /// `nil` if the Document is an error document. Otherwise, + /// the primary resource body, which will contain zero/one, one/many + /// resources dependening on the `PrimaryResourceBody` type. + /// + /// See `SingleResourceBody` and `ManyResourceBody`. + var primaryResource: PrimaryResourceBody? { get } + + /// Quick access to the `data`'s includes. + /// + /// `nil` if the Document is an error document. Otherwise, + /// zero or more includes. + var includes: Includes? { get } + + /// The metadata for the error or data document or `nil` if + /// no metadata is found. + var meta: MetaType? { get } + + /// The links for the error or data document or `nil` if + /// no links are found. + var links: LinksType? { get } +} + +/// An `EncodableJSONAPIDocument` supports encoding but not decoding. +/// It is actually more restrictive than `JSONAPIDocument` which supports both +/// encoding and decoding. +public protocol EncodableJSONAPIDocument: Equatable, Encodable, DocumentBodyContext { + associatedtype APIDescription: APIDescriptionType + associatedtype Body: DocumentBody + where + Body.PrimaryResourceBody == PrimaryResourceBody, + Body.MetaType == MetaType, + Body.LinksType == LinksType, + Body.IncludeType == IncludeType, + Body.Error == Error, + Body.BodyData == BodyData + + /// The Body of the Document. This body is either one or more errors + /// with links and metadata attempted to parse but not guaranteed or + /// it is a successful data struct containing all the primary and + /// included resources, the metadata, and the links that this + /// document type specifies. + var body: Body { get } + + /// The JSON API Spec calls this the JSON:API Object. It contains version + /// and metadata information about the API itself. + var apiDescription: APIDescription { get } +} + +/// A `CodableJSONAPIDocument` supports encoding and decoding of a JSON:API /// compliant Document. -public protocol JSONAPIDocument: EncodableJSONAPIDocument, Decodable where PrimaryResourceBody: JSONAPI.ResourceBody, IncludeType: Decodable {} +public protocol CodableJSONAPIDocument: EncodableJSONAPIDocument, Decodable where PrimaryResourceBody: JSONAPI.CodableResourceBody, IncludeType: Decodable {} /// A JSON API Document represents the entire body /// of a JSON API request or the entire body of /// a JSON API response. +/// /// Note that this type uses Camel case. If your /// API uses snake case, you will want to use /// a conversion such as the one offerred by the /// Foundation JSONEncoder/Decoder: `KeyDecodingStrategy` public struct Document: EncodableJSONAPIDocument { - public typealias Include = IncludeType + public typealias Include = IncludeType + public typealias BodyData = Body.Data - /// The JSON API Spec calls this the JSON:API Object. It contains version - /// and metadata information about the API itself. - public let apiDescription: APIDescription + // See `EncodableJSONAPIDocument` for documentation. + public let apiDescription: APIDescription - /// The Body of the Document. This body is either one or more errors - /// with links and metadata attempted to parse but not guaranteed or - /// it is a successful data struct containing all the primary and - /// included resources, the metadata, and the links that this - /// document type specifies. - public let body: Body - - public enum Body: Equatable { - case errors([Error], meta: MetaType?, links: LinksType?) - case data(Data) + // See `EncodableJSONAPIDocument` for documentation. + public let body: Body - public struct Data: Equatable { + public init(apiDescription: APIDescription, + errors: [Error], + meta: MetaType? = nil, + links: LinksType? = nil) { + body = .errors(errors, meta: meta, links: links) + self.apiDescription = apiDescription + } + + public init(apiDescription: APIDescription, + body: PrimaryResourceBody, + includes: Includes, + meta: MetaType, + links: LinksType) { + self.body = .data( + .init( + primary: body, + includes: includes, + meta: meta, + links: links + ) + ) + self.apiDescription = apiDescription + } +} + +extension Document { + public enum Body: DocumentBody, Equatable { + case errors([Error], meta: MetaType?, links: LinksType?) + case data(Data) + + public typealias BodyData = Data + + public struct Data: DocumentBodyData, Equatable { /// The document's Primary Resource object(s) - public let primary: PrimaryResourceBody + public let primary: PrimaryResourceBody /// The document's included objects - public let includes: Includes - public let meta: MetaType - public let links: LinksType + public let includes: Includes + public let meta: MetaType + public let links: LinksType - public init(primary: PrimaryResourceBody, includes: Includes, meta: MetaType, links: LinksType) { - self.primary = primary - self.includes = includes - self.meta = meta - self.links = links - } - } + public init(primary: PrimaryResourceBody, includes: Includes, meta: MetaType, links: LinksType) { + self.primary = primary + self.includes = includes + self.meta = meta + self.links = links + } + } /// `true` if the document represents one or more errors. `false` if the /// document represents JSON:API data and/or metadata. - public var isError: Bool { - guard case .errors = self else { return false } - return true - } + public var isError: Bool { + guard case .errors = self else { return false } + return true + } - public var errors: [Error]? { - guard case let .errors(errors, meta: _, links: _) = self else { return nil } - return errors - } + public var errors: [Error]? { + guard case let .errors(errors, meta: _, links: _) = self else { return nil } + return errors + } - public var data: Data? { - guard case let .data(data) = self else { return nil } - return data - } - - public var primaryResource: PrimaryResourceBody? { - guard case let .data(data) = self else { return nil } - return data.primary - } + public var data: Data? { + guard case let .data(data) = self else { return nil } + return data + } - public var includes: Includes? { - guard case let .data(data) = self else { return nil } - return data.includes - } + public var primaryResource: PrimaryResourceBody? { + guard case let .data(data) = self else { return nil } + return data.primary + } - public var meta: MetaType? { - switch self { - case .data(let data): - return data.meta - case .errors(_, meta: let metadata?, links: _): - return metadata - default: - return nil - } - } + public var includes: Includes? { + guard case let .data(data) = self else { return nil } + return data.includes + } - public var links: LinksType? { - switch self { - case .data(let data): - return data.links - case .errors(_, meta: _, links: let links?): - return links - default: - return nil - } - } - } + public var meta: MetaType? { + switch self { + case .data(let data): + return data.meta + case .errors(_, meta: let metadata?, links: _): + return metadata + default: + return nil + } + } - public init(apiDescription: APIDescription, errors: [Error], meta: MetaType? = nil, links: LinksType? = nil) { - body = .errors(errors, meta: meta, links: links) - self.apiDescription = apiDescription - } - - public init(apiDescription: APIDescription, - body: PrimaryResourceBody, - includes: Includes, - meta: MetaType, - links: LinksType) { - self.body = .data(.init(primary: body, includes: includes, meta: meta, links: links)) - self.apiDescription = apiDescription - } + public var links: LinksType? { + switch self { + case .data(let data): + return data.links + case .errors(_, meta: _, links: let links?): + return links + default: + return nil + } + } + } } -/* -extension Document where IncludeType == NoIncludes { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, meta: MetaType, links: LinksType) { - self.init(apiDescription: apiDescription, body: body, includes: .none, meta: meta, links: links) - } +extension Document.Body.Data where PrimaryResourceBody: ResourceBodyAppendable { + public func merging(_ other: Document.Body.Data, + combiningMetaWith metaMerge: (MetaType, MetaType) -> MetaType, + combiningLinksWith linksMerge: (LinksType, LinksType) -> LinksType) -> Document.Body.Data { + return Document.Body.Data(primary: primary.appending(other.primary), + includes: includes.appending(other.includes), + meta: metaMerge(meta, other.meta), + links: linksMerge(links, other.links)) + } } -extension Document where MetaType == NoMetadata { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, includes: Includes, links: LinksType) { - self.init(apiDescription: apiDescription, body: body, includes: includes, meta: .none, links: links) - } -} - -extension Document where LinksType == NoLinks { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, includes: Includes, meta: MetaType) { - self.init(apiDescription: apiDescription, body: body, includes: includes, meta: meta, links: .none) - } -} - -extension Document where APIDescription == NoAPIDescription { - public init(body: PrimaryResourceBody, includes: Includes, meta: MetaType, links: LinksType) { - self.init(apiDescription: .none, body: body, includes: includes, meta: meta, links: links) - } -} - -extension Document where IncludeType == NoIncludes, LinksType == NoLinks { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, meta: MetaType) { - self.init(apiDescription: apiDescription, body: body, meta: meta, links: .none) - } -} - -extension Document where IncludeType == NoIncludes, MetaType == NoMetadata { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, links: LinksType) { - self.init(apiDescription: apiDescription, body: body, meta: .none, links: links) - } -} - -extension Document where IncludeType == NoIncludes, APIDescription == NoAPIDescription { - public init(body: PrimaryResourceBody, meta: MetaType, links: LinksType) { - self.init(apiDescription: .none, body: body, meta: meta, links: links) - } -} - -extension Document where MetaType == NoMetadata, LinksType == NoLinks { - public init(apiDescription: APIDescription, body: PrimaryResourceBody, includes: Includes) { - self.init(apiDescription: apiDescription, body: body, includes: includes, links: .none) - } -} - -extension Document where MetaType == NoMetadata, APIDescription == NoAPIDescription { - public init(body: PrimaryResourceBody, includes: Includes, links: LinksType) { - self.init(apiDescription: .none, body: body, includes: includes, links: links) - } -} - -extension Document where IncludeType == NoIncludes, MetaType == NoMetadata, LinksType == NoLinks { - public init(apiDescription: APIDescription, body: PrimaryResourceBody) { - self.init(apiDescription: apiDescription, body: body, includes: .none) - } -} - -extension Document where MetaType == NoMetadata, LinksType == NoLinks, APIDescription == NoAPIDescription { - public init(body: PrimaryResourceBody, includes: Includes) { - self.init(apiDescription: .none, body: body, includes: includes) - } -} - -extension Document where IncludeType == NoIncludes, MetaType == NoMetadata, LinksType == NoLinks, APIDescription == NoAPIDescription { - public init(body: PrimaryResourceBody) { - self.init(apiDescription: .none, body: body) - } -} -*/ - -extension Document.Body.Data where PrimaryResourceBody: Appendable { - public func merging(_ other: Document.Body.Data, - combiningMetaWith metaMerge: (MetaType, MetaType) -> MetaType, - combiningLinksWith linksMerge: (LinksType, LinksType) -> LinksType) -> Document.Body.Data { - return Document.Body.Data(primary: primary.appending(other.primary), - includes: includes.appending(other.includes), - meta: metaMerge(meta, other.meta), - links: linksMerge(links, other.links)) - } -} - -extension Document.Body.Data where PrimaryResourceBody: Appendable, MetaType == NoMetadata, LinksType == NoLinks { - public func merging(_ other: Document.Body.Data) -> Document.Body.Data { - return merging(other, - combiningMetaWith: { _, _ in .none }, - combiningLinksWith: { _, _ in .none }) - } +extension Document.Body.Data where PrimaryResourceBody: ResourceBodyAppendable, MetaType == NoMetadata, LinksType == NoLinks { + public func merging(_ other: Document.Body.Data) -> Document.Body.Data { + return merging(other, + combiningMetaWith: { _, _ in .none }, + combiningLinksWith: { _, _ in .none }) + } } extension Document where IncludeType == NoIncludes { - /// Create a new Document with the given includes. - public func including(_ includes: Includes) -> Document { - // Note that if IncludeType is NoIncludes, then we allow anything - // to be included, but if IncludeType already specifies a type - // of thing to be expected then we lock that down. - // See: Document.including() where IncludeType: _Poly1 - switch body { - case .data(let data): - return .init(apiDescription: apiDescription, - body: data.primary, - includes: includes, - meta: data.meta, - links: data.links) - case .errors(let errors, meta: let meta, links: let links): - return .init(apiDescription: apiDescription, - errors: errors, - meta: meta, - links: links) - } - } + /// Create a new Document with the given includes. + public func including(_ includes: Includes) -> Document { + // Note that if IncludeType is NoIncludes, then we allow anything + // to be included, but if IncludeType already specifies a type + // of thing to be expected then we lock that down. + // See: Document.including() where IncludeType: _Poly1 + switch body { + case .data(let data): + return .init(apiDescription: apiDescription, + body: data.primary, + includes: includes, + meta: data.meta, + links: data.links) + case .errors(let errors, meta: let meta, links: let links): + return .init(apiDescription: apiDescription, + errors: errors, + meta: meta, + links: links) + } + } } // extending where _Poly1 means all non-zero _Poly arities are included extension Document where IncludeType: _Poly1 { - /// Create a new Document adding the given includes. This does not - /// remove existing includes; it is additive. - public func including(_ includes: Includes) -> Document { - // Note that if IncludeType is NoIncludes, then we allow anything - // to be included, but if IncludeType already specifies a type - // of thing to be expected then we lock that down. - // See: Document.including() where IncludeType == NoIncludes - switch body { - case .data(let data): - return .init(apiDescription: apiDescription, - body: data.primary, - includes: data.includes + includes, - meta: data.meta, - links: data.links) - case .errors(let errors, meta: let meta, links: let links): - return .init(apiDescription: apiDescription, - errors: errors, - meta: meta, - links: links) - } - } + /// Create a new Document adding the given includes. This does not + /// remove existing includes; it is additive. + public func including(_ includes: Includes) -> Document { + // Note that if IncludeType is NoIncludes, then we allow anything + // to be included, but if IncludeType already specifies a type + // of thing to be expected then we lock that down. + // See: Document.including() where IncludeType == NoIncludes + switch body { + case .data(let data): + return .init(apiDescription: apiDescription, + body: data.primary, + includes: data.includes + includes, + meta: data.meta, + links: data.links) + case .errors(let errors, meta: let meta, links: let links): + return .init(apiDescription: apiDescription, + errors: errors, + meta: meta, + links: links) + } + } } // MARK: - Codable extension Document { - private enum RootCodingKeys: String, CodingKey { - case data - case errors - case included - case meta - case links - case jsonapi - } + private enum RootCodingKeys: String, CodingKey { + case data + case errors + case included + case meta + case links + case jsonapi + } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: RootCodingKeys.self) + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RootCodingKeys.self) - switch body { - case .errors(let errors, meta: let meta, links: let links): - var errContainer = container.nestedUnkeyedContainer(forKey: .errors) + switch body { + case .errors(let errors, meta: let meta, links: let links): + var errContainer = container.nestedUnkeyedContainer(forKey: .errors) - for error in errors { - try errContainer.encode(error) - } + for error in errors { + try errContainer.encode(error) + } - if MetaType.self != NoMetadata.self, - let metaVal = meta { - try container.encode(metaVal, forKey: .meta) - } + if MetaType.self != NoMetadata.self, + let metaVal = meta { + try container.encode(metaVal, forKey: .meta) + } - if LinksType.self != NoLinks.self, - let linksVal = links { - try container.encode(linksVal, forKey: .links) - } + if LinksType.self != NoLinks.self, + let linksVal = links { + try container.encode(linksVal, forKey: .links) + } - case .data(let data): - try container.encode(data.primary, forKey: .data) + case .data(let data): + try container.encode(data.primary, forKey: .data) - if Include.self != NoIncludes.self { - try container.encode(data.includes, forKey: .included) - } + if Include.self != NoIncludes.self { + try container.encode(data.includes, forKey: .included) + } - if MetaType.self != NoMetadata.self { - try container.encode(data.meta, forKey: .meta) - } + if MetaType.self != NoMetadata.self { + try container.encode(data.meta, forKey: .meta) + } - if LinksType.self != NoLinks.self { - try container.encode(data.links, forKey: .links) - } - } + if LinksType.self != NoLinks.self { + try container.encode(data.links, forKey: .links) + } + } - if APIDescription.self != NoAPIDescription.self { - try container.encode(apiDescription, forKey: .jsonapi) - } - } + if APIDescription.self != NoAPIDescription.self { + try container.encode(apiDescription, forKey: .jsonapi) + } + } } -extension Document: Decodable, JSONAPIDocument where PrimaryResourceBody: ResourceBody, IncludeType: Decodable { +extension Document: Decodable, CodableJSONAPIDocument where PrimaryResourceBody: CodableResourceBody, IncludeType: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: RootCodingKeys.self) - if let noData = NoAPIDescription() as? APIDescription { - apiDescription = noData + if let noAPIDescription = NoAPIDescription() as? APIDescription { + apiDescription = noAPIDescription } else { apiDescription = try container.decode(APIDescription.self, forKey: .jsonapi) } @@ -372,18 +389,30 @@ extension Document: Decodable, JSONAPIDocument where PrimaryResourceBody: Resour if let noData = NoResourceBody() as? PrimaryResourceBody { data = noData } else { - data = try container.decode(PrimaryResourceBody.self, forKey: .data) + do { + data = try container.decode(PrimaryResourceBody.self, forKey: .data) + } catch let error as ResourceObjectDecodingError { + throw DocumentDecodingError(error) + } catch let error as ManyResourceBodyDecodingError { + throw DocumentDecodingError(error) + } catch let error as DecodingError { + throw DocumentDecodingError(error) + ?? error + } } - let maybeIncludes = try container.decodeIfPresent(Includes.self, forKey: .included) - - // TODO come back to this and make robust + let maybeIncludes: Includes? + do { + maybeIncludes = try container.decodeIfPresent(Includes.self, forKey: .included) + } catch let error as IncludesDecodingError { + throw DocumentDecodingError(error) + } guard let metaVal = meta else { - throw JSONAPIEncodingError.missingOrMalformedMetadata + throw JSONAPICodingError.missingOrMalformedMetadata(path: decoder.codingPath) } guard let linksVal = links else { - throw JSONAPIEncodingError.missingOrMalformedLinks + throw JSONAPICodingError.missingOrMalformedLinks(path: decoder.codingPath) } body = .data(.init(primary: data, includes: maybeIncludes ?? Includes.none, meta: metaVal, links: linksVal)) @@ -393,26 +422,26 @@ extension Document: Decodable, JSONAPIDocument where PrimaryResourceBody: Resour // MARK: - CustomStringConvertible extension Document: CustomStringConvertible { - public var description: String { - return "Document(\(String(describing: body)))" - } + public var description: String { + return "Document(\(String(describing: body)))" + } } extension Document.Body: CustomStringConvertible { - public var description: String { - switch self { - case .errors(let errors, meta: let meta, links: let links): - return "errors: \(String(describing: errors)), meta: \(String(describing: meta)), links: \(String(describing: links))" - case .data(let data): - return String(describing: data) - } - } + public var description: String { + switch self { + case .errors(let errors, meta: let meta, links: let links): + return "errors: \(String(describing: errors)), meta: \(String(describing: meta)), links: \(String(describing: links))" + case .data(let data): + return String(describing: data) + } + } } extension Document.Body.Data: CustomStringConvertible { - public var description: String { - return "primary: \(String(describing: primary)), includes: \(String(describing: includes)), meta: \(String(describing: meta)), links: \(String(describing: links))" - } + public var description: String { + return "primary: \(String(describing: primary)), includes: \(String(describing: includes)), meta: \(String(describing: meta)), links: \(String(describing: links))" + } } // MARK: - Error and Success Document Types @@ -420,8 +449,9 @@ extension Document.Body.Data: CustomStringConvertible { extension Document { /// A Document that only supports error bodies. This is useful if you wish to pass around a /// Document type but you wish to constrain it to error values. - @dynamicMemberLookup public struct ErrorDocument: EncodableJSONAPIDocument { + public typealias BodyData = Document.BodyData + public var body: Document.Body { return document.body } private let document: Document @@ -436,19 +466,43 @@ extension Document { try container.encode(document) } - public subscript(dynamicMember path: KeyPath) -> T { - return document[keyPath: path] + /// The JSON API Spec calls this the JSON:API Object. It contains version + /// and metadata information about the API itself. + public var apiDescription: APIDescription { + return document.apiDescription + } + + /// Get all errors in the document, if any. + public var errors: [Error] { + return document.body.errors ?? [] + } + + /// The metadata for the error or data document or `nil` if + /// no metadata is found. + public var meta: MetaType? { + return document.body.meta + } + + /// The links for the error or data document or `nil` if + /// no links are found. + public var links: LinksType? { + return document.body.links } public static func ==(lhs: Document, rhs: ErrorDocument) -> Bool { return lhs == rhs.document } + + public static func ==(lhs: ErrorDocument, rhs: Document) -> Bool { + return lhs.document == rhs + } } /// A Document that only supports success bodies. This is useful if you wish to pass around a /// Document type but you wish to constrain it to success values. - @dynamicMemberLookup public struct SuccessDocument: EncodableJSONAPIDocument { + public typealias BodyData = Document.BodyData + public var body: Document.Body { return document.body } private let document: Document @@ -471,38 +525,84 @@ extension Document { try container.encode(document) } - public subscript(dynamicMember path: KeyPath) -> T { - return document[keyPath: path] + /// The JSON API Spec calls this the JSON:API Object. It contains version + /// and metadata information about the API itself. + public var apiDescription: APIDescription { + return document.apiDescription + } + + /// Get the document data + /// + /// `nil` if the Document is an error response. Otherwise, + /// a structure containing the primary resource, any included + /// resources, metadata, and links. + public var data: BodyData? { + return document.body.data + } + + /// Quick access to the `data`'s primary resource. + /// + /// `nil` if the Document is an error document. Otherwise, + /// the primary resource body, which will contain zero/one, one/many + /// resources dependening on the `PrimaryResourceBody` type. + /// + /// See `SingleResourceBody` and `ManyResourceBody`. + public var primaryResource: PrimaryResourceBody? { + return document.body.primaryResource + } + + /// Quick access to the `data`'s includes. + /// + /// `nil` if the Document is an error document. Otherwise, + /// zero or more includes. + public var includes: Includes? { + return document.body.includes + } + + /// The metadata for the error or data document or `nil` if + /// no metadata is found. + public var meta: MetaType? { + return document.body.meta + } + + /// The links for the error or data document or `nil` if + /// no links are found. + public var links: LinksType? { + return document.body.links } public static func ==(lhs: Document, rhs: SuccessDocument) -> Bool { return lhs == rhs.document } + + public static func ==(lhs: SuccessDocument, rhs: Document) -> Bool { + return lhs.document == rhs + } } } -extension Document.ErrorDocument: Decodable, JSONAPIDocument - where PrimaryResourceBody: ResourceBody, IncludeType: Decodable { +extension Document.ErrorDocument: Decodable, CodableJSONAPIDocument + where PrimaryResourceBody: CodableResourceBody, IncludeType: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() document = try container.decode(Document.self) guard document.body.isError else { - throw JSONAPIDocumentDecodingError.foundSuccessDocumentWhenExpectingError + throw DocumentDecodingError.foundSuccessDocumentWhenExpectingError } } } -extension Document.SuccessDocument: Decodable, JSONAPIDocument - where PrimaryResourceBody: ResourceBody, IncludeType: Decodable { +extension Document.SuccessDocument: Decodable, CodableJSONAPIDocument + where PrimaryResourceBody: CodableResourceBody, IncludeType: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() document = try container.decode(Document.self) guard !document.body.isError else { - throw JSONAPIDocumentDecodingError.foundErrorDocumentWhenExpectingSuccess + throw DocumentDecodingError.foundErrorDocumentWhenExpectingSuccess } } } diff --git a/Sources/JSONAPI/Document/DocumentDecodingError.swift b/Sources/JSONAPI/Document/DocumentDecodingError.swift index 912a8f8..2bcfa60 100644 --- a/Sources/JSONAPI/Document/DocumentDecodingError.swift +++ b/Sources/JSONAPI/Document/DocumentDecodingError.swift @@ -1,11 +1,73 @@ // -// DocumentDecodingErro.swift +// DocumentDecodingError.swift // // // Created by Mathew Polzin on 10/20/19. // -public enum JSONAPIDocumentDecodingError: Swift.Error { +public enum DocumentDecodingError: Swift.Error, Equatable { + case primaryResource(error: ResourceObjectDecodingError, idx: Int?) + case primaryResourceMissing + case primaryResourcesMissing + + case includes(error: IncludesDecodingError) + case foundErrorDocumentWhenExpectingSuccess case foundSuccessDocumentWhenExpectingError + + init(_ decodingError: ResourceObjectDecodingError) { + self = .primaryResource(error: decodingError, idx: nil) + } + + init(_ decodingError: ManyResourceBodyDecodingError) { + self = .primaryResource(error: decodingError.error, idx: decodingError.idx) + } + + init(_ decodingError: IncludesDecodingError) { + self = .includes(error: decodingError) + } + + init?(_ decodingError: DecodingError) { + switch decodingError { + case .valueNotFound(let type, let context) where Location(context) == .data && type is AbstractResourceObject.Type: + self = .primaryResourceMissing + case .valueNotFound(let type, let context) where Location(context) == .data && type == UnkeyedDecodingContainer.self: + self = .primaryResourcesMissing + default: + return nil + } + } + + private enum Location: Equatable { + case data + + init?(_ context: DecodingError.Context) { + guard context.codingPath.contains(where: { $0.stringValue == "data" }) else { + return nil + } + self = .data + } + } +} + +extension DocumentDecodingError: CustomStringConvertible { + public var description: String { + switch self { + case .primaryResource(error: let error, idx: let idx): + let idxString = idx.map { " \($0 + 1)" } ?? "" + return "Primary Resource\(idxString) failed to parse because \(error)" + case .primaryResourceMissing: + return "Primary Resource missing." + case .primaryResourcesMissing: + return "Primary Resources array missing." + + case .includes(error: let error): + return "\(error)" + + case .foundErrorDocumentWhenExpectingSuccess: + return "Expected a success document with a 'data' property but found an error document." + case .foundSuccessDocumentWhenExpectingError: + return "Expected an error document but found a success document with a 'data' property." + } + } } diff --git a/Sources/JSONAPI/Document/Includes.swift b/Sources/JSONAPI/Document/Includes.swift index b6d93e0..32f90ea 100644 --- a/Sources/JSONAPI/Document/Includes.swift +++ b/Sources/JSONAPI/Document/Includes.swift @@ -14,35 +14,35 @@ public typealias Include = EncodableJSONPoly /// /// If you have /// -/// `let includes: Includes> = ...` +/// let includes: Includes> = ... /// /// then you can access all `Thing1` included resources with /// -/// `let includedThings = includes[Thing1.self]` +/// let includedThings = includes[Thing1.self] public struct Includes: Encodable, Equatable { - public static var none: Includes { return .init(values: []) } - - let values: [I] - - public init(values: [I]) { - self.values = values - } + public static var none: Includes { return .init(values: []) } - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() + public let values: [I] - guard I.self != NoIncludes.self else { - throw JSONAPIEncodingError.illegalEncoding("Attempting to encode Include0, which should be represented by the absense of an 'included' entry altogether.") - } + public init(values: [I]) { + self.values = values + } - for value in values { - try container.encode(value) - } - } - - public var count: Int { - return values.count - } + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + guard I.self != NoIncludes.self else { + throw JSONAPICodingError.illegalEncoding("Attempting to encode Include0, which should be represented by the absense of an 'included' entry altogether.", path: encoder.codingPath) + } + + for value in values { + try container.encode(value) + } + } + + public var count: Int { + return values.count + } } extension Includes: Decodable where I: Decodable { @@ -56,8 +56,35 @@ extension Includes: Decodable where I: Decodable { } var valueAggregator = [I]() + var idx = 0 while !container.isAtEnd { - valueAggregator.append(try container.decode(I.self)) + do { + valueAggregator.append(try container.decode(I.self)) + idx = idx + 1 + } catch let error as PolyDecodeNoTypesMatchedError { + let errors: [ResourceObjectDecodingError] = error + .individualTypeFailures + .compactMap { decodingError in + switch decodingError.error { + case .typeMismatch(_, let context), + .valueNotFound(_, let context), + .keyNotFound(_, let context), + .dataCorrupted(let context): + return context.underlyingError as? ResourceObjectDecodingError + @unknown default: + return nil + } + } + guard errors.count == error.individualTypeFailures.count else { + throw IncludesDecodingError(error: error, idx: idx) + } + throw IncludesDecodingError( + error: IncludeDecodingError(failures: errors), + idx: idx + ) + } catch let error { + throw IncludesDecodingError(error: error, idx: idx) + } } values = valueAggregator @@ -65,25 +92,25 @@ extension Includes: Decodable where I: Decodable { } extension Includes { - public func appending(_ other: Includes) -> Includes { - return Includes(values: values + other.values) - } + public func appending(_ other: Includes) -> Includes { + return Includes(values: values + other.values) + } } public func +(_ left: Includes, _ right: Includes) -> Includes { - return left.appending(right) + return left.appending(right) } extension Includes: CustomStringConvertible { - public var description: String { - return "Includes(\(String(describing: values))" - } + public var description: String { + return "Includes(\(String(describing: values))" + } } extension Includes where I == NoIncludes { - public init() { - values = [] - } + public init() { + values = [] + } } // MARK: - 0 includes @@ -93,73 +120,73 @@ public typealias NoIncludes = Include0 // MARK: - 1 include public typealias Include1 = Poly1 extension Includes where I: _Poly1 { - public subscript(_ lookup: I.A.Type) -> [I.A] { - return values.compactMap { $0.a } - } + public subscript(_ lookup: I.A.Type) -> [I.A] { + return values.compactMap { $0.a } + } } // MARK: - 2 includes public typealias Include2 = Poly2 extension Includes where I: _Poly2 { - public subscript(_ lookup: I.B.Type) -> [I.B] { - return values.compactMap { $0.b } - } + public subscript(_ lookup: I.B.Type) -> [I.B] { + return values.compactMap { $0.b } + } } // MARK: - 3 includes public typealias Include3 = Poly3 extension Includes where I: _Poly3 { - public subscript(_ lookup: I.C.Type) -> [I.C] { - return values.compactMap { $0.c } - } + public subscript(_ lookup: I.C.Type) -> [I.C] { + return values.compactMap { $0.c } + } } // MARK: - 4 includes public typealias Include4 = Poly4 extension Includes where I: _Poly4 { - public subscript(_ lookup: I.D.Type) -> [I.D] { - return values.compactMap { $0.d } - } + public subscript(_ lookup: I.D.Type) -> [I.D] { + return values.compactMap { $0.d } + } } // MARK: - 5 includes public typealias Include5 = Poly5 extension Includes where I: _Poly5 { - public subscript(_ lookup: I.E.Type) -> [I.E] { - return values.compactMap { $0.e } - } + public subscript(_ lookup: I.E.Type) -> [I.E] { + return values.compactMap { $0.e } + } } // MARK: - 6 includes public typealias Include6 = Poly6 extension Includes where I: _Poly6 { - public subscript(_ lookup: I.F.Type) -> [I.F] { - return values.compactMap { $0.f } - } + public subscript(_ lookup: I.F.Type) -> [I.F] { + return values.compactMap { $0.f } + } } // MARK: - 7 includes public typealias Include7 = Poly7 extension Includes where I: _Poly7 { - public subscript(_ lookup: I.G.Type) -> [I.G] { - return values.compactMap { $0.g } - } + public subscript(_ lookup: I.G.Type) -> [I.G] { + return values.compactMap { $0.g } + } } // MARK: - 8 includes public typealias Include8 = Poly8 extension Includes where I: _Poly8 { - public subscript(_ lookup: I.H.Type) -> [I.H] { - return values.compactMap { $0.h } - } + public subscript(_ lookup: I.H.Type) -> [I.H] { + return values.compactMap { $0.h } + } } // MARK: - 9 includes public typealias Include9 = Poly9 extension Includes where I: _Poly9 { - public subscript(_ lookup: I.I.Type) -> [I.I] { - return values.compactMap { $0.i } - } + public subscript(_ lookup: I.I.Type) -> [I.I] { + return values.compactMap { $0.i } + } } // MARK: - 10 includes @@ -177,3 +204,32 @@ extension Includes where I: _Poly11 { return values.compactMap { $0.k } } } + +// MARK: - DecodingError +public struct IncludesDecodingError: Swift.Error, Equatable { + public let error: Swift.Error + public let idx: Int + + public static func ==(lhs: Self, rhs: Self) -> Bool { + return lhs.idx == rhs.idx + && String(describing: lhs) == String(describing: rhs) + } +} + +extension IncludesDecodingError: CustomStringConvertible { + public var description: String { + return "Include \(idx + 1) failed to parse: \(error)" + } +} + +public struct IncludeDecodingError: Swift.Error, Equatable, CustomStringConvertible { + public let failures: [ResourceObjectDecodingError] + + public var description: String { + return failures + .enumerated() + .map { + "\nCould not have been Include Type \($0.offset + 1) because:\n\($0.element)" + }.joined(separator: "\n") + } +} diff --git a/Sources/JSONAPI/Document/ResourceBody.swift b/Sources/JSONAPI/Document/ResourceBody.swift index 3fdef10..5a7c96f 100644 --- a/Sources/JSONAPI/Document/ResourceBody.swift +++ b/Sources/JSONAPI/Document/ResourceBody.swift @@ -10,121 +10,132 @@ /// array should be used for no results). public protocol OptionalEncodablePrimaryResource: Equatable, Encodable {} -/// An `EncodablePrimaryResource` is a `PrimaryResource` that only supports encoding. -/// This is actually more restrictave than `PrimaryResource`, which supports both encoding and -/// decoding. +/// An `EncodablePrimaryResource` is a `CodablePrimaryResource` that only supports encoding. public protocol EncodablePrimaryResource: OptionalEncodablePrimaryResource {} /// This protocol allows for `SingleResourceBody` to contain a `null` /// data object where `ManyResourceBody` cannot (because an empty /// array should be used for no results). -public protocol OptionalPrimaryResource: OptionalEncodablePrimaryResource, Decodable {} +public protocol OptionalCodablePrimaryResource: OptionalEncodablePrimaryResource, Decodable {} -/// A `PrimaryResource` is a type that can be used in the body of a JSON API +/// A `CodablePrimaryResource` is a type that can be used in the body of a JSON API /// document as the primary resource. -public protocol PrimaryResource: EncodablePrimaryResource, OptionalPrimaryResource {} +public protocol CodablePrimaryResource: EncodablePrimaryResource, OptionalCodablePrimaryResource {} extension Optional: OptionalEncodablePrimaryResource where Wrapped: EncodablePrimaryResource {} -extension Optional: OptionalPrimaryResource where Wrapped: PrimaryResource {} +extension Optional: OptionalCodablePrimaryResource where Wrapped: CodablePrimaryResource {} /// An `EncodableResourceBody` is a `ResourceBody` that only supports being /// encoded. It is actually weaker than `ResourceBody`, which supports both encoding /// and decoding. -public protocol EncodableResourceBody: Equatable, Encodable {} +public protocol EncodableResourceBody: Equatable, Encodable { + associatedtype PrimaryResource +} -/// A ResourceBody is a representation of the body of the JSON API Document. +/// A `CodableResourceBody` is a representation of the body of the JSON:API Document. /// It can either be one resource (which can be specified as optional or not) /// or it can contain many resources (and array with zero or more entries). -public protocol ResourceBody: Decodable, EncodableResourceBody {} +public protocol CodableResourceBody: Decodable, EncodableResourceBody {} /// A `ResourceBody` that has the ability to take on more primary /// resources by appending another similarly typed `ResourceBody`. -public protocol Appendable { - func appending(_ other: Self) -> Self +public protocol ResourceBodyAppendable { + func appending(_ other: Self) -> Self } -public func +(_ left: R, right: R) -> R { - return left.appending(right) +public func +(_ left: R, right: R) -> R { + return left.appending(right) } /// A type allowing for a document body containing 1 primary resource. /// If the `Entity` specialization is an `Optional` type, the body can contain /// 0 or 1 primary resources. -public struct SingleResourceBody: EncodableResourceBody { - public let value: Entity +public struct SingleResourceBody: EncodableResourceBody { + public let value: PrimaryResource - public init(resourceObject: Entity) { - self.value = resourceObject - } + public init(resourceObject: PrimaryResource) { + self.value = resourceObject + } } /// A type allowing for a document body containing 0 or more primary resources. -public struct ManyResourceBody: EncodableResourceBody, Appendable { - public let values: [Entity] +public struct ManyResourceBody: EncodableResourceBody, ResourceBodyAppendable { + public let values: [PrimaryResource] - public init(resourceObjects: [Entity]) { - values = resourceObjects - } + public init(resourceObjects: [PrimaryResource]) { + values = resourceObjects + } - public func appending(_ other: ManyResourceBody) -> ManyResourceBody { - return ManyResourceBody(resourceObjects: values + other.values) - } + public func appending(_ other: ManyResourceBody) -> ManyResourceBody { + return ManyResourceBody(resourceObjects: values + other.values) + } } /// Use NoResourceBody to indicate you expect a JSON API document to not /// contain a "data" top-level key. -public struct NoResourceBody: ResourceBody { - public static var none: NoResourceBody { return NoResourceBody() } +public struct NoResourceBody: CodableResourceBody { + public typealias PrimaryResource = Void + + public static var none: NoResourceBody { return NoResourceBody() } } // MARK: Codable extension SingleResourceBody { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() let anyNil: Any? = nil - let nilValue = anyNil as? Entity + let nilValue = anyNil as? PrimaryResource guard value != nilValue else { try container.encodeNil() return } - try container.encode(value) - } + try container.encode(value) + } } -extension SingleResourceBody: Decodable, ResourceBody where Entity: OptionalPrimaryResource { +extension SingleResourceBody: Decodable, CodableResourceBody where PrimaryResource: OptionalCodablePrimaryResource { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let anyNil: Any? = nil if container.decodeNil(), - let val = anyNil as? Entity { + let val = anyNil as? PrimaryResource { value = val return } - value = try container.decode(Entity.self) + value = try container.decode(PrimaryResource.self) } } extension ManyResourceBody { - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() - for value in values { - try container.encode(value) - } - } + for value in values { + try container.encode(value) + } + } } -extension ManyResourceBody: Decodable, ResourceBody where Entity: PrimaryResource { +extension ManyResourceBody: Decodable, CodableResourceBody where PrimaryResource: CodablePrimaryResource { public init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() - var valueAggregator = [Entity]() + var valueAggregator = [PrimaryResource]() + var idx = 0 while !container.isAtEnd { - valueAggregator.append(try container.decode(Entity.self)) + do { + valueAggregator.append(try container.decode(PrimaryResource.self)) + } catch let error as ResourceObjectDecodingError { + throw ManyResourceBodyDecodingError( + error: error, + idx: idx + ) + } + idx = idx + 1 } values = valueAggregator } @@ -133,13 +144,19 @@ extension ManyResourceBody: Decodable, ResourceBody where Entity: PrimaryResourc // MARK: CustomStringConvertible extension SingleResourceBody: CustomStringConvertible { - public var description: String { - return "PrimaryResourceBody(\(String(describing: value)))" - } + public var description: String { + return "PrimaryResourceBody(\(String(describing: value)))" + } } extension ManyResourceBody: CustomStringConvertible { - public var description: String { - return "PrimaryResourceBody(\(String(describing: values)))" - } + public var description: String { + return "PrimaryResourceBody(\(String(describing: values)))" + } +} + +// MARK: - DecodingError +public struct ManyResourceBodyDecodingError: Swift.Error, Equatable { + public let error: ResourceObjectDecodingError + public let idx: Int } diff --git a/Sources/JSONAPI/EncodingError.swift b/Sources/JSONAPI/EncodingError.swift deleted file mode 100644 index 1d8145f..0000000 --- a/Sources/JSONAPI/EncodingError.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// EncodingError.swift -// JSONAPI -// -// Created by Mathew Polzin on 12/7/18. -// - -public enum JSONAPIEncodingError: Swift.Error { - case typeMismatch(expected: String, found: String) - case illegalEncoding(String) - case illegalDecoding(String) - case missingOrMalformedMetadata - case missingOrMalformedLinks -} diff --git a/Sources/JSONAPI/Error/BasicJSONAPIError.swift b/Sources/JSONAPI/Error/BasicJSONAPIError.swift index 28795b9..7d83814 100644 --- a/Sources/JSONAPI/Error/BasicJSONAPIError.swift +++ b/Sources/JSONAPI/Error/BasicJSONAPIError.swift @@ -6,10 +6,12 @@ // /// Most of the JSON:API Spec defined Error fields. -public struct BasicJSONAPIErrorPayload: Codable, Equatable, ErrorDictType { +public struct BasicJSONAPIErrorPayload: Codable, Equatable, ErrorDictType, CustomStringConvertible { /// a unique identifier for this particular occurrence of the problem public let id: IdType? -// public let links: Links? // we skip this for now to avoid adding complexity to using this basic type. + + // public let links: Links? // we skip this for now to avoid adding complexity to using this basic type. + /// the HTTP status code applicable to this problem public let status: String? /// an application-specific error code @@ -20,7 +22,8 @@ public struct BasicJSONAPIErrorPayload: Codable, Eq public let detail: String? /// an object containing references to the source of the error public let source: Source? -// public let meta: Meta? // we skip this for now to avoid adding complexity to using this basic type + + // public let meta: Meta? // we skip this for now to avoid adding complexity to using this basic type public init(id: IdType? = nil, status: String? = nil, @@ -61,6 +64,10 @@ public struct BasicJSONAPIErrorPayload: Codable, Eq ].compactMap { $0 } return Dictionary(uniqueKeysWithValues: keysAndValues) } + + public var description: String { + return definedFields.map { "\($0.key): \($0.value)" }.sorted().joined(separator: ", ") + } } /// `BasicJSONAPIError` optionally decodes many possible fields diff --git a/Sources/JSONAPI/Error/GenericJSONAPIError.swift b/Sources/JSONAPI/Error/GenericJSONAPIError.swift index 09383e7..473caac 100644 --- a/Sources/JSONAPI/Error/GenericJSONAPIError.swift +++ b/Sources/JSONAPI/Error/GenericJSONAPIError.swift @@ -8,7 +8,7 @@ /// `GenericJSONAPIError` can be used to specify whatever error /// payload you expect to need to parse in responses and handle any /// other payload structure as `.unknownError`. -public enum GenericJSONAPIError: JSONAPIError { +public enum GenericJSONAPIError: JSONAPIError, CustomStringConvertible { case unknownError case error(ErrorPayload) @@ -35,6 +35,15 @@ public enum GenericJSONAPIError: JSONAPIError public static var unknown: Self { return .unknownError } + + public var description: String { + switch self { + case .unknownError: + return "unknown error" + case .error(let payload): + return String(describing: payload) + } + } } public extension GenericJSONAPIError { diff --git a/Sources/JSONAPI/Error/JSONAPIError.swift b/Sources/JSONAPI/Error/JSONAPIError.swift index b997a69..6f19105 100644 --- a/Sources/JSONAPI/Error/JSONAPIError.swift +++ b/Sources/JSONAPI/Error/JSONAPIError.swift @@ -6,7 +6,7 @@ // public protocol JSONAPIError: Swift.Error, Equatable, Codable { - static var unknown: Self { get } + static var unknown: Self { get } } /// `UnknownJSONAPIError` can actually be used in any sitaution @@ -16,18 +16,18 @@ public protocol JSONAPIError: Swift.Error, Equatable, Codable { /// information the server might be providing in the error payload, /// use `BasicJSONAPIError` instead. public enum UnknownJSONAPIError: JSONAPIError { - case unknownError - - public init(from decoder: Decoder) throws { - self = .unknown - } + case unknownError - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode("unknown") - } - - public static var unknown: Self { - return .unknownError - } + public init(from decoder: Decoder) throws { + self = .unknown + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode("unknown") + } + + public static var unknown: Self { + return .unknownError + } } diff --git a/Sources/JSONAPI/JSONAPICodingError.swift b/Sources/JSONAPI/JSONAPICodingError.swift new file mode 100644 index 0000000..da0705d --- /dev/null +++ b/Sources/JSONAPI/JSONAPICodingError.swift @@ -0,0 +1,27 @@ +// +// JSONAPICodingError.swift +// JSONAPI +// +// Created by Mathew Polzin on 12/7/18. +// + +public enum JSONAPICodingError: Swift.Error { + case typeMismatch(expected: String, found: String, path: [CodingKey]) + case quantityMismatch(expected: Quantity, path: [CodingKey]) + case illegalEncoding(String, path: [CodingKey]) + case illegalDecoding(String, path: [CodingKey]) + case missingOrMalformedMetadata(path: [CodingKey]) + case missingOrMalformedLinks(path: [CodingKey]) + + public enum Quantity: String, Equatable { + case one + case many + + public var other: Quantity { + switch self { + case .one: return .many + case .many: return .one + } + } + } +} diff --git a/Sources/JSONAPI/Meta/Links.swift b/Sources/JSONAPI/Meta/Links.swift index 00658cb..ab2b7e1 100644 --- a/Sources/JSONAPI/Meta/Links.swift +++ b/Sources/JSONAPI/Meta/Links.swift @@ -5,63 +5,63 @@ // Created by Mathew Polzin on 11/24/18. // -/// A Links structure should contain nothing but JSONAPI.Link properties. +/// A Links structure should contain nothing but `JSONAPI.Link` properties. public protocol Links: Codable, Equatable {} /// Use NoLinks where no links should belong to a JSON API component public struct NoLinks: Links, CustomStringConvertible { - public static var none: NoLinks { return NoLinks() } - public init() {} - - public var description: String { return "No Links" } + public static var none: NoLinks { return NoLinks() } + public init() {} + + public var description: String { return "No Links" } } public protocol JSONAPIURL: Codable, Equatable {} public struct Link: Equatable, Codable { - public let url: URL - public let meta: Meta - - public init(url: URL, meta: Meta) { - self.url = url - self.meta = meta - } + public let url: URL + public let meta: Meta + + public init(url: URL, meta: Meta) { + self.url = url + self.meta = meta + } } extension Link where Meta == NoMetadata { - public init(url: URL) { - self.init(url: url, meta: .none) - } + public init(url: URL) { + self.init(url: url, meta: .none) + } } public extension Link { - private enum CodingKeys: String, CodingKey { - case href - case meta - } - - init(from decoder: Decoder) throws { - guard Meta.self == NoMetadata.self, - let noMeta = NoMetadata() as? Meta else { - let container = try decoder.container(keyedBy: CodingKeys.self) - meta = try container.decode(Meta.self, forKey: .meta) - url = try container.decode(URL.self, forKey: .href) - return - } - let container = try decoder.singleValueContainer() - url = try container.decode(URL.self) - meta = noMeta - } - - func encode(to encoder: Encoder) throws { - guard Meta.self == NoMetadata.self else { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(url, forKey: .href) - try container.encode(meta, forKey: .meta) - return - } - var container = encoder.singleValueContainer() - - try container.encode(url) - } + private enum CodingKeys: String, CodingKey { + case href + case meta + } + + init(from decoder: Decoder) throws { + guard Meta.self == NoMetadata.self, + let noMeta = NoMetadata() as? Meta else { + let container = try decoder.container(keyedBy: CodingKeys.self) + meta = try container.decode(Meta.self, forKey: .meta) + url = try container.decode(URL.self, forKey: .href) + return + } + let container = try decoder.singleValueContainer() + url = try container.decode(URL.self) + meta = noMeta + } + + func encode(to encoder: Encoder) throws { + guard Meta.self == NoMetadata.self else { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .href) + try container.encode(meta, forKey: .meta) + return + } + var container = encoder.singleValueContainer() + + try container.encode(url) + } } diff --git a/Sources/JSONAPI/Meta/Meta.swift b/Sources/JSONAPI/Meta/Meta.swift index 68b2c94..57bac49 100644 --- a/Sources/JSONAPI/Meta/Meta.swift +++ b/Sources/JSONAPI/Meta/Meta.swift @@ -6,25 +6,25 @@ // /// Conform a type to this protocol to indicate it can be encoded to or decoded from -/// the meta data attached to a component of a JSON API document. Different meta data +/// the meta data attached to a component of a JSON:API document. Different meta data /// can be stored all over the place: On the root document, on a resource object, on /// link objects, etc. /// -/// JSON API Metadata is totally open ended. It can take whatever JSON-compliant structure +/// JSON:API Metadata is totally open ended. It can take whatever JSON-compliant structure /// the server and client agree upon. public protocol Meta: Codable, Equatable { } -// We make Optional a Meta if it wraps a Meta so that Metadata can be specified as -// nullable. +// We make Optional a Meta if it wraps a Meta so that +// Metadata can be specified as nullable. extension Optional: Meta where Wrapped: Meta {} /// Use this type when you want to specify not to encode or decode any metadata /// for a type. public struct NoMetadata: Meta, CustomStringConvertible { - public static var none: NoMetadata { return NoMetadata() } + public static var none: NoMetadata { return NoMetadata() } - public init() { } + public init() { } - public var description: String { return "No Metadata" } + public var description: String { return "No Metadata" } } diff --git a/Sources/JSONAPI/Resource/Attribute+Functor.swift b/Sources/JSONAPI/Resource/Attribute+Functor.swift index 1da6ff0..6f4b4dc 100644 --- a/Sources/JSONAPI/Resource/Attribute+Functor.swift +++ b/Sources/JSONAPI/Resource/Attribute+Functor.swift @@ -6,31 +6,31 @@ // public extension TransformedAttribute { - /// Map an Attribute to a new wrapped type. - /// Note that the resulting Attribute will have no transformer, even if the - /// source Attribute has a transformer. - /// You are mapping the output of the source transform into - /// the RawValue of a new transformerless Attribute. - /// - /// Generally, this is the most useful operation. The transformer gives you - /// control over the decoding of the Attribute, but once the Attribute exists, - /// mapping on it is most useful for creating computed Attribute properties. - func map(_ transform: (Transformer.To) throws -> T) rethrows -> Attribute { - return Attribute(value: try transform(value)) - } + /// Map an Attribute to a new wrapped type. + /// Note that the resulting Attribute will have no transformer, even if the + /// source Attribute has a transformer. + /// You are mapping the output of the source transform into + /// the RawValue of a new transformerless Attribute. + /// + /// Generally, this is the most useful operation. The transformer gives you + /// control over the decoding of the Attribute, but once the Attribute exists, + /// mapping on it is most useful for creating computed Attribute properties. + func map(_ transform: (Transformer.To) throws -> T) rethrows -> Attribute { + return Attribute(value: try transform(value)) + } } public extension Attribute { - /// Map an Attribute to a new wrapped type. - /// Note that the resulting Attribute will have no transformer, even if the - /// source Attribute has a transformer. - /// You are mapping the output of the source transform into - /// the RawValue of a new transformerless Attribute. - /// - /// Generally, this is the most useful operation. The transformer gives you - /// control over the decoding of the Attribute, but once the Attribute exists, - /// mapping on it is most useful for creating computed Attribute properties. - func map(_ transform: (ValueType) throws -> T) rethrows -> Attribute { - return Attribute(value: try transform(value)) - } + /// Map an Attribute to a new wrapped type. + /// Note that the resulting Attribute will have no transformer, even if the + /// source Attribute has a transformer. + /// You are mapping the output of the source transform into + /// the RawValue of a new transformerless Attribute. + /// + /// Generally, this is the most useful operation. The transformer gives you + /// control over the decoding of the Attribute, but once the Attribute exists, + /// mapping on it is most useful for creating computed Attribute properties. + func map(_ transform: (ValueType) throws -> T) rethrows -> Attribute { + return Attribute(value: try transform(value)) + } } diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift index c05983d..5b0ed41 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -5,50 +5,58 @@ // Created by Mathew Polzin on 11/13/18. // -public protocol AttributeType: Codable { - associatedtype RawValue: Codable - associatedtype ValueType +public protocol AbstractAttributeType { + var rawValueType: Any.Type { get } +} - var value: ValueType { get } +public protocol AttributeType: Codable, AbstractAttributeType { + associatedtype RawValue: Codable + associatedtype ValueType + + var value: ValueType { get } +} + +extension AttributeType { + public var rawValueType: Any.Type { return RawValue.self } } // MARK: TransformedAttribute /// A TransformedAttribute takes a Codable type and attempts to turn it into another type. public struct TransformedAttribute: AttributeType where Transformer.From == RawValue { - public let rawValue: RawValue + public let rawValue: RawValue - public let value: Transformer.To + public let value: Transformer.To - public init(rawValue: RawValue) throws { - self.rawValue = rawValue - value = try Transformer.transform(rawValue) - } + public init(rawValue: RawValue) throws { + self.rawValue = rawValue + value = try Transformer.transform(rawValue) + } } extension TransformedAttribute where Transformer == IdentityTransformer { - // If we are using the identity transform, we can skip the transform and guarantee no - // error is thrown. - public init(value: RawValue) { - rawValue = value - self.value = value - } + // If we are using the identity transform, we can skip the transform and guarantee no + // error is thrown. + public init(value: RawValue) { + rawValue = value + self.value = value + } } extension TransformedAttribute where Transformer: ReversibleTransformer { - /// Initialize a TransformedAttribute from its transformed value. The - /// RawValue, which is what gets encoded/decoded, is determined using - /// The Transformer's reverse function. - public init(transformedValue: Transformer.To) throws { - self.value = transformedValue - rawValue = try Transformer.reverse(value) - } + /// Initialize a TransformedAttribute from its transformed value. The + /// RawValue, which is what gets encoded/decoded, is determined using + /// The Transformer's reverse function. + public init(transformedValue: Transformer.To) throws { + self.value = transformedValue + rawValue = try Transformer.reverse(value) + } } extension TransformedAttribute: CustomStringConvertible { - public var description: String { - return "Attribute<\(String(describing: Transformer.From.self)) -> \(String(describing: Transformer.To.self))>(\(String(describing: value)))" - } + public var description: String { + return "Attribute<\(String(describing: Transformer.From.self)) -> \(String(describing: Transformer.To.self))>(\(String(describing: value)))" + } } extension TransformedAttribute: Equatable where Transformer.From: Equatable, Transformer.To: Equatable {} @@ -63,85 +71,86 @@ public typealias ValidatedAttribute: AttributeType { - let attribute: TransformedAttribute> + let attribute: TransformedAttribute> - public var value: RawValue { - return attribute.value - } + public var value: RawValue { + return attribute.value + } - public init(value: RawValue) { - attribute = .init(value: value) - } + public init(value: RawValue) { + attribute = .init(value: value) + } } extension Attribute: CustomStringConvertible { - public var description: String { - return "Attribute<\(String(describing: RawValue.self))>(\(String(describing: value)))" - } + public var description: String { + return "Attribute<\(String(describing: RawValue.self))>(\(String(describing: value)))" + } } extension Attribute: Equatable where RawValue: Equatable {} // MARK: - Codable extension TransformedAttribute { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - let rawVal: RawValue - - // A little trickery follows. If the value is nil, the - // container.decode(Value.self) will fail even if Value - // is Optional. However, we can check if decoding nil - // succeeds and then attempt to coerce nil to a Value - // type at which point we can store nil in `value`. - let anyNil: Any? = nil - if container.decodeNil(), - let val = anyNil as? Transformer.From { - rawVal = val - } else { - rawVal = try container.decode(Transformer.From.self) - } - - rawValue = rawVal - value = try Transformer.transform(rawVal) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() - try container.encode(rawValue) - } + let rawVal: RawValue + + // A little trickery follows. If the value is nil, the + // container.decode(Value.self) will fail even if Value + // is Optional. However, we can check if decoding nil + // succeeds and then attempt to coerce nil to a Value + // type at which point we can store nil in `value`. + let anyNil: Any? = nil + if container.decodeNil(), + let val = anyNil as? Transformer.From { + rawVal = val + } else { + rawVal = try container.decode(Transformer.From.self) + } + + rawValue = rawVal + value = try Transformer.transform(rawVal) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(rawValue) + } } extension Attribute { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() - // A little trickery follows. If the value is nil, the - // container.decode(Value.self) will fail even if Value - // is Optional. However, we can check if decoding nil - // succeeds and then attempt to coerce nil to a Value - // type at which point we can store nil in `value`. - let anyNil: Any? = nil - if container.decodeNil(), - let val = anyNil as? RawValue { - attribute = .init(value: val) - } else { - attribute = try container.decode(TransformedAttribute>.self) - } - } + // A little trickery follows. If the value is nil, the + // container.decode(Value.self) will fail even if Value + // is Optional. However, we can check if decoding nil + // succeeds and then attempt to coerce nil to a Value + // type at which point we can store nil in `value`. + let anyNil: Any? = nil + if container.decodeNil(), + let val = anyNil as? RawValue { + attribute = .init(value: val) + } else { + attribute = try container.decode(TransformedAttribute>.self) + } + } - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() - try container.encode(attribute) - } + try container.encode(attribute) + } } // MARK: Attribute decoding and encoding defaults extension AttributeType { - public static func defaultDecoding(from container: Container, forKey key: Container.Key) throws -> Self { - return try container.decode(Self.self, forKey: key) - } + public static func defaultDecoding(from container: Container, + forKey key: Container.Key) throws -> Self { + return try container.decode(Self.self, forKey: key) + } } diff --git a/Sources/JSONAPI/Resource/Id.swift b/Sources/JSONAPI/Resource/Id.swift index a375c08..1957b87 100644 --- a/Sources/JSONAPI/Resource/Id.swift +++ b/Sources/JSONAPI/Resource/Id.swift @@ -23,7 +23,7 @@ public protocol RawIdType: MaybeRawId, Hashable {} /// Conformances for `String` and `UUID` /// are given in the README for this library. public protocol CreatableRawIdType: RawIdType { - static func unique() -> Self + static func unique() -> Self } extension String: RawIdType {} @@ -32,80 +32,83 @@ extension String: RawIdType {} /// have an Id (most likely because it was created by a client and the server will be responsible /// for assigning it an Id). public struct Unidentified: MaybeRawId, CustomStringConvertible { - public init() {} - - public var description: String { return "Unidentified" } + public init() {} + + public var description: String { return "Unidentified" } } public protocol OptionalId: Codable { - associatedtype IdentifiableType: JSONAPI.JSONTyped - associatedtype RawType: MaybeRawId + associatedtype IdentifiableType: JSONAPI.JSONTyped + associatedtype RawType: MaybeRawId - var rawValue: RawType { get } - init(rawValue: RawType) + var rawValue: RawType { get } + init(rawValue: RawType) } -public protocol IdType: OptionalId, CustomStringConvertible, Hashable where RawType: RawIdType {} +/// marker protocol +public protocol AbstractId {} + +public protocol IdType: AbstractId, OptionalId, CustomStringConvertible, Hashable where RawType: RawIdType {} extension Optional: MaybeRawId where Wrapped: Codable & Equatable {} extension Optional: OptionalId where Wrapped: IdType { - public typealias IdentifiableType = Wrapped.IdentifiableType - public typealias RawType = Wrapped.RawType? + public typealias IdentifiableType = Wrapped.IdentifiableType + public typealias RawType = Wrapped.RawType? - public var rawValue: Wrapped.RawType? { - guard case .some(let value) = self else { - return nil - } - return value.rawValue - } + public var rawValue: Wrapped.RawType? { + guard case .some(let value) = self else { + return nil + } + return value.rawValue + } - public init(rawValue: Wrapped.RawType?) { - self = rawValue.map { Wrapped(rawValue: $0) } - } + public init(rawValue: Wrapped.RawType?) { + self = rawValue.map { Wrapped(rawValue: $0) } + } } public extension IdType { - var description: String { return "Id(\(String(describing: rawValue)))" } + var description: String { return "Id(\(String(describing: rawValue)))" } } public protocol CreatableIdType: IdType { - init() + init() } /// An ResourceObject ID. These IDs can be encoded to or decoded from /// JSON API IDs. public struct Id: Equatable, OptionalId { - public let rawValue: RawType - - public init(rawValue: RawType) { - self.rawValue = rawValue - } + public let rawValue: RawType - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(RawType.self) - self.init(rawValue: rawValue) - } + public init(rawValue: RawType) { + self.rawValue = rawValue + } - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(RawType.self) + self.init(rawValue: rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } -extension Id: Hashable, CustomStringConvertible, IdType where RawType: RawIdType { - public static func id(from rawValue: RawType) -> Id { - return Id(rawValue: rawValue) - } +extension Id: Hashable, CustomStringConvertible, AbstractId, IdType where RawType: RawIdType { + public static func id(from rawValue: RawType) -> Id { + return Id(rawValue: rawValue) + } } extension Id: CreatableIdType where RawType: CreatableRawIdType { - public init() { - rawValue = .unique() - } + public init() { + rawValue = .unique() + } } extension Id where RawType == Unidentified { - public static var unidentified: Id { return .init(rawValue: Unidentified()) } + public static var unidentified: Id { return .init(rawValue: Unidentified()) } } diff --git a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift index 284e10a..73c1246 100644 --- a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift +++ b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift @@ -18,69 +18,147 @@ import Poly public typealias EncodableJSONPoly = Poly & EncodablePrimaryResource public typealias EncodablePolyWrapped = Encodable & Equatable -public typealias PolyWrapped = EncodablePolyWrapped & Decodable +public typealias CodablePolyWrapped = EncodablePolyWrapped & Decodable -extension Poly0: PrimaryResource { - public init(from decoder: Decoder) throws { - throw JSONAPIEncodingError.illegalDecoding("Attempted to decode Poly0, which should represent a thing that is not expected to be found in a document.") - } +extension Poly0: CodablePrimaryResource { + public init(from decoder: Decoder) throws { + throw JSONAPICodingError.illegalDecoding("Attempted to decode Poly0, which should represent a thing that is not expected to be found in a document.", path: decoder.codingPath) + } - public func encode(to encoder: Encoder) throws { - throw JSONAPIEncodingError.illegalEncoding("Attempted to encode Poly0, which should represent a thing that is not expected to be found in a document.") - } + public func encode(to encoder: Encoder) throws { + throw JSONAPICodingError.illegalEncoding("Attempted to encode Poly0, which should represent a thing that is not expected to be found in a document.", path: encoder.codingPath) + } } // MARK: - 1 type -extension Poly1: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped {} +extension Poly1: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped {} -extension Poly1: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped {} +extension Poly1: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped {} // MARK: - 2 types -extension Poly2: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped {} +extension Poly2: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped {} -extension Poly2: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped {} +extension Poly2: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped {} // MARK: - 3 types -extension Poly3: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped {} +extension Poly3: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped {} -extension Poly3: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped {} +extension Poly3: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped {} // MARK: - 4 types -extension Poly4: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped {} +extension Poly4: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped {} -extension Poly4: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped {} +extension Poly4: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped {} // MARK: - 5 types -extension Poly5: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped {} +extension Poly5: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped {} -extension Poly5: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped {} +extension Poly5: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped {} // MARK: - 6 types -extension Poly6: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped {} +extension Poly6: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped {} -extension Poly6: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped {} +extension Poly6: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped {} // MARK: - 7 types -extension Poly7: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped, G: EncodablePolyWrapped {} +extension Poly7: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped {} -extension Poly7: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped {} +extension Poly7: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped, G: CodablePolyWrapped {} // MARK: - 8 types -extension Poly8: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped, G: EncodablePolyWrapped, H: EncodablePolyWrapped {} +extension Poly8: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped, + H: EncodablePolyWrapped {} -extension Poly8: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped {} +extension Poly8: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped, G: CodablePolyWrapped, H: CodablePolyWrapped {} // MARK: - 9 types -extension Poly9: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped, G: EncodablePolyWrapped, H: EncodablePolyWrapped, I: EncodablePolyWrapped {} +extension Poly9: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped, + H: EncodablePolyWrapped, + I: EncodablePolyWrapped {} -extension Poly9: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped, I: PolyWrapped {} +extension Poly9: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped, G: CodablePolyWrapped, H: CodablePolyWrapped, I: CodablePolyWrapped {} // MARK: - 10 types -extension Poly10: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped, G: EncodablePolyWrapped, H: EncodablePolyWrapped, I: EncodablePolyWrapped, J: EncodablePolyWrapped {} +extension Poly10: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped, + H: EncodablePolyWrapped, + I: EncodablePolyWrapped, + J: EncodablePolyWrapped {} -extension Poly10: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped, I: PolyWrapped, J: PolyWrapped {} +extension Poly10: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped, G: CodablePolyWrapped, H: CodablePolyWrapped, I: CodablePolyWrapped, J: CodablePolyWrapped {} // MARK: - 11 types -extension Poly11: EncodablePrimaryResource, OptionalEncodablePrimaryResource where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped, G: EncodablePolyWrapped, H: EncodablePolyWrapped, I: EncodablePolyWrapped, J: EncodablePolyWrapped, K: EncodablePolyWrapped {} +extension Poly11: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped, + H: EncodablePolyWrapped, + I: EncodablePolyWrapped, + J: EncodablePolyWrapped, + K: EncodablePolyWrapped {} -extension Poly11: PrimaryResource, OptionalPrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped, I: PolyWrapped, J: PolyWrapped, K: PolyWrapped {} +extension Poly11: CodablePrimaryResource, OptionalCodablePrimaryResource + where + A: CodablePolyWrapped, + B: CodablePolyWrapped, + C: CodablePolyWrapped, + D: CodablePolyWrapped, + E: CodablePolyWrapped, + F: CodablePolyWrapped, + G: CodablePolyWrapped, + H: CodablePolyWrapped, + I: CodablePolyWrapped, + J: CodablePolyWrapped, + K: CodablePolyWrapped {} diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index 43f5457..a11031e 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -6,11 +6,11 @@ // public protocol RelationshipType { - associatedtype LinksType - associatedtype MetaType + associatedtype LinksType + associatedtype MetaType - var links: LinksType { get } - var meta: MetaType { get } + var links: LinksType { get } + var meta: MetaType { get } } /// An ResourceObject relationship that can be encoded to or decoded from @@ -19,46 +19,46 @@ public protocol RelationshipType { /// A convenient typealias might make your code much more legible: `One` public struct ToOneRelationship: RelationshipType, Equatable { - public let id: Identifiable.Identifier + public let id: Identifiable.Identifier - public let meta: MetaType - public let links: LinksType + public let meta: MetaType + public let links: LinksType - public init(id: Identifiable.Identifier, meta: MetaType, links: LinksType) { - self.id = id - self.meta = meta - self.links = links - } + public init(id: Identifiable.Identifier, meta: MetaType, links: LinksType) { + self.id = id + self.meta = meta + self.links = links + } } extension ToOneRelationship where MetaType == NoMetadata, LinksType == NoLinks { - public init(id: Identifiable.Identifier) { - self.init(id: id, meta: .none, links: .none) - } + public init(id: Identifiable.Identifier) { + self.init(id: id, meta: .none, links: .none) + } } extension ToOneRelationship { - public init(resourceObject: T, meta: MetaType, links: LinksType) where T.Id == Identifiable.Identifier { - self.init(id: resourceObject.id, meta: meta, links: links) - } + public init(resourceObject: T, meta: MetaType, links: LinksType) where T.Id == Identifiable.Identifier { + self.init(id: resourceObject.id, meta: meta, links: links) + } } extension ToOneRelationship where MetaType == NoMetadata, LinksType == NoLinks { - public init(resourceObject: T) where T.Id == Identifiable.Identifier { - self.init(id: resourceObject.id, meta: .none, links: .none) - } + public init(resourceObject: T) where T.Id == Identifiable.Identifier { + self.init(id: resourceObject.id, meta: .none, links: .none) + } } extension ToOneRelationship where Identifiable: OptionalRelatable { - public init(resourceObject: T?, meta: MetaType, links: LinksType) where T.Id == Identifiable.Wrapped.Identifier { - self.init(id: resourceObject?.id, meta: meta, links: links) - } + public init(resourceObject: T?, meta: MetaType, links: LinksType) where T.Id == Identifiable.Wrapped.Identifier { + self.init(id: resourceObject?.id, meta: meta, links: links) + } } extension ToOneRelationship where Identifiable: OptionalRelatable, MetaType == NoMetadata, LinksType == NoLinks { - public init(resourceObject: T?) where T.Id == Identifiable.Wrapped.Identifier { - self.init(id: resourceObject?.id, meta: .none, links: .none) - } + public init(resourceObject: T?) where T.Id == Identifiable.Wrapped.Identifier { + self.init(id: resourceObject?.id, meta: .none, links: .none) + } } /// An ResourceObject relationship that can be encoded to or decoded from @@ -67,57 +67,57 @@ extension ToOneRelationship where Identifiable: OptionalRelatable, MetaType == N /// A convenient typealias might make your code much more legible: `Many` public struct ToManyRelationship: RelationshipType, Equatable { - public let ids: [Relatable.Identifier] + public let ids: [Relatable.Identifier] - public let meta: MetaType - public let links: LinksType + public let meta: MetaType + public let links: LinksType - public init(ids: [Relatable.Identifier], meta: MetaType, links: LinksType) { - self.ids = ids - self.meta = meta - self.links = links - } + public init(ids: [Relatable.Identifier], meta: MetaType, links: LinksType) { + self.ids = ids + self.meta = meta + self.links = links + } - public init(pointers: [ToOneRelationship], meta: MetaType, links: LinksType) where T.Identifier == Relatable.Identifier { - ids = pointers.map { $0.id } - self.meta = meta - self.links = links - } + public init(pointers: [ToOneRelationship], meta: MetaType, links: LinksType) where T.Identifier == Relatable.Identifier { + ids = pointers.map { $0.id } + self.meta = meta + self.links = links + } - public init(resourceObjects: [T], meta: MetaType, links: LinksType) where T.Id == Relatable.Identifier { - self.init(ids: resourceObjects.map { $0.id }, meta: meta, links: links) - } + public init(resourceObjects: [T], meta: MetaType, links: LinksType) where T.Id == Relatable.Identifier { + self.init(ids: resourceObjects.map { $0.id }, meta: meta, links: links) + } - private init(meta: MetaType, links: LinksType) { - self.init(ids: [], meta: meta, links: links) - } + private init(meta: MetaType, links: LinksType) { + self.init(ids: [], meta: meta, links: links) + } - public static func none(withMeta meta: MetaType, links: LinksType) -> ToManyRelationship { - return ToManyRelationship(meta: meta, links: links) - } + public static func none(withMeta meta: MetaType, links: LinksType) -> ToManyRelationship { + return ToManyRelationship(meta: meta, links: links) + } } extension ToManyRelationship where MetaType == NoMetadata, LinksType == NoLinks { - public init(ids: [Relatable.Identifier]) { - self.init(ids: ids, meta: .none, links: .none) - } + public init(ids: [Relatable.Identifier]) { + self.init(ids: ids, meta: .none, links: .none) + } - public init(pointers: [ToOneRelationship]) where T.Identifier == Relatable.Identifier { - self.init(pointers: pointers, meta: .none, links: .none) - } + public init(pointers: [ToOneRelationship]) where T.Identifier == Relatable.Identifier { + self.init(pointers: pointers, meta: .none, links: .none) + } - public static var none: ToManyRelationship { - return .none(withMeta: .none, links: .none) - } + public static var none: ToManyRelationship { + return .none(withMeta: .none, links: .none) + } - public init(resourceObjects: [T]) where T.Id == Relatable.Identifier { - self.init(resourceObjects: resourceObjects, meta: .none, links: .none) - } + public init(resourceObjects: [T]) where T.Id == Relatable.Identifier { + self.init(resourceObjects: resourceObjects, meta: .none, links: .none) + } } public protocol Identifiable: JSONTyped { - associatedtype Identifier: Equatable + associatedtype Identifier: Equatable } /// The Relatable protocol describes anything that @@ -128,152 +128,186 @@ public protocol Relatable: Identifiable where Identifier: JSONAPI.IdType { /// OptionalRelatable just describes an Optional /// with a Reltable Wrapped type. public protocol OptionalRelatable: Identifiable where Identifier == Wrapped.Identifier? { - associatedtype Wrapped: JSONAPI.Relatable + associatedtype Wrapped: JSONAPI.Relatable } extension Optional: Identifiable, OptionalRelatable, JSONTyped where Wrapped: JSONAPI.Relatable { - public typealias Identifier = Wrapped.Identifier? + public typealias Identifier = Wrapped.Identifier? - public static var jsonType: String { return Wrapped.jsonType } + public static var jsonType: String { return Wrapped.jsonType } } // MARK: Codable private enum ResourceLinkageCodingKeys: String, CodingKey { - case data = "data" - case meta = "meta" - case links = "links" + case data = "data" + case meta = "meta" + case links = "links" } private enum ResourceIdentifierCodingKeys: String, CodingKey { - case id = "id" - case entityType = "type" + case id = "id" + case entityType = "type" } extension ToOneRelationship: Codable where Identifiable.Identifier: OptionalId { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self) + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self) - if let noMeta = NoMetadata() as? MetaType { - meta = noMeta - } else { - meta = try container.decode(MetaType.self, forKey: .meta) - } + if let noMeta = NoMetadata() as? MetaType { + meta = noMeta + } else { + meta = try container.decode(MetaType.self, forKey: .meta) + } - if let noLinks = NoLinks() as? LinksType { - links = noLinks - } else { - links = try container.decode(LinksType.self, forKey: .links) - } + if let noLinks = NoLinks() as? LinksType { + links = noLinks + } else { + links = try container.decode(LinksType.self, forKey: .links) + } - // A little trickery follows. If the id is nil, the - // container.decode(Identifier.self) will fail even if Identifier - // is Optional. However, we can check if decoding nil - // succeeds and then attempt to coerce nil to a Identifier - // type at which point we can store nil in `id`. - let anyNil: Any? = nil - if try container.decodeNil(forKey: .data), - let val = anyNil as? Identifiable.Identifier { - id = val - return - } + // A little trickery follows. If the id is nil, the + // container.decode(Identifier.self) will fail even if Identifier + // is Optional. However, we can check if decoding nil + // succeeds and then attempt to coerce nil to a Identifier + // type at which point we can store nil in `id`. + let anyNil: Any? = nil + if try container.decodeNil(forKey: .data) { + guard let val = anyNil as? Identifiable.Identifier else { + throw DecodingError.valueNotFound( + Self.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected non-null relationship data." + ) + ) + } + id = val + return + } - let identifier = try container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) - - let type = try identifier.decode(String.self, forKey: .entityType) - - guard type == Identifiable.jsonType else { - throw JSONAPIEncodingError.typeMismatch(expected: Identifiable.jsonType, found: type) - } - - id = Identifiable.Identifier(rawValue: try identifier.decode(Identifiable.Identifier.RawType.self, forKey: .id)) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self) + let identifier: KeyedDecodingContainer + do { + identifier = try container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) + } catch let error as DecodingError { + guard case let .typeMismatch(type, context) = error, + type is _DictionaryType.Type else { + throw error + } + throw JSONAPICodingError.quantityMismatch(expected: .one, + path: context.codingPath) + } - if MetaType.self != NoMetadata.self { - try container.encode(meta, forKey: .meta) - } + let type = try identifier.decode(String.self, forKey: .entityType) - if LinksType.self != NoLinks.self { - try container.encode(links, forKey: .links) - } + guard type == Identifiable.jsonType else { + throw JSONAPICodingError.typeMismatch(expected: Identifiable.jsonType, found: type, path: decoder.codingPath) + } - // If id is nil, instead of {id: , type: } we will just - // encode `null` - let anyNil: Any? = nil - let nilId = anyNil as? Identifiable.Identifier - guard id != nilId else { - try container.encodeNil(forKey: .data) - return - } + id = Identifiable.Identifier(rawValue: try identifier.decode(Identifiable.Identifier.RawType.self, forKey: .id)) + } - var identifier = container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) - - try identifier.encode(id.rawValue, forKey: .id) - try identifier.encode(Identifiable.jsonType, forKey: .entityType) - } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self) + + if MetaType.self != NoMetadata.self { + try container.encode(meta, forKey: .meta) + } + + if LinksType.self != NoLinks.self { + try container.encode(links, forKey: .links) + } + + // If id is nil, instead of {id: , type: } we will just + // encode `null` + let anyNil: Any? = nil + let nilId = anyNil as? Identifiable.Identifier + guard id != nilId else { + try container.encodeNil(forKey: .data) + return + } + + var identifier = container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) + + try identifier.encode(id.rawValue, forKey: .id) + try identifier.encode(Identifiable.jsonType, forKey: .entityType) + } } extension ToManyRelationship: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self) + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self) - if let noMeta = NoMetadata() as? MetaType { - meta = noMeta - } else { - meta = try container.decode(MetaType.self, forKey: .meta) - } + if let noMeta = NoMetadata() as? MetaType { + meta = noMeta + } else { + meta = try container.decode(MetaType.self, forKey: .meta) + } - if let noLinks = NoLinks() as? LinksType { - links = noLinks - } else { - links = try container.decode(LinksType.self, forKey: .links) - } + if let noLinks = NoLinks() as? LinksType { + links = noLinks + } else { + links = try container.decode(LinksType.self, forKey: .links) + } - var identifiers = try container.nestedUnkeyedContainer(forKey: .data) - - var newIds = [Relatable.Identifier]() - while !identifiers.isAtEnd { - let identifier = try identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) - - let type = try identifier.decode(String.self, forKey: .entityType) - - guard type == Relatable.jsonType else { - throw JSONAPIEncodingError.typeMismatch(expected: Relatable.jsonType, found: type) - } - - newIds.append(Relatable.Identifier(rawValue: try identifier.decode(Relatable.Identifier.RawType.self, forKey: .id))) - } - ids = newIds - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self) + var identifiers: UnkeyedDecodingContainer + do { + identifiers = try container.nestedUnkeyedContainer(forKey: .data) + } catch let error as DecodingError { + guard case let .typeMismatch(type, context) = error, + type is _ArrayType.Type else { + throw error + } + throw JSONAPICodingError.quantityMismatch(expected: .many, + path: context.codingPath) + } - if MetaType.self != NoMetadata.self { - try container.encode(meta, forKey: .meta) - } + var newIds = [Relatable.Identifier]() + while !identifiers.isAtEnd { + let identifier = try identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) - if LinksType.self != NoLinks.self { - try container.encode(links, forKey: .links) - } + let type = try identifier.decode(String.self, forKey: .entityType) - var identifiers = container.nestedUnkeyedContainer(forKey: .data) - - for id in ids { - var identifier = identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) - - try identifier.encode(id.rawValue, forKey: .id) - try identifier.encode(Relatable.jsonType, forKey: .entityType) - } - } + guard type == Relatable.jsonType else { + throw JSONAPICodingError.typeMismatch(expected: Relatable.jsonType, found: type, path: decoder.codingPath) + } + + newIds.append(Relatable.Identifier(rawValue: try identifier.decode(Relatable.Identifier.RawType.self, forKey: .id))) + } + ids = newIds + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self) + + if MetaType.self != NoMetadata.self { + try container.encode(meta, forKey: .meta) + } + + if LinksType.self != NoLinks.self { + try container.encode(links, forKey: .links) + } + + var identifiers = container.nestedUnkeyedContainer(forKey: .data) + + for id in ids { + var identifier = identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) + + try identifier.encode(id.rawValue, forKey: .id) + try identifier.encode(Relatable.jsonType, forKey: .entityType) + } + } } // MARK: CustomStringDescribable extension ToOneRelationship: CustomStringConvertible { - public var description: String { return "Relationship(\(String(describing: id)))" } + public var description: String { return "Relationship(\(String(describing: id)))" } } extension ToManyRelationship: CustomStringConvertible { - public var description: String { return "Relationship([\(ids.map(String.init(describing:)).joined(separator: ", "))])" } + public var description: String { return "Relationship([\(ids.map(String.init(describing:)).joined(separator: ", "))])" } } + +private protocol _DictionaryType {} +extension Dictionary: _DictionaryType {} + +private protocol _ArrayType {} +extension Array: _ArrayType {} diff --git a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift index a62d271..83f0d1c 100644 --- a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift @@ -28,34 +28,34 @@ public protocol SparsableAttributes: Attributes { /// Can be used as `Relationships` Type for Entities that do not /// have any Relationships. public struct NoRelationships: Relationships { - public static var none: NoRelationships { return .init() } + public static var none: NoRelationships { return .init() } } extension NoRelationships: CustomStringConvertible { - public var description: String { return "No Relationships" } + public var description: String { return "No Relationships" } } /// Can be used as `Attributes` Type for Entities that do not /// have any Attributes. public struct NoAttributes: Attributes { - public static var none: NoAttributes { return .init() } + public static var none: NoAttributes { return .init() } } extension NoAttributes: CustomStringConvertible { - public var description: String { return "No Attributes" } + public var description: String { return "No Attributes" } } /// Something that is JSONTyped provides a String representation /// of its type. public protocol JSONTyped { - static var jsonType: String { get } + static var jsonType: String { get } } /// A `ResourceObjectProxyDescription` is an `ResourceObjectDescription` /// without Codable conformance. public protocol ResourceObjectProxyDescription: JSONTyped { - associatedtype Attributes: Equatable - associatedtype Relationships: Equatable + associatedtype Attributes: Equatable + associatedtype Relationships: Equatable } /// A `ResourceObjectDescription` describes a JSON API @@ -70,38 +70,47 @@ public protocol ResourceObjectDescription: ResourceObjectProxyDescription where /// or decoded as ResourceObjects. @dynamicMemberLookup public protocol ResourceObjectProxy: Equatable, JSONTyped { - associatedtype Description: ResourceObjectProxyDescription - associatedtype EntityRawIdType: JSONAPI.MaybeRawId + associatedtype Description: ResourceObjectProxyDescription + associatedtype EntityRawIdType: JSONAPI.MaybeRawId - typealias Id = JSONAPI.Id + typealias Id = JSONAPI.Id - typealias Attributes = Description.Attributes - typealias Relationships = Description.Relationships + typealias Attributes = Description.Attributes + typealias Relationships = Description.Relationships - /// The `Entity`'s Id. This can be of type `Unidentified` if - /// the entity is being created clientside and the - /// server is being asked to create a unique Id. Otherwise, - /// this should be of a type conforming to `IdType`. - var id: Id { get } + /// The `Entity`'s Id. This can be of type `Unidentified` if + /// the entity is being created clientside and the + /// server is being asked to create a unique Id. Otherwise, + /// this should be of a type conforming to `IdType`. + var id: Id { get } - /// The JSON API compliant attributes of this `Entity`. - var attributes: Attributes { get } + /// The JSON API compliant attributes of this `Entity`. + var attributes: Attributes { get } - /// The JSON API compliant relationships of this `Entity`. - var relationships: Relationships { get } + /// The JSON API compliant relationships of this `Entity`. + var relationships: Relationships { get } } extension ResourceObjectProxy { - /// The JSON API compliant "type" of this `ResourceObject`. - public static var jsonType: String { return Description.jsonType } + /// The JSON API compliant "type" of this `ResourceObject`. + public static var jsonType: String { return Description.jsonType } } +/// A marker protocol. +public protocol AbstractResourceObject {} + /// ResourceObjectType is the protocol that ResourceObject conforms to. This /// protocol lets other types accept any ResourceObject as a generic /// specialization. -public protocol ResourceObjectType: ResourceObjectProxy, PrimaryResource where Description: ResourceObjectDescription { - associatedtype Meta: JSONAPI.Meta - associatedtype Links: JSONAPI.Links +public protocol ResourceObjectType: AbstractResourceObject, ResourceObjectProxy, CodablePrimaryResource where Description: ResourceObjectDescription { + associatedtype Meta: JSONAPI.Meta + associatedtype Links: JSONAPI.Links + + /// Any additional metadata packaged with the entity. + var meta: Meta { get } + + /// Links related to the entity. + var links: Links { get } } public protocol IdentifiableResourceObjectType: ResourceObjectType, Relatable where EntityRawIdType: JSONAPI.RawIdType {} @@ -112,369 +121,142 @@ public protocol IdentifiableResourceObjectType: ResourceObjectType, Relatable wh /// See https://jsonapi.org/format/#document-resource-objects public struct ResourceObject: ResourceObjectType { - public typealias Meta = MetaType - public typealias Links = LinksType + public typealias Meta = MetaType + public typealias Links = LinksType - /// The `ResourceObject`'s Id. This can be of type `Unidentified` if - /// the entity is being created clientside and the - /// server is being asked to create a unique Id. Otherwise, - /// this should be of a type conforming to `IdType`. - public let id: ResourceObject.Id - - /// The JSON API compliant attributes of this `ResourceObject`. - public let attributes: Description.Attributes - - /// The JSON API compliant relationships of this `ResourceObject`. - public let relationships: Description.Relationships + /// The `ResourceObject`'s Id. This can be of type `Unidentified` if + /// the entity is being created clientside and the + /// server is being asked to create a unique Id. Otherwise, + /// this should be of a type conforming to `IdType`. + public let id: ResourceObject.Id - /// Any additional metadata packaged with the entity. - public let meta: MetaType + /// The JSON API compliant attributes of this `ResourceObject`. + public let attributes: Description.Attributes - /// Links related to the entity. - public let links: LinksType - - public init(id: ResourceObject.Id, attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.id = id - self.attributes = attributes - self.relationships = relationships - self.meta = meta - self.links = links - } + /// The JSON API compliant relationships of this `ResourceObject`. + public let relationships: Description.Relationships + + /// Any additional metadata packaged with the entity. + public let meta: MetaType + + /// Links related to the entity. + public let links: LinksType + + public init(id: ResourceObject.Id, attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { + self.id = id + self.attributes = attributes + self.relationships = relationships + self.meta = meta + self.links = links + } } extension ResourceObject: Identifiable, IdentifiableResourceObjectType, Relatable where EntityRawIdType: JSONAPI.RawIdType { - public typealias Identifier = ResourceObject.Id + public typealias Identifier = ResourceObject.Id } extension ResourceObject: CustomStringConvertible { - public var description: String { - return "ResourceObject<\(ResourceObject.jsonType)>(id: \(String(describing: id)), attributes: \(String(describing: attributes)), relationships: \(String(describing: relationships)))" - } + public var description: String { + return "ResourceObject<\(ResourceObject.jsonType)>(id: \(String(describing: id)), attributes: \(String(describing: attributes)), relationships: \(String(describing: relationships)))" + } } // MARK: - Convenience initializers extension ResourceObject where EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.id = ResourceObject.Id() - self.attributes = attributes - self.relationships = relationships - self.meta = meta - self.links = links - } + public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { + self.id = ResourceObject.Id() + self.attributes = attributes + self.relationships = relationships + self.meta = meta + self.links = links + } } extension ResourceObject where EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.id = .unidentified - self.attributes = attributes - self.relationships = relationships - self.meta = meta - self.links = links - } + public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType, links: LinksType) { + self.id = .unidentified + self.attributes = attributes + self.relationships = relationships + self.meta = meta + self.links = links + } } -/* -extension ResourceObject where Description.Attributes == NoAttributes { - public init(id: ResourceObject.Id, relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.init(id: id, attributes: NoAttributes(), relationships: relationships, meta: meta, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, MetaType == NoMetadata { - public init(id: ResourceObject.Id, relationships: Description.Relationships, links: LinksType) { - self.init(id: id, relationships: relationships, meta: .none, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, LinksType == NoLinks { - public init(id: ResourceObject.Id, relationships: Description.Relationships, meta: MetaType) { - self.init(id: id, relationships: relationships, meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, MetaType == NoMetadata, LinksType == NoLinks { - public init(id: ResourceObject.Id, relationships: Description.Relationships) { - self.init(id: id, relationships: relationships, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, EntityRawIdType: CreatableRawIdType { - public init(relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.init(attributes: NoAttributes(), relationships: relationships, meta: meta, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, MetaType == NoMetadata, EntityRawIdType: CreatableRawIdType { - public init(relationships: Description.Relationships, links: LinksType) { - self.init(attributes: NoAttributes(), relationships: relationships, meta: .none, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(relationships: Description.Relationships, meta: MetaType) { - self.init(attributes: NoAttributes(), relationships: relationships, meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(relationships: Description.Relationships) { - self.init(attributes: NoAttributes(), relationships: relationships, meta: .none, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, EntityRawIdType == Unidentified { - public init(relationships: Description.Relationships, meta: MetaType, links: LinksType) { - self.init(attributes: NoAttributes(), relationships: relationships, meta: meta, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships { - public init(id: ResourceObject.Id, attributes: Description.Attributes, meta: MetaType, links: LinksType) { - self.init(id: id, attributes: attributes, relationships: NoRelationships(), meta: meta, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata { - public init(id: ResourceObject.Id, attributes: Description.Attributes, links: LinksType) { - self.init(id: id, attributes: attributes, meta: .none, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, LinksType == NoLinks { - public init(id: ResourceObject.Id, attributes: Description.Attributes, meta: MetaType) { - self.init(id: id, attributes: attributes, meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata, LinksType == NoLinks { - public init(id: ResourceObject.Id, attributes: Description.Attributes) { - self.init(id: id, attributes: attributes, meta: .none, links: .none) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, meta: MetaType, links: LinksType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: meta, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, links: LinksType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: .none, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, meta: MetaType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: .none, links: .none) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, meta: MetaType, links: LinksType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: meta, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, links: LinksType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: .none, links: links) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, LinksType == NoLinks, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, meta: MetaType) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Relationships == NoRelationships, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes) { - self.init(attributes: attributes, relationships: NoRelationships(), meta: .none, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships { - public init(id: ResourceObject.Id, meta: MetaType, links: LinksType) { - self.init(id: id, attributes: NoAttributes(), relationships: NoRelationships(), meta: meta, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, MetaType == NoMetadata { - public init(id: ResourceObject.Id, links: LinksType) { - self.init(id: id, attributes: NoAttributes(), relationships: NoRelationships(), meta: .none, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, LinksType == NoLinks { - public init(id: ResourceObject.Id, meta: MetaType) { - self.init(id: id, attributes: NoAttributes(), relationships: NoRelationships(), meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, MetaType == NoMetadata, LinksType == NoLinks { - public init(id: ResourceObject.Id) { - self.init(id: id, attributes: NoAttributes(), relationships: NoRelationships(), meta: .none, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, EntityRawIdType: CreatableRawIdType { - public init(meta: MetaType, links: LinksType) { - self.init(attributes: NoAttributes(), relationships: NoRelationships(), meta: meta, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, MetaType == NoMetadata, EntityRawIdType: CreatableRawIdType { - public init(links: LinksType) { - self.init(attributes: NoAttributes(), relationships: NoRelationships(), meta: .none, links: links) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(meta: MetaType) { - self.init(attributes: NoAttributes(), relationships: NoRelationships(), meta: meta, links: .none) - } -} - -extension ResourceObject where Description.Attributes == NoAttributes, Description.Relationships == NoRelationships, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init() { - self.init(attributes: NoAttributes(), relationships: NoRelationships(), meta: .none, links: .none) - } -} - -extension ResourceObject where MetaType == NoMetadata { - public init(id: ResourceObject.Id, attributes: Description.Attributes, relationships: Description.Relationships, links: LinksType) { - self.init(id: id, attributes: attributes, relationships: relationships, meta: .none, links: links) - } -} - -extension ResourceObject where MetaType == NoMetadata, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, relationships: Description.Relationships, links: LinksType) { - self.init(attributes: attributes, relationships: relationships, meta: .none, links: links) - } -} - -extension ResourceObject where MetaType == NoMetadata, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, relationships: Description.Relationships, links: LinksType) { - self.init(attributes: attributes, relationships: relationships, meta: .none, links: links) - } -} - -extension ResourceObject where LinksType == NoLinks { - public init(id: ResourceObject.Id, attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType) { - self.init(id: id, attributes: attributes, relationships: relationships, meta: meta, links: .none) - } -} - -extension ResourceObject where LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType) { - self.init(attributes: attributes, relationships: relationships, meta: meta, links: .none) - } -} - -extension ResourceObject where LinksType == NoLinks, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, relationships: Description.Relationships, meta: MetaType) { - self.init(attributes: attributes, relationships: relationships, meta: meta, links: .none) - } -} - -extension ResourceObject where MetaType == NoMetadata, LinksType == NoLinks { - public init(id: ResourceObject.Id, attributes: Description.Attributes, relationships: Description.Relationships) { - self.init(id: id, attributes: attributes, relationships: relationships, meta: .none, links: .none) - } -} - -extension ResourceObject where MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType: CreatableRawIdType { - public init(attributes: Description.Attributes, relationships: Description.Relationships) { - self.init(attributes: attributes, relationships: relationships, meta: .none, links: .none) - } -} - -extension ResourceObject where MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType == Unidentified { - public init(attributes: Description.Attributes, relationships: Description.Relationships) { - self.init(attributes: attributes, relationships: relationships, meta: .none, links: .none) - } -} -*/ - // MARK: - Pointer for Relationships use public extension ResourceObject where EntityRawIdType: JSONAPI.RawIdType { - /// A `ResourceObject.Pointer` is a `ToOneRelationship` with no metadata or links. - /// This is just a convenient way to reference a `ResourceObject` so that - /// other ResourceObjects' Relationships can be built up from it. - typealias Pointer = ToOneRelationship + /// A `ResourceObject.Pointer` is a `ToOneRelationship` with no metadata or links. + /// This is just a convenient way to reference a `ResourceObject` so that + /// other ResourceObjects' Relationships can be built up from it. + typealias Pointer = ToOneRelationship - /// `ResourceObject.Pointers` is a `ToManyRelationship` with no metadata or links. - /// This is just a convenient way to reference a bunch of ResourceObjects so - /// that other ResourceObjects' Relationships can be built up from them. - typealias Pointers = ToManyRelationship + /// `ResourceObject.Pointers` is a `ToManyRelationship` with no metadata or links. + /// This is just a convenient way to reference a bunch of ResourceObjects so + /// that other ResourceObjects' Relationships can be built up from them. + typealias Pointers = ToManyRelationship - /// Get a pointer to this resource object that can be used as a - /// relationship to another resource object. - var pointer: Pointer { - return Pointer(resourceObject: self) - } + /// Get a pointer to this resource object that can be used as a + /// relationship to another resource object. + var pointer: Pointer { + return Pointer(resourceObject: self) + } /// Get a pointer (i.e. `ToOneRelationship`) to this resource /// object with the given metadata and links attached. - func pointer(withMeta meta: MType, links: LType) -> ToOneRelationship { - return ToOneRelationship(resourceObject: self, meta: meta, links: links) - } + func pointer(withMeta meta: MType, links: LType) -> ToOneRelationship { + return ToOneRelationship(resourceObject: self, meta: meta, links: links) + } } // MARK: - Identifying Unidentified Entities public extension ResourceObject where EntityRawIdType == Unidentified { - /// Create a new `ResourceObject` from this one with a newly created - /// unique Id of the given type. - func identified(byType: RawIdType.Type) -> ResourceObject { - return .init(attributes: attributes, relationships: relationships, meta: meta, links: links) - } + /// Create a new `ResourceObject` from this one with a newly created + /// unique Id of the given type. + func identified(byType: RawIdType.Type) -> ResourceObject { + return .init(attributes: attributes, relationships: relationships, meta: meta, links: links) + } - /// Create a new `ResourceObject` from this one with the given Id. - func identified(by id: RawIdType) -> ResourceObject { - return .init(id: ResourceObject.Identifier(rawValue: id), attributes: attributes, relationships: relationships, meta: meta, links: links) - } + /// Create a new `ResourceObject` from this one with the given Id. + func identified(by id: RawIdType) -> ResourceObject { + return .init(id: ResourceObject.Identifier(rawValue: id), attributes: attributes, relationships: relationships, meta: meta, links: links) + } } public extension ResourceObject where EntityRawIdType: CreatableRawIdType { - /// Create a copy of this `ResourceObject` with a new unique Id. - func withNewIdentifier() -> ResourceObject { - return ResourceObject(attributes: attributes, relationships: relationships, meta: meta, links: links) - } + /// Create a copy of this `ResourceObject` with a new unique Id. + func withNewIdentifier() -> ResourceObject { + return ResourceObject(attributes: attributes, relationships: relationships, meta: meta, links: links) + } } // MARK: - Attribute Access public extension ResourceObjectProxy { // MARK: Keypath Subscript Lookup - /// Access the attribute at the given keypath. This just - /// allows you to write `resourceObject[\.propertyName]` instead - /// of `resourceObject.attributes.propertyName.value`. - subscript(_ path: KeyPath) -> T.ValueType { - return attributes[keyPath: path].value - } + /// Access the attribute at the given keypath. This just + /// allows you to write `resourceObject[\.propertyName]` instead + /// of `resourceObject.attributes.propertyName.value`. + @available(*, deprecated, message: "This will be removed in a future version in favor of `resource.` (dynamic member lookup)") + subscript(_ path: KeyPath) -> T.ValueType { + return attributes[keyPath: path].value + } - /// Access the attribute at the given keypath. This just - /// allows you to write `resourceObject[\.propertyName]` instead - /// of `resourceObject.attributes.propertyName.value`. - subscript(_ path: KeyPath) -> T.ValueType? { - return attributes[keyPath: path]?.value - } + /// Access the attribute at the given keypath. This just + /// allows you to write `resourceObject[\.propertyName]` instead + /// of `resourceObject.attributes.propertyName.value`. + @available(*, deprecated, message: "This will be removed in a future version in favor of `resource.` (dynamic member lookup)") + subscript(_ path: KeyPath) -> T.ValueType? { + return attributes[keyPath: path]?.value + } - /// Access the attribute at the given keypath. This just - /// allows you to write `resourceObject[\.propertyName]` instead - /// of `resourceObject.attributes.propertyName.value`. - subscript(_ path: KeyPath) -> U? where T.ValueType == U? { - // Implementation Note: Handles Transform that returns optional - // type. - return attributes[keyPath: path].flatMap { $0.value } - } + /// Access the attribute at the given keypath. This just + /// allows you to write `resourceObject[\.propertyName]` instead + /// of `resourceObject.attributes.propertyName.value`. + @available(*, deprecated, message: "This will be removed in a future version in favor of `resource.` (dynamic member lookup)") + subscript(_ path: KeyPath) -> U? where T.ValueType == U? { + // Implementation Note: Handles Transform that returns optional + // type. + return attributes[keyPath: path].flatMap { $0.value } + } // MARK: Dynaminc Member Keypath Lookup /// Access the attribute at the given keypath. This just @@ -499,27 +281,28 @@ public extension ResourceObjectProxy { } // MARK: Direct Keypath Subscript Lookup - /// Access the storage of the attribute at the given keypath. This just + /// Access the storage of the attribute at the given keypath. This just /// allows you to write `resourceObject[direct: \.propertyName]` instead - /// of `resourceObject.attributes.propertyName`. + /// of `resourceObject.attributes.propertyName`. /// Most of the subscripts dig into an `AttributeType`. This subscript /// returns the `AttributeType` (or another type, if you are accessing /// an attribute that is not stored in an `AttributeType`). - subscript(direct path: KeyPath) -> T { - // Implementation Note: Handles attributes that are not - // AttributeType. These should only exist as computed properties. - return attributes[keyPath: path] - } + subscript(direct path: KeyPath) -> T { + // Implementation Note: Handles attributes that are not + // AttributeType. These should only exist as computed properties. + return attributes[keyPath: path] + } } // MARK: - Meta-Attribute Access public extension ResourceObjectProxy { // MARK: Keypath Subscript Lookup - /// Access an attribute requiring a transformation on the RawValue _and_ - /// a secondary transformation on this entity (self). - subscript(_ path: KeyPath T>) -> T { - return attributes[keyPath: path](self) - } + /// Access an attribute requiring a transformation on the RawValue _and_ + /// a secondary transformation on this entity (self). + @available(*, deprecated, message: "This will be removed in a future version in favor of `resource.` (dynamic member lookup)") + subscript(_ path: KeyPath T>) -> T { + return attributes[keyPath: path](self) + } // MARK: Dynamic Member Keypath Lookup /// Access an attribute requiring a transformation on the RawValue _and_ @@ -531,61 +314,61 @@ public extension ResourceObjectProxy { // MARK: - Relationship Access public extension ResourceObjectProxy { - /// Access to an Id of a `ToOneRelationship`. - /// This allows you to write `resourceObject ~> \.other` instead - /// of `resourceObject.relationships.other.id`. - static func ~>(entity: Self, path: KeyPath>) -> OtherEntity.Identifier { - return entity.relationships[keyPath: path].id - } + /// Access to an Id of a `ToOneRelationship`. + /// This allows you to write `resourceObject ~> \.other` instead + /// of `resourceObject.relationships.other.id`. + static func ~>(entity: Self, path: KeyPath>) -> OtherEntity.Identifier { + return entity.relationships[keyPath: path].id + } - /// Access to an Id of an optional `ToOneRelationship`. - /// This allows you to write `resourceObject ~> \.other` instead - /// of `resourceObject.relationships.other?.id`. - static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.Identifier { - // Implementation Note: This signature applies to `ToOneRelationship?` - // whereas the one below applies to `ToOneRelationship?` - return entity.relationships[keyPath: path]?.id - } + /// Access to an Id of an optional `ToOneRelationship`. + /// This allows you to write `resourceObject ~> \.other` instead + /// of `resourceObject.relationships.other?.id`. + static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.Identifier { + // Implementation Note: This signature applies to `ToOneRelationship?` + // whereas the one below applies to `ToOneRelationship?` + return entity.relationships[keyPath: path]?.id + } - /// Access to an Id of an optional `ToOneRelationship`. - /// This allows you to write `resourceObject ~> \.other` instead - /// of `resourceObject.relationships.other?.id`. - static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.Identifier? { - // Implementation Note: This signature applies to `ToOneRelationship?` - // whereas the one above applies to `ToOneRelationship?` - return entity.relationships[keyPath: path]?.id - } + /// Access to an Id of an optional `ToOneRelationship`. + /// This allows you to write `resourceObject ~> \.other` instead + /// of `resourceObject.relationships.other?.id`. + static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.Identifier? { + // Implementation Note: This signature applies to `ToOneRelationship?` + // whereas the one above applies to `ToOneRelationship?` + return entity.relationships[keyPath: path]?.id + } - /// Access to all Ids of a `ToManyRelationship`. - /// This allows you to write `resourceObject ~> \.others` instead - /// of `resourceObject.relationships.others.ids`. - static func ~>(entity: Self, path: KeyPath>) -> [OtherEntity.Identifier] { - return entity.relationships[keyPath: path].ids - } + /// Access to all Ids of a `ToManyRelationship`. + /// This allows you to write `resourceObject ~> \.others` instead + /// of `resourceObject.relationships.others.ids`. + static func ~>(entity: Self, path: KeyPath>) -> [OtherEntity.Identifier] { + return entity.relationships[keyPath: path].ids + } - /// Access to all Ids of an optional `ToManyRelationship`. - /// This allows you to write `resourceObject ~> \.others` instead - /// of `resourceObject.relationships.others?.ids`. - static func ~>(entity: Self, path: KeyPath?>) -> [OtherEntity.Identifier]? { - return entity.relationships[keyPath: path]?.ids - } + /// Access to all Ids of an optional `ToManyRelationship`. + /// This allows you to write `resourceObject ~> \.others` instead + /// of `resourceObject.relationships.others?.ids`. + static func ~>(entity: Self, path: KeyPath?>) -> [OtherEntity.Identifier]? { + return entity.relationships[keyPath: path]?.ids + } } // MARK: - Meta-Relationship Access public extension ResourceObjectProxy { - /// Access to an Id of a `ToOneRelationship`. - /// This allows you to write `resourceObject ~> \.other` instead - /// of `resourceObject.relationships.other.id`. - static func ~>(entity: Self, path: KeyPath Identifier>) -> Identifier { - return entity.relationships[keyPath: path](entity) - } + /// Access to an Id of a `ToOneRelationship`. + /// This allows you to write `resourceObject ~> \.other` instead + /// of `resourceObject.relationships.other.id`. + static func ~>(entity: Self, path: KeyPath Identifier>) -> Identifier { + return entity.relationships[keyPath: path](entity) + } - /// Access to all Ids of a `ToManyRelationship`. - /// This allows you to write `resourceObject ~> \.others` instead - /// of `resourceObject.relationships.others.ids`. - static func ~>(entity: Self, path: KeyPath [Identifier]>) -> [Identifier] { - return entity.relationships[keyPath: path](entity) - } + /// Access to all Ids of a `ToManyRelationship`. + /// This allows you to write `resourceObject ~> \.others` instead + /// of `resourceObject.relationships.others.ids`. + static func ~>(entity: Self, path: KeyPath [Identifier]>) -> [Identifier] { + return entity.relationships[keyPath: path](entity) + } } infix operator ~> @@ -631,21 +414,56 @@ public extension ResourceObject { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ResourceObjectCodingKeys.self) - let type = try container.decode(String.self, forKey: .type) + let type: String + do { + type = try container.decode(String.self, forKey: .type) + } catch let error as DecodingError { + throw ResourceObjectDecodingError(error) + ?? error + } guard ResourceObject.jsonType == type else { - throw JSONAPIEncodingError.typeMismatch(expected: Description.jsonType, found: type) + throw ResourceObjectDecodingError( + expectedJSONAPIType: ResourceObject.jsonType, + found: type + ) } let maybeUnidentified = Unidentified() as? EntityRawIdType id = try maybeUnidentified.map { ResourceObject.Id(rawValue: $0) } ?? container.decode(ResourceObject.Id.self, forKey: .id) - attributes = try (NoAttributes() as? Description.Attributes) ?? - container.decode(Description.Attributes.self, forKey: .attributes) + do { + attributes = try (NoAttributes() as? Description.Attributes) + ?? container.decodeIfPresent(Description.Attributes.self, forKey: .attributes) + ?? Description.Attributes(from: EmptyObjectDecoder()) + } catch let decodingError as DecodingError { + throw ResourceObjectDecodingError(decodingError) + ?? decodingError + } catch _ as EmptyObjectDecodingError { + throw ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .attributes + ) + } - relationships = try (NoRelationships() as? Description.Relationships) - ?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships) - ?? Description.Relationships(from: EmptyObjectDecoder()) + do { + relationships = try (NoRelationships() as? Description.Relationships) + ?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships) + ?? Description.Relationships(from: EmptyObjectDecoder()) + } catch let decodingError as DecodingError { + throw ResourceObjectDecodingError(decodingError) + ?? decodingError + } catch let decodingError as JSONAPICodingError { + throw ResourceObjectDecodingError(decodingError) + ?? decodingError + } catch _ as EmptyObjectDecodingError { + throw ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .relationships + ) + } meta = try (NoMetadata() as? MetaType) ?? container.decode(MetaType.self, forKey: .meta) diff --git a/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift new file mode 100644 index 0000000..a79f5a1 --- /dev/null +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift @@ -0,0 +1,135 @@ +// +// ResourceObjectDecodingError.swift +// +// +// Created by Mathew Polzin on 11/10/19. +// + +public struct ResourceObjectDecodingError: Swift.Error, Equatable { + public let subjectName: String + public let cause: Cause + public let location: Location + + static let entireObject = "entire object" + + public enum Cause: Equatable { + case keyNotFound + case valueNotFound + case typeMismatch(expectedTypeName: String) + case jsonTypeMismatch(expectedType: String, foundType: String) + case quantityMismatch(expected: JSONAPICodingError.Quantity) + } + + public enum Location: String, Equatable { + case attributes + case relationships + case type + + var singular: String { + switch self { + case .attributes: return "attribute" + case .relationships: return "relationship" + case .type: return "type" + } + } + } + + init?(_ decodingError: DecodingError) { + switch decodingError { + case .typeMismatch(let expectedType, let ctx): + (location, subjectName) = Self.context(ctx) + let typeString = String(describing: expectedType) + cause = .typeMismatch(expectedTypeName: typeString) + case .valueNotFound(_, let ctx): + (location, subjectName) = Self.context(ctx) + cause = .valueNotFound + case .keyNotFound(let missingKey, let ctx): + (location, _) = Self.context(ctx) + subjectName = missingKey.stringValue + cause = .keyNotFound + default: + return nil + } + } + + init?(_ jsonAPIError: JSONAPICodingError) { + switch jsonAPIError { + case .typeMismatch(expected: let expected, found: let found, path: let path): + (location, subjectName) = Self.context(path: path) + cause = .jsonTypeMismatch(expectedType: expected, foundType: found) + case .quantityMismatch(expected: let expected, path: let path): + (location, subjectName) = Self.context(path: path) + cause = .quantityMismatch(expected: expected) + default: + return nil + } + } + + init(expectedJSONAPIType: String, found: String) { + location = .type + subjectName = "self" + cause = .jsonTypeMismatch(expectedType: expectedJSONAPIType, foundType: found) + } + + init(subjectName: String, cause: Cause, location: Location) { + self.subjectName = subjectName + self.cause = cause + self.location = location + } + + static func context(_ decodingContext: DecodingError.Context) -> (Location, name: String) { + + return context(path: decodingContext.codingPath) + } + + static func context(path: [CodingKey]) -> (Location, name: String) { + let location: Location + if path.contains(where: { $0.stringValue == "attributes" }) { + location = .attributes + } else if path.contains(where: { $0.stringValue == "relationships" }) { + location = .relationships + } else { + location = .type + } + + return ( + location, + name: path.last?.stringValue ?? "unnamed" + ) + } +} + +extension ResourceObjectDecodingError: CustomStringConvertible { + public var description: String { + switch cause { + case .keyNotFound where subjectName == ResourceObjectDecodingError.entireObject: + return "\(location) object is required and missing." + case .keyNotFound where location == .type: + return "'type' (a.k.a. JSON:API type name) is required and missing." + case .keyNotFound: + return "'\(subjectName)' \(location.singular) is required and missing." + case .valueNotFound where location == .type: + return "'\(location.singular)' (a.k.a. JSON:API type name) is not nullable but null was found." + case .valueNotFound: + return "'\(subjectName)' \(location.singular) is not nullable but null was found." + case .typeMismatch(expectedTypeName: let expected) where location == .type: + return "'\(location.singular)' (a.k.a. the JSON:API type name) is not a \(expected) as expected." + case .typeMismatch(expectedTypeName: let expected): + return "'\(subjectName)' \(location.singular) is not a \(expected) as expected." + case .jsonTypeMismatch(expectedType: let expected, foundType: let found) where location == .type: + return "found JSON:API type \"\(found)\" but expected \"\(expected)\"" + case .jsonTypeMismatch(expectedType: let expected, foundType: let found): + return "'\(subjectName)' \(location.singular) is of JSON:API type \"\(found)\" but it was expected to be \"\(expected)\"" + case .quantityMismatch(expected: let expected): + let expecation: String = { + switch expected { + case .many: + return "\(expected) values" + case .one: + return "\(expected) value" + } + }() + return "'\(subjectName)' \(location.singular) should contain \(expecation) but found \(expected.other)" + } + } +} diff --git a/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift b/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift index 36a1a95..2c555bd 100644 --- a/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift +++ b/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift @@ -158,7 +158,7 @@ struct SparseFieldKeyedEncodingContainer: KeyedEncodingContainer forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { guard shouldAllow(key: key) else { return KeyedEncodingContainer( - // TODO: not needed by JSONAPI library, but for completeness could + // NOTE: not needed by JSONAPI library, but for completeness could // add an EmptyObjectEncoder that can be returned here so that // at least nothing gets encoded within the nested container SparseFieldKeyedEncodingContainer(wrapping: wrappedContainer.nestedContainer(keyedBy: keyType, @@ -176,7 +176,7 @@ struct SparseFieldKeyedEncodingContainer: KeyedEncodingContainer public mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { guard shouldAllow(key: key) else { - // TODO: not needed by JSONAPI library, but for completeness could + // NOTE: not needed by JSONAPI library, but for completeness could // add an EmptyObjectEncoder that can be returned here so that // at least nothing gets encoded within the nested container return wrappedContainer.nestedUnkeyedContainer(forKey: key) diff --git a/Sources/JSONAPI/SparseFields/SparseFieldset.swift b/Sources/JSONAPI/SparseFields/SparseFieldset.swift index b326f13..f7fca89 100644 --- a/Sources/JSONAPI/SparseFields/SparseFieldset.swift +++ b/Sources/JSONAPI/SparseFields/SparseFieldset.swift @@ -36,15 +36,12 @@ public struct SparseFieldset< public extension ResourceObject where Description.Attributes: SparsableAttributes { + /// The `SparseFieldset` type for this `ResourceObject` + typealias SparseType = SparseFieldset + /// Get a Sparse Fieldset of this `ResourceObject` that can be encoded /// as a `SparsePrimaryResource`. - func sparse(with fields: [Description.Attributes.CodingKeys]) -> SparseFieldset { + func sparse(with fields: [Description.Attributes.CodingKeys]) -> SparseType { return SparseFieldset(self, fields: fields) } } - -public extension ResourceObject where Description.Attributes: SparsableAttributes { - - /// The `SparseFieldset` type for this `ResourceObject` - typealias SparseType = SparseFieldset -} diff --git a/Sources/JSONAPITesting/Attribute+Literal.swift b/Sources/JSONAPITesting/Attribute+Literal.swift index 706b6e4..b631bab 100644 --- a/Sources/JSONAPITesting/Attribute+Literal.swift +++ b/Sources/JSONAPITesting/Attribute+Literal.swift @@ -2,81 +2,81 @@ import JSONAPI extension Attribute: ExpressibleByUnicodeScalarLiteral where RawValue: ExpressibleByUnicodeScalarLiteral { - public typealias UnicodeScalarLiteralType = RawValue.UnicodeScalarLiteralType + public typealias UnicodeScalarLiteralType = RawValue.UnicodeScalarLiteralType - public init(unicodeScalarLiteral value: RawValue.UnicodeScalarLiteralType) { - self.init(value: RawValue(unicodeScalarLiteral: value)) - } + public init(unicodeScalarLiteral value: RawValue.UnicodeScalarLiteralType) { + self.init(value: RawValue(unicodeScalarLiteral: value)) + } } extension Attribute: ExpressibleByExtendedGraphemeClusterLiteral where RawValue: ExpressibleByExtendedGraphemeClusterLiteral { - public typealias ExtendedGraphemeClusterLiteralType = RawValue.ExtendedGraphemeClusterLiteralType + public typealias ExtendedGraphemeClusterLiteralType = RawValue.ExtendedGraphemeClusterLiteralType - public init(extendedGraphemeClusterLiteral value: RawValue.ExtendedGraphemeClusterLiteralType) { - self.init(value: RawValue(extendedGraphemeClusterLiteral: value)) - } + public init(extendedGraphemeClusterLiteral value: RawValue.ExtendedGraphemeClusterLiteralType) { + self.init(value: RawValue(extendedGraphemeClusterLiteral: value)) + } } extension Attribute: ExpressibleByStringLiteral where RawValue: ExpressibleByStringLiteral { - public typealias StringLiteralType = RawValue.StringLiteralType + public typealias StringLiteralType = RawValue.StringLiteralType - public init(stringLiteral value: RawValue.StringLiteralType) { - self.init(value: RawValue(stringLiteral: value)) - } + public init(stringLiteral value: RawValue.StringLiteralType) { + self.init(value: RawValue(stringLiteral: value)) + } } extension Attribute: ExpressibleByNilLiteral where RawValue: ExpressibleByNilLiteral { - public init(nilLiteral: ()) { - self.init(value: RawValue(nilLiteral: ())) - } + public init(nilLiteral: ()) { + self.init(value: RawValue(nilLiteral: ())) + } } extension Attribute: ExpressibleByFloatLiteral where RawValue: ExpressibleByFloatLiteral { - public typealias FloatLiteralType = RawValue.FloatLiteralType + public typealias FloatLiteralType = RawValue.FloatLiteralType - public init(floatLiteral value: RawValue.FloatLiteralType) { - self.init(value: RawValue(floatLiteral: value)) - } + public init(floatLiteral value: RawValue.FloatLiteralType) { + self.init(value: RawValue(floatLiteral: value)) + } } extension Optional: ExpressibleByFloatLiteral where Wrapped: ExpressibleByFloatLiteral { - public typealias FloatLiteralType = Wrapped.FloatLiteralType + public typealias FloatLiteralType = Wrapped.FloatLiteralType - public init(floatLiteral value: FloatLiteralType) { - self = .some(Wrapped(floatLiteral: value)) - } + public init(floatLiteral value: FloatLiteralType) { + self = .some(Wrapped(floatLiteral: value)) + } } extension Attribute: ExpressibleByBooleanLiteral where RawValue: ExpressibleByBooleanLiteral { - public typealias BooleanLiteralType = RawValue.BooleanLiteralType + public typealias BooleanLiteralType = RawValue.BooleanLiteralType - public init(booleanLiteral value: BooleanLiteralType) { - self.init(value: RawValue(booleanLiteral: value)) - } + public init(booleanLiteral value: BooleanLiteralType) { + self.init(value: RawValue(booleanLiteral: value)) + } } extension Optional: ExpressibleByBooleanLiteral where Wrapped: ExpressibleByBooleanLiteral { - public typealias BooleanLiteralType = Wrapped.BooleanLiteralType + public typealias BooleanLiteralType = Wrapped.BooleanLiteralType - public init(booleanLiteral value: BooleanLiteralType) { - self = .some(Wrapped(booleanLiteral: value)) - } + public init(booleanLiteral value: BooleanLiteralType) { + self = .some(Wrapped(booleanLiteral: value)) + } } extension Attribute: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral { - public typealias IntegerLiteralType = RawValue.IntegerLiteralType + public typealias IntegerLiteralType = RawValue.IntegerLiteralType - public init(integerLiteral value: IntegerLiteralType) { - self.init(value: RawValue(integerLiteral: value)) - } + public init(integerLiteral value: IntegerLiteralType) { + self.init(value: RawValue(integerLiteral: value)) + } } extension Optional: ExpressibleByIntegerLiteral where Wrapped: ExpressibleByIntegerLiteral { - public typealias IntegerLiteralType = Wrapped.IntegerLiteralType + public typealias IntegerLiteralType = Wrapped.IntegerLiteralType - public init(integerLiteral value: IntegerLiteralType) { - self = .some(Wrapped(integerLiteral: value)) - } + public init(integerLiteral value: IntegerLiteralType) { + self = .some(Wrapped(integerLiteral: value)) + } } // regretably, array and dictionary literals are not so easy because Dictionaries and Arrays @@ -84,55 +84,55 @@ extension Optional: ExpressibleByIntegerLiteral where Wrapped: ExpressibleByInte // we can still provide a case for the Array and Dictionary types, though. public protocol DictionaryType { - associatedtype Key: Hashable - associatedtype Value + associatedtype Key: Hashable + associatedtype Value - init(_ keysAndValues: S, uniquingKeysWith combine: (Dictionary.Value, Dictionary.Value) throws -> Dictionary.Value) rethrows where S : Sequence, S.Element == (Key, Value) + init(_ keysAndValues: S, uniquingKeysWith combine: (Dictionary.Value, Dictionary.Value) throws -> Dictionary.Value) rethrows where S : Sequence, S.Element == (Key, Value) } extension Dictionary: DictionaryType {} extension Attribute: ExpressibleByDictionaryLiteral where RawValue: DictionaryType { - public typealias Key = RawValue.Key + public typealias Key = RawValue.Key - public typealias Value = RawValue.Value + public typealias Value = RawValue.Value - public init(dictionaryLiteral elements: (RawValue.Key, RawValue.Value)...) { + public init(dictionaryLiteral elements: (RawValue.Key, RawValue.Value)...) { - // we arbitrarily keep the first value if two values are assigned to the same key - self.init(value: RawValue(elements, uniquingKeysWith: { val, _ in val })) - } + // we arbitrarily keep the first value if two values are assigned to the same key + self.init(value: RawValue(elements, uniquingKeysWith: { val, _ in val })) + } } extension Optional: DictionaryType where Wrapped: DictionaryType { - public typealias Key = Wrapped.Key + public typealias Key = Wrapped.Key - public typealias Value = Wrapped.Value + public typealias Value = Wrapped.Value - public init(_ keysAndValues: S, uniquingKeysWith combine: (Dictionary.Value, Dictionary.Value) throws -> Dictionary.Value) rethrows where S : Sequence, S.Element == (Key, Value) { - self = try .some(Wrapped(keysAndValues, uniquingKeysWith: combine)) - } + public init(_ keysAndValues: S, uniquingKeysWith combine: (Dictionary.Value, Dictionary.Value) throws -> Dictionary.Value) rethrows where S : Sequence, S.Element == (Key, Value) { + self = try .some(Wrapped(keysAndValues, uniquingKeysWith: combine)) + } } public protocol ArrayType { - associatedtype Element + associatedtype Element - init(_ s: S) where Element == S.Element, S : Sequence + init(_ s: S) where Element == S.Element, S : Sequence } extension Array: ArrayType {} extension ArraySlice: ArrayType {} extension Attribute: ExpressibleByArrayLiteral where RawValue: ArrayType { - public typealias ArrayLiteralElement = RawValue.Element + public typealias ArrayLiteralElement = RawValue.Element - public init(arrayLiteral elements: ArrayLiteralElement...) { - self.init(value: RawValue(elements)) - } + public init(arrayLiteral elements: ArrayLiteralElement...) { + self.init(value: RawValue(elements)) + } } extension Optional: ArrayType where Wrapped: ArrayType { - public typealias Element = Wrapped.Element + public typealias Element = Wrapped.Element - public init(_ s: S) where Element == S.Element, S : Sequence { - self = .some(Wrapped(s)) - } + public init(_ s: S) where Element == S.Element, S : Sequence { + self = .some(Wrapped(s)) + } } diff --git a/Sources/JSONAPITesting/Comparisons/ArrayCompare.swift b/Sources/JSONAPITesting/Comparisons/ArrayCompare.swift new file mode 100644 index 0000000..f93ab62 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/ArrayCompare.swift @@ -0,0 +1,67 @@ +// +// File.swift +// +// +// Created by Mathew Polzin on 11/5/19. +// + +import JSONAPI + +public enum ArrayElementComparison: Equatable, CustomStringConvertible { + case same + case missing + case differentTypes(String, String) + case differentValues(String, String) + case prebuilt(String) + + public init(resourceObjectComparison: ResourceObjectComparison) { + guard !resourceObjectComparison.isSame else { + self = .same + return + } + + self = .prebuilt( + resourceObjectComparison + .differences + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: ", ") + ) + } + + public var description: String { + switch self { + case .same: + return "same" + case .missing: + return "missing" + case .differentTypes(let one, let two), + .differentValues(let one, let two): + return "\(one) ≠ \(two)" + case .prebuilt(let description): + return description + } + } + + public var rawValue: String { description } +} + +extension Array { + func compare(to other: Self, using compare: (Element, Element) -> ArrayElementComparison) -> [ArrayElementComparison] { + let isSelfLonger = count >= other.count + + let longer = isSelfLonger ? self : other + let shorter = isSelfLonger ? other : self + + return longer.indices.map { idx in + guard shorter.indices.contains(idx) else { + return .missing + } + + let this = longer[idx] + let other = shorter[idx] + + return compare(this, other) + } + } +} diff --git a/Sources/JSONAPITesting/Comparisons/AttributesCompare.swift b/Sources/JSONAPITesting/Comparisons/AttributesCompare.swift new file mode 100644 index 0000000..83aeea2 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/AttributesCompare.swift @@ -0,0 +1,116 @@ +// +// AttributesCompare.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +import JSONAPI + +extension Attributes { + public func compare(to other: Self) -> [String: BasicComparison] { + let mirror1 = Mirror(reflecting: self) + let mirror2 = Mirror(reflecting: other) + + var comparisons = [String: BasicComparison]() + + for child in mirror1.children { + guard let childLabel = child.label else { continue } + + let childDescription = attributeDescription(of: child.value) + + guard let otherChild = mirror2.children.first(where: { $0.label == childLabel }) else { + comparisons[childLabel] = .different(childDescription, "missing") + continue + } + + do { + if (try attributesEqual(child.value, otherChild.value)) { + comparisons[childLabel] = .same + } else { + let otherChildDescription = attributeDescription(of: otherChild.value) + + comparisons[childLabel] = .different(childDescription, otherChildDescription) + } + } catch let error { + comparisons[childLabel] = .prebuilt(String(describing: error)) + } + } + + return comparisons + } +} + +enum AttributeCompareError: Swift.Error, CustomStringConvertible { + case nonAttributeTypeProperty(String) + + var description: String { + switch self { + case .nonAttributeTypeProperty(let type): + return "comparison on non-JSON:API Attribute type (\(type)) not supported." + } + } +} + +fileprivate func attributesEqual(_ one: Any, _ two: Any) throws -> Bool { + guard let attr = one as? AbstractAttribute else { + throw AttributeCompareError.nonAttributeTypeProperty(String(describing: type(of: one))) + } + + return attr.equals(two) +} + +fileprivate func attributeDescription(of thing: Any) -> String { + return (thing as? AbstractAttribute)?.abstractDescription ?? String(describing: thing) +} + +protocol AbstractAttribute { + var abstractDescription: String { get } + + func equals(_ other: Any) -> Bool +} + +extension Optional: AbstractAttribute where Wrapped: AbstractAttribute { + var abstractDescription: String { + switch self { + case .none: + return "nil" + case .some(let rel): + return rel.abstractDescription + } + } + + func equals(_ other: Any) -> Bool { + switch self { + case .none: + return (other as? _AbstractWrapper).map { $0.abstractSelf == nil } ?? false + case .some(let rel): + guard case let .some(otherVal) = (other as? _AbstractWrapper)?.abstractSelf else { + return rel.equals(other) + } + return rel.equals(otherVal) + } + } +} + +extension Attribute: AbstractAttribute { + var abstractDescription: String { String(describing: value) } + + func equals(_ other: Any) -> Bool { + guard let attributeB = other as? Self else { + return false + } + return abstractDescription == attributeB.abstractDescription + } +} + +extension TransformedAttribute: AbstractAttribute { + var abstractDescription: String { String(describing: value) } + + func equals(_ other: Any) -> Bool { + guard let attributeB = other as? Self else { + return false + } + return abstractDescription == attributeB.abstractDescription + } +} diff --git a/Sources/JSONAPITesting/Comparisons/Comparison.swift b/Sources/JSONAPITesting/Comparisons/Comparison.swift new file mode 100644 index 0000000..c0f3240 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/Comparison.swift @@ -0,0 +1,74 @@ +// +// Comparison.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +public protocol Comparison: CustomStringConvertible { + var rawValue: String { get } + + var isSame: Bool { get } +} + +public enum BasicComparison: Comparison, Equatable { + case same + case different(String, String) + case prebuilt(String) + + init(_ one: T, _ two: T) { + guard one == two else { + self = .different(String(describing: one), String(describing: two)) + return + } + self = .same + } + + init(reducing other: ArrayElementComparison) { + switch other { + case .same: + self = .same + case .differentTypes(let one, let two), + .differentValues(let one, let two): + self = .different(one, two) + case .missing: + self = .different("array length 1", "array length 2") + case .prebuilt(let str): + self = .prebuilt(str) + } + } + + public var description: String { + switch self { + case .same: + return "same" + case .different(let one, let two): + return "\(one) ≠ \(two)" + case .prebuilt(let str): + return str + } + } + + public var rawValue: String { description } + + public var isSame: Bool { self == .same } +} + +public typealias NamedDifferences = [String: String] + +public protocol PropertyComparison: Comparison { + var differences: NamedDifferences { get } +} + +extension PropertyComparison { + public var description: String { + return differences + .map { "(\($0): \($1))" } + .sorted() + .joined(separator: ", ") + } + + public var rawValue: String { description } + + public var isSame: Bool { differences.isEmpty } +} diff --git a/Sources/JSONAPITesting/Comparisons/DocumentCompare.swift b/Sources/JSONAPITesting/Comparisons/DocumentCompare.swift new file mode 100644 index 0000000..4cee720 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/DocumentCompare.swift @@ -0,0 +1,132 @@ +// +// DocumentCompare.swift +// JSONAPITesting +// +// Created by Mathew Polzin on 11/4/19. +// + +import JSONAPI + +public struct DocumentComparison: Equatable, PropertyComparison { + public let apiDescription: BasicComparison + public let body: BodyComparison + + init(apiDescription: BasicComparison, body: BodyComparison) { + self.apiDescription = apiDescription + self.body = body + } + + public var differences: NamedDifferences { + return Dictionary( + [ + apiDescription != .same ? ("API Description", apiDescription.rawValue) : nil, + body != .same ? ("Body", body.rawValue) : nil + ].compactMap { $0 }, + uniquingKeysWith: { $1 } + ) + } +} + +public enum BodyComparison: Equatable, CustomStringConvertible { + case same + case dataErrorMismatch(errorOnLeft: Bool) + case differentErrors(ErrorComparison) + case differentData(DocumentDataComparison) + + public typealias ErrorComparison = [String: BasicComparison] + + static func compare(errors errors1: [E], _ meta1: M?, _ links1: L?, with errors2: [E], _ meta2: M?, _ links2: L?) -> ErrorComparison { + let errorComparisons = errors1.compare( + to: errors2, + using: { error1, error2 in + guard error1 != error2 else { + return .same + } + + return .differentValues( + String(describing: error1), + String(describing: error2) + ) + } + ).map(BasicComparison.init) + .filter { !$0.isSame } + .map { $0.rawValue } + .joined(separator: ", ") + + let errorComparisonString = errorComparisons.isEmpty + ? nil + : errorComparisons + + return [ + "Errors": errorComparisonString.map { BasicComparison.prebuilt("(\($0))") } ?? .same, + "Metadata": BasicComparison(meta1, meta2), + "Links": BasicComparison(links1, links2) + ] + } + + public var description: String { + switch self { + case .same: + return "same" + case .dataErrorMismatch(errorOnLeft: let errorOnLeft): + let errorString = "error response" + let dataString = "data response" + let left = errorOnLeft ? errorString : dataString + let right = errorOnLeft ? dataString : errorString + + return "\(left) ≠ \(right)" + case .differentErrors(let comparisons): + return comparisons + .filter { !$0.value.isSame } + .map { "\($0.key): \($0.value.rawValue)" } + .sorted() + .joined(separator: ", ") + case .differentData(let comparison): + return comparison.rawValue + } + } + + public var rawValue: String { description } +} + +extension EncodableJSONAPIDocument where Body: Equatable, PrimaryResourceBody: TestableResourceBody { + public func compare(to other: Self) -> DocumentComparison { + return DocumentComparison( + apiDescription: BasicComparison( + String(describing: apiDescription), + String(describing: other.apiDescription) + ), + body: body.compare(to: other.body) + ) + } +} + +extension DocumentBody where Self: Equatable, PrimaryResourceBody: TestableResourceBody { + public func compare(to other: Self) -> BodyComparison { + + // rule out case where they are the same + guard self != other else { + return .same + } + + // rule out case where they are both error bodies + if let errors1 = errors, let errors2 = other.errors { + return .differentErrors( + BodyComparison.compare( + errors: errors1, meta, links, + with: errors2, other.meta, other.links + ) + ) + } + + // rule out the case where they are both data + if let data1 = data, let data2 = other.data { + return .differentData(data1.compare(to: data2)) + } + + // we are left with the case where one is data and the + // other is an error if self.isError, then "the error + // is on the left" + return .dataErrorMismatch(errorOnLeft: isError) + } +} diff --git a/Sources/JSONAPITesting/Comparisons/DocumentDataCompare.swift b/Sources/JSONAPITesting/Comparisons/DocumentDataCompare.swift new file mode 100644 index 0000000..da7f27e --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/DocumentDataCompare.swift @@ -0,0 +1,157 @@ +// +// DocumentDataCompare.swift +// +// +// Created by Mathew Polzin on 11/5/19. +// + +import JSONAPI + +public struct DocumentDataComparison: Equatable, PropertyComparison { + public let primary: PrimaryResourceBodyComparison + public let includes: IncludesComparison + public let meta: BasicComparison + public let links: BasicComparison + + init(primary: PrimaryResourceBodyComparison, includes: IncludesComparison, meta: BasicComparison, links: BasicComparison) { + self.primary = primary + self.includes = includes + self.meta = meta + self.links = links + } + + public var differences: NamedDifferences { + return Dictionary( + [ + !primary.isSame ? ("Primary Resource", primary.rawValue) : nil, + !includes.isSame ? ("Includes", includes.rawValue) : nil, + !meta.isSame ? ("Meta", meta.rawValue) : nil, + !links.isSame ? ("Links", links.rawValue) : nil + ].compactMap { $0 }, + uniquingKeysWith: { $1 } + ) + } +} + +extension DocumentBodyData where PrimaryResourceBody: TestableResourceBody { + public func compare(to other: Self) -> DocumentDataComparison { + return .init( + primary: primary.compare(to: other.primary), + includes: includes.compare(to: other.includes), + meta: BasicComparison(meta, other.meta), + links: BasicComparison(links, other.links) + ) + } +} + +public enum PrimaryResourceBodyComparison: Equatable, CustomStringConvertible { + case oneOrMore(ManyResourceObjectComparison) + case optionalSingle(BasicComparison) + + public var isSame: Bool { + switch self { + case .optionalSingle(let comparison): + return comparison == .same + case .oneOrMore(let comparison): + return comparison.isSame + } + } + + public var description: String { + switch self { + case .optionalSingle(let comparison): + return comparison.rawValue + case .oneOrMore(let comparison): + return comparison.rawValue + } + } + + public var rawValue: String { return description } +} + +public struct ManyResourceObjectComparison: Equatable, PropertyComparison { + public let comparisons: [ArrayElementComparison] + + public init(_ comparisons: [ArrayElementComparison]) { + self.comparisons = comparisons + } + + public var differences: NamedDifferences { + return comparisons + .enumerated() + .filter { $0.element != .same } + .reduce(into: [String: String]()) { hash, next in + hash["resource \(next.offset + 1)"] = next.element.rawValue + } + } +} + +extension TestableResourceBody where TestablePrimaryResourceType: ResourceObjectType { + public func compare(to other: Self) -> PrimaryResourceBodyComparison { + guard let one = testableResourceObject, + let two = other.testableResourceObject else { + + func nilOrName(_ resObj: [T]?) -> String { + resObj.map { _ in String(describing: T.self) } ?? "nil" + } + + return .optionalSingle(BasicComparison(nilOrName(testableResourceObject), nilOrName(other.testableResourceObject))) + } + + return .oneOrMore(.init(one.compare(to: two, using: { r1, r2 in + let r1AsResource = r1 as? AbstractResourceObjectType + + let maybeComparison = r1AsResource + .flatMap { resource in + try? ArrayElementComparison( + resourceObjectComparison: resource.abstractCompare(to: r2) + ) + } + + guard let comparison = maybeComparison else { + return .differentValues( + String(describing: r1), + String(describing: r2) + ) + } + + return comparison + }))) + } +} + +public protocol TestableResourceBody { + associatedtype TestablePrimaryResourceType: ResourceObjectType + var testableResourceObject: [TestablePrimaryResourceType]? { get } +} + +public protocol OptionalResourceObjectType { + associatedtype Wrapped: ResourceObjectType + + var maybeValue: Wrapped? { get } +} + +extension Optional: OptionalResourceObjectType where Wrapped: ResourceObjectType { + public var maybeValue: Wrapped? { + switch self { + case .none: + return nil + case .some(let value): + return value + } + } +} + +extension ResourceObject: OptionalResourceObjectType { + public var maybeValue: Self? { self } +} + +extension ManyResourceBody: TestableResourceBody where PrimaryResource: ResourceObjectType { + public var testableResourceObject: [PrimaryResource]? { values } +} + +extension SingleResourceBody: TestableResourceBody where PrimaryResource: OptionalResourceObjectType { + public typealias TestablePrimaryResourceType = PrimaryResource.Wrapped + + public var testableResourceObject: [TestablePrimaryResourceType]? { value.maybeValue.map { [$0] } } +} diff --git a/Sources/JSONAPITesting/Comparisons/IncludesCompare.swift b/Sources/JSONAPITesting/Comparisons/IncludesCompare.swift new file mode 100644 index 0000000..13b293d --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/IncludesCompare.swift @@ -0,0 +1,66 @@ +// +// IncludesCompare.swift +// +// +// Created by Mathew Polzin on 11/4/19. +// + +import JSONAPI +import Poly + +public struct IncludesComparison: Equatable, PropertyComparison { + public let comparisons: [ArrayElementComparison] + + public init(_ comparisons: [ArrayElementComparison]) { + self.comparisons = comparisons + } + + public var differences: NamedDifferences { + return comparisons + .enumerated() + .filter { $0.element != .same } + .reduce(into: [String: String]()) { hash, next in + hash["include \(next.offset + 1)"] = next.element.rawValue + } + } +} + +extension Includes { + public func compare(to other: Self) -> IncludesComparison { + + return IncludesComparison( + values.compare(to: other.values) { thisInclude, otherInclude in + guard thisInclude != otherInclude else { + return .same + } + + let thisWrappedValue = thisInclude.value + let otherWrappedValue = otherInclude.value + guard type(of: thisWrappedValue) == type(of: otherWrappedValue) else { + return .differentTypes( + String(describing: type(of: thisWrappedValue)), + String(describing: type(of: otherWrappedValue)) + ) + } + + let thisAsAResource = thisWrappedValue as? AbstractResourceObjectType + + let maybeComparison = thisAsAResource + .flatMap { resource in + try? ArrayElementComparison( + resourceObjectComparison: resource.abstractCompare(to: otherWrappedValue) + ) + } + + guard let comparison = maybeComparison else { + return .differentValues( + String(describing: thisWrappedValue), + String(describing: otherWrappedValue) + ) + } + + return comparison + } + ) + } +} diff --git a/Sources/JSONAPITesting/Comparisons/Optional+AbstractWrapper.swift b/Sources/JSONAPITesting/Comparisons/Optional+AbstractWrapper.swift new file mode 100644 index 0000000..2a068d0 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/Optional+AbstractWrapper.swift @@ -0,0 +1,16 @@ +// +// Optional+AbstractWrapper.swift +// JSONAPITesting +// +// Created by Mathew Polzin on 11/15/19. +// + +protocol _AbstractWrapper { + var abstractSelf: Any? { get } +} + +extension Optional: _AbstractWrapper { + var abstractSelf: Any? { + return self + } +} diff --git a/Sources/JSONAPITesting/Comparisons/RelationshipsCompare.swift b/Sources/JSONAPITesting/Comparisons/RelationshipsCompare.swift new file mode 100644 index 0000000..9a8c010 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/RelationshipsCompare.swift @@ -0,0 +1,143 @@ +// +// RelationshipsCompare.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +import JSONAPI + +extension Relationships { + public func compare(to other: Self) -> [String: BasicComparison] { + let mirror1 = Mirror(reflecting: self) + let mirror2 = Mirror(reflecting: other) + + var comparisons = [String: BasicComparison]() + + for child in mirror1.children { + guard let childLabel = child.label else { continue } + + let childDescription = relationshipDescription(of: child.value) + + guard let otherChild = mirror2.children.first(where: { $0.label == childLabel }) else { + comparisons[childLabel] = .different(childDescription, "missing") + continue + } + + do { + if (try relationshipsEqual(child.value, otherChild.value)) { + comparisons[childLabel] = .same + } else { + let otherChildDescription = relationshipDescription(of: otherChild.value) + + comparisons[childLabel] = .different(childDescription, otherChildDescription) + } + } catch let error { + comparisons[childLabel] = .prebuilt(String(describing: error)) + } + } + + return comparisons + } +} + +enum RelationshipCompareError: Swift.Error, CustomStringConvertible { + case nonRelationshipTypeProperty(String) + + var description: String { + switch self { + case .nonRelationshipTypeProperty(let type): + return "comparison on non-JSON:API Relationship type (\(type)) not supported." + } + } +} + +fileprivate func relationshipsEqual(_ one: Any, _ two: Any) throws -> Bool { + guard let attr = one as? AbstractRelationship else { + throw RelationshipCompareError.nonRelationshipTypeProperty(String(describing: type(of: one))) + } + + return attr.equals(two) +} + +fileprivate func relationshipDescription(of thing: Any) -> String { + return (thing as? AbstractRelationship)?.abstractDescription ?? String(describing: thing) +} + +protocol AbstractRelationship { + var abstractDescription: String { get } + + func equals(_ other: Any) -> Bool +} + +extension Optional: AbstractRelationship where Wrapped: AbstractRelationship { + var abstractDescription: String { + switch self { + case .none: + return "nil" + case .some(let rel): + return rel.abstractDescription + } + } + + func equals(_ other: Any) -> Bool { + switch self { + case .none: + return (other as? _AbstractWrapper).map { $0.abstractSelf == nil } ?? false + case .some(let rel): + guard case let .some(otherVal) = (other as? _AbstractWrapper)?.abstractSelf else { + return rel.equals(other) + } + return rel.equals(otherVal) + } + } +} + +extension ToOneRelationship: AbstractRelationship { + var abstractDescription: String { + if meta is NoMetadata && links is NoLinks { + return String(describing: id) + } + + return String(describing: + ( + String(describing: id), + String(describing: meta), + String(describing: links) + ) + ) + } + + func equals(_ other: Any) -> Bool { + guard let attributeB = other as? Self else { + return false + } + return abstractDescription == attributeB.abstractDescription + } +} + +extension ToManyRelationship: AbstractRelationship { + var abstractDescription: String { + + let idsString = ids.map { String.init(describing: $0.rawValue) }.joined(separator: ", ") + + if meta is NoMetadata && links is NoLinks { + return idsString + } + + return String(describing: + ( + idsString, + String(describing: meta), + String(describing: links) + ) + ) + } + + func equals(_ other: Any) -> Bool { + guard let attributeB = other as? Self else { + return false + } + return abstractDescription == attributeB.abstractDescription + } +} diff --git a/Sources/JSONAPITesting/Comparisons/ResourceObjectCompare.swift b/Sources/JSONAPITesting/Comparisons/ResourceObjectCompare.swift new file mode 100644 index 0000000..ddfeb49 --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/ResourceObjectCompare.swift @@ -0,0 +1,71 @@ +// +// ResourceObjectCompare.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +import JSONAPI + +public struct ResourceObjectComparison: Equatable, PropertyComparison { + public typealias ComparisonHash = [String: BasicComparison] + + public let id: BasicComparison + public let attributes: ComparisonHash + public let relationships: ComparisonHash + public let meta: BasicComparison + public let links: BasicComparison + + public init(_ one: T, _ two: T) { + id = BasicComparison(one.id.rawValue, two.id.rawValue) + attributes = one.attributes.compare(to: two.attributes) + relationships = one.relationships.compare(to: two.relationships) + meta = BasicComparison(one.meta, two.meta) + links = BasicComparison(one.links, two.links) + } + + public var differences: NamedDifferences { + return attributes.reduce(into: ComparisonHash()) { hash, next in + hash["'\(next.key)' attribute"] = next.value + } + .merging( + relationships.reduce(into: ComparisonHash()) { hash, next in + hash["'\(next.key)' relationship"] = next.value + }, + uniquingKeysWith: { $1 } + ) + .merging( + [ + "id": id, + "meta": meta, + "links": links + ], + uniquingKeysWith: { $1 } + ) + .filter { $1 != .same } + .mapValues { $0.rawValue } + } +} + +extension ResourceObjectType { + public func compare(to other: Self) -> ResourceObjectComparison { + return ResourceObjectComparison(self, other) + } +} + +protocol AbstractResourceObjectType { + func abstractCompare(to other: Any) throws -> ResourceObjectComparison +} + +enum AbstractCompareError: Swift.Error { + case typeMismatch +} + +extension ResourceObject: AbstractResourceObjectType { + func abstractCompare(to other: Any) throws -> ResourceObjectComparison { + guard let otherResource = other as? Self else { + throw AbstractCompareError.typeMismatch + } + return self.compare(to: otherResource) + } +} diff --git a/Sources/JSONAPITesting/Id+Literal.swift b/Sources/JSONAPITesting/Id+Literal.swift index b05b8b1..92121f9 100644 --- a/Sources/JSONAPITesting/Id+Literal.swift +++ b/Sources/JSONAPITesting/Id+Literal.swift @@ -8,33 +8,33 @@ import JSONAPI extension Id: ExpressibleByUnicodeScalarLiteral where RawType: ExpressibleByUnicodeScalarLiteral { - public typealias UnicodeScalarLiteralType = RawType.UnicodeScalarLiteralType - - public init(unicodeScalarLiteral value: RawType.UnicodeScalarLiteralType) { - self.init(rawValue: RawType(unicodeScalarLiteral: value)) - } + public typealias UnicodeScalarLiteralType = RawType.UnicodeScalarLiteralType + + public init(unicodeScalarLiteral value: RawType.UnicodeScalarLiteralType) { + self.init(rawValue: RawType(unicodeScalarLiteral: value)) + } } extension Id: ExpressibleByExtendedGraphemeClusterLiteral where RawType: ExpressibleByExtendedGraphemeClusterLiteral { - public typealias ExtendedGraphemeClusterLiteralType = RawType.ExtendedGraphemeClusterLiteralType - - public init(extendedGraphemeClusterLiteral value: RawType.ExtendedGraphemeClusterLiteralType) { - self.init(rawValue: RawType(extendedGraphemeClusterLiteral: value)) - } + public typealias ExtendedGraphemeClusterLiteralType = RawType.ExtendedGraphemeClusterLiteralType + + public init(extendedGraphemeClusterLiteral value: RawType.ExtendedGraphemeClusterLiteralType) { + self.init(rawValue: RawType(extendedGraphemeClusterLiteral: value)) + } } extension Id: ExpressibleByStringLiteral where RawType: ExpressibleByStringLiteral { - public typealias StringLiteralType = RawType.StringLiteralType - - public init(stringLiteral value: RawType.StringLiteralType) { - self.init(rawValue: RawType(stringLiteral: value)) - } + public typealias StringLiteralType = RawType.StringLiteralType + + public init(stringLiteral value: RawType.StringLiteralType) { + self.init(rawValue: RawType(stringLiteral: value)) + } } extension Id: ExpressibleByIntegerLiteral where RawType: ExpressibleByIntegerLiteral { - public typealias IntegerLiteralType = RawType.IntegerLiteralType - - public init(integerLiteral value: IntegerLiteralType) { - self.init(rawValue: RawType(integerLiteral: value)) - } + public typealias IntegerLiteralType = RawType.IntegerLiteralType + + public init(integerLiteral value: IntegerLiteralType) { + self.init(rawValue: RawType(integerLiteral: value)) + } } diff --git a/Sources/JSONAPITesting/Optional+Literal.swift b/Sources/JSONAPITesting/Optional+Literal.swift index e0d81b5..8c87fab 100644 --- a/Sources/JSONAPITesting/Optional+Literal.swift +++ b/Sources/JSONAPITesting/Optional+Literal.swift @@ -6,25 +6,25 @@ // extension Optional: ExpressibleByUnicodeScalarLiteral where Wrapped: ExpressibleByUnicodeScalarLiteral { - public typealias UnicodeScalarLiteralType = Wrapped.UnicodeScalarLiteralType + public typealias UnicodeScalarLiteralType = Wrapped.UnicodeScalarLiteralType - public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { - self = .some(Wrapped(unicodeScalarLiteral: value)) - } + public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { + self = .some(Wrapped(unicodeScalarLiteral: value)) + } } extension Optional: ExpressibleByExtendedGraphemeClusterLiteral where Wrapped: ExpressibleByExtendedGraphemeClusterLiteral { - public typealias ExtendedGraphemeClusterLiteralType = Wrapped.ExtendedGraphemeClusterLiteralType + public typealias ExtendedGraphemeClusterLiteralType = Wrapped.ExtendedGraphemeClusterLiteralType - public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { - self = .some(Wrapped(extendedGraphemeClusterLiteral: value)) - } + public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { + self = .some(Wrapped(extendedGraphemeClusterLiteral: value)) + } } extension Optional: ExpressibleByStringLiteral where Wrapped: ExpressibleByStringLiteral { - public typealias StringLiteralType = Wrapped.StringLiteralType + public typealias StringLiteralType = Wrapped.StringLiteralType - public init(stringLiteral value: StringLiteralType) { - self = .some(Wrapped(stringLiteral: value)) - } + public init(stringLiteral value: StringLiteralType) { + self = .some(Wrapped(stringLiteral: value)) + } } diff --git a/Sources/JSONAPITesting/Optional+ZipWith.swift b/Sources/JSONAPITesting/Optional+ZipWith.swift new file mode 100644 index 0000000..aacbb7b --- /dev/null +++ b/Sources/JSONAPITesting/Optional+ZipWith.swift @@ -0,0 +1,12 @@ +// +// Optional+ZipWith.swift +// +// Created by Mathew Polzin on 1/19/19. +// + +/// Zip two optionals together with the given operation performed on +/// the unwrapped contents. If either optional is nil, the zip +/// yields nil. +func zip(_ left: X?, _ right: Y?, with fn: (X, Y) -> Z) -> Z? { + return left.flatMap { lft in right.map { rght in fn(lft, rght) }} +} diff --git a/Sources/JSONAPITesting/Relationship+Literal.swift b/Sources/JSONAPITesting/Relationship+Literal.swift index 9af692c..551fd88 100644 --- a/Sources/JSONAPITesting/Relationship+Literal.swift +++ b/Sources/JSONAPITesting/Relationship+Literal.swift @@ -8,40 +8,40 @@ import JSONAPI extension ToOneRelationship: ExpressibleByNilLiteral where Identifiable.Identifier: ExpressibleByNilLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public init(nilLiteral: ()) { + public init(nilLiteral: ()) { - self.init(id: Identifiable.Identifier(nilLiteral: ())) - } + self.init(id: Identifiable.Identifier(nilLiteral: ())) + } } extension ToOneRelationship: ExpressibleByUnicodeScalarLiteral where Identifiable.Identifier: ExpressibleByUnicodeScalarLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias UnicodeScalarLiteralType = Identifiable.Identifier.UnicodeScalarLiteralType + public typealias UnicodeScalarLiteralType = Identifiable.Identifier.UnicodeScalarLiteralType - public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { - self.init(id: Identifiable.Identifier(unicodeScalarLiteral: value)) - } + public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { + self.init(id: Identifiable.Identifier(unicodeScalarLiteral: value)) + } } extension ToOneRelationship: ExpressibleByExtendedGraphemeClusterLiteral where Identifiable.Identifier: ExpressibleByExtendedGraphemeClusterLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias ExtendedGraphemeClusterLiteralType = Identifiable.Identifier.ExtendedGraphemeClusterLiteralType + public typealias ExtendedGraphemeClusterLiteralType = Identifiable.Identifier.ExtendedGraphemeClusterLiteralType - public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { - self.init(id: Identifiable.Identifier(extendedGraphemeClusterLiteral: value)) - } + public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { + self.init(id: Identifiable.Identifier(extendedGraphemeClusterLiteral: value)) + } } extension ToOneRelationship: ExpressibleByStringLiteral where Identifiable.Identifier: ExpressibleByStringLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias StringLiteralType = Identifiable.Identifier.StringLiteralType + public typealias StringLiteralType = Identifiable.Identifier.StringLiteralType - public init(stringLiteral value: StringLiteralType) { - self.init(id: Identifiable.Identifier(stringLiteral: value)) - } + public init(stringLiteral value: StringLiteralType) { + self.init(id: Identifiable.Identifier(stringLiteral: value)) + } } extension ToManyRelationship: ExpressibleByArrayLiteral where MetaType == NoMetadata, LinksType == NoLinks { - public typealias ArrayLiteralElement = Relatable.Identifier + public typealias ArrayLiteralElement = Relatable.Identifier - public init(arrayLiteral elements: ArrayLiteralElement...) { - self.init(ids: elements) - } + public init(arrayLiteral elements: ArrayLiteralElement...) { + self.init(ids: elements) + } } diff --git a/Sources/JSONAPITesting/ResourceObjectCheck.swift b/Sources/JSONAPITesting/ResourceObjectCheck.swift index 0749ec8..13c1724 100644 --- a/Sources/JSONAPITesting/ResourceObjectCheck.swift +++ b/Sources/JSONAPITesting/ResourceObjectCheck.swift @@ -8,29 +8,29 @@ import JSONAPI public enum ResourceObjectCheckError: Swift.Error { - /// The attributes should live in a struct, not - /// another type class. - case attributesNotStruct + /// The attributes should live in a struct, not + /// another type class. + case attributesNotStruct - /// The relationships should live in a struct, not - /// another type class. - case relationshipsNotStruct + /// The relationships should live in a struct, not + /// another type class. + case relationshipsNotStruct - /// All stored properties on an Attributes struct should - /// be one of the supplied Attribute types. - case nonAttribute(named: String) + /// All stored properties on an Attributes struct should + /// be one of the supplied Attribute types. + case nonAttribute(named: String) - /// All stored properties on a Relationships struct should - /// be one of the supplied Relationship types. - case nonRelationship(named: String) + /// All stored properties on a Relationships struct should + /// be one of the supplied Relationship types. + case nonRelationship(named: String) - /// It is explicitly stated by the JSON spec - /// a "none" value for arrays is an empty array, not `nil`. - case nullArray(named: String) + /// It is explicitly stated by the JSON spec + /// a "none" value for arrays is an empty array, not `nil`. + case nullArray(named: String) } public struct ResourceObjectCheckErrors: Swift.Error { - let problems: [ResourceObjectCheckError] + let problems: [ResourceObjectCheckError] } private protocol OptionalAttributeType {} @@ -55,40 +55,40 @@ extension TransformedAttribute: _AttributeType {} extension Attribute: _AttributeType {} public extension ResourceObject { - static func check(_ entity: ResourceObject) throws { - var problems = [ResourceObjectCheckError]() + static func check(_ entity: ResourceObject) throws { + var problems = [ResourceObjectCheckError]() - let attributesMirror = Mirror(reflecting: entity.attributes) + let attributesMirror = Mirror(reflecting: entity.attributes) - if attributesMirror.displayStyle != .`struct` { - problems.append(.attributesNotStruct) - } + if attributesMirror.displayStyle != .`struct` { + problems.append(.attributesNotStruct) + } - for attribute in attributesMirror.children { - if attribute.value as? _AttributeType == nil, - attribute.value as? OptionalAttributeType == nil { - problems.append(.nonAttribute(named: attribute.label ?? "unnamed")) - } - if attribute.value as? AttributeTypeWithOptionalArray != nil { - problems.append(.nullArray(named: attribute.label ?? "unnamed")) - } - } + for attribute in attributesMirror.children { + if attribute.value as? _AttributeType == nil, + attribute.value as? OptionalAttributeType == nil { + problems.append(.nonAttribute(named: attribute.label ?? "unnamed")) + } + if attribute.value as? AttributeTypeWithOptionalArray != nil { + problems.append(.nullArray(named: attribute.label ?? "unnamed")) + } + } - let relationshipsMirror = Mirror(reflecting: entity.relationships) + let relationshipsMirror = Mirror(reflecting: entity.relationships) - if relationshipsMirror.displayStyle != .`struct` { - problems.append(.relationshipsNotStruct) - } + if relationshipsMirror.displayStyle != .`struct` { + problems.append(.relationshipsNotStruct) + } - for relationship in relationshipsMirror.children { - if relationship.value as? _RelationshipType == nil, - relationship.value as? OptionalRelationshipType == nil { - problems.append(.nonRelationship(named: relationship.label ?? "unnamed")) - } - } + for relationship in relationshipsMirror.children { + if relationship.value as? _RelationshipType == nil, + relationship.value as? OptionalRelationshipType == nil { + problems.append(.nonRelationship(named: relationship.label ?? "unnamed")) + } + } - guard problems.count == 0 else { - throw ResourceObjectCheckErrors(problems: problems) - } - } + guard problems.count == 0 else { + throw ResourceObjectCheckErrors(problems: problems) + } + } } diff --git a/Tests/JSONAPITestingTests/Comparisons/ArrayCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/ArrayCompareTests.swift new file mode 100644 index 0000000..fccf84c --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/ArrayCompareTests.swift @@ -0,0 +1,86 @@ +// +// ArrayCompareTests.swift +// JSONAPITestingTests +// +// Created by Mathew Polzin on 11/14/19. +// + +import XCTest +@testable import JSONAPITesting + +final class ArrayCompareTests: XCTestCase { + func test_same() { + let arr1 = ["a", "b", "c"] + let arr2 = ["a", "b", "c"] + + let comparison = arr1.compare(to: arr2) { str1, str2 in + str1 == str2 ? .same : .differentValues(str1, str2) + } + + XCTAssertEqual( + comparison, + [.same, .same, .same] + ) + + XCTAssertEqual(comparison.map { $0.description }, ["same", "same", "same"]) + + XCTAssertEqual(comparison.map(BasicComparison.init(reducing:)), [.same, .same, .same]) + + XCTAssertEqual(comparison.map(BasicComparison.init(reducing:)).map { $0.description }, ["same", "same", "same"]) + } + + func test_differentLengths() { + let arr1 = ["a", "b", "c"] + let arr2 = ["a", "b"] + + let comparison1 = arr1.compare(to: arr2) { str1, str2 in + str1 == str2 ? .same : .differentValues(str1, str2) + } + + XCTAssertEqual( + comparison1, + [.same, .same, .missing] + ) + + XCTAssertEqual(comparison1.map { $0.description }, ["same", "same", "missing"]) + + XCTAssertEqual(comparison1.map(BasicComparison.init(reducing:)), [.same, .same, .different("array length 1", "array length 2")]) + + let comparison2 = arr2.compare(to: arr1) { str1, str2 in + str1 == str2 ? .same : .differentValues(str1, str2) + } + + XCTAssertEqual( + comparison2, + [.same, .same, .missing] + ) + + XCTAssertEqual(comparison2.map { $0.description }, ["same", "same", "missing"]) + + XCTAssertEqual(comparison2.map(BasicComparison.init(reducing:)), [.same, .same, .different("array length 1", "array length 2")]) + } + + func test_differentValues() { + let arr1 = ["c", "b", "a"] + let arr2 = ["a", "b", "c"] + + let comparison = arr1.compare(to: arr2) { str1, str2 in + str1 == str2 ? .same : .differentValues(str1, str2) + } + + XCTAssertEqual( + comparison, + [.differentValues("c", "a"), .same, .differentValues("a", "c")] + ) + + XCTAssertEqual(comparison.map { $0.description }, ["c ≠ a", "same", "a ≠ c"]) + } + + func test_reducePrebuilt() { + let prebuilt = ArrayElementComparison.prebuilt("hello world") + + XCTAssertEqual(BasicComparison(reducing: prebuilt), .prebuilt("hello world")) + + XCTAssertEqual(BasicComparison(reducing: prebuilt).description, "hello world") + } +} diff --git a/Tests/JSONAPITestingTests/Comparisons/AttributesCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/AttributesCompareTests.swift new file mode 100644 index 0000000..6141be6 --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/AttributesCompareTests.swift @@ -0,0 +1,127 @@ +// +// AttributesCompareTests.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +final class AttributesCompareTests: XCTestCase { + func test_sameAttributes() throws { + let attr1 = TestAttributes( + string: "hello world", + int: 10, + bool: true, + double: 105.4, + struct: .init(value: .init()), + transformed: try .init(rawValue: 10), + optional: .init(value: 20), + optionalTransformed: try .init(rawValue: 10) + ) + let attr2 = attr1 + + XCTAssertEqual(attr1.compare(to: attr2), [ + "string": .same, + "int": .same, + "bool": .same, + "double": .same, + "struct": .same, + "transformed": .same, + "optional": .same, + "optionalTransformed": .same + ]) + } + + func test_differentAttributes() throws { + let attr1 = TestAttributes( + string: "hello world", + int: 10, + bool: true, + double: 105.4, + struct: .init(value: .init()), + transformed: try .init(rawValue: 10), + optional: nil, + optionalTransformed: nil + ) + let attr2 = TestAttributes( + string: "hello", + int: 11, + bool: false, + double: 1.4, + struct: .init(value: .init(val: "there")), + transformed: try .init(rawValue: 11), + optional: .init(value: 20.5), + optionalTransformed: try .init(rawValue: 10) + ) + + XCTAssertEqual(attr1.compare(to: attr2), [ + "string": .different("hello world", "hello"), + "int": .different("10", "11"), + "bool": .different("true", "false"), + "double": .different("105.4", "1.4"), + "struct": .different("string: hello", "string: there"), + "transformed": .different("10", "11"), + "optional": .different("nil", "20.5"), + "optionalTransformed": .different("nil", "10") + ]) + } + + func test_nonAttributeTypes() { + let attr1 = NonAttributeTest( + string: "hello", + int: 10, + double: 11.2, + bool: true, + struct: .init(), + optional: nil + ) + + XCTAssertEqual(attr1.compare(to: attr1), [ + "string": .prebuilt("comparison on non-JSON:API Attribute type (String) not supported."), + "int": .prebuilt("comparison on non-JSON:API Attribute type (Int) not supported."), + "double": .prebuilt("comparison on non-JSON:API Attribute type (Double) not supported."), + "bool": .prebuilt("comparison on non-JSON:API Attribute type (Bool) not supported."), + "struct": .prebuilt("comparison on non-JSON:API Attribute type (Struct) not supported."), + "optional": .prebuilt("comparison on non-JSON:API Attribute type (Optional) not supported.") + ]) + } +} + +private struct TestAttributes: JSONAPI.Attributes { + let string: Attribute + let int: Attribute + let bool: Attribute + let double: Attribute + let `struct`: Attribute + let transformed: TransformedAttribute + let optional: Attribute? + let optionalTransformed: TransformedAttribute? +} + +private struct Struct: Equatable, Codable, CustomStringConvertible { + let string: String + + init(val: String = "hello") { + self.string = val + } + + var description: String { return "string: \(string)" } +} + +private enum TestTransformer: Transformer { + static func transform(_ value: Int) throws -> String { + return "\(value)" + } +} + +private struct NonAttributeTest: JSONAPI.Attributes { + let string: String + let int: Int + let double: Double + let bool: Bool + let `struct`: Struct + let optional: Int? +} diff --git a/Tests/JSONAPITestingTests/Comparisons/DocumentCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/DocumentCompareTests.swift new file mode 100644 index 0000000..8b464e6 --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/DocumentCompareTests.swift @@ -0,0 +1,315 @@ +// +// DocumentCompareTests.swift +// +// +// Created by Mathew Polzin on 11/4/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +final class DocumentCompareTests: XCTestCase { + func test_same() { + XCTAssertTrue(d1.compare(to: d1).differences.isEmpty) + XCTAssertTrue(d2.compare(to: d2).differences.isEmpty) + XCTAssertTrue(d3.compare(to: d3).differences.isEmpty) + XCTAssertTrue(d4.compare(to: d4).differences.isEmpty) + XCTAssertTrue(d5.compare(to: d5).differences.isEmpty) + XCTAssertTrue(d6.compare(to: d6).differences.isEmpty) + XCTAssertTrue(d7.compare(to: d7).differences.isEmpty) + XCTAssertTrue(d8.compare(to: d8).differences.isEmpty) + XCTAssertTrue(d9.compare(to: d9).differences.isEmpty) + XCTAssertTrue(d10.compare(to: d10).differences.isEmpty) + + XCTAssertEqual(String(describing: d1.compare(to: d1).body), "same") + } + + func test_errorAndData() { + XCTAssertEqual(d1.compare(to: d2).differences, [ + "Body": "data response ≠ error response" + ]) + + XCTAssertEqual(d2.compare(to: d1).differences, [ + "Body": "error response ≠ data response" + ]) + } + + func test_differentErrors() { + let comparison = d2.compare(to: d4) + XCTAssertEqual(comparison.differences, [ + "Body": "Errors: (status: 500, title: Internal Error ≠ status: 404, title: Not Found)" + ]) + + XCTAssertEqual(String(describing: comparison), "(Body: Errors: (status: 500, title: Internal Error ≠ status: 404, title: Not Found))") + } + + func test_sameErrorsDifferentMetadata() { + let errors = [ + BasicJSONAPIError.error(.init(id: nil, status: "500", title: "Internal Error")) + ] + let doc1 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "1", meta: .none), + errors: errors, + meta: nil, + links: nil + ) + let doc2 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "1", meta: .none), + errors: errors, + meta: .init(total: 11), + links: nil + ) + + XCTAssertEqual(doc1.compare(to: doc2).differences, [ + "Body": "Metadata: nil ≠ Optional(total: 11)" + ]) + } + + func test_differentData() { + XCTAssertEqual(d3.compare(to: d5).differences, [ + "Body": "(Includes: (include 2: missing)), (Primary Resource: (resource 2: missing))" + ]) + + XCTAssertEqual(d3.compare(to: d6).differences, [ + "Body": ##"(Includes: (include 2: missing)), (Primary Resource: (resource 2: 'age' attribute: 10 ≠ 12, 'bestFriend' relationship: Optional(Id(2)) ≠ nil, 'favoriteColor' attribute: nil ≠ Optional("blue"), 'name' attribute: name ≠ Fig, id: 1 ≠ 5), (resource 3: missing))"## + ]) + + XCTAssertEqual(d7.compare(to: d8).differences, [ + "Body": ##"(Primary Resource: nil ≠ ResourceObject)"## + ]) + + XCTAssertEqual(d8.compare(to: d9).differences, [ + "Body": ##"(Primary Resource: (resource 1: 'age' attribute: 10 ≠ 12, 'bestFriend' relationship: Optional(Id(2)) ≠ nil, 'favoriteColor' attribute: nil ≠ Optional("blue"), 'name' attribute: name ≠ Fig, id: 1 ≠ 5))"## + ]) + + XCTAssertEqual(d1.compare(to: d10).differences, [ + "Body": ##"(Primary Resource: (resource 1: 'age' attribute: 10 ≠ 12, 'bestFriend' relationship: Optional(Id(2)) ≠ nil, 'favoriteColor' attribute: nil ≠ Optional("blue"), 'name' attribute: name ≠ Fig, id: 1 ≠ 5))"## + ]) + } + + func test_differentMetadata() { + XCTAssertEqual(d11.compare(to: d12).differences, [ + "Body": "(Meta: total: 10 ≠ total: 10000)" + ]) + } + + func test_differentLinks() { + XCTAssertEqual(d11.compare(to: d13).differences, [ + "Body": ##"(Links: TestLinks(link: JSONAPI.Link(url: "http://google.com", meta: No Metadata)) ≠ TestLinks(link: JSONAPI.Link(url: "http://yahoo.com", meta: No Metadata)))"## + ]) + } + + func test_differentAPIDescription() { + XCTAssertEqual(d11.compare(to: d14).differences, [ + "API Description": ##"APIDescription(version: "10", meta: No Metadata) ≠ APIDescription(version: "1", meta: No Metadata)"## + ]) + } +} + +fileprivate enum TestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let bestFriend: ToOneRelationship + let parents: ToManyRelationship + } +} + +fileprivate typealias TestType = ResourceObject + +fileprivate enum TestDescription2: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type2" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let bestFriend: ToOneRelationship + let parents: ToManyRelationship + } +} + +fileprivate typealias TestType2 = ResourceObject + +fileprivate typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, BasicJSONAPIError> + +fileprivate struct TestMetadata: JSONAPI.Meta, CustomStringConvertible { + let total: Int + + var description: String { + "total: \(total)" + } +} + +fileprivate struct TestLinks: JSONAPI.Links { + let link: Link +} + +typealias TestAPIDescription = APIDescription + +fileprivate typealias SingleDocumentWithMetaAndLinks = JSONAPI.Document, TestMetadata, TestLinks, Include2, TestAPIDescription, BasicJSONAPIError> + +fileprivate typealias OptionalSingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, BasicJSONAPIError> + +fileprivate typealias ManyDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, BasicJSONAPIError> + +fileprivate let r1 = TestType( + id: "1", + attributes: .init( + name: "name", + age: 10, + favoriteColor: nil + ), + relationships: .init( + bestFriend: "2", + parents: ["3", "4"] + ), + meta: .none, + links: .none +) + +fileprivate let r2 = TestType( + id: "5", + attributes: .init( + name: "Fig", + age: 12, + favoriteColor: "blue" + ), + relationships: .init( + bestFriend: nil, + parents: ["3", "4"] + ), + meta: .none, + links: .none +) + +fileprivate let r3 = TestType2( + id: "2", + attributes: .init( + name: "Tully", + age: 100, + favoriteColor: "red" + ), + relationships: .init( + bestFriend: nil, + parents: [] + ), + meta: .none, + links: .none +) + +fileprivate let d1 = SingleDocument( + apiDescription: .none, + body: .init(resourceObject: r1), + includes: .none, + meta: .none, + links: .none +) + +fileprivate let d2 = SingleDocument( + apiDescription: .none, + errors: [.error(.init(id: nil, status: "500", title: "Internal Error"))] +) + +fileprivate let d3 = ManyDocument( + apiDescription: .none, + body: .init(resourceObjects: [r1, r2]), + includes: .init(values: [.init(r3)]), + meta: .none, + links: .none +) + +fileprivate let d4 = SingleDocument( + apiDescription: .none, + errors: [.error(.init(id: nil, status: "404", title: "Not Found"))] +) + +fileprivate let d5 = ManyDocument( + apiDescription: .none, + body: .init(resourceObjects: [r1]), + includes: .init(values: [.init(r3), .init(r2)]), + meta: .none, + links: .none +) + +fileprivate let d6 = ManyDocument( + apiDescription: .none, + body: .init(resourceObjects: [r1, r1, r2]), + includes: .init(values: [.init(r3), .init(r2)]), + meta: .none, + links: .none +) + +fileprivate let d7 = OptionalSingleDocument( + apiDescription: .none, + body: .init(resourceObject: nil), + includes: .none, + meta: .none, + links: .none +) + +fileprivate let d8 = OptionalSingleDocument( + apiDescription: .none, + body: .init(resourceObject: r1), + includes: .none, + meta: .none, + links: .none +) + +fileprivate let d9 = OptionalSingleDocument( + apiDescription: .none, + body: .init(resourceObject: r2), + includes: .none, + meta: .none, + links: .none +) + +fileprivate let d10 = SingleDocument( + apiDescription: .none, + body: .init(resourceObject: r2), + includes: .none, + meta: .none, + links: .none +) + +fileprivate let d11 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "10", meta: .none), + body: .init(resourceObject: r2), + includes: .none, + meta: TestMetadata(total: 10), + links: TestLinks(link: .init(url: "http://google.com")) +) + +fileprivate let d12 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "10", meta: .none), + body: .init(resourceObject: r2), + includes: .none, + meta: TestMetadata(total: 10000), + links: TestLinks(link: .init(url: "http://google.com")) +) + +fileprivate let d13 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "10", meta: .none), + body: .init(resourceObject: r2), + includes: .none, + meta: TestMetadata(total: 10), + links: TestLinks(link: .init(url: "http://yahoo.com")) +) + +fileprivate let d14 = SingleDocumentWithMetaAndLinks( + apiDescription: TestAPIDescription(version: "1", meta: .none), + body: .init(resourceObject: r2), + includes: .none, + meta: TestMetadata(total: 10), + links: TestLinks(link: .init(url: "http://google.com")) +) diff --git a/Tests/JSONAPITestingTests/Comparisons/IncludesCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/IncludesCompareTests.swift new file mode 100644 index 0000000..7d34521 --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/IncludesCompareTests.swift @@ -0,0 +1,239 @@ +// +// IncludeCompareTests.swift +// +// +// Created by Mathew Polzin on 11/4/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting +import Poly + +final class IncludesCompareTests: XCTestCase { + func test_same() { + let includes1 = Includes(values: justTypeOnes) + let includes2 = Includes(values: justTypeOnes) + XCTAssertTrue(includes1.compare(to: includes2).differences.isEmpty) + + let includes3 = Includes(values: longerTypeOnes) + let includes4 = Includes(values: longerTypeOnes) + XCTAssertTrue(includes3.compare(to: includes4).differences.isEmpty) + + let includes5 = Includes(values: onesAndTwos) + let includes6 = Includes(values: onesAndTwos) + XCTAssertTrue(includes5.compare(to: includes6).differences.isEmpty) + } + + func test_missing() { + let includes1 = Includes(values: justTypeOnes) + let includes2 = Includes(values: longerTypeOnes) + XCTAssertEqual(includes1.compare(to: includes2).differences, ["include 3": "missing"]) + XCTAssertEqual(includes2.compare(to: includes1).differences, ["include 3": "missing"]) + } + + func test_typeMismatch() { + let includes1 = Includes(values: onesAndTwos) + let includes2 = Includes(values: justTypeOnes) + XCTAssertEqual(includes1.compare(to: includes2).differences, ["include 2": "ResourceObject ≠ ResourceObject"]) + XCTAssertEqual(includes2.compare(to: includes1).differences, ["include 2": "ResourceObject ≠ ResourceObject"]) + } + + func test_valueMismatch() { + let includes1 = Includes(values: onesAndTwos) + let includes2 = Includes(values: differentOnesAndTwos) + XCTAssertEqual(includes1.compare(to: includes2).differences, [ + "include 1": #"'favoriteColor' attribute: Optional("red") ≠ nil, 'name' attribute: Matt ≠ Todd, 'parents' relationship: 4, 5 ≠ 7, 8, id: 1 ≠ 2"# + ]) + } + + fileprivate let justTypeOnes: [Poly2] = [ + .a( + TestType1( + id: "1", + attributes: .init( + name: "Matt", + age: 23, + favoriteColor: "red" + ), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + ), + .a( + TestType1( + id: "3", + attributes: .init( + name: "Helen", + age: 24, + favoriteColor: nil + ), + relationships: .init( + bestFriend: nil, + parents: ["2"] + ), + meta: .none, + links: .none + ) + ) + ] + + fileprivate let longerTypeOnes: [Poly2] = [ + .a( + TestType1( + id: "1", + attributes: .init( + name: "Matt", + age: 23, + favoriteColor: "red" + ), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + ), + .a( + TestType1( + id: "3", + attributes: .init( + name: "Helen", + age: 24, + favoriteColor: nil + ), + relationships: .init( + bestFriend: nil, + parents: ["2"] + ), + meta: .none, + links: .none + ) + ), + .a( + TestType1( + id: "2", + attributes: .init( + name: "Troy", + age: 45, + favoriteColor: "blue" + ), + relationships: .init( + bestFriend: nil, + parents: [] + ), + meta: .none, + links: .none + ) + ) + ] + + fileprivate let onesAndTwos: [Poly2] = [ + .a( + TestType1( + id: "1", + attributes: .init( + name: "Matt", + age: 23, + favoriteColor: "red" + ), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + ), + .b( + TestType2( + id: "1", + attributes: .init( + name: "Lucy", + age: 33, + favoriteColor: nil + ), + relationships: .init( + bestFriend: nil, + parents: [] + ), + meta: .none, + links: .none + ) + ) + ] + + fileprivate let differentOnesAndTwos: [Poly2] = [ + .a( + TestType1( + id: "2", + attributes: .init( + name: "Todd", + age: 23, + favoriteColor: nil + ), + relationships: .init( + bestFriend: "3", + parents: ["7", "8"] + ), + meta: .none, + links: .none + ) + ), + .b( + TestType2( + id: "1", + attributes: .init( + name: "Lucy", + age: 33, + favoriteColor: nil + ), + relationships: .init( + bestFriend: nil, + parents: [] + ), + meta: .none, + links: .none + ) + ) + ] +} + +private enum TestDescription1: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type1" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let bestFriend: ToOneRelationship + let parents: ToManyRelationship + } +} + +private typealias TestType1 = ResourceObject + +private enum TestDescription2: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type2" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let bestFriend: ToOneRelationship + let parents: ToManyRelationship + } +} + +private typealias TestType2 = ResourceObject diff --git a/Tests/JSONAPITestingTests/Comparisons/RelationshipsCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/RelationshipsCompareTests.swift new file mode 100644 index 0000000..44b720a --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/RelationshipsCompareTests.swift @@ -0,0 +1,185 @@ +// +// RelationshipCompareTests.swift +// +// +// Created by Mathew Polzin on 11/5/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +final class RelationshipsCompareTests: XCTestCase { + func test_same() { + let r1 = TestRelationships( + a: t1, + b: t2, + c: t3, + d: t4 + ) + let r2 = r1 + + XCTAssertTrue(r1.compare(to: r2).allSatisfy { $0.value == .same }) + + let r3 = TestRelationships( + a: t1_differentId, + b: t2_differentLinks, + c: t3_differentId, + d: t4_differentLinks + ) + let r4 = r3 + + XCTAssertTrue(r3.compare(to: r4).allSatisfy { $0.value == .same }) + + let r5 = TestRelationships( + a: nil, + b: nil, + c: nil, + d: nil + ) + let r6 = r5 + + XCTAssertTrue(r5.compare(to: r6).allSatisfy { $0.value == .same }) + } + + func test_differentIds() { + let r1 = TestRelationships( + a: t1, + b: nil, + c: t3, + d: nil + ) + + let r2 = TestRelationships( + a: t1_differentId, + b: nil, + c: t3_differentId, + d: nil + ) + + XCTAssertEqual(r1.compare(to: r2), [ + "a": .different("Id(123)", "Id(999)"), + "b": .same, + "c": .different("123, 456", "999, 1010"), + "d": .same + ]) + } + + func test_differentMetadata() { + let r1 = TestRelationships( + a: nil, + b: t2, + c: nil, + d: t4 + ) + + let r2 = TestRelationships( + a: nil, + b: t2_differentMeta, + c: nil, + d: t4_differentMeta + ) + + XCTAssertEqual(r1.compare(to: r2), [ + "a": .same, + "b": .different(#"("Id(456)", "hello: world", "link: http://google.com")"#, #"("Id(456)", "hello: there", "link: http://google.com")"#), + "c": .same, + "d": .different(#"("123, 456", "hello: world", "link: http://google.com")"#, #"("123, 456", "hello: there", "link: http://google.com")"#) + ]) + } + + func test_differentLinks() { + let r1 = TestRelationships( + a: nil, + b: t2, + c: nil, + d: t4 + ) + + let r2 = TestRelationships( + a: nil, + b: t2_differentLinks, + c: nil, + d: t4_differentLinks + ) + + XCTAssertEqual(r1.compare(to: r2), [ + "a": .same, + "b": .different(#"("Id(456)", "hello: world", "link: http://google.com")"#, #"("Id(456)", "hello: world", "link: http://yahoo.com")"#), + "c": .same, + "d": .different(#"("123, 456", "hello: world", "link: http://google.com")"#, #"("123, 456", "hello: world", "link: http://yahoo.com")"#) + ]) + } + + func test_nonRelationshipTypes() { + let r1 = TestNonRelationships( + a: .init(attributes: .none, relationships: .none, meta: .none, links: .none), + b: false, + c: 10, + d: "1234" + ) + + XCTAssertEqual(r1.compare(to: r1), [ + "a": .prebuilt("comparison on non-JSON:API Relationship type (ResourceObject) not supported."), + "b": .prebuilt("comparison on non-JSON:API Relationship type (Bool) not supported."), + "c": .prebuilt("comparison on non-JSON:API Relationship type (Int) not supported."), + "d": .prebuilt("comparison on non-JSON:API Relationship type (Id>) not supported.") + ]) + } + + let t1 = ToOneRelationship(id: "123") + let t2 = ToOneRelationship(id: "456", meta: .init(hello: "world"), links: .init(link: .init(url: "http://google.com"))) + let t3 = ToManyRelationship(ids: ["123", "456"]) + let t4 = ToManyRelationship(ids: ["123", "456"], meta: .init(hello: "world"), links: .init(link: .init(url: "http://google.com"))) + + let t1_differentId = ToOneRelationship(id: "999") + let t3_differentId = ToManyRelationship(ids: ["999", "1010"]) + + let t2_differentLinks = ToOneRelationship(id: "456", meta: .init(hello: "world"), links: .init(link: .init(url: "http://yahoo.com"))) + let t4_differentLinks = ToManyRelationship(ids: ["123", "456"], meta: .init(hello: "world"), links: .init(link: .init(url: "http://yahoo.com"))) + + let t2_differentMeta = ToOneRelationship(id: "456", meta: .init(hello: "there"), links: .init(link: .init(url: "http://google.com"))) + let t4_differentMeta = ToManyRelationship(ids: ["123", "456"], meta: .init(hello: "there"), links: .init(link: .init(url: "http://google.com"))) +} + +// MARK: - Test Types +extension RelationshipsCompareTests { + enum TestTypeDescription: ResourceObjectDescription { + static let jsonType: String = "test" + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships + } + + typealias TestType = ResourceObject + + struct TestMeta: JSONAPI.Meta, CustomDebugStringConvertible { + let hello: String + + var debugDescription: String { + "hello: \(hello)" + } + } + + struct TestLinks: JSONAPI.Links, CustomDebugStringConvertible { + let link: Link + + var debugDescription: String { + "link: \(link.url)" + } + } + + struct TestRelationships: JSONAPI.Relationships { + let a: ToOneRelationship? + let b: ToOneRelationship? + let c: ToManyRelationship? + let d: ToManyRelationship? + } + + struct TestNonRelationships: JSONAPI.Relationships { + let a: TestType + let b: Bool + let c: Int + let d: JSONAPI.Id + } +} diff --git a/Tests/JSONAPITestingTests/Comparisons/ResourceObjectCompareTests.swift b/Tests/JSONAPITestingTests/Comparisons/ResourceObjectCompareTests.swift new file mode 100644 index 0000000..1f3a245 --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/ResourceObjectCompareTests.swift @@ -0,0 +1,180 @@ +// +// ResourceObjectCompareTests.swift +// +// +// Created by Mathew Polzin on 11/3/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +final class ResourceObjectCompareTests: XCTestCase { + func test_same() { + XCTAssertTrue(test1.compare(to: test1).differences.isEmpty) + XCTAssertTrue(test1_differentId.compare(to: test1_differentId).differences.isEmpty) + XCTAssertTrue(test1_differentAttributes.compare(to: test1_differentAttributes).differences.isEmpty) + } + + func test_differentAttributes() { + XCTAssertEqual(test1.compare(to: test1_differentAttributes).differences, [ + "'favoriteColor' attribute": #"Optional("red") ≠ nil"#, + "'name' attribute": "James ≠ Fred", + "'age' attribute": "12 ≠ 10" + ]) + } + + func test_differentRelationships() { + XCTAssertEqual(test1.compare(to: test1_differentRelationships).differences, [ + "'parents' relationship": "4, 5 ≠ 3", + "'bestFriend' relationship": "Optional(Id(3)) ≠ nil" + ]) + } + + func test_differentIds() { + XCTAssertEqual(test1.compare(to: test1_differentId).differences, [ + "id": "2 ≠ 3" + ]) + } + + func test_differentMetadata() { + let test1 = TestType2( + id: "2", + attributes: .none, + relationships: .none, + meta: .init(total: 10), + links: .init(link: .init(url: "http://google.com")) + ) + let test1_differentMeta = TestType2( + id: "2", + attributes: .none, + relationships: .none, + meta: .init(total: 12), + links: .init(link: .init(url: "http://google.com")) + ) + + XCTAssertEqual(test1.compare(to: test1_differentMeta).differences, [ + "meta": "total: 10 ≠ total: 12" + ]) + } + + func test_differentLinks() { + let test1 = TestType2( + id: "2", + attributes: .none, + relationships: .none, + meta: .init(total: 10), + links: .init(link: .init(url: "http://google.com")) + ) + let test1_differentLinks = TestType2( + id: "2", + attributes: .none, + relationships: .none, + meta: .init(total: 10), + links: .init(link: .init(url: "http://yahoo.com")) + ) + + XCTAssertEqual(test1.compare(to: test1_differentLinks).differences, [ + "links": "link: http://google.com ≠ link: http://yahoo.com" + ]) + } + + fileprivate let test1 = TestType( + id: "2", + attributes: .init( + name: "James", + age: 12, + favoriteColor: "red"), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + + fileprivate let test1_differentId = TestType( + id: "3", + attributes: .init( + name: "James", + age: 12, + favoriteColor: "red"), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + + fileprivate let test1_differentAttributes = TestType( + id: "2", + attributes: .init( + name: "Fred", + age: 10, + favoriteColor: .init(value: nil)), + relationships: .init( + bestFriend: "3", + parents: ["4", "5"] + ), + meta: .none, + links: .none + ) + + fileprivate let test1_differentRelationships = TestType( + id: "2", + attributes: .init( + name: "James", + age: 12, + favoriteColor: "red"), + relationships: .init( + bestFriend: nil, + parents: ["3"] + ), + meta: .none, + links: .none + ) +} + +private enum TestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let bestFriend: ToOneRelationship + let parents: ToManyRelationship + } +} + +private typealias TestType = ResourceObject + +private struct TestMetadata: JSONAPI.Meta, CustomStringConvertible { + let total: Int + + var description: String { + "total: \(total)" + } +} + +private struct TestLinks: JSONAPI.Links, CustomStringConvertible { + let link: Link + + var description: String { + "link: \(link.url)" + } +} + +private enum TestDescription2: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test_type2" + + typealias Attributes = NoAttributes + + typealias Relationships = NoRelationships +} + +private typealias TestType2 = ResourceObject diff --git a/Tests/JSONAPITestingTests/Test Helpers/EntityTestTypes.swift b/Tests/JSONAPITestingTests/Test Helpers/EntityTestTypes.swift index af99a71..1729dbc 100644 --- a/Tests/JSONAPITestingTests/Test Helpers/EntityTestTypes.swift +++ b/Tests/JSONAPITestingTests/Test Helpers/EntityTestTypes.swift @@ -12,3 +12,5 @@ public typealias Entity = Entity public typealias NewEntity = JSONAPI.ResourceObject + +extension String: JSONAPI.JSONAPIURL {} diff --git a/Tests/JSONAPITestingTests/XCTestManifests.swift b/Tests/JSONAPITestingTests/XCTestManifests.swift index c916df3..e77e922 100644 --- a/Tests/JSONAPITestingTests/XCTestManifests.swift +++ b/Tests/JSONAPITestingTests/XCTestManifests.swift @@ -1,6 +1,18 @@ #if !canImport(ObjectiveC) import XCTest +extension ArrayCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ArrayCompareTests = [ + ("test_differentLengths", test_differentLengths), + ("test_differentValues", test_differentValues), + ("test_reducePrebuilt", test_reducePrebuilt), + ("test_same", test_same), + ] +} + extension Attribute_LiteralTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -35,6 +47,33 @@ extension Attribute_LiteralTests { ] } +extension AttributesCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__AttributesCompareTests = [ + ("test_differentAttributes", test_differentAttributes), + ("test_nonAttributeTypes", test_nonAttributeTypes), + ("test_sameAttributes", test_sameAttributes), + ] +} + +extension DocumentCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__DocumentCompareTests = [ + ("test_differentAPIDescription", test_differentAPIDescription), + ("test_differentData", test_differentData), + ("test_differentErrors", test_differentErrors), + ("test_differentLinks", test_differentLinks), + ("test_differentMetadata", test_differentMetadata), + ("test_errorAndData", test_errorAndData), + ("test_same", test_same), + ("test_sameErrorsDifferentMetadata", test_sameErrorsDifferentMetadata), + ] +} + extension EntityCheckTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -58,6 +97,18 @@ extension Id_LiteralTests { ] } +extension IncludesCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__IncludesCompareTests = [ + ("test_missing", test_missing), + ("test_same", test_same), + ("test_typeMismatch", test_typeMismatch), + ("test_valueMismatch", test_valueMismatch), + ] +} + extension Relationship_LiteralTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -69,12 +120,45 @@ extension Relationship_LiteralTests { ] } +extension RelationshipsCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__RelationshipsCompareTests = [ + ("test_differentIds", test_differentIds), + ("test_differentLinks", test_differentLinks), + ("test_differentMetadata", test_differentMetadata), + ("test_nonRelationshipTypes", test_nonRelationshipTypes), + ("test_same", test_same), + ] +} + +extension ResourceObjectCompareTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ResourceObjectCompareTests = [ + ("test_differentAttributes", test_differentAttributes), + ("test_differentIds", test_differentIds), + ("test_differentLinks", test_differentLinks), + ("test_differentMetadata", test_differentMetadata), + ("test_differentRelationships", test_differentRelationships), + ("test_same", test_same), + ] +} + public func __allTests() -> [XCTestCaseEntry] { return [ + testCase(ArrayCompareTests.__allTests__ArrayCompareTests), testCase(Attribute_LiteralTests.__allTests__Attribute_LiteralTests), + testCase(AttributesCompareTests.__allTests__AttributesCompareTests), + testCase(DocumentCompareTests.__allTests__DocumentCompareTests), testCase(EntityCheckTests.__allTests__EntityCheckTests), testCase(Id_LiteralTests.__allTests__Id_LiteralTests), + testCase(IncludesCompareTests.__allTests__IncludesCompareTests), testCase(Relationship_LiteralTests.__allTests__Relationship_LiteralTests), + testCase(RelationshipsCompareTests.__allTests__RelationshipsCompareTests), + testCase(ResourceObjectCompareTests.__allTests__ResourceObjectCompareTests), ] } #endif diff --git a/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift b/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift index ab2d7b7..78f217f 100644 --- a/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift +++ b/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift @@ -15,27 +15,46 @@ class Attribute_FunctorTests: XCTestCase { XCTAssertNotNil(entity) - XCTAssertEqual(entity?[\.computedString], "Frankie2") + XCTAssertEqual(entity?.computedString, "Frankie2") } + @available(*, deprecated, message: "remove next major version") + func test_mapGuaranteed_deprecated() { + let entity = try? TestType(attributes: .init(name: "Frankie", number: .init(rawValue: 22.0)), relationships: .none, meta: .none, links: .none) + + XCTAssertEqual(entity?[\.computedString], "Frankie2") + } + func test_mapOptionalSuccess() { let entity = try? TestType(attributes: .init(name: "Frankie", number: .init(rawValue: 22.0)), relationships: .none, meta: .none, links: .none) XCTAssertNotNil(entity) - XCTAssertEqual(entity?[\.computedNumber], 22) XCTAssertEqual(entity?.computedNumber, 22) } + @available(*, deprecated, message: "remove next major version") + func test_mapOptionalSuccess_deprecated() { + let entity = try? TestType(attributes: .init(name: "Frankie", number: .init(rawValue: 22.0)), relationships: .none, meta: .none, links: .none) + + XCTAssertEqual(entity?[\.computedNumber], 22) + } + func test_mapOptionalFailure() { let entity = try? TestType(attributes: .init(name: "Frankie", number: .init(rawValue: 22.5)), relationships: .none, meta: .none, links: .none) XCTAssertNotNil(entity) - XCTAssertNil(entity?[\.computedNumber]) XCTAssertNil(entity?.computedNumber) } + + @available(*, deprecated, message: "remove next major version") + func test_mapOptionalFailure_deprecated() { + let entity = try? TestType(attributes: .init(name: "Frankie", number: .init(rawValue: 22.5)), relationships: .none, meta: .none, links: .none) + + XCTAssertNil(entity?[\.computedNumber]) + } } // MARK: Test types diff --git a/Tests/JSONAPITests/Attribute/AttributeTests.swift b/Tests/JSONAPITests/Attribute/AttributeTests.swift index f68831c..6ffdd22 100644 --- a/Tests/JSONAPITests/Attribute/AttributeTests.swift +++ b/Tests/JSONAPITests/Attribute/AttributeTests.swift @@ -14,6 +14,10 @@ class AttributeTests: XCTestCase { XCTAssertEqual(Attribute(value: "hello").value, "hello") } + func test_AttributeRawType() { + XCTAssert(Attribute(value: "hello").rawValueType == String.self) + } + func test_TransformedAttributeNoThrow() { XCTAssertNoThrow(try TransformedAttribute(rawValue: "10")) } @@ -26,6 +30,10 @@ class AttributeTests: XCTestCase { XCTAssertNoThrow(try TransformedAttribute(transformedValue: 10)) } + func test_TransformedAttributeRawType() throws { + try XCTAssert(TransformedAttribute(rawValue: "10").rawValueType == String.self) + } + func test_EncodedPrimitives() { testEncodedPrimitive(attribute: Attribute(value: 10)) testEncodedPrimitive(attribute: Attribute(value: false)) diff --git a/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift b/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift index add302d..39ea74d 100644 --- a/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift +++ b/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift @@ -14,12 +14,18 @@ class ComputedPropertiesTests: XCTestCase { let entity = decoded(type: TestType.self, data: computed_property_attribute) XCTAssertEqual(entity.id, "1234") - XCTAssertEqual(entity[\.name], "Sarah") XCTAssertEqual(entity.name, "Sarah") XCTAssertEqual(entity ~> \.other, "5678") XCTAssertNoThrow(try TestType.check(entity)) } + @available(*, deprecated, message: "remove next major version") + func test_DecodeIgnoresComputed_deprecated() { + let entity = decoded(type: TestType.self, data: computed_property_attribute) + + XCTAssertEqual(entity[\.name], "Sarah") + } + func test_EncodeIgnoresComputed() { test_DecodeEncodeEquality(type: TestType.self, data: computed_property_attribute) } @@ -27,11 +33,17 @@ class ComputedPropertiesTests: XCTestCase { func test_ComputedAttributeAccess() { let entity = decoded(type: TestType.self, data: computed_property_attribute) - XCTAssertEqual(entity[\.computed], "Sarah2") XCTAssertEqual(entity.computed, "Sarah2") XCTAssertEqual(entity[direct: \.directSecretsOut], "shhhh") } + @available(*, deprecated, message: "remove next major version") + func test_ComputedAttributeAccess_deprecated() { + let entity = decoded(type: TestType.self, data: computed_property_attribute) + + XCTAssertEqual(entity[\.computed], "Sarah2") + } + func test_ComputedNonAttributeAccess() { let entity = decoded(type: TestType.self, data: computed_property_attribute) diff --git a/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift b/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift index 91e8d8f..ec0e7f1 100644 --- a/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift +++ b/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift @@ -13,13 +13,19 @@ class CustomAttributesTests: XCTestCase { func test_customDecode() { let entity = decoded(type: CustomAttributeEntity.self, data: customAttributeEntityData) - XCTAssertEqual(entity[\.firstName], "Cool") XCTAssertEqual(entity.firstName, "Cool") - XCTAssertEqual(entity[\.name], "Cool Name") XCTAssertEqual(entity.name, "Cool Name") XCTAssertNoThrow(try CustomAttributeEntity.check(entity)) } + @available(*, deprecated, message: "remove next major version") + func test_customDecode_deprecated() { + let entity = decoded(type: CustomAttributeEntity.self, data: customAttributeEntityData) + + XCTAssertEqual(entity[\.firstName], "Cool") + XCTAssertEqual(entity[\.name], "Cool Name") + } + func test_customEncode() { test_DecodeEncodeEquality(type: CustomAttributeEntity.self, data: customAttributeEntityData) @@ -28,13 +34,19 @@ class CustomAttributesTests: XCTestCase { func test_customKeysDecode() { let entity = decoded(type: CustomKeysEntity.self, data: customAttributeEntityData) - XCTAssertEqual(entity[\.firstNameSilly], "Cool") XCTAssertEqual(entity.firstNameSilly, "Cool") - XCTAssertEqual(entity[\.lastNameSilly], "Name") XCTAssertEqual(entity.lastNameSilly, "Name") XCTAssertNoThrow(try CustomKeysEntity.check(entity)) } + @available(*, deprecated, message: "remove next major version") + func test_customKeysDecode_deprecated() { + let entity = decoded(type: CustomKeysEntity.self, data: customAttributeEntityData) + + XCTAssertEqual(entity[\.firstNameSilly], "Cool") + XCTAssertEqual(entity[\.lastNameSilly], "Name") + } + func test_customKeysEncode() { test_DecodeEncodeEquality(type: CustomKeysEntity.self, data: customAttributeEntityData) diff --git a/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift b/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift new file mode 100644 index 0000000..6fa2fb4 --- /dev/null +++ b/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift @@ -0,0 +1,237 @@ +// +// DocumentDecodingErrorTests.swift +// +// +// Created by Mathew Polzin on 11/10/19. +// + +import XCTest +import JSONAPI +import Poly + +final class DocumentDecodingErrorTests: XCTestCase { + func test_singlePrimaryResource_missing() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, + from: single_document_null + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .primaryResourceMissing = docError else { + XCTFail("Expected primary resource missing error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), "Primary Resource missing.") + } + } + + func test_singlePrimaryResource_failure() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, + from: single_document_no_includes_missing_relationship + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .primaryResource = docError else { + XCTFail("Expected primary resource document error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), "Primary Resource failed to parse because 'author' relationship is required and missing.") + } + } + + func test_manyPrimaryResource_missing() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, + from: many_document_no_includes_data_is_null + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .primaryResourcesMissing = docError else { + XCTFail("Expected primary resource missing error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), "Primary Resources array missing.") + } + } + + func test_manyPrimaryResource_failure() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, + from: many_document_no_includes_missing_relationship + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .primaryResource = docError else { + XCTFail("Expected primary resource document error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), "Primary Resource 2 failed to parse because 'author' relationship is required and missing.") + } + } + + func test_include_failure() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError>.self, + from: single_document_some_includes_wrong_type + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .includes = docError else { + XCTFail("Expected primary resource document error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), #"Include 3 failed to parse: found JSON:API type "not_an_author" but expected "authors""#) + } + } + + func test_include_failure2() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError>.self, + from: single_document_some_includes_wrong_type + ) + ) { error in + guard let docError = error as? DocumentDecodingError, + case .includes = docError else { + XCTFail("Expected primary resource document error. Got \(error)") + return + } + + XCTAssertEqual(String(describing: error), +#""" +Include 3 failed to parse: +Could not have been Include Type 1 because: +found JSON:API type "not_an_author" but expected "articles" + +Could not have been Include Type 2 because: +found JSON:API type "not_an_author" but expected "authors" +"""# + ) + } + } + + func test_wantSuccess_foundError() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError>.SuccessDocument.self, + from: error_document_no_metadata + ) + ) { error in + XCTAssertEqual(String(describing: error), #"Expected a success document with a 'data' property but found an error document."#) + } + } + + func test_wantError_foundSuccess() { + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError>.ErrorDocument.self, + from: single_document_some_includes_with_metadata_with_api_description + ) + ) { error in + XCTAssertEqual(String(describing: error), #"Expected an error document but found a success document with a 'data' property."#) + } + } +} + +// MARK: - Test Types +extension DocumentDecodingErrorTests { + enum AuthorType: ResourceObjectDescription { + static var jsonType: String { return "authors" } + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships + } + + typealias Author = BasicEntity + + enum ArticleType: ResourceObjectDescription { + static var jsonType: String { return "articles" } + + typealias Attributes = NoAttributes + + struct Relationships: JSONAPI.Relationships { + let author: ToOneRelationship + } + } + + typealias Article = BasicEntity + + enum BookType: ResourceObjectDescription { + static var jsonType: String { return "books" } + + struct Attributes: JSONAPI.SparsableAttributes { + let pageCount: Attribute + + enum CodingKeys: String, SparsableCodingKey { + case pageCount + } + } + + struct Relationships: JSONAPI.Relationships { + let author: ToOneRelationship + let series: ToManyRelationship + } + } + + typealias Book = BasicEntity + + struct TestPageMetadata: JSONAPI.Meta { + let total: Int + let limit: Int + let offset: Int + } + + struct TestLinks: JSONAPI.Links { + let link: Link + let link2: Link + + struct TestMetadata: JSONAPI.Meta { + let hello: String + } + } + + typealias TestAPIDescription = APIDescription + + enum TestError: JSONAPIError { + case unknownError + case basic(BasicError) + + struct BasicError: Codable, Equatable { + let code: Int + let description: String + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + self = (try? .basic(container.decode(BasicError.self))) ?? .unknown + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .unknownError: + try container.encode("unknown") + case .basic(let error): + try container.encode(error) + } + } + + public static var unknown: Self { + return .unknownError + } + } +} + diff --git a/Tests/JSONAPITests/Document/DocumentTests.swift b/Tests/JSONAPITests/Document/DocumentTests.swift index bbf36e9..5911a0d 100644 --- a/Tests/JSONAPITests/Document/DocumentTests.swift +++ b/Tests/JSONAPITests/Document/DocumentTests.swift @@ -12,7 +12,7 @@ import Poly class DocumentTests: XCTestCase { func test_genericDocFunc() { - func test(_ doc: Doc) { + func test(_ doc: Doc) { let _ = encoded(value: doc) XCTAssert(Doc.PrimaryResourceBody.self == NoResourceBody.self) @@ -1554,5 +1554,3 @@ extension DocumentTests { } } } - -extension String: JSONAPI.JSONAPIURL {} diff --git a/Tests/JSONAPITests/Document/SuccessAndErrorDocumentTests.swift b/Tests/JSONAPITests/Document/SuccessAndErrorDocumentTests.swift new file mode 100644 index 0000000..9fa1144 --- /dev/null +++ b/Tests/JSONAPITests/Document/SuccessAndErrorDocumentTests.swift @@ -0,0 +1,139 @@ +// +// SuccessAndErrorDocumentTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 11/12/19. +// + +import XCTest +import JSONAPI + +final class SuccessAndErrorDocumentTests: XCTestCase { + func test_errorAccessors() { + let apiDescription = TestErrorDocument.APIDescription( + version: "1.0", + meta: .none + ) + let errors = [ + BasicJSONAPIError.error(.init(status: "500")) + ] + let meta = TestMeta(hello: "world") + let links = TestLinks(testLink: .init(url: "http://google.com")) + let errorDoc = TestErrorDocument( + apiDescription: apiDescription, + errors: errors, + meta: meta, + links: links + ) + + guard case let .errors(testErrors, meta: testMeta, links: testLinks) = errorDoc.body else { + XCTFail("Expected an error body") + return + } + + XCTAssertEqual(testErrors, errors) + XCTAssertEqual(testMeta, meta) + XCTAssertEqual(testLinks, links) + + XCTAssertEqual(errorDoc.apiDescription, apiDescription) + XCTAssertEqual(errorDoc.errors, errors) + XCTAssertEqual(errorDoc.meta, meta) + XCTAssertEqual(errorDoc.links, links) + + let equivalentDocument = TestDocument( + apiDescription: apiDescription, + errors: errors, + meta: meta, + links: links + ) + + XCTAssert(equivalentDocument == errorDoc) + XCTAssert(errorDoc == equivalentDocument) + } + + func test_successAccessors() { + let apiDescription = TestErrorDocument.APIDescription( + version: "1.0", + meta: .none + ) + let primaryResource = TestType( + id: "123", + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + let resourceBody = SingleResourceBody(resourceObject: primaryResource) + let includedResource = TestType( + id: "456", + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + let includes = Includes(values: [.init(includedResource)]) + let meta = TestMeta(hello: "world") + let links = TestLinks(testLink: .init(url: "http://google.com")) + let successDoc = TestSuccessDocument( + apiDescription: apiDescription, + body: resourceBody, + includes: includes, + meta: meta, + links: links + ) + + guard case let .data(data) = successDoc.body else { + XCTFail("Expected an data body") + return + } + + XCTAssertEqual(data.primary, resourceBody) + XCTAssertEqual(data.includes, includes) + XCTAssertEqual(data.meta, meta) + XCTAssertEqual(data.links, links) + + XCTAssertEqual(successDoc.data, data) + XCTAssertEqual(successDoc.apiDescription, apiDescription) + XCTAssertEqual(successDoc.primaryResource, resourceBody) + XCTAssertEqual(successDoc.includes, includes) + XCTAssertEqual(successDoc.meta, meta) + XCTAssertEqual(successDoc.links, links) + + let equivalentDocument = TestDocument( + apiDescription: apiDescription, + body: resourceBody, + includes: includes, + meta: meta, + links: links + ) + + XCTAssert(equivalentDocument == successDoc) + XCTAssert(successDoc == equivalentDocument) + } +} + +// MARK: - Test Type +extension SuccessAndErrorDocumentTests { + enum TestTypeDescription: ResourceObjectDescription { + static let jsonType: String = "tests" + + typealias Attributes = NoAttributes + + typealias Relationships = NoRelationships + } + + struct TestMeta: JSONAPI.Meta { + let hello: String + } + + struct TestLinks: JSONAPI.Links { + let testLink: Link + } + + typealias TestType = ResourceObject + + typealias TestDocument = Document, TestMeta, TestLinks, Include1, APIDescription, BasicJSONAPIError> + + typealias TestSuccessDocument = TestDocument.SuccessDocument + typealias TestErrorDocument = TestDocument.ErrorDocument +} diff --git a/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift b/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift index 7455a0c..ed83ebf 100644 --- a/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift +++ b/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift @@ -37,6 +37,17 @@ let single_document_no_includes = """ } """.data(using: .utf8)! +let single_document_no_includes_missing_relationship = """ +{ + "data": { + "id": "1", + "type": "articles", + "relationships": { + } + } +} +""".data(using: .utf8)! + let single_document_no_includes_with_api_description = """ { "data": { @@ -247,6 +258,37 @@ let single_document_some_includes = """ } """.data(using: .utf8)! +let single_document_some_includes_wrong_type = """ +{ + "data": { + "id": "1", + "type": "articles", + "relationships": { + "author": { + "data": { + "type": "authors", + "id": "33" + } + } + } + }, + "included": [ + { + "id": "30", + "type": "authors" + }, + { + "id": "31", + "type": "authors" + }, + { + "id": "33", + "type": "not_an_author" + } + ] +} +""".data(using: .utf8)! + let single_document_some_includes_with_api_description = """ { "data": { @@ -452,6 +494,49 @@ let many_document_no_includes = """ } """.data(using: .utf8)! +let many_document_no_includes_data_is_null = """ +{ + "data": null +} +""".data(using: .utf8)! + +let many_document_no_includes_missing_relationship = """ +{ + "data": [ + { + "id": "1", + "type": "articles", + "relationships": { + "author": { + "data": { + "type": "authors", + "id": "33" + } + } + } + }, + { + "id": "2", + "type": "articles", + "relationships": { + } + }, + { + "id": "3", + "type": "articles", + "relationships": { + "author": { + "data": { + "type": "authors", + "id": "11" + } + } + } + } + ] +} +""".data(using: .utf8)! + let many_document_no_includes_with_api_description = """ { "data": [ diff --git a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift index 21172ed..a3c3552 100644 --- a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift +++ b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift @@ -64,6 +64,7 @@ final class GenericJSONAPIErrorTests: XCTestCase { let error = decoded(type: TestGenericJSONAPIError.self, data: data) XCTAssertEqual(error, .unknown) + XCTAssertEqual(String(describing: error), "unknown error") } func test_encode() { diff --git a/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift b/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift new file mode 100644 index 0000000..2db4f75 --- /dev/null +++ b/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift @@ -0,0 +1,109 @@ +// +// IncludesDecodingErrorTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 11/14/19. +// + +import XCTest +import JSONAPI + +final class IncludesDecodingErrorTests: XCTestCase { + func test_unexpectedIncludeType() { + var error1: Error! + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: three_different_type_includes)) { (error: Error) -> Void in + XCTAssertEqual( + (error as? IncludesDecodingError)?.idx, + 2 + ) + + XCTAssertEqual( + (error as? IncludesDecodingError).map(String.init(describing:)), +""" +Include 3 failed to parse: \nCould not have been Include Type 1 because: +found JSON:API type "test_entity4" but expected "test_entity1" + +Could not have been Include Type 2 because: +found JSON:API type "test_entity4" but expected "test_entity2" +""" + ) + + error1 = error + } + + // now test that we get the same error from a different test stub + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: four_different_type_includes)) { (error2: Error) -> Void in + XCTAssertEqual( + error1 as? IncludesDecodingError, + error2 as? IncludesDecodingError + ) + } + } +} + +// MARK: - Test Types +extension IncludesDecodingErrorTests { + enum TestEntityType: ResourceObjectDescription { + + typealias Relationships = NoRelationships + + public static var jsonType: String { return "test_entity1" } + + public struct Attributes: JSONAPI.SparsableAttributes { + let foo: Attribute + let bar: Attribute + + public enum CodingKeys: String, Equatable, CodingKey { + case foo + case bar + } + } + } + + typealias TestEntity = BasicEntity + + enum TestEntityType2: ResourceObjectDescription { + + public static var jsonType: String { return "test_entity2" } + + public struct Relationships: JSONAPI.Relationships { + let entity1: ToOneRelationship + } + + public struct Attributes: JSONAPI.SparsableAttributes { + let foo: Attribute + let bar: Attribute + + public enum CodingKeys: String, Equatable, CodingKey { + case foo + case bar + } + } + } + + typealias TestEntity2 = BasicEntity + + enum TestEntityType4: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + typealias Relationships = NoRelationships + + public static var jsonType: String { return "test_entity4" } + } + + typealias TestEntity4 = BasicEntity + + enum TestEntityType6: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity6" } + + struct Relationships: JSONAPI.Relationships { + let entity4: ToOneRelationship + } + } + + typealias TestEntity6 = BasicEntity +} diff --git a/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift b/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift index 5e5f593..e53b144 100644 --- a/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift +++ b/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift @@ -129,6 +129,10 @@ let four_different_type_includes = """ } } }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, { "type": "test_entity6", "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", @@ -140,10 +144,6 @@ let four_different_type_includes = """ } } } - }, - { - "type": "test_entity4", - "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" } ] """.data(using: .utf8)! diff --git a/Tests/JSONAPITests/Poly/PolyProxyTests.swift b/Tests/JSONAPITests/Poly/PolyProxyTests.swift index 3d12259..efae216 100644 --- a/Tests/JSONAPITests/Poly/PolyProxyTests.swift +++ b/Tests/JSONAPITests/Poly/PolyProxyTests.swift @@ -15,18 +15,31 @@ public class PolyProxyTests: XCTestCase { XCTAssertEqual(User.jsonType, "users") } + func test_CannotEncodeOrDecodePoly0() { + XCTAssertThrowsError(try testDecoder.decode(Poly0.self, from: poly_user_stub_1)) + + XCTAssertThrowsError(try testEncoder.encode(Poly0())) + } + func test_UserADecode() { let polyUserA = decoded(type: User.self, data: poly_user_stub_1) let userA = decoded(type: UserA.self, data: poly_user_stub_1) XCTAssertEqual(polyUserA.userA, userA) XCTAssertNil(polyUserA.userB) - XCTAssertEqual(polyUserA[\.name], "Ken Moore") + XCTAssertEqual(polyUserA.name, "Ken Moore") XCTAssertEqual(polyUserA.id, "1") XCTAssertEqual(polyUserA.relationships, .none) XCTAssertEqual(polyUserA[direct: \.x], .init(x: "y")) } + @available(*, deprecated, message: "remove next major version") + func test_UserADecode_deprecated() { + let polyUserA = decoded(type: User.self, data: poly_user_stub_1) + + XCTAssertEqual(polyUserA[\.name], "Ken Moore") + } + func test_UserAAndBEncodeEquality() { test_DecodeEncodeEquality(type: User.self, data: poly_user_stub_1) test_DecodeEncodeEquality(type: User.self, data: poly_user_stub_2) @@ -56,11 +69,18 @@ public class PolyProxyTests: XCTestCase { XCTAssertEqual(polyUserB.userB, userB) XCTAssertNil(polyUserB.userA) - XCTAssertEqual(polyUserB[\.name], "Ken Less") + XCTAssertEqual(polyUserB.name, "Ken Less") XCTAssertEqual(polyUserB.id, "2") XCTAssertEqual(polyUserB.relationships, .none) XCTAssertEqual(polyUserB[direct: \.x], .init(x: "y")) } + + @available(*, deprecated, message: "remove next major version") + func test_UserBDecode_deprecated() { + let polyUserB = decoded(type: User.self, data: poly_user_stub_2) + + XCTAssertEqual(polyUserB[\.name], "Ken Less") + } } // MARK: - Test types @@ -114,9 +134,9 @@ extension Poly2: ResourceObjectProxy, JSONTyped where A == PolyProxyTests.UserA, public var attributes: SharedUserDescription.Attributes { switch self { case .a(let a): - return .init(name: .init(value: "\(a[\.firstName]) \(a[\.lastName])"), x: .init(x: "y")) + return .init(name: .init(value: "\(a.firstName) \(a.lastName)"), x: .init(x: "y")) case .b(let b): - return .init(name: .init(value: b[\.name].joined(separator: " ")), x: .init(x: "y")) + return .init(name: .init(value: b.name.joined(separator: " ")), x: .init(x: "y")) } } diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift new file mode 100644 index 0000000..ce8e334 --- /dev/null +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift @@ -0,0 +1,459 @@ +// +// ResourceObjectDecodingErrorTests.swift +// +// +// Created by Mathew Polzin on 11/8/19. +// + +import XCTest +@testable import JSONAPI + +// MARK: - Relationships +final class ResourceObjectDecodingErrorTests: XCTestCase { + func test_missingRelationshipsObject() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_relationships_entirely_missing + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "relationships object is required and missing." + ) + } + } + + func test_required_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_required_relationship_is_omitted + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is required and missing." + ) + } + } + + func test_NonNullable_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_nonNullable_relationship_is_null + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is not nullable but null was found." + ) + } + } + + func test_NonNullable_relationship2() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_nonNullable_relationship_is_null2 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is not nullable but null was found." + ) + } + } + + func test_oneTypeVsAnother_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_relationship_is_wrong_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .jsonTypeMismatch(expectedType: "thirteenth_test_entities", foundType: "not_the_same"), + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'required' relationship is of JSON:API type "not_the_same" but it was expected to be "thirteenth_test_entities""# + ) + } + } + + func test_twoOneVsToMany_relationship() { + + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_single_relationship_is_many + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .quantityMismatch(expected: .one), + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship should contain one value but found many" + ) + } + + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_many_relationship_is_single + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "omittable", + cause: .quantityMismatch(expected: .many), + location: .relationships + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'omittable' relationship should contain many values but found one" + ) + } + } +} + +// MARK: - Attributes +extension ResourceObjectDecodingErrorTests { + func test_missingAttributesObject() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attributes_entirely_missing + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "attributes object is required and missing." + ) + } + } + + func test_required_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_required_attribute_is_omitted + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is required and missing." + ) + } + } + + func test_NonNullable_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_nonNullable_attribute_is_null + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is not nullable but null was found." + ) + } + } + + func test_oneTypeVsAnother_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .typeMismatch(expectedTypeName: String(describing: String.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is not a String as expected." + ) + } + } + + func test_oneTypeVsAnother_attribute2() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type2 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "other", + cause: .typeMismatch(expectedTypeName: String(describing: Int.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'other' attribute is not a Int as expected." + ) + } + } + + func test_oneTypeVsAnother_attribute3() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type3 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "yetAnother", + cause: .typeMismatch(expectedTypeName: String(describing: Bool.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'yetAnother' attribute is not a Bool as expected." + ) + } + } + + func test_transformed_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type4 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "transformed", + cause: .typeMismatch(expectedTypeName: String(describing: Int.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'transformed' attribute is not a Int as expected." + ) + } + } + + func test_transformed_attribute2() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_always_fails + )) { error in + XCTAssertEqual( + String(describing: error), + "Error: Always Fails" + ) + } + } +} + +// MARK: - JSON:API Type +extension ResourceObjectDecodingErrorTests { + func test_wrongJSONAPIType() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_is_wrong_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "self", + cause: .jsonTypeMismatch(expectedType: "fourteenth_test_entities", foundType: "not_correct_type"), + location: .type + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"found JSON:API type "not_correct_type" but expected "fourteenth_test_entities""# + ) + } + } + + func test_wrongDecodedType() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_type_is_wrong_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "type", + cause: .typeMismatch(expectedTypeName: String(describing: String.self)), + location: .type + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'type' (a.k.a. the JSON:API type name) is not a String as expected."# + ) + } + } + + func test_type_missing() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_type_is_missing + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "type", + cause: .keyNotFound, + location: .type + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'type' (a.k.a. JSON:API type name) is required and missing."# + ) + } + } + + func test_type_null() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_type_is_null + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "type", + cause: .valueNotFound, + location: .type + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'type' (a.k.a. JSON:API type name) is not nullable but null was found."# + ) + } + } +} + +// MARK: - Test Types +extension ResourceObjectDecodingErrorTests { + enum TestEntityType: ResourceObjectDescription { + public static var jsonType: String { return "thirteenth_test_entities" } + + typealias Attributes = NoAttributes + + public struct Relationships: JSONAPI.Relationships { + + let required: ToOneRelationship + let omittable: ToManyRelationship? + } + } + + typealias TestEntity = BasicEntity + + enum TestEntityType2: ResourceObjectDescription { + public static var jsonType: String { return "fourteenth_test_entities" } + + public struct Attributes: JSONAPI.Attributes { + + let required: Attribute + let other: Attribute? + let yetAnother: Attribute? + let transformed: TransformedAttribute? + let transformed2: TransformedAttribute? + } + + typealias Relationships = NoRelationships + } + + typealias TestEntity2 = BasicEntity + + enum IntToString: Transformer { + static func transform(_ value: Int) throws -> String { + return "\(value)" + } + typealias From = Int + typealias To = String + } + + enum AlwaysFails: Transformer { + static func transform(_ value: String) throws -> String { + throw Error() + } + + struct Error: Swift.Error, CustomStringConvertible { + let description: String = "Error: Always Fails" + } + } +} diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift index 2e023a0..c061f1e 100644 --- a/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift @@ -69,10 +69,16 @@ class ResourceObjectTests: XCTestCase { func test_unidentifiedEntityAttributeAccess() { let entity = UnidentifiedTestEntity(attributes: .init(me: "hello"), relationships: .none, meta: .none, links: .none) - XCTAssertEqual(entity[\.me], "hello") XCTAssertEqual(entity.me, "hello") } + @available(*, deprecated, message: "remove next major version") + func test_unidentifiedEntityAttributeAccess_deprecated() { + let entity = UnidentifiedTestEntity(attributes: .init(me: "hello"), relationships: .none, meta: .none, links: .none) + + XCTAssertEqual(entity[\.me], "hello") + } + func test_initialization() { let entity1 = TestEntity1(id: .init(rawValue: "wow"), attributes: .none, relationships: .none, meta: .none, links: .none) let entity2 = TestEntity2(id: .init(rawValue: "cool"), attributes: .none, relationships: .init(other: .init(resourceObject: entity1)), meta: .none, links: .none) @@ -158,13 +164,19 @@ extension ResourceObjectTests { XCTAssert(type(of: entity.relationships) == NoRelationships.self) - XCTAssertEqual(entity[\.floater], 123.321) XCTAssertEqual(entity.floater, 123.321) XCTAssertNoThrow(try TestEntity5.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_EntityNoRelationshipsSomeAttributes_deprecated() { + let entity = decoded(type: TestEntity5.self, + data: entity_no_relationships_some_attributes) + XCTAssertEqual(entity[\.floater], 123.321) + } + func test_EntityNoRelationshipsSomeAttributes_encode() { test_DecodeEncodeEquality(type: TestEntity5.self, data: entity_no_relationships_some_attributes) @@ -191,9 +203,7 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity4.self, data: entity_some_relationships_some_attributes) - XCTAssertEqual(entity[\.word], "coolio") XCTAssertEqual(entity.word, "coolio") - XCTAssertEqual(entity[\.number], 992299) XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertNoThrow(try TestEntity4.check(entity)) @@ -201,6 +211,15 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_EntitySomeRelationshipsSomeAttributes_deprecated() { + let entity = decoded(type: TestEntity4.self, + data: entity_some_relationships_some_attributes) + + XCTAssertEqual(entity[\.word], "coolio") + XCTAssertEqual(entity[\.number], 992299) + } + func test_EntitySomeRelationshipsSomeAttributes_encode() { test_DecodeEncodeEquality(type: TestEntity4.self, data: entity_some_relationships_some_attributes) @@ -214,17 +233,24 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity6.self, data: entity_one_omitted_attribute) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertNil(entity[\.maybeHere]) XCTAssertNil(entity.maybeHere) - XCTAssertEqual(entity[\.maybeNull], "World") XCTAssertEqual(entity.maybeNull, "World") XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_entityOneOmittedAttribute_deprecated() { + let entity = decoded(type: TestEntity6.self, + data: entity_one_omitted_attribute) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertNil(entity[\.maybeHere]) + XCTAssertEqual(entity[\.maybeNull], "World") + } + func test_entityOneOmittedAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_omitted_attribute) @@ -234,17 +260,24 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity6.self, data: entity_one_null_attribute) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertEqual(entity[\.maybeHere], "World") XCTAssertEqual(entity.maybeHere, "World") - XCTAssertNil(entity[\.maybeNull]) XCTAssertNil(entity.maybeNull) XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_entityOneNullAttribute_deprecated() { + let entity = decoded(type: TestEntity6.self, + data: entity_one_null_attribute) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertEqual(entity[\.maybeHere], "World") + XCTAssertNil(entity[\.maybeNull]) + } + func test_entityOneNullAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_null_attribute) @@ -254,17 +287,24 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity6.self, data: entity_all_attributes) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertEqual(entity[\.maybeHere], "World") XCTAssertEqual(entity.maybeHere, "World") - XCTAssertEqual(entity[\.maybeNull], "!") XCTAssertEqual(entity.maybeNull, "!") XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_entityAllAttribute_deprecated() { + let entity = decoded(type: TestEntity6.self, + data: entity_all_attributes) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertEqual(entity[\.maybeHere], "World") + XCTAssertEqual(entity[\.maybeNull], "!") + } + func test_entityAllAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_all_attributes) @@ -274,17 +314,24 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity6.self, data: entity_one_null_and_one_missing_attribute) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertNil(entity[\.maybeHere]) XCTAssertNil(entity.maybeHere) - XCTAssertNil(entity[\.maybeNull]) XCTAssertNil(entity.maybeNull) XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_entityOneNullAndOneOmittedAttribute_deprecated() { + let entity = decoded(type: TestEntity6.self, + data: entity_one_null_and_one_missing_attribute) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertNil(entity[\.maybeHere]) + XCTAssertNil(entity[\.maybeNull]) + } + func test_entityOneNullAndOneOmittedAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_null_and_one_missing_attribute) @@ -299,15 +346,22 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity7.self, data: entity_null_optional_nullable_attribute) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertNil(entity[\.maybeHereMaybeNull]) XCTAssertNil(entity.maybeHereMaybeNull) XCTAssertNoThrow(try TestEntity7.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_NullOptionalNullableAttribute_deprecated() { + let entity = decoded(type: TestEntity7.self, + data: entity_null_optional_nullable_attribute) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertNil(entity[\.maybeHereMaybeNull]) + } + func test_NullOptionalNullableAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity7.self, data: entity_null_optional_nullable_attribute) @@ -317,15 +371,22 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity7.self, data: entity_non_null_optional_nullable_attribute) - XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity.here, "Hello") - XCTAssertEqual(entity[\.maybeHereMaybeNull], "World") XCTAssertEqual(entity.maybeHereMaybeNull, "World") XCTAssertNoThrow(try TestEntity7.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_NonNullOptionalNullableAttribute_deprecated() { + let entity = decoded(type: TestEntity7.self, + data: entity_non_null_optional_nullable_attribute) + + XCTAssertEqual(entity[\.here], "Hello") + XCTAssertEqual(entity[\.maybeHereMaybeNull], "World") + } + func test_NonNullOptionalNullableAttribute_encode() { test_DecodeEncodeEquality(type: TestEntity7.self, data: entity_non_null_optional_nullable_attribute) @@ -338,23 +399,30 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity8.self, data: entity_int_to_string_attribute) - XCTAssertEqual(entity[\.string], "22") XCTAssertEqual(entity.string, "22") - XCTAssertEqual(entity[\.int], 22) XCTAssertEqual(entity.int, 22) - XCTAssertEqual(entity[\.stringFromInt], "22") XCTAssertEqual(entity.stringFromInt, "22") - XCTAssertEqual(entity[\.plus], 122) XCTAssertEqual(entity.plus, 122) - XCTAssertEqual(entity[\.doubleFromInt], 22.0) XCTAssertEqual(entity.doubleFromInt, 22.0) - XCTAssertEqual(entity[\.nullToString], "nil") XCTAssertEqual(entity.nullToString, "nil") XCTAssertNoThrow(try TestEntity8.check(entity)) testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_IntToString_deprecated() { + let entity = decoded(type: TestEntity8.self, + data: entity_int_to_string_attribute) + + XCTAssertEqual(entity[\.string], "22") + XCTAssertEqual(entity[\.int], 22) + XCTAssertEqual(entity[\.stringFromInt], "22") + XCTAssertEqual(entity[\.plus], 122) + XCTAssertEqual(entity[\.doubleFromInt], 22.0) + XCTAssertEqual(entity[\.nullToString], "nil") + } + func test_IntToString_encode() { test_DecodeEncodeEquality(type: TestEntity8.self, data: entity_int_to_string_attribute) @@ -503,7 +571,6 @@ extension ResourceObjectTests { let entity = decoded(type: UnidentifiedTestEntity.self, data: entity_unidentified) - XCTAssertNil(entity[\.me]) XCTAssertNil(entity.me) XCTAssertEqual(entity.id, .unidentified) XCTAssertNoThrow(try UnidentifiedTestEntity.check(entity)) @@ -511,6 +578,14 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_UnidentifiedEntity_deprecated() { + let entity = decoded(type: UnidentifiedTestEntity.self, + data: entity_unidentified) + + XCTAssertNil(entity[\.me]) + } + func test_UnidentifiedEntity_encode() { test_DecodeEncodeEquality(type: UnidentifiedTestEntity.self, data: entity_unidentified) @@ -520,7 +595,6 @@ extension ResourceObjectTests { let entity = decoded(type: UnidentifiedTestEntity.self, data: entity_unidentified_with_attributes) - XCTAssertEqual(entity[\.me], "unknown") XCTAssertEqual(entity.me, "unknown") XCTAssertEqual(entity.id, .unidentified) XCTAssertNoThrow(try UnidentifiedTestEntity.check(entity)) @@ -528,6 +602,14 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_UnidentifiedEntityWithAttributes_deprecated() { + let entity = decoded(type: UnidentifiedTestEntity.self, + data: entity_unidentified_with_attributes) + + XCTAssertEqual(entity[\.me], "unknown") + } + func test_UnidentifiedEntityWithAttributes_encode() { test_DecodeEncodeEquality(type: UnidentifiedTestEntity.self, data: entity_unidentified_with_attributes) @@ -541,7 +623,6 @@ extension ResourceObjectTests { let entity = decoded(type: UnidentifiedTestEntityWithMeta.self, data: entity_unidentified_with_attributes_and_meta) - XCTAssertEqual(entity[\.me], "unknown") XCTAssertEqual(entity.me, "unknown") XCTAssertEqual(entity.id, .unidentified) XCTAssertEqual(entity.meta.x, "world") @@ -551,6 +632,14 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_UnidentifiedEntityWithAttributesAndMeta_deprecated() { + let entity = decoded(type: UnidentifiedTestEntityWithMeta.self, + data: entity_unidentified_with_attributes_and_meta) + + XCTAssertEqual(entity[\.me], "unknown") + } + func test_UnidentifiedEntityWithAttributesAndMeta_encode() { test_DecodeEncodeEquality(type: UnidentifiedTestEntityWithMeta.self, data: entity_unidentified_with_attributes_and_meta) @@ -560,7 +649,6 @@ extension ResourceObjectTests { let entity = decoded(type: UnidentifiedTestEntityWithLinks.self, data: entity_unidentified_with_attributes_and_links) - XCTAssertEqual(entity[\.me], "unknown") XCTAssertEqual(entity.me, "unknown") XCTAssertEqual(entity.id, .unidentified) XCTAssertEqual(entity.links.link1, .init(url: "https://image.com/image.png")) @@ -569,6 +657,14 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_UnidentifiedEntityWithAttributesAndLinks_deprecated() { + let entity = decoded(type: UnidentifiedTestEntityWithLinks.self, + data: entity_unidentified_with_attributes_and_links) + + XCTAssertEqual(entity[\.me], "unknown") + } + func test_UnidentifiedEntityWithAttributesAndLinks_encode() { test_DecodeEncodeEquality(type: UnidentifiedTestEntityWithLinks.self, data: entity_unidentified_with_attributes_and_links) @@ -578,7 +674,6 @@ extension ResourceObjectTests { let entity = decoded(type: UnidentifiedTestEntityWithMetaAndLinks.self, data: entity_unidentified_with_attributes_and_meta_and_links) - XCTAssertEqual(entity[\.me], "unknown") XCTAssertEqual(entity.me, "unknown") XCTAssertEqual(entity.id, .unidentified) XCTAssertEqual(entity.meta.x, "world") @@ -589,6 +684,14 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_UnidentifiedEntityWithAttributesAndMetaAndLinks_deprecated() { + let entity = decoded(type: UnidentifiedTestEntityWithMetaAndLinks.self, + data: entity_unidentified_with_attributes_and_meta_and_links) + + XCTAssertEqual(entity[\.me], "unknown") + } + func test_UnidentifiedEntityWithAttributesAndMetaAndLinks_encode() { test_DecodeEncodeEquality(type: UnidentifiedTestEntityWithMetaAndLinks.self, data: entity_unidentified_with_attributes_and_meta_and_links) @@ -598,9 +701,7 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity4WithMeta.self, data: entity_some_relationships_some_attributes_with_meta) - XCTAssertEqual(entity[\.word], "coolio") XCTAssertEqual(entity.word, "coolio") - XCTAssertEqual(entity[\.number], 992299) XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.meta.x, "world") @@ -610,6 +711,15 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_EntitySomeRelationshipsSomeAttributesWithMeta_deprecated() { + let entity = decoded(type: TestEntity4WithMeta.self, + data: entity_some_relationships_some_attributes_with_meta) + + XCTAssertEqual(entity[\.word], "coolio") + XCTAssertEqual(entity[\.number], 992299) + } + func test_EntitySomeRelationshipsSomeAttributesWithMeta_encode() { test_DecodeEncodeEquality(type: TestEntity4WithMeta.self, data: entity_some_relationships_some_attributes_with_meta) @@ -619,9 +729,7 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity4WithLinks.self, data: entity_some_relationships_some_attributes_with_links) - XCTAssertEqual(entity[\.word], "coolio") XCTAssertEqual(entity.word, "coolio") - XCTAssertEqual(entity[\.number], 992299) XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.links.link1, .init(url: "https://image.com/image.png")) @@ -630,6 +738,15 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_EntitySomeRelationshipsSomeAttributesWithLinks_deprecated() { + let entity = decoded(type: TestEntity4WithLinks.self, + data: entity_some_relationships_some_attributes_with_links) + + XCTAssertEqual(entity[\.word], "coolio") + XCTAssertEqual(entity[\.number], 992299) + } + func test_EntitySomeRelationshipsSomeAttributesWithLinks_encode() { test_DecodeEncodeEquality(type: TestEntity4WithLinks.self, data: entity_some_relationships_some_attributes_with_links) @@ -639,9 +756,7 @@ extension ResourceObjectTests { let entity = decoded(type: TestEntity4WithMetaAndLinks.self, data: entity_some_relationships_some_attributes_with_meta_and_links) - XCTAssertEqual(entity[\.word], "coolio") XCTAssertEqual(entity.word, "coolio") - XCTAssertEqual(entity[\.number], 992299) XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.meta.x, "world") @@ -652,6 +767,15 @@ extension ResourceObjectTests { testEncoded(entity: entity) } + @available(*, deprecated, message: "remove next major version") + func test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_deprecated() { + let entity = decoded(type: TestEntity4WithMetaAndLinks.self, + data: entity_some_relationships_some_attributes_with_meta_and_links) + + XCTAssertEqual(entity[\.word], "coolio") + XCTAssertEqual(entity[\.number], 992299) + } + func test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_encode() { test_DecodeEncodeEquality(type: TestEntity4WithMetaAndLinks.self, data: entity_some_relationships_some_attributes_with_meta_and_links) @@ -673,11 +797,26 @@ extension ResourceObjectTests { meta: .none, links: .none) - XCTAssertEqual(entity1[\.metaAttribute], true) XCTAssertEqual(entity1.metaAttribute, true) - XCTAssertEqual(entity2[\.metaAttribute], false) XCTAssertEqual(entity2.metaAttribute, false) } + + @available(*, deprecated, message: "remove next major version") + func test_MetaEntityAttributeAccessWorks_deprecated() { + let entity1 = TestEntityWithMetaAttribute(id: "even", + attributes: .init(), + relationships: .none, + meta: .none, + links: .none) + let entity2 = TestEntityWithMetaAttribute(id: "odd", + attributes: .init(), + relationships: .none, + meta: .none, + links: .none) + + XCTAssertEqual(entity1[\.metaAttribute], true) + XCTAssertEqual(entity2[\.metaAttribute], false) + } } // MARK: With a Meta Relationship diff --git a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift index 58343ce..181826f 100644 --- a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift +++ b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift @@ -393,6 +393,215 @@ let entity_all_relationships_optional_and_omitted = """ } """.data(using: .utf8)! +let entity_nonNullable_relationship_is_null = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": null + } +} +""".data(using: .utf8)! + +let entity_nonNullable_relationship_is_null2 = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": null + } + } +} +""".data(using: .utf8)! + +let entity_required_relationship_is_omitted = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + } +} +""".data(using: .utf8)! + +let entity_relationship_is_wrong_type = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": { + "id": "123", + "type": "not_the_same" + } + } + } +} +""".data(using: .utf8)! + +let entity_single_relationship_is_many = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": [{ + "id": "123", + "type": "thirteenth_test_entities" + }] + } + } +} +""".data(using: .utf8)! + +let entity_many_relationship_is_single = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": { + "id": "123", + "type": "thirteenth_test_entities" + } + }, + "omittable": { + "data": { + "id": "456", + "type": "thirteenth_test_entities" + } + } + } +} +""".data(using: .utf8)! + +let entity_relationships_entirely_missing = """ +{ + "id": "1", + "type": "thirteenth_test_entities", +} +""".data(using: .utf8)! + +let entity_required_attribute_is_omitted = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + } +} +""".data(using: .utf8)! + +let entity_nonNullable_attribute_is_null = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": null + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": 10 + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type2 = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": "hello", + "other": "world" + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type3 = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": "hello", + "yetAnother": 101 + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type4 = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": "hello", + "transformed": "world" + } +} +""".data(using: .utf8)! + +let entity_attribute_always_fails = """ +{ + "id": "1", + "type": "fourteenth_test_entities", + "attributes": { + "required": "hello", + "transformed2": "world" + } +} +""".data(using: .utf8)! + +let entity_attributes_entirely_missing = """ +{ + "id": "1", + "type": "fourteenth_test_entities" +} +""".data(using: .utf8)! + +let entity_is_wrong_type = """ +{ + "id": "1", + "type": "not_correct_type", + "attributes": { + "required": "hello", + "yetAnother": 101 + } +} +""".data(using: .utf8)! + +let entity_type_is_wrong_type = """ +{ + "id": "1", + "type": 10, + "attributes": { + "required": "hello" + } +} +""".data(using: .utf8)! + +let entity_type_is_missing = """ +{ + "id": "1", + "attributes": { + "required": "hello" + } +} +""".data(using: .utf8)! + +let entity_type_is_null = """ +{ + "id": "1", + "type": null, + "attributes": { + "required": "hello" + } +} +""".data(using: .utf8)! + let entity_unidentified = """ { "type": "unidentified_test_entities", diff --git a/Tests/JSONAPITests/SparseFields/SparseFieldEncoderTests.swift b/Tests/JSONAPITests/SparseFields/SparseFieldEncoderTests.swift index c6192f8..2aa9fb2 100644 --- a/Tests/JSONAPITests/SparseFields/SparseFieldEncoderTests.swift +++ b/Tests/JSONAPITests/SparseFields/SparseFieldEncoderTests.swift @@ -17,7 +17,6 @@ class SparseFieldEncoderTests: XCTestCase { do { let _ = try encoder.encode(Wrapper()) } catch let err as Wrapper.OuterFail.FailError { - print(err.path) XCTAssertEqual(err.path.first as? Wrapper.OuterFail.CodingKeys, Wrapper.OuterFail.CodingKeys.inner) } catch { XCTFail("received unexpected error during test") diff --git a/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift b/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift index 003dcc5..bffe9dd 100644 --- a/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift +++ b/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift @@ -31,6 +31,38 @@ class SparseFieldsetTests: XCTestCase { XCTAssertNil(relationships) XCTAssertEqual(attributesDict?.count, 9) // note not 10 because one value is omitted intentionally at initialization + XCTAssertEqual(attributesDict?["bool"] as? Bool, + testEverythingObject.bool) + XCTAssertEqual(attributesDict?["int"] as? Int, + testEverythingObject.int) + XCTAssertEqual(attributesDict?["double"] as? Double, + testEverythingObject.double) + XCTAssertEqual(attributesDict?["string"] as? String, + testEverythingObject.string) + XCTAssertEqual((attributesDict?["nestedStruct"] as? [String: String])?["hello"], + testEverythingObject.nestedStruct.hello) + XCTAssertEqual(attributesDict?["nestedEnum"] as? String, + testEverythingObject.nestedEnum.rawValue) + XCTAssertEqual(attributesDict?["array"] as? [Bool], + testEverythingObject.array) + XCTAssertNil(attributesDict?["optional"]) + XCTAssertNotNil(attributesDict?["nullable"] as? NSNull) + XCTAssertNotNil(attributesDict?["optionalNullable"] as? NSNull) + } + + @available(*, deprecated, message: "remove next major version") + func test_FullEncode_deprecated() { + let jsonEncoder = JSONEncoder() + let sparseWithEverything = SparseFieldset(testEverythingObject, fields: EverythingTest.Attributes.CodingKeys.allCases) + + let encoded = try! jsonEncoder.encode(sparseWithEverything) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let outerDict = deserialized as? [String: Any] + let attributesDict = outerDict?["attributes"] as? [String: Any] + XCTAssertEqual(attributesDict?["bool"] as? Bool, testEverythingObject[\.bool]) XCTAssertEqual(attributesDict?["int"] as? Int, @@ -45,9 +77,6 @@ class SparseFieldsetTests: XCTestCase { testEverythingObject[\.nestedEnum].rawValue) XCTAssertEqual(attributesDict?["array"] as? [Bool], testEverythingObject[\.array]) - XCTAssertNil(attributesDict?["optional"]) - XCTAssertNotNil(attributesDict?["nullable"] as? NSNull) - XCTAssertNotNil(attributesDict?["optionalNullable"] as? NSNull) } func test_PartialEncode() { @@ -71,20 +100,48 @@ class SparseFieldsetTests: XCTestCase { XCTAssertEqual(attributesDict?.count, 3) XCTAssertEqual(attributesDict?["bool"] as? Bool, - testEverythingObject[\.bool]) + testEverythingObject.bool) XCTAssertNil(attributesDict?["int"]) XCTAssertNil(attributesDict?["double"]) XCTAssertEqual(attributesDict?["string"] as? String, - testEverythingObject[\.string]) + testEverythingObject.string) XCTAssertNil(attributesDict?["nestedStruct"]) XCTAssertNil(attributesDict?["nestedEnum"]) XCTAssertEqual(attributesDict?["array"] as? [Bool], - testEverythingObject[\.array]) + testEverythingObject.array) XCTAssertNil(attributesDict?["optional"]) XCTAssertNil(attributesDict?["nullable"]) XCTAssertNil(attributesDict?["optionalNullable"]) } + @available(*, deprecated, message: "remove next major version") + func test_PartialEncode_deprecated() { + let jsonEncoder = JSONEncoder() + let sparseObject = SparseFieldset(testEverythingObject, fields: [.string, .bool, .array]) + + let encoded = try! jsonEncoder.encode(sparseObject) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let outerDict = deserialized as? [String: Any] + let id = outerDict?["id"] as? String + let type = outerDict?["type"] as? String + let attributesDict = outerDict?["attributes"] as? [String: Any] + let relationships = outerDict?["relationships"] + + XCTAssertEqual(id, testEverythingObject.id.rawValue) + XCTAssertEqual(type, EverythingTest.jsonType) + XCTAssertNil(relationships) + + XCTAssertEqual(attributesDict?["bool"] as? Bool, + testEverythingObject[\.bool]) + XCTAssertEqual(attributesDict?["string"] as? String, + testEverythingObject[\.string]) + XCTAssertEqual(attributesDict?["array"] as? [Bool], + testEverythingObject[\.array]) + } + func test_sparseFieldsMethod() { let jsonEncoder = JSONEncoder() let sparseObject = testEverythingObject.sparse(with: [.string, .bool, .array]) @@ -106,19 +163,47 @@ class SparseFieldsetTests: XCTestCase { XCTAssertEqual(attributesDict?.count, 3) XCTAssertEqual(attributesDict?["bool"] as? Bool, - testEverythingObject[\.bool]) + testEverythingObject.bool) XCTAssertNil(attributesDict?["int"]) XCTAssertNil(attributesDict?["double"]) XCTAssertEqual(attributesDict?["string"] as? String, - testEverythingObject[\.string]) + testEverythingObject.string) XCTAssertNil(attributesDict?["nestedStruct"]) XCTAssertNil(attributesDict?["nestedEnum"]) XCTAssertEqual(attributesDict?["array"] as? [Bool], - testEverythingObject[\.array]) + testEverythingObject.array) XCTAssertNil(attributesDict?["optional"]) XCTAssertNil(attributesDict?["nullable"]) XCTAssertNil(attributesDict?["optionalNullable"]) } + + @available(*, deprecated, message: "remove next major version") + func test_sparseFieldsMethod_deprecated() { + let jsonEncoder = JSONEncoder() + let sparseObject = testEverythingObject.sparse(with: [.string, .bool, .array]) + + let encoded = try! jsonEncoder.encode(sparseObject) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let outerDict = deserialized as? [String: Any] + let id = outerDict?["id"] as? String + let type = outerDict?["type"] as? String + let attributesDict = outerDict?["attributes"] as? [String: Any] + let relationships = outerDict?["relationships"] + + XCTAssertEqual(id, testEverythingObject.id.rawValue) + XCTAssertEqual(type, EverythingTest.jsonType) + XCTAssertNil(relationships) + + XCTAssertEqual(attributesDict?["bool"] as? Bool, + testEverythingObject[\.bool]) + XCTAssertEqual(attributesDict?["string"] as? String, + testEverythingObject[\.string]) + XCTAssertEqual(attributesDict?["array"] as? [Bool], + testEverythingObject[\.array]) + } } struct EverythingTestDescription: JSONAPI.ResourceObjectDescription { diff --git a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift index a2eeb56..a588b71 100644 --- a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift +++ b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift @@ -8,12 +8,15 @@ import Foundation import XCTest +let testDecoder = JSONDecoder() +let testEncoder = JSONEncoder() + func decoded(type: T.Type, data: Data) -> T { - return try! JSONDecoder().decode(T.self, from: data) + return try! testDecoder.decode(T.self, from: data) } func encoded(value: T) -> Data { - return try! JSONEncoder().encode(value) + return try! testEncoder.encode(value) } /// A helper function that tests that decode() == decode().encode().decode(). diff --git a/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift b/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift index 56070a0..2a340d1 100644 --- a/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift +++ b/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift @@ -33,12 +33,9 @@ func testEncodedPrimitive(at let wrapperObject = try! JSONSerialization.jsonObject(with: encodedAttributeData, options: []) as! [String: Any] let jsonObject = wrapperObject["x"] - guard let jsonAttribute = jsonObject as? Transform.From else { - XCTFail("Attribute did not encode to the correct type") - return - } + XCTAssert(jsonObject is Transform.From) - XCTAssertEqual(attribute.rawValue, jsonAttribute) + XCTAssertEqual(attribute.rawValue, jsonObject as? Transform.From) } /// This function attempts to just cast to the type, so it only works @@ -48,10 +45,7 @@ func testEncodedPrimitive(attribute: Attribute = Entity public typealias NewEntity = JSONAPI.ResourceObject + +extension String: JSONAPI.JSONAPIURL {} diff --git a/Tests/JSONAPITests/XCTestManifests.swift b/Tests/JSONAPITests/XCTestManifests.swift index cd6323d..ed1efbb 100644 --- a/Tests/JSONAPITests/XCTestManifests.swift +++ b/Tests/JSONAPITests/XCTestManifests.swift @@ -22,10 +22,12 @@ extension AttributeTests { // to regenerate. static let __allTests__AttributeTests = [ ("test_AttributeConstructor", test_AttributeConstructor), + ("test_AttributeRawType", test_AttributeRawType), ("test_EncodedPrimitives", test_EncodedPrimitives), ("test_NullableIsEqualToNonNullableIfNotNil", test_NullableIsEqualToNonNullableIfNotNil), ("test_NullableIsNullIfNil", test_NullableIsNullIfNil), ("test_TransformedAttributeNoThrow", test_TransformedAttributeNoThrow), + ("test_TransformedAttributeRawType", test_TransformedAttributeRawType), ("test_TransformedAttributeReversNoThrow", test_TransformedAttributeReversNoThrow), ("test_TransformedAttributeThrows", test_TransformedAttributeThrows), ] @@ -37,8 +39,11 @@ extension Attribute_FunctorTests { // to regenerate. static let __allTests__Attribute_FunctorTests = [ ("test_mapGuaranteed", test_mapGuaranteed), + ("test_mapGuaranteed_deprecated", test_mapGuaranteed_deprecated), ("test_mapOptionalFailure", test_mapOptionalFailure), + ("test_mapOptionalFailure_deprecated", test_mapOptionalFailure_deprecated), ("test_mapOptionalSuccess", test_mapOptionalSuccess), + ("test_mapOptionalSuccess_deprecated", test_mapOptionalSuccess_deprecated), ] } @@ -59,9 +64,11 @@ extension ComputedPropertiesTests { // to regenerate. static let __allTests__ComputedPropertiesTests = [ ("test_ComputedAttributeAccess", test_ComputedAttributeAccess), + ("test_ComputedAttributeAccess_deprecated", test_ComputedAttributeAccess_deprecated), ("test_ComputedNonAttributeAccess", test_ComputedNonAttributeAccess), ("test_ComputedRelationshipAccess", test_ComputedRelationshipAccess), ("test_DecodeIgnoresComputed", test_DecodeIgnoresComputed), + ("test_DecodeIgnoresComputed_deprecated", test_DecodeIgnoresComputed_deprecated), ("test_EncodeIgnoresComputed", test_EncodeIgnoresComputed), ] } @@ -72,12 +79,30 @@ extension CustomAttributesTests { // to regenerate. static let __allTests__CustomAttributesTests = [ ("test_customDecode", test_customDecode), + ("test_customDecode_deprecated", test_customDecode_deprecated), ("test_customEncode", test_customEncode), ("test_customKeysDecode", test_customKeysDecode), + ("test_customKeysDecode_deprecated", test_customKeysDecode_deprecated), ("test_customKeysEncode", test_customKeysEncode), ] } +extension DocumentDecodingErrorTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__DocumentDecodingErrorTests = [ + ("test_include_failure", test_include_failure), + ("test_include_failure2", test_include_failure2), + ("test_manyPrimaryResource_failure", test_manyPrimaryResource_failure), + ("test_manyPrimaryResource_missing", test_manyPrimaryResource_missing), + ("test_singlePrimaryResource_failure", test_singlePrimaryResource_failure), + ("test_singlePrimaryResource_missing", test_singlePrimaryResource_missing), + ("test_wantError_foundSuccess", test_wantError_foundSuccess), + ("test_wantSuccess_foundError", test_wantSuccess_foundError), + ] +} + extension DocumentTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -263,6 +288,15 @@ extension IncludedTests { ] } +extension IncludesDecodingErrorTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__IncludesDecodingErrorTests = [ + ("test_unexpectedIncludeType", test_unexpectedIncludeType), + ] +} + extension LinksTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -298,10 +332,13 @@ extension PolyProxyTests { static let __allTests__PolyProxyTests = [ ("test_AsymmetricEncodeDecodeUserA", test_AsymmetricEncodeDecodeUserA), ("test_AsymmetricEncodeDecodeUserB", test_AsymmetricEncodeDecodeUserB), + ("test_CannotEncodeOrDecodePoly0", test_CannotEncodeOrDecodePoly0), ("test_generalReasonableness", test_generalReasonableness), ("test_UserAAndBEncodeEquality", test_UserAAndBEncodeEquality), ("test_UserADecode", test_UserADecode), + ("test_UserADecode_deprecated", test_UserADecode_deprecated), ("test_UserBDecode", test_UserBDecode), + ("test_UserBDecode_deprecated", test_UserBDecode_deprecated), ] } @@ -353,6 +390,32 @@ extension ResourceBodyTests { ] } +extension ResourceObjectDecodingErrorTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ResourceObjectDecodingErrorTests = [ + ("test_missingAttributesObject", test_missingAttributesObject), + ("test_missingRelationshipsObject", test_missingRelationshipsObject), + ("test_NonNullable_attribute", test_NonNullable_attribute), + ("test_NonNullable_relationship", test_NonNullable_relationship), + ("test_NonNullable_relationship2", test_NonNullable_relationship2), + ("test_oneTypeVsAnother_attribute", test_oneTypeVsAnother_attribute), + ("test_oneTypeVsAnother_attribute2", test_oneTypeVsAnother_attribute2), + ("test_oneTypeVsAnother_attribute3", test_oneTypeVsAnother_attribute3), + ("test_oneTypeVsAnother_relationship", test_oneTypeVsAnother_relationship), + ("test_required_attribute", test_required_attribute), + ("test_required_relationship", test_required_relationship), + ("test_transformed_attribute", test_transformed_attribute), + ("test_transformed_attribute2", test_transformed_attribute2), + ("test_twoOneVsToMany_relationship", test_twoOneVsToMany_relationship), + ("test_type_missing", test_type_missing), + ("test_type_null", test_type_null), + ("test_wrongDecodedType", test_wrongDecodedType), + ("test_wrongJSONAPIType", test_wrongJSONAPIType), + ] +} + extension ResourceObjectReplacingTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -378,37 +441,49 @@ extension ResourceObjectTests { ("test_copyIdentifiedByValue", test_copyIdentifiedByValue), ("test_copyWithNewId", test_copyWithNewId), ("test_entityAllAttribute", test_entityAllAttribute), + ("test_entityAllAttribute_deprecated", test_entityAllAttribute_deprecated), ("test_entityAllAttribute_encode", test_entityAllAttribute_encode), ("test_entityBrokenNullableOmittedAttribute", test_entityBrokenNullableOmittedAttribute), ("test_EntityNoRelationshipsNoAttributes", test_EntityNoRelationshipsNoAttributes), ("test_EntityNoRelationshipsNoAttributes_encode", test_EntityNoRelationshipsNoAttributes_encode), ("test_EntityNoRelationshipsSomeAttributes", test_EntityNoRelationshipsSomeAttributes), + ("test_EntityNoRelationshipsSomeAttributes_deprecated", test_EntityNoRelationshipsSomeAttributes_deprecated), ("test_EntityNoRelationshipsSomeAttributes_encode", test_EntityNoRelationshipsSomeAttributes_encode), ("test_entityOneNullAndOneOmittedAttribute", test_entityOneNullAndOneOmittedAttribute), + ("test_entityOneNullAndOneOmittedAttribute_deprecated", test_entityOneNullAndOneOmittedAttribute_deprecated), ("test_entityOneNullAndOneOmittedAttribute_encode", test_entityOneNullAndOneOmittedAttribute_encode), ("test_entityOneNullAttribute", test_entityOneNullAttribute), + ("test_entityOneNullAttribute_deprecated", test_entityOneNullAttribute_deprecated), ("test_entityOneNullAttribute_encode", test_entityOneNullAttribute_encode), ("test_entityOneOmittedAttribute", test_entityOneOmittedAttribute), + ("test_entityOneOmittedAttribute_deprecated", test_entityOneOmittedAttribute_deprecated), ("test_entityOneOmittedAttribute_encode", test_entityOneOmittedAttribute_encode), ("test_EntitySomeRelationshipsNoAttributes", test_EntitySomeRelationshipsNoAttributes), ("test_EntitySomeRelationshipsNoAttributes_encode", test_EntitySomeRelationshipsNoAttributes_encode), ("test_EntitySomeRelationshipsSomeAttributes", test_EntitySomeRelationshipsSomeAttributes), + ("test_EntitySomeRelationshipsSomeAttributes_deprecated", test_EntitySomeRelationshipsSomeAttributes_deprecated), ("test_EntitySomeRelationshipsSomeAttributes_encode", test_EntitySomeRelationshipsSomeAttributes_encode), ("test_EntitySomeRelationshipsSomeAttributesWithLinks", test_EntitySomeRelationshipsSomeAttributesWithLinks), + ("test_EntitySomeRelationshipsSomeAttributesWithLinks_deprecated", test_EntitySomeRelationshipsSomeAttributesWithLinks_deprecated), ("test_EntitySomeRelationshipsSomeAttributesWithLinks_encode", test_EntitySomeRelationshipsSomeAttributesWithLinks_encode), ("test_EntitySomeRelationshipsSomeAttributesWithMeta", test_EntitySomeRelationshipsSomeAttributesWithMeta), + ("test_EntitySomeRelationshipsSomeAttributesWithMeta_deprecated", test_EntitySomeRelationshipsSomeAttributesWithMeta_deprecated), ("test_EntitySomeRelationshipsSomeAttributesWithMeta_encode", test_EntitySomeRelationshipsSomeAttributesWithMeta_encode), ("test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks", test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks), + ("test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_deprecated", test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_deprecated), ("test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_encode", test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks_encode), ("test_initialization", test_initialization), ("test_IntOver10_encode", test_IntOver10_encode), ("test_IntOver10_failure", test_IntOver10_failure), ("test_IntOver10_success", test_IntOver10_success), ("test_IntToString", test_IntToString), + ("test_IntToString_deprecated", test_IntToString_deprecated), ("test_IntToString_encode", test_IntToString_encode), ("test_MetaEntityAttributeAccessWorks", test_MetaEntityAttributeAccessWorks), + ("test_MetaEntityAttributeAccessWorks_deprecated", test_MetaEntityAttributeAccessWorks_deprecated), ("test_MetaEntityRelationshipAccessWorks", test_MetaEntityRelationshipAccessWorks), ("test_NonNullOptionalNullableAttribute", test_NonNullOptionalNullableAttribute), + ("test_NonNullOptionalNullableAttribute_deprecated", test_NonNullOptionalNullableAttribute_deprecated), ("test_NonNullOptionalNullableAttribute_encode", test_NonNullOptionalNullableAttribute_encode), ("test_nullableRelationshipIsNull", test_nullableRelationshipIsNull), ("test_nullableRelationshipIsNull_encode", test_nullableRelationshipIsNull_encode), @@ -417,6 +492,7 @@ extension ResourceObjectTests { ("test_nullableRelationshipNotNullOrOmitted", test_nullableRelationshipNotNullOrOmitted), ("test_nullableRelationshipNotNullOrOmitted_encode", test_nullableRelationshipNotNullOrOmitted_encode), ("test_NullOptionalNullableAttribute", test_NullOptionalNullableAttribute), + ("test_NullOptionalNullableAttribute_deprecated", test_NullOptionalNullableAttribute_deprecated), ("test_NullOptionalNullableAttribute_encode", test_NullOptionalNullableAttribute_encode), ("test_optional_relationship_operator_access", test_optional_relationship_operator_access), ("test_optionalNullableRelationshipNulled", test_optionalNullableRelationshipNulled), @@ -434,15 +510,21 @@ extension ResourceObjectTests { ("test_toMany_relationship_operator_access", test_toMany_relationship_operator_access), ("test_toManyMetaRelationshipAccessWorks", test_toManyMetaRelationshipAccessWorks), ("test_UnidentifiedEntity", test_UnidentifiedEntity), + ("test_UnidentifiedEntity_deprecated", test_UnidentifiedEntity_deprecated), ("test_UnidentifiedEntity_encode", test_UnidentifiedEntity_encode), ("test_unidentifiedEntityAttributeAccess", test_unidentifiedEntityAttributeAccess), + ("test_unidentifiedEntityAttributeAccess_deprecated", test_unidentifiedEntityAttributeAccess_deprecated), ("test_UnidentifiedEntityWithAttributes", test_UnidentifiedEntityWithAttributes), + ("test_UnidentifiedEntityWithAttributes_deprecated", test_UnidentifiedEntityWithAttributes_deprecated), ("test_UnidentifiedEntityWithAttributes_encode", test_UnidentifiedEntityWithAttributes_encode), ("test_UnidentifiedEntityWithAttributesAndLinks", test_UnidentifiedEntityWithAttributesAndLinks), + ("test_UnidentifiedEntityWithAttributesAndLinks_deprecated", test_UnidentifiedEntityWithAttributesAndLinks_deprecated), ("test_UnidentifiedEntityWithAttributesAndLinks_encode", test_UnidentifiedEntityWithAttributesAndLinks_encode), ("test_UnidentifiedEntityWithAttributesAndMeta", test_UnidentifiedEntityWithAttributesAndMeta), + ("test_UnidentifiedEntityWithAttributesAndMeta_deprecated", test_UnidentifiedEntityWithAttributesAndMeta_deprecated), ("test_UnidentifiedEntityWithAttributesAndMeta_encode", test_UnidentifiedEntityWithAttributesAndMeta_encode), ("test_UnidentifiedEntityWithAttributesAndMetaAndLinks", test_UnidentifiedEntityWithAttributesAndMetaAndLinks), + ("test_UnidentifiedEntityWithAttributesAndMetaAndLinks_deprecated", test_UnidentifiedEntityWithAttributesAndMetaAndLinks_deprecated), ("test_UnidentifiedEntityWithAttributesAndMetaAndLinks_encode", test_UnidentifiedEntityWithAttributesAndMetaAndLinks_encode), ] } @@ -468,8 +550,21 @@ extension SparseFieldsetTests { // to regenerate. static let __allTests__SparseFieldsetTests = [ ("test_FullEncode", test_FullEncode), + ("test_FullEncode_deprecated", test_FullEncode_deprecated), ("test_PartialEncode", test_PartialEncode), + ("test_PartialEncode_deprecated", test_PartialEncode_deprecated), ("test_sparseFieldsMethod", test_sparseFieldsMethod), + ("test_sparseFieldsMethod_deprecated", test_sparseFieldsMethod_deprecated), + ] +} + +extension SuccessAndErrorDocumentTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__SuccessAndErrorDocumentTests = [ + ("test_errorAccessors", test_errorAccessors), + ("test_successAccessors", test_successAccessors), ] } @@ -491,19 +586,23 @@ public func __allTests() -> [XCTestCaseEntry] { testCase(BasicJSONAPIErrorTests.__allTests__BasicJSONAPIErrorTests), testCase(ComputedPropertiesTests.__allTests__ComputedPropertiesTests), testCase(CustomAttributesTests.__allTests__CustomAttributesTests), + testCase(DocumentDecodingErrorTests.__allTests__DocumentDecodingErrorTests), testCase(DocumentTests.__allTests__DocumentTests), testCase(EmptyObjectDecoderTests.__allTests__EmptyObjectDecoderTests), testCase(GenericJSONAPIErrorTests.__allTests__GenericJSONAPIErrorTests), testCase(IncludedTests.__allTests__IncludedTests), + testCase(IncludesDecodingErrorTests.__allTests__IncludesDecodingErrorTests), testCase(LinksTests.__allTests__LinksTests), testCase(NonJSONAPIRelatableTests.__allTests__NonJSONAPIRelatableTests), testCase(PolyProxyTests.__allTests__PolyProxyTests), testCase(RelationshipTests.__allTests__RelationshipTests), testCase(ResourceBodyTests.__allTests__ResourceBodyTests), + testCase(ResourceObjectDecodingErrorTests.__allTests__ResourceObjectDecodingErrorTests), testCase(ResourceObjectReplacingTests.__allTests__ResourceObjectReplacingTests), testCase(ResourceObjectTests.__allTests__ResourceObjectTests), testCase(SparseFieldEncoderTests.__allTests__SparseFieldEncoderTests), testCase(SparseFieldsetTests.__allTests__SparseFieldsetTests), + testCase(SuccessAndErrorDocumentTests.__allTests__SuccessAndErrorDocumentTests), testCase(TransformerTests.__allTests__TransformerTests), ] } diff --git a/documentation/usage.md b/documentation/usage.md new file mode 100644 index 0000000..7f6e8ee --- /dev/null +++ b/documentation/usage.md @@ -0,0 +1,611 @@ + +## Usage + +In this documentation, in order to draw attention to the difference between the `JSONAPI` framework (this Swift library) and the **JSON API Spec** (the specification this library helps you follow), the specification will consistently be referred to below as simply the **SPEC**. + + + +- [`JSONAPI.ResourceObjectDescription`](#jsonapiresourceobjectdescription) +- [`JSONAPI.ResourceObject`](#jsonapiresourceobject) + - [`Meta`](#meta) + - [`Links`](#links) + - [`MaybeRawId`](#mayberawid) + - [`RawIdType`](#rawidtype) + - [Convenient `typealiases`](#convenient-typealiases) +- [`JSONAPI.Relationships`](#jsonapirelationships) +- [`JSONAPI.Attributes`](#jsonapiattributes) + - [`Transformer`](#transformer) + - [`Validator`](#validator) + - [Computed `Attribute`](#computed-attribute) +- [Copying/Mutating `ResourceObjects`](#copyingmutating-resourceobjects) +- [`JSONAPI.Document`](#jsonapidocument) + - [`ResourceBody`](#resourcebody) + - [nullable `PrimaryResource`](#nullable-primaryresource) + - [`MetaType`](#metatype) + - [`LinksType`](#linkstype) + - [`IncludeType`](#includetype) + - [`APIDescriptionType`](#apidescriptiontype) + - [`Error`](#error) + - [`UnknownJSONAPIError`](#unknownjsonapierror) + - [`BasicJSONAPIError`](#basicjsonapierror) + - [`GenericJSONAPIError`](#genericjsonapierror) +- [`JSONAPI.Meta`](#jsonapimeta) +- [`JSONAPI.Links`](#jsonapilinks) +- [`JSONAPI.RawIdType`](#jsonapirawidtype) +- [Sparse Fieldsets](#sparse-fieldsets) + - [Supporting Sparse Fieldset Encoding](#supporting-sparse-fieldset-encoding) + - [Sparse Fieldset `typealias` comparisons](#sparse-fieldset-typealias-comparisons) +- [Replacing and Tapping Attributes/Relationships](#replacing-and-tapping-attributesrelationships) + - [Tapping](#tapping) + - [Replacing](#replacing) +- [Custom Attribute or Relationship Key Mapping](#custom-attribute-or-relationship-key-mapping) +- [Custom Attribute Encode/Decode](#custom-attribute-encodedecode) +- [Meta-Attributes](#meta-attributes) +- [Meta-Relationships](#meta-relationships) + + + +### `JSONAPI.ResourceObjectDescription` + +A `ResourceObjectDescription` is the `JSONAPI` framework's representation of what the **SPEC** calls a *Resource Object*. You might create the following `ResourceObjectDescription` to represent a person in a network of friends: + +```swift +enum PersonDescription: IdentifiedResourceObjectDescription { + static var jsonType: String { return "people" } + + struct Attributes: JSONAPI.Attributes { + let name: Attribute<[String]> + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let friends: ToManyRelationship + } +} +``` + +The requirements of a `ResourceObjectDescription` are: +1. A static `var` "jsonType" that matches the JSON type; The **SPEC** requires every *Resource Object* to have a "type". +2. A `struct` of `Attributes` **- OR -** `typealias Attributes = NoAttributes` +3. A `struct` of `Relationships` **- OR -** `typealias Relationships = NoRelationships` + +Note that an `enum` type is used here for the `ResourceObjectDescription`; it could have been a `struct`, but `ResourceObjectDescription`s do not ever need to be created so an `enum` with no `case`s is a nice fit for the job. + +This readme doesn't go into detail on the **SPEC**, but the following *Resource Object* would be described by the above `PersonDescription`: + +```json +{ + "type": "people", + "id": "9", + "attributes": { + "name": [ + "Jane", + "Doe" + ], + "favoriteColor": "Green" + }, + "relationships": { + "friends": { + "data": [ + { + "id": "7", + "type": "people" + }, + { + "id": "8", + "type": "people" + } + ] + } + } +} +``` + +### `JSONAPI.ResourceObject` + +Once you have a `ResourceObjectDescription`, you _create_, _encode_, and _decode_ `ResourceObjects` that "fit the description". If you have a `CreatableRawIdType` (see the section on `RawIdType`s below) then you can create new `ResourceObjects` that will automatically be given unique Ids, but even without a `CreatableRawIdType` you can encode, decode and work with resource objects. + +The `ResourceObject` and `ResourceObjectDescription` together with a `JSONAPI.Meta` type and a `JSONAPI.Links` type embody the rules and properties of a JSON API *Resource Object*. + +A `ResourceObject` needs to be specialized on four generic types. The first is the `ResourceObjectDescription` described above. The others are a `Meta`, `Links`, and `MaybeRawId`. + +#### `Meta` + +The second generic specialization on `ResourceObject` is `Meta`. This is described in its own section [below](#jsonapimeta). All `Meta` at any level of a JSON API Document follow the same rules. You can use `NoMetadata` if you do not need to package any metadata with the `ResourceObject`. + +#### `Links` + +The third generic specialization on `ResourceObject` is `Links`. This is described in its own section [below](#jsonnapilinks). All `Links` at any level of a JSON API Document follow the same rules, although the **SPEC** makes different suggestions as to what types of links might live on which parts of the Document. You can use `NoLinks` if you do not need to package any links with the `ResourceObject`. + +#### `MaybeRawId` + +The last generic specialization on `ResourceObject` is `MaybeRawId`. This is either a `RawIdType` that can be used to uniquely identify `ResourceObjects` or it is `Unidentified` which is used to indicate a `ResourceObject` does not have an `Id` (which is useful when a client is requesting that the server create a `ResourceObject` and assign it a new `Id`). + +##### `RawIdType` + +The raw type of `Id` to use for the `ResourceObject`. The actual `Id` of the `ResourceObject` will not be a `RawIdType`, though. The `Id` will package a value of `RawIdType` with a specialized reference back to the `ResourceObject` type it identifies. This just looks like `Id>`. + +Having the `ResourceObject` type associated with the `Id` makes it easy to store all of your resource objects in a hash broken out by `ResourceObject` type; You can pass `Ids` around and always know where to look for the `ResourceObject` to which the `Id` refers. This encapsulation provides some type safety because the Ids of two `ResourceObjects` with the "raw ID" of `"1"` but different types will not compare as equal. + +A `RawIdType` is the underlying type that uniquely identifies a `ResourceObject`. This is often a `String` or a `UUID`. + +#### Convenient `typealiases` + +Often you can use one `RawIdType` for many if not all of your `ResourceObjects`. That means you can save yourself some boilerplate by using `typealias`es like the following: +```swift +public typealias ResourceObject = JSONAPI.ResourceObject + +public typealias NewResourceObject = JSONAPI.ResourceObject +``` + +It can also be nice to create a `typealias` for each type of resource object you want to work with: +```swift +typealias Person = ResourceObject + +typealias NewPerson = NewResourceObject +``` + +Note that I am assuming an unidentified person is a "new" person. I suspect that is generally an acceptable conflation because the only time the **SPEC** allows a *Resource Object* to be encoded without an `Id` is when a client is requesting the given *Resource Object* be created by the server and the client wants the server to create the `Id` for that object. + +### `JSONAPI.Relationships` + +There are two types of `Relationships`: `ToOneRelationship` and `ToManyRelationship`. A `ResourceObjectDescription`'s `Relationships` type can contain any number of `Relationship` properties of either of these types. Do not store anything other than `Relationship` properties in the `Relationships` struct of a `ResourceObjectDescription`. + +In addition to identifying resource objects by Id and type, `Relationships` can contain `Meta` or `Links` that follow the same rules as [`Meta`](#jsonapimeta) and [`Links`](#jsonapilinks) elsewhere in the JSON API Document. + +To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire `ToOneRelationship` or `ToManyRelationship` optional. However, this is not recommended because you can also represent optional relationships as nullable which means the key is always present. A `ToManyRelationship` can naturally represent the absence of related values with an empty array, so `ToManyRelationship` does not support nullability at all. A `ToOneRelationship` can be marked as nullable (i.e. the value could be either `null` or a resource identifier) like this: +```swift +let nullableRelative: ToOneRelationship +``` + +A `ResourceObject` that does not have relationships can be described by adding the following to a `ResourceObjectDescription`: +```swift +typealias Relationships = NoRelationships +``` + +`Relationship` values boil down to `Ids` of other resource objects. To access the `Id` of a related `ResourceObject`, you can use the custom `~>` operator with the `KeyPath` of the `Relationship` from which you want the `Id`. The friends of the above `Person` `ResourceObject` can be accessed as follows (type annotations for clarity): +```swift +let friendIds: [Person.Identifier] = person ~> \.friends +``` + +### `JSONAPI.Attributes` + +The `Attributes` of a `ResourceObjectDescription` can contain any JSON encodable/decodable types as long as they are wrapped in an `Attribute`, `ValidatedAttribute`, or `TransformedAttribute` `struct`. + +To describe an attribute that may be omitted (i.e. the key might not even be in the JSON object), you make the entire `Attribute` optional: +```swift +let optionalAttribute: Attribute? +``` + +To describe an attribute that is expected to exist but might have a `null` value, you make the value within the `Attribute` optional: +```swift +let nullableAttribute: Attribute +``` + +A resource object that does not have attributes can be described by adding the following to an `ResourceObjectDescription`: +```swift +typealias Attributes = NoAttributes +``` + +As of Swift 5.1, `Attributes` can be accessed via dynamic member keypath lookup as follows: +```swift +let favoriteColor: String = person.favoriteColor +``` + +:warning: `Attributes` can also be accessed via the older `subscript` operator, but this is a deprecated feature that will be removed in the next major version: +```swift +let favoriteColor: String = person[\.favoriteColor] +``` + +#### `Transformer` + +Sometimes you need to use a type that does not encode or decode itself in the way you need to represent it as a serialized JSON object. For example, the Swift `Foundation` type `Date` can encode/decode itself to `Double` out of the box, but you might want to represent dates as ISO 8601 compliant `String`s instead. The Foundation library `JSONDecoder` has a setting to make this adjustment, but for the sake of an example, you could create a `Transformer`. + +A `Transformer` just provides one static function that transforms one type to another. You might define one for an ISO 8601 compliant `Date` like this: +```swift +enum ISODateTransformer: Transformer { + public static func transform(_ value: String) throws -> Date { + // parse Date out of input and return + } +} +``` + +Then you define the attribute as a `TransformedAttribute` instead of an `Attribute`: +```swift +let date: TransformedAttribute +``` + +Note that the first generic parameter of `TransformAttribute` is the type you expect to decode from JSON, not the type you want to end up with after transformation. + +If you make your `Transformer` a `ReversibleTransformer` then your life will be a bit easier when you construct `TransformedAttributes` because you have access to initializers for both the pre- and post-transformed value types. Continuing with the above example of a `ISODateTransformer`: +```swift +extension ISODateTransformer: ReversibleTransformer { + public static func reverse(_ value: Date) throws -> String { + // serialize Date to a String + } +} + +let exampleAttribute = try? TransformedAttribute(transformedValue: Date()) +let otherAttribute = try? TransformedAttribute(rawValue: "2018-12-01 09:06:41 +0000") +``` + +#### `Validator` + +You can also creator `Validators` and `ValidatedAttribute`s. A `Validator` is just a `Transformer` that by convention does not perform a transformation. It simply `throws` if an attribute value is invalid. + +#### Computed `Attribute` + +You can add computed properties to your `ResourceObjectDescription.Attributes` struct if you would like to expose attributes that are not explicitly represented by the JSON. These computed properties do not have to be wrapped in `Attribute`, `ValidatedAttribute`, or `TransformedAttribute`. This allows computed attributes to be of types that are not `Codable`. Here's an example of how you might take the `person.name` attribute from the example above and create a `fullName` computed property. + +```swift +public var fullName: Attribute { + return name.map { $0.joined(separator: " ") } +} +``` + +If your computed property is wrapped in a `AttributeType` then you can still use the default subscript operator to access it (as would be the case with the `person.fullName` example above). However, if you add a property to the `Attributes` `struct` that is not wrapped in an `AttributeType`, you must either access it from its full path (`person.attributes.newThing`) or with the "direct" subscript accessor (`person[direct: \.newThing]`). This keeps the subscript access unambiguous enough for the compiler to be helpful prior to explicitly casting, comparing, or storing the result. + +### Copying/Mutating `ResourceObjects` +`ResourceObject` is a value type, so copying is its default behavior. There are two common mutations you might want to make when copying a `ResourceObject`: +1. Assigning a new `Identifier` to the copy of an identified `ResourceObject`. +2. Assigning a new `Identifier` to the copy of an unidentified `ResourceObject`. + +The above can be accomplished with code like the following: + +```swift +// use case 1 +let person1 = person.withNewIdentifier() + +// use case 2 +let newlyIdentifiedPerson1 = unidentifiedPerson.identified(byType: String.self) + +let newlyIdentifiedPerson2 = unidentifiedPerson.identified(by: "2232") +``` + +### `JSONAPI.Document` + +The entirety of a JSON API request or response is encoded or decoded from- or to a `Document`. As an example, a JSON API response containing one `Person` and no included resource objects could be decoded as follows: +```swift +let decoder = JSONDecoder() + +let responseStructure = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError>.self + +let document = try decoder.decode(responseStructure, from: data) +``` + +A JSON API Document is guaranteed by the **SPEC** to be "data", "metadata", or "errors." If it is "data", it may also contain "metadata" and/or other "included" resources. If it is "errors," it may also contain "metadata." + +#### `ResourceBody` + +The first generic type of a `JSONAPIDocument` is a `ResourceBody`. This can either be a `SingleResourceBody` or a `ManyResourceBody`. You will find zero or one `PrimaryResource` values in a JSON API document that has a `SingleResourceBody` and you will find zero or more `PrimaryResource` values in a JSON API document that has a `ManyResourceBody`. You can use the `Poly` types (`Poly1` through `Poly6`) to specify that a `ResourceBody` will be one of a few different types of `ResourceObject`. These `Poly` types work in the same way as the `Include` types described below. + +If you expect a response to not have a "data" top-level key at all, then use `NoResourceBody` instead. + +##### nullable `PrimaryResource` + +If you expect a `SingleResourceBody` to sometimes come back `null`, you should make your `PrimaryResource` optional. If you do not make your `PrimaryResource` optional then a `null` primary resource will be considered an error when parsing the JSON. + +You cannot, however, use an optional `PrimaryResource` with a `ManyResourceBody` because the **SPEC** requires that an empty document in that case be represented by an empty array rather than `null`. + +#### `MetaType` + +The second generic type of a `JSONAPIDocument` is a `Meta`. This `Meta` follows the same rules as `Meta` at any other part of a JSON API Document. It is described below in its own section, but as an example, the JSON API document could contain the following pagination info in its meta entry: +```json +{ + "meta": { + "total": 100, + "limit": 50, + "offset": 50 + } +} +``` + +You would then create the following `Meta` type: +```swift +struct PageMetadata: JSONAPI.Meta { + let total: Int + let limit: Int + let offset: Int +} +``` + +You can always use `NoMetadata` if this JSON API feature is not needed. + +#### `LinksType` + +The third generic type of a `JSONAPIDocument` is a `Links` struct. `Links` are described in their own section [below](#jsonapilinks). + +#### `IncludeType` + +The fourth generic type of a `JSONAPIDocument` is an `Include`. This type controls which types of `ResourceObject` are looked for when decoding the "included" part of the JSON API document. If you do not expect any included resource objects to be in the document, `NoIncludes` is the way to go. The `JSONAPI` framework provides `Include`s for up to 10 types of included resource objects. These are named `Include1`, `Include2`, `Include3`, and so on. + +**IMPORTANT**: The number trailing "Include" in these type names does not indicate a number of included resource objects, it indicates a number of _types_ of included resource objects. `Include1` can be used to decode any number of included resource objects as long as all the resource objects are of the same _type_. + +To specify that we expect friends of a person to be included in the above example `JSONAPIDocument`, we would use `Include1` instead of `NoIncludes`. + +#### `APIDescriptionType` + +The fifth generic type of a `JSONAPIDocument` is an `APIDescription`. The type represents the "JSON:API Object" described by the **SPEC**. This type describes the highest version of the **SPEC** supported and can carry additional metadata to describe the API. + +You can specify this is not part of the document by using the `NoAPIDescription` type. + +You can describe the API by a version with no metadata by using `APIDescription`. + +You can supply any `JSONAPI.Meta` type as the metadata type of the API description. + +#### `Error` + +The final generic type of a `JSONAPIDocument` is the `Error`. + +You can either create an error type that can handle all the errors you expect your `JSONAPIDocument` to be able to encode/decode or use an out-of-box error type described here. As prescribed by the **SPEC**, these errors will be found under the root document key `errors`. + +##### `UnknownJSONAPIError` +The `UnknownJSONAPIError` type will always succeed in parsing errors but it will not give you any information about what error occurred. You will generally get more bang for your buck out of the next error type described. + +##### `BasicJSONAPIError` +The `BasicJSONAPIError` type will always succeed unless it is faced with an `id` field of an unexpected type, although it still "succeeds" in falling back to its `.unknown` case when that happens. This type extracts _most_ of the fields the **SPEC** describes [here](https://jsonapi.org/format/#error-objects). Because all of these fields are optional in the **SPEC**, they are optional on the `BasicJSONAPIError` type. You will have to create your own error type if you want to define certain fields as non-optional or parse metadata or links out of error objects. + +🗒Metadata and links are supported at the Document level for error responses, the are just not supported hanging off of the individual errors in the `errors` array of the response when using this error type. + +The `BasicJSONAPIError` type is generic on one thing: The type it expects for the `id` field. If you expect integer `ids` back, you use `BasicJSONAPIError`. The same can be done for `String` or any other type that is both `Codable` and `Equatable`. You can even employ something like `AnyCodable` from *Flight-School* as your id field type. If you only need to handle a small subset of possible `id` field types, you can also use the `Poly` library that is already a dependency of `JSONAPI`. For example, you might expect a mix of `String` and `Int` ids for some reason: `BasicJSONAPIError>`. + +The two easiest ways to access the available properties of an error response are under the `payload` property of the error (this property is `nil` if the error was parsed as `.unknown`) or by asking the error for its `definedFields` dictionary. + +As an example, let's say you have the following `Document` type that is destined for errors: +```swift +typealias ErrorDoc = JSONAPI.Document> +``` +And you've parsed an error response +```swift +let errorResponse = try! JSONDecoder().decode(ErrorDoc.self, from: mockErrorData) +``` +You can get at the `Document` body and errors in a couple of different ways, but for one you can switch on the body: +```swift +switch errorResponse.body { +case .data: + print("cool, data!") + +case .errors(let errors, let meta, let links): + let errorDetails = errors.compactMap { $0.payload?.detail } + + print("error details: \(errorDetails)") +} +``` + +##### `GenericJSONAPIError` +This type makes it simple to use your own error payload structures as `JSONAPIError` types. Simply define a `Codable` and `Equatable` struct and then use `GenericJSONAPIError` as the error type for a `Document`. + +### `JSONAPI.Meta` + +A `Meta` struct is totally open-ended. It is described by the **SPEC** as a place to put any information that does not fit into the standard JSON API Document structure anywhere else. + +You can specify `NoMetadata` if the part of the document being described should not contain any `Meta`. + +If you need to support metadata with structure that is not pre-determined, consider an "Any Codable" type such as that found at https://github.com/Flight-School/AnyCodable. + +### `JSONAPI.Links` + +A `Links` struct must contain only `Link` properties. Each `Link` property can either be a `URL` or a `URL` and some `Meta`. Each part of the document has some suggested common `Links` to include but generally any link can be included. + +You can specify `NoLinks` if the part of the document being described should not contain any `Links`. + +### `JSONAPI.RawIdType` + +If you want to create new `JSONAPI.ResourceObject` values and assign them Ids then you will need to conform at least one type to `CreatableRawIdType`. Doing so is easy; here are two example conformances for `UUID` and `String` (via `UUID`): +```swift +extension UUID: CreatableRawIdType { + public static func unique() -> UUID { + return UUID() + } +} + +extension String: CreatableRawIdType { + public static func unique() -> String { + return UUID().uuidString + } +} +``` + +### Sparse Fieldsets +Sparse Fieldsets are currently supported when encoding only. When decoding, Sparse Fieldsets become tricker to support under the current types this library uses and it is assumed that clients will request one or maybe two sparse fieldset combinations for any given model at most so it can simply define the `JSONAPI` models needed to decode those subsets of all possible fields. A server, on the other hand, likely needs to support arbitrary combinations of sparse fieldsets and this library provides a mechanism for encoding those sparse fieldsets without too much extra footwork. + +You can use sparse fieldsets on the primary resources(s) _and_ includes of a `JSONAPI.Document`. + +There is a sparse fieldsets example included with this repository as a Playground page. + +#### Supporting Sparse Fieldset Encoding +1. The `JSONAPI` `ResourceObjectDescription`'s `Attributes` struct must conform to `JSONAPI.SparsableAttributes` rather than `JSONAPI.Attributes`. +2. The `JSONAPI` `ResourceObjectDescription`'s `Attributes` struct must contain a `CodingKeys` enum that conforms to `JSONAPI.SparsableCodingKey` instead of `Swift.CodingKey`. +3. `typealiases` you may have created for `JSONAPI.Document` that allow you to decode Documents will not support the "encode-only" nature of sparse fieldsets. See the next section for `typealias` comparisons. +4. To create a sparse fieldset from a `ResourceObject` just call its `sparse(with: fields)` method and pass an array of `Attributes.CodingKeys` values you would like included in the encoding. +5. Initialize and encode a `Document` containing one or more sparse or full primary resource(s) and any number of sparse or full includes. + +#### Sparse Fieldset `typealias` comparisons +You might have found a `typealias` like the following for encoding/decoding `JSONAPI.Document`s (note the primary resource body is a `JSONAPI.ResourceBody`): +```swift +typealias Document = JSONAPI.Document> +``` + +In order to support sparse fieldsets (which are encode-only), the following companion `typealias` would be useful (note the primary resource body is a `JSONAPI.EncodableResourceBody`): +```swift +typealias SparseDocument = JSONAPI.Document> +``` + +### Replacing and Tapping Attributes/Relationships +When you are working with an immutable Resource Object, it can be useful to replace its attributes or relationships. As a client, you might receive a resource from the server, update something, and then send the server a PATCH request. + +`ResourceObject` is immutable, but you can create a new copy of a `ResourceObject` having updated attributes or relationships. + +#### Tapping +If your `Attributes` or `Relationships` struct is mutable (i.e. its properties are `var`s) then you may find `ResourceObject`'s `tappingAttributes()` and `tappingRelationships()` functions useful. For both, you pass a function that takes an `inout` copy of the respective object or value that you can mutate. The mutated value is then used to create a new `ResourceObject`. + +For example, to take a hypothetical `Dog` resource object and change the name attribute: +```swift +let resourceObject = Dog(...) + +let newResourceObject = resourceObject + .tappingAttributes { $0.name = .init(value: "Charlie") } +``` + +#### Replacing +If your `Attributes` or `Relationships` struct is immutable (i.e. its properties are `let`s) then you may find `ResourceObject`'s `replacingAttributes()` and `replacingRelationships()` functions useful. For both, you pass a function that takes the current attributes or relationships and you return a new value. The new value is then used to create a new `ResourceObject`. + +For example, to take a hypothetical `Dog` resource object and change the name attribute: +```swift +let resourceObject = Dog(...) + +let newResourceObject = resourceObject + .replacingAttributes { _ in + return Dog.Attributes(name: .init(value: "Charlie")) +} +``` + +### Custom Attribute or Relationship Key Mapping +There is not anything special going on at the `JSONAPI.Attributes` and `JSONAPI.Relationships` levels, so you can easily provide custom key mappings by taking advantage of `Codable`'s `CodingKeys` pattern. Here are two models that will encode/decode equivalently but offer different naming in your codebase: +```swift +public enum ResourceObjectDescription1: JSONAPI.ResourceObjectDescription { + public static var jsonType: String { return "entity" } + + public struct Attributes: JSONAPI.Attributes { + public let coolProperty: Attribute + } + + public typealias Relationships = NoRelationships +} + +public enum ResourceObjectDescription2: JSONAPI.ResourceObjectDescription { + public static var jsonType: String { return "entity" } + + public struct Attributes: JSONAPI.Attributes { + public let wholeOtherThing: Attribute + + enum CodingKeys: String, CodingKey { + case wholeOtherThing = "coolProperty" + } + } + + public typealias Relationships = NoRelationships +} +``` + +### Custom Attribute Encode/Decode +You can safely provide your own encoding or decoding functions for your Attributes struct if you need to as long as you are careful that your encode operation correctly reverses your decode operation. Although this is generally not necessary, `AttributeType` provides a convenience method to make your decoding a bit less boilerplate ridden. This is what it looks like: +```swift +public enum ResourceObjectDescription1: JSONAPI.ResourceObjectDescription { + public static var jsonType: String { return "entity" } + + public struct Attributes: JSONAPI.Attributes { + public let property1: Attribute + public let property2: Attribute + public let property3: Attribute + + public let weirdThing: Attribute + + enum CodingKeys: String, CodingKey { + case property1 + case property2 + case property3 + } + } + + public typealias Relationships = NoRelationships +} + +extension ResourceObjectDescription1.Attributes { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + property1 = try .defaultDecoding(from: container, forKey: .property1) + property2 = try .defaultDecoding(from: container, forKey: .property2) + property3 = try .defaultDecoding(from: container, forKey: .property3) + + weirdThing = .init(value: "hello world") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(property1, forKey: .property1) + try container.encode(property2, forKey: .property2) + try container.encode(property3, forKey: .property3) + } +} +``` + +### Meta-Attributes +This advanced feature may not ever be useful, but if you find yourself in the situation of dealing with an API that does not 100% follow the **SPEC** then you might find meta-attributes are just the thing to make your resource objects more natural to work with. + +Suppose, for example, you are presented with the unfortunate situation where a piece of information you need is only available as part of the `Id` of a resource object. Perhaps a user's `Id` is formatted "{integer}-{createdAt}" where "createdAt" is the unix timestamp when the user account was created. The following `UserDescription` will expose what you need as an attribute. Realistically, the following example code is still terrible for its error handling. Using a `Result` type and/or invariants would clean things up substantially. + +```swift +enum UserDescription: ResourceObjectDescription { + public static var jsonType: String { return "users" } + + struct Attributes: JSONAPI.Attributes { + var createdAt: (User) -> Date { + return { user in + let components = user.id.rawValue.split(separator: "-") + + guard components.count == 2 else { + assertionFailure() + return Date() + } + + let timestamp = TimeInterval(components[1]) + + guard let date = timestamp.map(Date.init(timeIntervalSince1970:)) else { + assertionFailure() + return Date() + } + + return date + } + } + } + + typealias Relationships = NoRelationships +} + +typealias User = JSONAPI.ResourceObject +``` + +Given a value `user` of the above resource object type, you can access the `createdAt` attribute just like you would any other: + +```swift +let createdAt = user.createdAt +``` + +This works because `createdAt` is defined in the form: `var {name}: ({ResourceObject}) -> {Value}` where `{ResourceObject}` is the `JSONAPI.ResourceObject` described by the `ResourceObjectDescription` containing the meta-attribute. + +### Meta-Relationships +This advanced feature may not ever be useful, but if you find yourself in the situation of dealing with an API that does not 100% follow the **SPEC** then you might find meta-relationships are just the thing to make your resource objects more natural to work with. + +Similarly to Meta-Attributes, Meta-Relationships allow you to represent non-compliant relationships as computed relationship properties. In the following example, a relationship is created from some attributes on the JSON model. + +```swift +enum UserDescription: ResourceObjectDescription { + public static var jsonType: String { return "users" } + + struct Attributes: JSONAPI.Attributes { + let friend_id: Attribute + } + + struct Relationships: JSONAPI.Relationships { + public var friend: (User) -> User.Identifier { + return { user in + return User.Identifier(rawValue: user.friend_id) + } + } + } +} + +typealias User = JSONAPI.ResourceObject +``` + +Given a value `user` of the above resource object type, you can access the `friend` relationship just like you would any other: + +```swift +let friendId = user ~> \.friend +``` + +This works because `friend` is defined in the form: `var {name}: ({ResourceObject}) -> {Identifier}` where `{ResourceObject}` is the `JSONAPI.ResourceObject` described by the `ResourceObjectDescription` containing the meta-relationship.