diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3d720c0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Platform (only for runtime bugs):** +i.e. Linux, OS X, iOS, etc. + +**Context** +Your code, examples of JSON data, etc. + +**Do you plan to fix yourself?** +If you plan to open a PR to address this bug, let others know here so no one duplicates your effort in the meantime. diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000..3bc4170 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,8 @@ +--- +name: Idea +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..5ec1109 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,9 @@ +--- +name: Question +about: Ask a question +title: '' +labels: 'question' +assignees: '' + +--- + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..df8f649 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: + - swift:6.0 + - swift:6.1 + container: ${{ matrix.image }} + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Run tests + run: swift test --enable-test-discovery + osx: + runs-on: macOS-latest + steps: + - name: Select latest available Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: { 'xcode-version': 'latest' } + - name: Checkout code + uses: actions/checkout@v5 + - name: Run tests + run: swift test --enable-test-discovery diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b54a852 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +Thanks for working to make the `JSONAPI` framework better for everyone! + +## Issues +Please create an Issue for any new bug you find or any new feature you would like to see in the future. If you are also able to work on the issue yourself, reference the issue by URL from the description of your PR. + +In the issue, +1. Describe both the expected behavior and the current (buggy) behavior of the library. +2. Specify the version of the framework you are using. +3. Specify the platform you are running the framework on (i.e. Linux, Mac OS, iOS, etc.). +4. Give any information you can about the code you've written that uses the framework or the API requests/responses you are attempting to pass through the framework unsuccessfully. +5. Drop a note if you intend to fix the issue so no one else duplicates your effort in the meantime! + +## Pull Requests +Please try to create Issues prior to creating Pull Requests. In the body of your Pull Request description, mention the issue the PR addresses by URL. + +For all code additions, add tests that cover the new code. If you are fixing a bug, include at least 1 test case that would have failed prior to your fix but succeeds now that you have fixed the bug. This is important to avoid regressions in the future. + +Before creating the pull request, make sure all tests are passing and run `swift test --generate-linuxmain` to generate the test files for running in Linux environments. 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 2e54355..8e633ef 100644 --- a/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift @@ -4,65 +4,66 @@ import Poly // MARK: - Preamble (setup) -// We make String a CreatableRawIdType. This is actually done in +// Make String a CreatableRawIdType. This is actually done in // this Playground's Entities.swift file, so it is commented out here. /* -var GlobalStringId: Int = 0 -extension String: CreatableRawIdType { - public static func unique() -> String { - GlobalStringId += 1 - return String(GlobalStringId) - } -} -*/ - -// We create a typealias given that we do not expect JSON:API Resource + var globalStringId: Int = 0 + extension String: CreatableRawIdType { + public static func unique() -> String { + globalStringId += 1 + return String(globalStringId) + } + } + */ + +// Create a typealias because we do not expect JSON:API Resource // Objects for this particular API to have Metadata or Links associated // with them. We also expect them to have String Identifiers. typealias JSONEntity = JSONAPI.ResourceObject -// Similarly, we create a typealias for unidentified entities. JSON:API +// Similarly, create a typealias for unidentified entities. JSON:API // only allows unidentified entities (i.e. no "id" field) for client // requests that create new entities. In these situations, the server // is expected to assign the new entity a unique ID. typealias UnidentifiedJSONEntity = JSONAPI.ResourceObject -// We create typealiases given that we do not expect JSON:API Relationships -// for this particular API to have Metadata or Links associated -// with them. -typealias ToOneRelationship = JSONAPI.ToOneRelationship -typealias ToManyRelationship = JSONAPI.ToManyRelationship +// Create relationship typealiases because we do not expect +// JSON:API Relationships for this particular API to have +// Metadata or Links associated with them. +typealias ToOneRelationship = JSONAPI.ToOneRelationship +typealias ToManyRelationship = JSONAPI.ToManyRelationship -// We create a typealias for a Document given that we do not expect +// Create a typealias for a Document because we do not expect // JSON:API Documents for this particular API to have Metadata, Links, -// useful Errors, or a JSON:API Object (i.e. APIDescription). -typealias Document = JSONAPI.Document +// useful Errors, or an APIDescription (The *SPEC* calls this +// "API Description" the "JSON:API Object"). +typealias Document = JSONAPI.Document> // MARK: Entity Definitions enum AuthorDescription: ResourceObjectDescription { - public static var jsonType: String { return "authors" } + public static var jsonType: String { return "authors" } - public struct Attributes: JSONAPI.Attributes { - public let name: Attribute - } + public struct Attributes: JSONAPI.Attributes { + public let name: Attribute + } - public typealias Relationships = NoRelationships + public typealias Relationships = NoRelationships } typealias Author = JSONEntity enum ArticleDescription: ResourceObjectDescription { - public static var jsonType: String { return "articles" } + public static var jsonType: String { return "articles" } - public struct Attributes: JSONAPI.Attributes { - public let title: Attribute - public let abstract: Attribute - } + public struct Attributes: JSONAPI.Attributes { + public let title: Attribute + public let abstract: Attribute + } - public struct Relationships: JSONAPI.Relationships { - public let author: ToOneRelationship - } + public struct Relationships: JSONAPI.Relationships { + public let author: ToOneRelationship + } } typealias Article = JSONEntity @@ -71,50 +72,62 @@ typealias Article = JSONEntity // We create a typealias to represent a document containing one Article // and including its Author -typealias SingleArticleDocumentWithIncludes = Document, Include1> +typealias SingleArticleDocument = Document, Include1> -// ... and a typealias to represent a document containing one Article and -// not including any related entities. -typealias SingleArticleDocument = Document, NoIncludes> +// ... and a typealias to represent a batch document containing any number of Articles +typealias ManyArticleDocument = Document, Include1> // MARK: - Server Pseudo-example // Skipping over all the API and database stuff, here's a chunk of code // that creates a document. Note that this document is the entirety // of a JSON:API response body. -func articleDocument(includeAuthor: Bool) -> Either { - // Let's pretend all of this is coming from a database: - - let authorId = Author.Identifier(rawValue: "1234") - - let article = Article(id: .init(rawValue: "5678"), - attributes: .init(title: .init(value: "JSON:API in Swift"), - abstract: .init(value: "Not yet written")), - relationships: .init(author: .init(id: authorId)), - meta: .none, - links: .none) - - let document = SingleArticleDocument(apiDescription: .none, - body: .init(resourceObject: article), - includes: .none, - meta: .none, - links: .none) - - switch includeAuthor { - case false: - return .a(document) - - case true: - let author = Author(id: authorId, - attributes: .init(name: .init(value: "Janice Bluff")), - relationships: .none, - meta: .none, - links: .none) - - let includes: Includes = .init(values: [.init(author)]) - - return .b(document.including(.init(values: [.init(author)]))) - } +func article(includeAuthor: Bool) -> CompoundResource { + // Let's pretend all of this is coming from a database: + + let authorId = Author.Id(rawValue: "1234") + + let article = Article( + id: .init(rawValue: "5678"), + attributes: .init( + title: .init(value: "JSON:API in Swift"), + abstract: .init(value: "Not yet written") + ), + relationships: .init(author: .init(id: authorId)), + meta: .none, + links: .none + ) + + let authorInclude: SingleArticleDocument.Include? + if includeAuthor { + let author = Author( + id: authorId, + attributes: .init(name: .init(value: "Janice Bluff")), + relationships: .none, + meta: .none, + links: .none + ) + authorInclude = .init(author) + } else { + authorInclude = nil + } + + return CompoundResource( + primary: article, + relatives: authorInclude.map { [$0] } ?? [] + ) +} + +func articleDocument(includeAuthor: Bool) -> SingleArticleDocument { + + let compoundResource = article(includeAuthor: includeAuthor) + + return SingleArticleDocument( + apiDescription: .none, + resource: compoundResource, + meta: .none, + links: .none + ) } let encoder = JSONEncoder() @@ -124,8 +137,8 @@ encoder.outputFormatting = .prettyPrinted let responseBody = articleDocument(includeAuthor: true) let responseData = try! encoder.encode(responseBody) -// Next step would be encoding and setting as the HTTP body of a response. -// we will just print it out instead: +// Next step would be setting the HTTP body of a response. +// We will just print it out instead: print("-----") print(String(data: responseData, encoding: .utf8)!) @@ -139,31 +152,31 @@ print(String(data: otherResponseData, encoding: .utf8)!) // MARK: - Client Pseudo-example enum NetworkError: Swift.Error { - case serverError - case quantityMismatch + case serverError + case quantityMismatch } // Skipping over all the API stuff, here's a chunk of code that will // decode a document. We will assume we have made a request for a // single article including the author. func docode(articleResponseData: Data) throws -> (article: Article, author: Author) { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase - let articleDocument = try decoder.decode(SingleArticleDocumentWithIncludes.self, from: articleResponseData) + let articleDocument = try decoder.decode(SingleArticleDocument.self, from: articleResponseData) - switch articleDocument.body { - case .data(let data): - let authors = data.includes[Author.self] + switch articleDocument.body { + case .data(let data): + let authors = data.includes[Author.self] - guard authors.count == 1 else { - throw NetworkError.quantityMismatch - } + guard authors.count == 1 else { + throw NetworkError.quantityMismatch + } - return (article: data.primary.value, author: authors[0]) - case .errors(let errors, meta: _, links: _): - throw NetworkError.serverError - } + return (article: data.primary.value, author: authors[0]) + case .errors(let errors, meta: _, links: _): + throw NetworkError.serverError + } } let response = try! docode(articleResponseData: responseData) diff --git a/JSONAPI.playground/Pages/Full Document Verbose Generation.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Full Document Verbose Generation.xcplaygroundpage/Contents.swift index 81f985b..e06dfa2 100644 --- a/JSONAPI.playground/Pages/Full Document Verbose Generation.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Full Document Verbose Generation.xcplaygroundpage/Contents.swift @@ -72,7 +72,7 @@ enum AuthorDescription: ResourceObjectDescription { } struct Relationships: JSONAPI.Relationships { - let articles: ToManyRelationship + let articles: ToManyRelationship } } @@ -88,11 +88,11 @@ enum ArticleDescription: ResourceObjectDescription { struct Relationships: JSONAPI.Relationships { /// The primary attributed author of the article. - let primaryAuthor: ToOneRelationship + let primaryAuthor: ToOneRelationship /// All authors excluding the primary author. /// It is customary to print the primary author's /// name first, followed by the other authors. - let otherAuthors: ToManyRelationship + let otherAuthors: ToManyRelationship } } @@ -129,9 +129,9 @@ enum ArticleDocumentError: String, JSONAPIError, Codable { typealias SingleArticleDocument = JSONAPI.Document, DocumentMetadata, SingleArticleDocumentLinks, Include1, APIDescription, ArticleDocumentError> // MARK: - Instantiations -let authorId1 = Author.Identifier() -let authorId2 = Author.Identifier() -let authorId3 = Author.Identifier() +let authorId1 = Author.Id() +let authorId2 = Author.Id() +let authorId3 = Author.Id() let now = Date() let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now)! @@ -155,7 +155,7 @@ let author1Links = EntityLinks(selfLink: .init(url: URL(string: "https://article meta: .init(expiry: tomorrow))) let author1 = Author(id: authorId1, attributes: .init(name: .init(value: "James Kinney")), - relationships: .init(articles: .init(ids: [article.id, Article.Identifier(), Article.Identifier()], + relationships: .init(articles: .init(ids: [article.id, Article.Id(), Article.Id()], meta: .init(pagination: .init(total: 3, limit: 50, offset: 0)), @@ -167,7 +167,7 @@ let author2Links = EntityLinks(selfLink: .init(url: URL(string: "https://article meta: .init(expiry: tomorrow))) let author2 = Author(id: authorId2, attributes: .init(name: .init(value: "James Kinney")), - relationships: .init(articles: .init(ids: [article.id, Article.Identifier()], + relationships: .init(articles: .init(ids: [article.id, Article.Id()], meta: .init(pagination: .init(total: 2, limit: 50, offset: 0)), diff --git a/JSONAPI.playground/Pages/PATCHing.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/PATCHing.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..f916054 --- /dev/null +++ b/JSONAPI.playground/Pages/PATCHing.xcplaygroundpage/Contents.swift @@ -0,0 +1,124 @@ +//: [Previous](@previous) + +import Foundation +import JSONAPI + +/******* + + Please enjoy these examples, but allow me the forced casting and the lack of error checking for the sake of brevity. + + This playground focuses on receiving a resource, making some changes, and then creating a request body for a PATCH request. + As with all examples in these playround pages, no actual networking code will be provided. + + ********/ + +// Mock up a server response +let mockDogData = """ +{ + "data": { + "id": "1234", + "type": "dogs", + "attributes": { + "name": "Sparky" + }, + "relationships": { + "owner": { + "data": null + } + } + } +} +""".data(using: .utf8)! + +// +// MARK: - EXAMPLE 1 (Mutable Attributes) +// + +// pretend to have requested a Dog and received the mock data +// now parse it. +let parsedResponse = try! JSONDecoder().decode(MutableDogDocument.self, from: mockDogData) + +// extract our Dog (skipping over any robustness to handle errors) +var dog = parsedResponse.body.primaryResource!.value +print("Received dog named: \(dog.name)") + +// change the dog's name +let changedDog = dog.tappingAttributes { $0.name = .init(value: "Julia") } + +// create a document to be used as a request body for a PATCH request +let patchRequest = MutableDogDocument(apiDescription: .none, + body: .init(resourceObject: changedDog), + includes: .none, + meta: .none, + links: .none) + +// encode and send off to server +let encodedPatchRequest = try! JSONEncoder().encode(patchRequest) +print("----") +print(String(data: encodedPatchRequest, encoding:.utf8)!) + + +// +// MARK: - EXAMPLE 2 (Immutable Attributes) +// +print() +print("####") +print() + +// pretend to have requested a Dog and received the mock data +// now parse it. +let parsedResponse2 = try! JSONDecoder().decode(SingleDogDocument.self, from: mockDogData) + +// extract our Dog (skipping over any robustness to handle errors) +var dog2 = parsedResponse2.body.primaryResource!.value +print("Received dog named: \(dog2.name)") + +// change the dog's name +let changedDog2 = dog2.replacingAttributes { _ in + return .init(name: .init(value: "Nigel")) +} + +// create a document to be used as a request body for a PATCH request +let patchRequest2 = SingleDogDocument(apiDescription: .none, + body: .init(resourceObject: changedDog2), + includes: .none, + meta: .none, + links: .none) + +// encode and send off to server +let encodedPatchRequest2 = try! JSONEncoder().encode(patchRequest2) +print("----") +print(String(data: encodedPatchRequest2, encoding:.utf8)!) + + +// +// MARK: - EXAMPLE 3 (Change relationship) +// +print() +print("####") +print() + +// pretend to have requested a Dog and received the mock data +// now parse it. +let parsedResponse3 = try! JSONDecoder().decode(SingleDogDocument.self, from: mockDogData) + +// extract our Dog (skipping over any robustness to handle errors) +var dog3 = parsedResponse2.body.primaryResource!.value +print("Received dog with owner: \(dog3 ~> \.owner)") + +// give the dog an owner +let changedDog3 = dog3.replacingRelationships { _ in + return .init(owner: .init(id: Id(rawValue: "1"))) +} + +// create a document to be used as a request body for a PATCH request +let patchRequest3 = SingleDogDocument(apiDescription: .none, + body: .init(resourceObject: changedDog3), + includes: .none, + meta: .none, + links: .none) + +// encode and send off to server +let encodedPatchRequest3 = try! JSONEncoder().encode(patchRequest3) +print("----") +print(String(data: encodedPatchRequest3, encoding:.utf8)!) diff --git a/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..5a9dc6c --- /dev/null +++ b/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift @@ -0,0 +1,72 @@ + +import JSONAPI +import Foundation + +// MARK: - Resource Object + +enum ThingWithPropertiesDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "thing" + + // + // NOTE: `JSONAPI.SparsableAttributes` as opposed to `JSONAPI.Attributes` + // + struct Attributes: JSONAPI.SparsableAttributes { + let stringThing: Attribute + let numberThing: Attribute + let boolThing: Attribute + + // + // NOTE: Special implementation of `CodingKeys` + // + enum CodingKeys: String, JSONAPI.SparsableCodingKey { + case stringThing + case numberThing + case boolThing + } + } + + typealias Relationships = NoRelationships +} + +typealias ThingWithProperties = JSONAPI.ResourceObject + +// MARK: - Document + +// +// NOTE: Using `JSONAPI.EncodableResourceBody` which means the document type will be `Encodable` but not `Decodable`. +// +typealias Document = JSONAPI.Document> + +// +// NOTE: Using `JSONAPI.EncodablePrimaryResource` which means the `ResourceBody` will be `Encodable` but not `Decodable. +// +typealias SingleDocument = Document, NoIncludes> + +// MARK: - Resource Initialization + +let resource = ThingWithProperties(id: .init(rawValue: "1234"), + attributes: .init(stringThing: .init(value: "hello world"), + numberThing: .init(value: 10), + boolThing: .init(value: nil)), + relationships: .none, + meta: .none, + links: .none) +// +// NOTE: Creating a sparse resource that will only encode +// the attribute named "stringThing" +// +let sparseResource = resource.sparse(with: [.stringThing]) + +// MARK: - Encoding + +let encoder = JSONEncoder() + +let sparseResourceDoc = SingleDocument(apiDescription: .none, + body: .init(resourceObject: sparseResource), + includes: .none, + meta: .none, + links: .none) + +let data = try! encoder.encode(sparseResourceDoc) + +print(String(data: data, encoding: .utf8)!) diff --git a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift index 22bb3c0..1d3da07 100644 --- a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift @@ -8,6 +8,7 @@ Please enjoy these examples, but allow me the forced casting and the lack of err ********/ + // MARK: - Create a request or response body with one Dog in it let dogFromCode = try! Dog(name: "Buddy", owner: nil) @@ -15,19 +16,22 @@ let singleDogDocument = SingleDogDocument(apiDescription: .none, body: .init(res let singleDogData = try! JSONEncoder().encode(singleDogDocument) + // MARK: - Parse a request or response body with one Dog in it let dogResponse = try! JSONDecoder().decode(SingleDogDocument.self, from: singleDogData) let dogFromData = dogResponse.body.primaryResource?.value -let dogOwner: Person.Identifier? = dogFromData.flatMap { $0 ~> \.owner } +let dogOwner: Person.Id? = dogFromData.flatMap { $0 ~> \.owner } -// MARKL - Parse a request or response body with one Dog in it using an alternative model -typealias AltSingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +// MARK: - Parse a request or response body with one Dog in it using an alternative model +typealias AltSingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, BasicJSONAPIError> let altDogResponse = try! JSONDecoder().decode(AltSingleDogDocument.self, from: singleDogData) let altDogFromData = altDogResponse.body.primaryResource?.value -let altDogHuman: Person.Identifier? = altDogFromData.flatMap { $0 ~> \.human } +let altDogHuman: Person.Id? = altDogFromData.flatMap { $0 ~> \.human } + // MARK: - Create a request or response with multiple people and dogs and houses included -let personIds = [Person.Identifier(), Person.Identifier()] +let personIds = [Person.Id(), Person.Id()] let dogs = try! [Dog(name: "Buddy", owner: personIds[0]), Dog(name: "Joy", owner: personIds[0]), Dog(name: "Travis", owner: personIds[1])] let houses = [House(attributes: .none, relationships: .none, meta: .none, links: .none), House(attributes: .none, relationships: .none, meta: .none, links: .none)] let people = try! [Person(id: personIds[0], name: ["Gary", "Doe"], favoriteColor: "Orange-Red", friends: [], dogs: [dogs[0], dogs[1]], home: houses[0]), Person(id: personIds[1], name: ["Elise", "Joy"], favoriteColor: "Red", friends: [], dogs: [dogs[2]], home: houses[1])] @@ -36,6 +40,7 @@ let includes = dogs.map { BatchPeopleDocument.Include($0) } + houses.map { Batch let batchPeopleDocument = BatchPeopleDocument(apiDescription: .none, body: .init(resourceObjects: people), includes: .init(values: includes), meta: .none, links: .none) let batchPeopleData = try! JSONEncoder().encode(batchPeopleDocument) + // MARK: - Parse a request or response body with multiple people in it and dogs and houses included let peopleResponse = try! JSONDecoder().decode(BatchPeopleDocument.self, from: batchPeopleData) @@ -47,24 +52,48 @@ print("-----") print(peopleResponse) print("-----") -// MARK: - Pass successfully parsed body to other parts of the code -/* - ---- CRASHING IN XCODE 10.2 PLAYGROUND ---- +// MARK: - Pass successfully parsed body to other parts of the code if case let .data(bodyData) = peopleResponse.body { - print("first person's name: \(bodyData.primary.values[0][\.fullName])") + print("first person's name: \(bodyData.primary.values[0].fullName)") } else { print("no body data") } - */ -// MARK: - Work in the abstract -func process(document: T) { - guard case let .data(body) = document.body else { +// MARK: - Work in the abstract +print("-----") +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) + +// MARK: - Work with errors +typealias ErrorDoc = JSONAPI.Document> + +let mockErrorData = +""" +{ + "errors": [ + { + "status": "500", + "title": "Internal Server Error", + "detail": "Server fell over while parsing your request." + } + ] +} +""".data(using: .utf8)! + +let errorResponse = try! JSONDecoder().decode(ErrorDoc.self, from: mockErrorData) + +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)") +} diff --git a/JSONAPI.playground/Sources/Entities.swift b/JSONAPI.playground/Sources/Entities.swift index a62c993..023c9b1 100644 --- a/JSONAPI.playground/Sources/Entities.swift +++ b/JSONAPI.playground/Sources/Entities.swift @@ -15,18 +15,18 @@ Please enjoy these examples, but allow me the forced casting and the lack of err ********/ // MARK: - String as CreatableRawIdType -var GlobalStringId: Int = 0 +var globalStringId: Int = 0 extension String: CreatableRawIdType { public static func unique() -> String { - GlobalStringId += 1 - return String(GlobalStringId) + globalStringId += 1 + return String(globalStringId) } } // MARK: - typealiases for convenience public typealias ExampleEntity = ResourceObject -public typealias ToOne = ToOneRelationship -public typealias ToMany = ToManyRelationship +public typealias ToOne = ToOneRelationship +public typealias ToMany = ToManyRelationship // MARK: - A few resource objects (entities) public enum PersonDescription: ResourceObjectDescription { @@ -119,6 +119,29 @@ public enum AlternativeDogDescription: ResourceObjectDescription { public typealias AlternativeDog = ExampleEntity +public enum MutableDogDescription: ResourceObjectDescription { + + public static var jsonType: String { return "dogs" } + + public struct Attributes: JSONAPI.Attributes { + public var name: Attribute + + public init(name: Attribute) { + self.name = name + } + } + + public struct Relationships: JSONAPI.Relationships { + public var owner: ToOne + + public init(owner: ToOne) { + self.owner = owner + } + } +} + +public typealias MutableDog = ExampleEntity + public extension ResourceObject where Description == DogDescription, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType == String { init(name: String, owner: Person?) throws { self = Dog(attributes: .init(name: .init(value: name)), relationships: DogDescription.Relationships(owner: .init(resourceObject: owner)), meta: .none, links: .none) @@ -139,6 +162,8 @@ public enum HouseDescription: ResourceObjectDescription { public typealias House = ExampleEntity -public typealias SingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +public typealias SingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, BasicJSONAPIError> + +public typealias MutableDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, BasicJSONAPIError> -public typealias BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError> +public typealias BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, BasicJSONAPIError> diff --git a/JSONAPI.playground/contents.xcplayground b/JSONAPI.playground/contents.xcplayground index 3da156e..e777e3c 100644 --- a/JSONAPI.playground/contents.xcplayground +++ b/JSONAPI.playground/contents.xcplayground @@ -5,5 +5,7 @@ + + \ No newline at end of file diff --git a/JSONAPI.podspec b/JSONAPI.podspec deleted file mode 100644 index d884b6c..0000000 --- a/JSONAPI.podspec +++ /dev/null @@ -1,140 +0,0 @@ -# -# Be sure to run `pod spec lint JSONAPI.podspec' to ensure this is a -# valid spec and to remove all comments including this before submitting the spec. -# -# To learn more about Podspec attributes see https://docs.cocoapods.org/specification.html -# To see working Podspecs in the CocoaPods repo see https://github.com/CocoaPods/Specs/ -# - -Pod::Spec.new do |spec| - - # ――― Spec Metadata ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # These will help people to find your library, and whilst it - # can feel like a chore to fill in it's definitely to your advantage. The - # summary should be tweet-length, and the description more in depth. - # - - spec.name = "JSONAPI" - spec.version = "0.31.1" - spec.summary = "Swift Codable JSON API framework." - - # This description is used to generate tags and improve search results. - # * Think: What does it do? Why did you write it? What is the focus? - # * Try to keep it short, snappy and to the point. - # * Write the description between the DESC delimiters below. - # * Finally, don't worry about the indent, CocoaPods strips it! - spec.description = <<-DESC - A Swift package for encoding to- and decoding from JSON API compliant requests and responses. - -See the JSON API Spec here: https://jsonapi.org/format/ - DESC - - spec.homepage = "https://github.com/mattpolzin/JSONAPI" - # spec.screenshots = "www.example.com/screenshots_1.gif", "www.example.com/screenshots_2.gif" - - - # ――― Spec License ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # Licensing your code is important. See https://choosealicense.com for more info. - # CocoaPods will detect a license file if there is a named LICENSE* - # Popular ones are 'MIT', 'BSD' and 'Apache License, Version 2.0'. - # - - # spec.license = "MIT" - spec.license = { :type => "MIT", :file => "LICENSE.txt" } - - - # ――― Author Metadata ――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # Specify the authors of the library, with email addresses. Email addresses - # of the authors are extracted from the SCM log. E.g. $ git log. CocoaPods also - # accepts just a name if you'd rather not provide an email address. - # - # Specify a social_media_url where others can refer to, for example a twitter - # profile URL. - # - - spec.author = { "Mathew Polzin" => "matt.polzin@gmail.com" } - # Or just: spec.author = "Mathew Polzin" - # spec.authors = { "Mathew Polzin" => "matt.polzin@gmail.com" } - # spec.social_media_url = "https://twitter.com/Mathew Polzin" - - # ――― Platform Specifics ――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # If this Pod runs only on iOS or OS X, then specify the platform and - # the deployment target. You can optionally include the target after the platform. - # - - # spec.platform = :ios - # spec.platform = :ios, "5.0" - - # When using multiple platforms - spec.ios.deployment_target = "8.0" - spec.osx.deployment_target = "10.9" - # spec.watchos.deployment_target = "2.0" - # spec.tvos.deployment_target = "9.0" - - - # ――― Source Location ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # Specify the location from where the source should be retrieved. - # Supports git, hg, bzr, svn and HTTP. - # - - spec.source = { :git => "https://github.com/mattpolzin/JSONAPI.git", :tag => "#{spec.version}" } - - - # ――― Source Code ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # CocoaPods is smart about how it includes source code. For source files - # giving a folder will include any swift, h, m, mm, c & cpp files. - # For header files it will include any header in the folder. - # Not including the public_header_files will make all headers public. - # - - spec.source_files = "Sources/JSONAPI/**/*.{swift}" - # spec.exclude_files = "Classes/Exclude" - - # spec.public_header_files = "Classes/**/*.h" - - - # ――― Resources ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # A list of resources included with the Pod. These are copied into the - # target bundle with a build phase script. Anything else will be cleaned. - # You can preserve files from being cleaned, please don't preserve - # non-essential files like tests, examples and documentation. - # - - # spec.resource = "icon.png" - # spec.resources = "Resources/*.png" - - # spec.preserve_paths = "FilesToSave", "MoreFilesToSave" - - - # ――― Project Linking ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # Link your library with frameworks, or libraries. Libraries do not include - # the lib prefix of their name. - # - - # spec.frameworks = "SomeFramework", "AnotherFramework" - - # spec.library = "iconv" - # spec.libraries = "iconv", "xml2" - - - # ――― Project Settings ――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # - # - # If your library depends on compiler flags you can set them in the xcconfig hash - # where they will only apply to your library. If you depend on other Podspecs - # you can include multiple dependencies to ensure it works. - - spec.swift_version = "5.0" - # spec.requires_arc = true - - # spec.xcconfig = { "HEADER_SEARCH_PATHS" => "$(SDKROOT)/usr/include/libxml2" } - spec.dependency "Poly", "~> 2.0" - -end diff --git a/Package.resolved b/Package.resolved index 4a251e7..f0e1825 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,15 @@ { - "object": { - "pins": [ - { - "package": "Poly", - "repositoryURL": "https://github.com/mattpolzin/Poly.git", - "state": { - "branch": null, - "revision": "38051821d7ef49e590e26e819a2fe447e50be9ff", - "version": "2.0.1" - } + "originHash" : "54aae326cbf0090b80ab7860957be502f5396744f5579cf522be7cb3ffa67caf", + "pins" : [ + { + "identity" : "poly", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/Poly.git", + "state" : { + "revision" : "37c942daa23ab373ce05ef87f42103342e59cf3a", + "version" : "3.0.0" } - ] - }, - "version": 1 + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index 11fea30..642091f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,32 +1,31 @@ -// swift-tools-version:5.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "JSONAPI", platforms: [ - .macOS(.v10_10), - .iOS(.v10) + .macOS(.v10_15), + .iOS(.v13) ], products: [ .library( name: "JSONAPI", targets: ["JSONAPI"]), - .library( - name: "JSONAPITesting", - targets: ["JSONAPITesting"]) + .library( + name: "JSONAPITesting", + targets: ["JSONAPITesting"]) ], dependencies: [ - .package(url: "https://github.com/mattpolzin/Poly.git", .upToNextMajor(from: "2.0.0")), + .package(url: "https://github.com/mattpolzin/Poly.git", .upToNextMajor(from: "3.0.0")), ], targets: [ .target( name: "JSONAPI", dependencies: ["Poly"]), - .target( - name: "JSONAPITesting", - dependencies: ["JSONAPI"]), + .target( + name: "JSONAPITesting", + dependencies: ["JSONAPI"]), .testTarget( name: "JSONAPITests", dependencies: ["JSONAPI", "JSONAPITesting"]), @@ -34,5 +33,5 @@ let package = Package( name: "JSONAPITestingTests", dependencies: ["JSONAPI", "JSONAPITesting"]) ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v5, .v6] ) diff --git a/README.md b/README.md index ea5a6ee..873a186 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,46 @@ # JSONAPI -[![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) [![Swift 5.0](http://img.shields.io/badge/Swift-5.0-blue.svg)](https://swift.org) [![Build Status](https://app.bitrise.io/app/c8295b9589aa401e/status.svg?token=vzcyqWD5bQ4xqQfZsaVzNw&branch=master)](https://app.bitrise.io/app/c8295b9589aa401e) +[![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) [![Swift 6.0+](http://img.shields.io/badge/Swift-6.0+-blue.svg)](https://swift.org) [![Tests](https://github.com/mattpolzin/JSONAPI/actions/workflows/tests.yml/badge.svg)](https://github.com/mattpolzin/JSONAPI/actions/workflows/tests.yml) A Swift package for encoding to- and decoding from **JSON API** compliant requests and responses. See the JSON API Spec here: https://jsonapi.org/format/ -:warning: Although I find the type-safety of this framework appealing, the Swift compiler currently has enough trouble with it that it can become difficult to reason about errors produced by small typos. Similarly, auto-complete fails to provide reasonable suggestions much of the time. If you get the code right, everything compiles, otherwise it can suck to figure out what is wrong. This is mostly a concern when creating resource objects in-code (servers and test suites must do this). Writing a client that uses this framework to ingest JSON API Compliant API responses is much less painful. :warning: +## Quick Start -## Table of Contents - +### Clientside +- [Basic Example](./documentation/examples/basic-example.md) +- [Compound Example](./documentation/examples/compound-example.md) +- [Metadata Example](./documentation/examples/metadata-example.md) +- [Custom Errors Example](./documentation/examples/custom-errors-example.md) +- [PATCH Example](./documentation/examples/patch-example.md) +- [Resource Storage Example](./documentation/examples/resource-storage-example.md) (using [JSONAPI-ResourceStorage](#jsonapi-resourcestorage)) + +### Serverside +- [GET Example](./documentation/examples/serverside-get-example.md) +- [POST Example](./documentation/examples/serverside-post-example.md) -- [JSONAPI](#jsonapi) - - [Table of Contents](#table-of-contents) +### Client+Server +This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](./documentation/examples/client-server-example.md). + +## Table of Contents +- JSONAPI - [Primary Goals](#primary-goals) - - [Caveat](#caveat) - [Dev Environment](#dev-environment) - [Prerequisites](#prerequisites) - - [CocoaPods](#cocoapods) + - [Swift Package Manager](#swift-package-manager) - [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 `ResourceObjects`](#copying-resourceobjects) - - [`JSONAPI.Document`](#jsonapidocument) - - [`ResourceBody`](#resourcebody) - - [nullable `PrimaryResource`](#nullable-primaryresource) - - [`MetaType`](#metatype) - - [`LinksType`](#linkstype) - - [`IncludeType`](#includetype) - - [`APIDescriptionType`](#apidescriptiontype) - - [`Error`](#error) - - [`JSONAPI.Meta`](#jsonapimeta) - - [`JSONAPI.Links`](#jsonapilinks) - - [`JSONAPI.RawIdType`](#jsonapirawidtype) - - [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) + - [Project Status](./documentation/project-status.md) + - [Server & Client Example](./documentation/examples/client-server-example.md) + - [Usage](./documentation/usage.md) - [JSONAPI+Testing](#jsonapitesting) -- [JSONAPI+Arbitrary](#jsonapiarbitrary) -- [JSONAPI+OpenAPI](#jsonapiopenapi) - - + - [Literal Expressibility](#literal-expressibility) + - [Resource Object `check()`](#resource-object-check) + - [Comparisons](#comparisons) +- [JSONAPI-Arbitrary](#jsonapi-arbitrary) +- [JSONAPI-OpenAPI](#jsonapi-openapi) +- [JSONAPI-ResourceStorage](#jsonapi-resourcestorage) ## Primary Goals @@ -75,6 +49,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. @@ -83,734 +58,76 @@ If you find something wrong with this library and it isn't already mentioned und ## Dev Environment ### Prerequisites -1. Swift 4.2+ -2. Swift Package Manager *OR* Cocoapods - -### CocoaPods -To use this framework in your project via Cocoapods instead of Swift Package Manager, add the following dependencies to your Podfile. -``` - pod 'Poly', :git => 'https://github.com/mattpolzin/Poly.git' - pod 'JSONAPI', :git => 'https://github.com/mattpolzin/JSONAPI.git' -``` - -### Xcode project -To create an Xcode project for JSONAPI, run -`swift package generate-xcodeproj` - -### 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. - -Note that Playground support for importing non-system Frameworks is still a bit touchy as of Swift 4.2. Sometimes building, cleaning and building, or commenting out and then uncommenting import statements (especially in the Entities.swift Playground Source file) can get things working for me when I am getting an error about `JSONAPI` not being found. - -## Project Status - -### JSON:API -#### Document -- `data` - - [x] Encoding/Decoding -- `included` - - [x] Encoding/Decoding -- `errors` - - [x] Encoding/Decoding -- `meta` - - [x] Encoding/Decoding -- `jsonapi` (i.e. API Information) - - [x] Encoding/Decoding -- `links` - - [x] Encoding/Decoding - -#### Resource Object -- `id` - - [x] Encoding/Decoding -- `type` - - [x] Encoding/Decoding -- `attributes` - - [x] Encoding/Decoding -- `relationships` - - [x] Encoding/Decoding -- `links` - - [x] Encoding/Decoding -- `meta` - - [x] Encoding/Decoding - -#### Relationship Object -- `data` - - [x] Encoding/Decoding -- `links` - - [x] Encoding/Decoding -- `meta` - - [x] Encoding/Decoding - -#### Links Object -- `href` - - [x] Encoding/Decoding -- `meta` - - [x] Encoding/Decoding - -### Misc -- [x] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) -- [x] Support validation on `Attributes`. -- [ ] Support sparse fieldsets. At the moment, not sure what this support will look like. A client can likely just define a new model to represent a sparse population of another model in a very specific use case. On the server side, it becomes much more appealing to be able to support arbitrary combinations of omitted fields. -- [ ] Create more descriptive errors that are easier to use for troubleshooting. - -### Testing -#### Resource Object Validator -- [x] Disallow optional array in `Attribute` (should be empty array, not `null`). -- [x] Only allow `TransformedAttribute` and its derivatives as stored properties within `Attributes` struct. Computed properties can still be any type because they do not get encoded or decoded. -- [x] Only allow `ToManyRelationship` and `ToOneRelationship` within `Relationships` struct. - -### Potential Improvements -- [ ] (Maybe) Use `KeyPath` to specify `Includes` thus creating type safety around the relationship between a primary resource type and the types of included resources. -- [ ] (Maybe) Replace `SingleResourceBody` and `ManyResourceBody` with support at the `Document` level to just interpret `PrimaryResource`, `PrimaryResource?`, or `[PrimaryResource]` as the same decoding/encoding strategies. -- [ ] 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` +1. Swift 6.0+ +2. Swift Package Manager -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 -``` - -`Attributes` can be accessed via the `subscript` operator of the `ResourceObject` type as follows: -```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 Package Manager +Just include the following in your package's dependencies and add `JSONAPI` to the dependencies for any of your targets. ```swift -let date: TransformedAttribute +.package(url: "https://github.com/mattpolzin/JSONAPI.git", from: "6.0.0") ``` -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, UnknownJSONAPIError>.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. +## Deeper Dive +- [Project Status](./documentation/project-status.md) +- [Server & Client Example](./documentation/examples/client-server-example.md) +- [Usage Documentation](./documentation/usage.md) -##### 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 six 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 should create an error type that can decode all the errors you expect your `JSONAPIDocument` to be able to decode. As prescribed by the **SPEC**, these errors will be found in the root document member `errors`. - -### `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`. - -### `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 - } -} -``` - -### 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" - } - } -} -``` - -### 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 -``` +# JSONAPI+Testing +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. -Given a value `user` of the above resource object type, you can access the `createdAt` attribute just like you would any other: +## Literal Expressibility +Literal expressibility for `Attribute`, `ToOneRelationship`, and `Id` are provided so that you can easily write test `ResourceObject` values into your unit tests. +For example, you could create a mock `Author` (from the above example) as follows ```swift -let createdAt = user[\.createdAt] +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 +) ``` -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. +## 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 -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]) - } - } - } +func test_initAuthor() { + let author = Author(...) + Author.check(author) } - -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: +## Comparisons +You can compare `Documents`, `ResourceObjects`, `Attributes`, etc. and get human-readable output using the `compare(to:)` methods included with `JSONAPITesting`. ```swift -let friendId = user ~> \.friend -``` +func test_articleResponse() { + let endToEndAPITestResponse: SingleArticleDocumentWithIncludes = ... -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. + let expectedResponse: SingleArticleDocumentWithIncludes = ... -## 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. + let comparison = endToEndAPITestResponse.compare(to: expectedResponse) -### Preamble (Setup shared by server and client) -```swift -// We make String a CreatableRawIdType. -var GlobalStringId: Int = 0 -extension String: CreatableRawIdType { - public static func unique() -> String { - GlobalStringId += 1 - return String(GlobalStringId) - } + XCTAssert(comparison.isSame, String(describing: comparison)) } - -// We create a typealias given that we do not expect JSON:API Resource -// Objects for this particular API to have Metadata or Links associated -// with them. We also expect them to have String Identifiers. -typealias JSONResourceObject = JSONAPI.ResourceObject - -// Similarly, we create a typealias for unidentified resource objects. JSON:API -// only allows unidentified resource objects (i.e. no "id" field) for client -// requests that create new resource objects. In these situations, the server -// is expected to assign the new resource object a unique ID. -typealias UnidentifiedJSONResourceObject = JSONAPI.ResourceObject - -// We create typealiases given that we do not expect JSON:API Relationships -// for this particular API to have Metadata or Links associated -// with them. -typealias ToOneRelationship = JSONAPI.ToOneRelationship -typealias ToManyRelationship = JSONAPI.ToManyRelationship - -// We create a typealias for a Document given that we do not expect -// JSON:API Documents for this particular API to have Metadata, Links, -// useful Errors, or a JSON:API Object (i.e. APIDescription). -typealias Document = JSONAPI.Document - -// MARK: ResourceObject Definitions - -enum AuthorDescription: ResourceObjectDescription { - public static var jsonType: String { return "authors" } - - public struct Attributes: JSONAPI.Attributes { - public let name: Attribute - } - - public typealias Relationships = NoRelationships -} - -typealias Author = JSONResourceObject - -enum ArticleDescription: ResourceObjectDescription { - public static var jsonType: String { return "articles" } - - public struct Attributes: JSONAPI.Attributes { - public let title: Attribute - public let abstract: Attribute - } - - public struct Relationships: JSONAPI.Relationships { - public let author: ToOneRelationship - } -} - -typealias Article = JSONResourceObject - -// MARK: Document Definitions - -// We create a typealias to represent a document containing one Article -// and including its Author -typealias SingleArticleDocumentWithIncludes = Document, Include1> - -// ... and a typealias to represent a document containing one Article and -// not including any related resource objects. -typealias SingleArticleDocument = Document, NoIncludes> -``` -### Server Pseudo-example -```swift -// Skipping over all the API and database stuff, here's a chunk of code -// that creates a document. Note that this document is the entirety -// of a JSON:API response body. -func articleDocument(includeAuthor: Bool) -> Either { - // Let's pretend all of this is coming from a database: - - let authorId = Author.Identifier(rawValue: "1234") - - let article = Article(id: .init(rawValue: "5678"), - attributes: .init(title: .init(value: "JSON:API in Swift"), - abstract: .init(value: "Not yet written")), - relationships: .init(author: .init(id: authorId)), - meta: .none, - links: .none) - - let document = SingleArticleDocument(apiDescription: .none, - body: .init(resourceObject: article), - includes: .none, - meta: .none, - links: .none) - - switch includeAuthor { - case false: - return .a(document) - - case true: - let author = Author(id: authorId, - attributes: .init(name: .init(value: "Janice Bluff")), - relationships: .none, - meta: .none, - links: .none) - - let includes: Includes = .init(values: [.init(author)]) - - return .b(document.including(.init(values: [.init(author)]))) - } -} - -let encoder = JSONEncoder() -encoder.keyEncodingStrategy = .convertToSnakeCase -encoder.outputFormatting = .prettyPrinted - -let responseBody = articleDocument(includeAuthor: true) -let responseData = try! encoder.encode(responseBody) - -// Next step would be encoding and setting as the HTTP body of a response. -// we will just print it out instead: -print("-----") -print(String(data: responseData, encoding: .utf8)!) - -// ... and if we had received a request for an article without -// including the author: -let otherResponseBody = articleDocument(includeAuthor: false) -let otherResponseData = try! encoder.encode(otherResponseBody) -print("-----") -print(String(data: otherResponseData, encoding: .utf8)!) ``` -### Client Pseudo-example -```swift -enum NetworkError: Swift.Error { - case serverError - case quantityMismatch -} +# JSONAPI-Arbitrary +The `JSONAPI-Arbitrary` library provides `SwiftCheck` `Arbitrary` conformance for many of the `JSONAPI` types. -// Skipping over all the API stuff, here's a chunk of code that will -// decode a document. We will assume we have made a request for a -// single article including the author. -func docode(articleResponseData: Data) throws -> (article: Article, author: Author) { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase +See https://github.com/mattpolzin/JSONAPI-Arbitrary for more information. - let articleDocument = try decoder.decode(SingleArticleDocumentWithIncludes.self, from: articleResponseData) +# 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. - switch articleDocument.body { - case .data(let data): - let authors = data.includes[Author.self] +`JSONAPI-OpenAPI` also has experimental support for generating `JSONAPI` Swift code from Open API documentation (this currently lives on the `feature/gen-swift` branch). - guard authors.count == 1 else { - throw NetworkError.quantityMismatch - } - - return (article: data.primary.value, author: authors[0]) - case .errors(let errors, meta: _, links: _): - throw NetworkError.serverError - } -} - -let response = try! docode(articleResponseData: responseData) - -// Next step would be to do something useful with the article and author but we will print them instead. -print("-----") -print(response.article) -print(response.author) -``` - -# JSONAPI+Testing -The `JSONAPI` framework is packaged with a test library to help you test your `JSONAPI` integration. 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. You can see the `JSONAPITesting` in action in the Playground included with the `JSONAPI` repository. +See https://github.com/mattpolzin/JSONAPI-OpenAPI for more information. -# JSONAPI+Arbitrary -This library has moved into its own Package. See https://github.com/mattpolzin/JSONAPI-Arbitrary +# JSONAPI-ResourceStorage +The `JSONAPI-ResourceStorage` package has two _very_ early stage modules supporting storage and retrieval of `JSONAPI.ResourceObjects`. Please consider these modules to be more of examples of two directions you could head in than anything else. -# JSONAPI+OpenAPI -This library has moved into its own Package. See https://github.com/mattpolzin/JSONAPI-OpenAPI +https://github.com/mattpolzin/JSONAPI-ResourceStorage diff --git a/Sources/JSONAPI/Document/APIDescription.swift b/Sources/JSONAPI/Document/APIDescription.swift index a0ba2ff..123d056 100644 --- a/Sources/JSONAPI/Document/APIDescription.swift +++ b/Sources/JSONAPI/Document/APIDescription.swift @@ -7,58 +7,62 @@ /// 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 + } } +extension APIDescription: Sendable where Meta: Sendable {} + /// 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 NoAPIDescription: Sendable {} + 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/CompoundResource.swift b/Sources/JSONAPI/Document/CompoundResource.swift new file mode 100644 index 0000000..9baaf2f --- /dev/null +++ b/Sources/JSONAPI/Document/CompoundResource.swift @@ -0,0 +1,121 @@ +// +// CompoundResource.swift +// +// +// Created by Mathew Polzin on 5/25/20. +// + +public protocol CompoundResourceProtocol { + associatedtype JSONAPIModel: JSONAPI.ResourceObjectType + associatedtype JSONAPIIncludeType: JSONAPI.Include + + var primary: JSONAPIModel { get } + var relatives: [JSONAPIIncludeType] { get } + + func filteringRelatives(by filter: (JSONAPIIncludeType) -> Bool) -> Self +} + +/// A Resource Object and any relevant related resources. This object +/// is helpful in the context of constructing a Document. +/// +/// You can resolve a primary resource and all of the intended includes +/// for that resource and pass them around as a `CompoundResource` +/// prior to constructing a Document. +/// +/// Among other things, using this abstraction means you do not need to +/// specialized for a single or batch document at the same time as you are +/// resolving (i.e. materializing or decoding) one or more resources and its +/// relatives. +/// +/// - Important: This type is not intended to guarantee +/// that all `relationships` of the primary resource are available +/// in the `relatives` array. +public struct CompoundResource: Equatable, CompoundResourceProtocol { + public let primary: JSONAPIModel + public let relatives: [JSONAPIIncludeType] + + public init(primary: JSONAPIModel, relatives: [JSONAPIIncludeType]) { + self.primary = primary + self.relatives = relatives + } + + /// Create a new Compound Resource having + /// filtered the relatives by the given closure (which + /// must return `true` for any relative that should + /// remain part of the `CompoundObject`). + /// + /// This does not remove relatives from the primary + /// resource's `relationships`, it just filters out + /// which relatives have complete resource objects + /// in the newly created `CompoundResource`. + public func filteringRelatives(by filter: (JSONAPIIncludeType) -> Bool) -> CompoundResource { + return .init( + primary: primary, + relatives: relatives.filter(filter) + ) + } +} + +extension Sequence where Element: CompoundResourceProtocol { + /// Create new Compound Resources having + /// filtered the relatives by the given closure (which + /// must return `true` for any relative that should + /// remain part of the `CompoundObject`). + /// + /// This does not remove relatives from the primary + /// resource's `relationships`, it just filters out + /// which relatives have complete resource objects + /// in the newly created `CompoundResource`. + public func filteringRelatives(by filter: (Element.JSONAPIIncludeType) -> Bool) -> [Element] { + return map { $0.filteringRelatives(by: filter) } + } +} + +extension EncodableJSONAPIDocument where PrimaryResourceBody.PrimaryResource: ResourceObjectType { + public typealias CompoundResource = JSONAPI.CompoundResource +} + +extension SucceedableJSONAPIDocument where PrimaryResourceBody: SingleResourceBodyProtocol, PrimaryResourceBody.PrimaryResource: ResourceObjectType { + + public init( + apiDescription: APIDescription, + resource: CompoundResource, + meta: MetaType, + links: LinksType + ) { + self.init( + apiDescription: apiDescription, + body: .init(resourceObject: resource.primary), + includes: .init(values: resource.relatives), + meta: meta, + links: links + ) + } +} + +extension SucceedableJSONAPIDocument where PrimaryResourceBody: ManyResourceBodyProtocol, PrimaryResourceBody.PrimaryResource: ResourceObjectType, IncludeType: Hashable { + + public init( + apiDescription: APIDescription, + resources: [CompoundResource], + meta: MetaType, + links: LinksType + ) { + var included = Set() + let includes = resources.reduce(into: [IncludeType]()) { (result, next) in + for include in next.relatives { + if !included.contains(include.hashValue) { + included.insert(include.hashValue) + result.append(include) + } + } + } + self.init( + apiDescription: apiDescription, + body: .init(resourceObjects: resources.map(\.primary)), + includes: .init(values: Array(includes)), + meta: meta, + links: links + ) + } +} diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index 1a0fd5f..402cc6e 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,397 +7,742 @@ import Poly -public protocol JSONAPIDocument: Codable, Equatable { - associatedtype PrimaryResourceBody: JSONAPI.ResourceBody - associatedtype MetaType: JSONAPI.Meta - associatedtype LinksType: JSONAPI.Links - associatedtype IncludeType: JSONAPI.Include - associatedtype APIDescription: APIDescriptionType - associatedtype Error: JSONAPIError +public protocol DocumentBodyDataContext { + associatedtype PrimaryResourceBody: JSONAPI.EncodableResourceBody + associatedtype MetaType: JSONAPI.Meta + associatedtype LinksType: JSONAPI.Links + associatedtype IncludeType: JSONAPI.Include +} + +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 } +} - typealias Body = Document.Body +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 more restrictive than `CodableJSONAPIDocument` 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 Document that can be constructed as successful (i.e. not an error document). +public protocol SucceedableJSONAPIDocument: EncodableJSONAPIDocument { + /// Create a successful JSONAPI:Document. + /// + /// - Parameters: + /// - apiDescription: The description of the API (a.k.a. the "JSON:API Object"). + /// - body: The primary resource body of the JSON:API Document. Generally a single resource or a batch of resources. + /// - includes: All related resources that are included in this Document. + /// - meta: Any metadata associated with the Document. + /// - links: Any links associated with the Document. + /// + init( + apiDescription: APIDescription, + body: PrimaryResourceBody, + includes: Includes, + meta: MetaType, + links: LinksType + ) +} - var body: Body { get } +/// A Document that can be constructed as failed (i.e. an error document with no primary +/// resource). +public protocol FailableJSONAPIDocument: EncodableJSONAPIDocument { + /// Create an error JSONAPI:Document. + init( + apiDescription: APIDescription, + errors: [Error], + meta: MetaType?, + links: LinksType? + ) } +/// A `CodableJSONAPIDocument` supports encoding and decoding of a JSON:API +/// compliant Document. +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: JSONAPIDocument { - public typealias Include = IncludeType - - /// The JSON API Spec calls this the JSON:API Object. It contains version - /// and metadata information about the API itself. - 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) - - public struct Data: Equatable { - public let primary: PrimaryResourceBody - 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 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 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 includes: Includes? { - guard case let .data(data) = self else { return nil } - return data.includes - } - - 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 links: LinksType? { - switch self { - case .data(let data): - return data.links - case .errors(_, meta: _, links: let links?): - return links - 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 struct Document: EncodableJSONAPIDocument, SucceedableJSONAPIDocument, FailableJSONAPIDocument { + public typealias Include = IncludeType + public typealias BodyData = Body.Data + + // See `EncodableJSONAPIDocument` for documentation. + public let apiDescription: APIDescription + + // See `EncodableJSONAPIDocument` for documentation. + public let body: Body + + 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 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: Sendable where + APIDescription: Sendable, + Body: Sendable {} -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 { + 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 + /// The document's included objects + 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 + } + } + + /// `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 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 includes: Includes? { + guard case let .data(data) = self else { return nil } + return data.includes + } + + 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 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 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.Body: Sendable where + MetaType: Sendable, + LinksType: Sendable, + Data: Sendable {} + +extension Document.Body.Data: Sendable where + PrimaryResourceBody: Sendable, + IncludeType: Sendable, + MetaType: Sendable, + LinksType: Sendable {} + +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 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.Body.Data where PrimaryResourceBody: ResourceBodyAppendable, MetaType == NoMetadata, LinksType == NoLinks { + public func merging(_ other: Document.Body.Data) -> Document.Body.Data { + return .init(primary: primary.appending(other.primary), + includes: includes.appending(other.includes), + meta: other.meta, + links: other.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 { + /// 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) + } + } } -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) - } +// 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) + } + } } -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) - } +// MARK: - Codable +extension Document { + 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) + + 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) + } + + 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) + } + + case .data(let data): + try container.encode(data.primary, forKey: .data) + + 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 LinksType.self != NoLinks.self { + try container.encode(data.links, forKey: .links) + } + } + + if APIDescription.self != NoAPIDescription.self { + try container.encode(apiDescription, forKey: .jsonapi) + } + } } -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: Decodable, CodableJSONAPIDocument where PrimaryResourceBody: CodableResourceBody, IncludeType: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: RootCodingKeys.self) + + if let noAPIDescription = NoAPIDescription() as? APIDescription { + apiDescription = noAPIDescription + } else { + apiDescription = try container.decode(APIDescription.self, forKey: .jsonapi) + } + + let errors = try container.decodeIfPresent([Error].self, forKey: .errors) + + let meta: MetaType? + if let noMeta = NoMetadata() as? MetaType { + meta = noMeta + } else { + do { + meta = try container.decode(MetaType.self, forKey: .meta) + } catch { + meta = nil + } + } + + let links: LinksType? + if let noLinks = NoLinks() as? LinksType { + links = noLinks + } else { + do { + links = try container.decode(LinksType.self, forKey: .links) + } catch { + links = nil + } + } + + // If there are errors, there cannot be a body. Return errors and any metadata found. + if let errors = errors { + body = .errors(errors, meta: meta, links: links) + return + } + + let data: PrimaryResourceBody + if let noData = NoResourceBody() as? PrimaryResourceBody { + data = noData + } else { + 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: Includes? + do { + maybeIncludes = try container.decodeIfPresent(Includes.self, forKey: .included) + } catch let error as IncludesDecodingError { + throw DocumentDecodingError(error) + } + + guard let metaVal = meta else { + throw JSONAPICodingError.missingOrMalformedMetadata(path: decoder.codingPath) + } + guard let linksVal = links else { + throw JSONAPICodingError.missingOrMalformedLinks(path: decoder.codingPath) + } + + body = .data(.init(primary: data, includes: maybeIncludes ?? Includes.none, meta: metaVal, links: linksVal)) + } } -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) - } -} +// MARK: - CustomStringConvertible -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: CustomStringConvertible { + public var description: String { + return "Document(\(String(describing: body)))" + } } -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.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) + } + } } -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: AppendableResourceBody { - 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: CustomStringConvertible { + public var description: String { + return "primary: \(String(describing: primary)), includes: \(String(describing: includes)), meta: \(String(describing: meta)), links: \(String(describing: links))" + } } -extension Document.Body.Data where PrimaryResourceBody: AppendableResourceBody, MetaType == NoMetadata, LinksType == NoLinks { - public func merging(_ other: Document.Body.Data) -> Document.Body.Data { - return merging(other, - combiningMetaWith: { _, _ in .none }, - combiningLinksWith: { _, _ in .none }) - } +// MARK: - Error and Success Document Types + +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. + public struct ErrorDocument: EncodableJSONAPIDocument, FailableJSONAPIDocument { + public typealias BodyData = Document.BodyData + + public var body: Document.Body { return document.body } + + private let document: Document + + public init( + apiDescription: APIDescription, + errors: [Error], + meta: MetaType? = nil, + links: LinksType? = nil + ) { + document = .init(apiDescription: apiDescription, errors: errors, meta: meta, links: links) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(document) + } + + /// 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. + public struct SuccessDocument: EncodableJSONAPIDocument, SucceedableJSONAPIDocument { + public typealias BodyData = Document.BodyData + public typealias APIDescription = Document.APIDescription + public typealias Body = Document.Body + public typealias PrimaryResourceBody = Document.PrimaryResourceBody + public typealias Include = Document.Include + public typealias MetaType = Document.MetaType + public typealias LinksType = Document.LinksType + + public let apiDescription: APIDescription + public let data: BodyData + public let body: Body + + public var document: Document { + Document( + apiDescription: apiDescription, + body: data.primary, + includes: data.includes, + meta: data.meta, + links: data.links + ) + } + + public init( + apiDescription: APIDescription, + body: PrimaryResourceBody, + includes: Includes, + meta: MetaType, + links: LinksType + ) { + self.apiDescription = apiDescription + data = .init( + primary: body, + includes: includes, + meta: meta, + links: links + ) + self.body = .data(data) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(document) + } + + /// Quick access to the `data`'s primary resource. + /// + /// Guaranteed to exist for a `SuccessDocument`. + /// 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 data.primary + } + + /// Quick access to the `data`'s includes. + /// + /// Zero or more includes. + public var includes: Includes { + return data.includes + } + + /// The metadata for the data document. + public var meta: MetaType { + return data.meta + } + + /// The links for the data document. + public var links: LinksType { + return data.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 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) - } - } +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 DocumentDecodingError.foundSuccessDocumentWhenExpectingError + } + } } -// 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) - } - } +extension Document.SuccessDocument: Decodable, CodableJSONAPIDocument + where PrimaryResourceBody: CodableResourceBody, IncludeType: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + let document = try container.decode(Document.self) + + guard case .data(let data) = document.body else { + throw DocumentDecodingError.foundErrorDocumentWhenExpectingSuccess + } + + self.apiDescription = document.apiDescription + self.data = data + self.body = .data(data) + } } -// MARK: - Codable -extension Document { - private enum RootCodingKeys: String, CodingKey { - case data - case errors - case included - case meta - case links - case jsonapi - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: RootCodingKeys.self) - - if let noData = NoAPIDescription() as? APIDescription { - apiDescription = noData - } else { - apiDescription = try container.decode(APIDescription.self, forKey: .jsonapi) - } - - let errors = try container.decodeIfPresent([Error].self, forKey: .errors) - - let meta: MetaType? - if let noMeta = NoMetadata() as? MetaType { - meta = noMeta - } else { - do { - meta = try container.decode(MetaType.self, forKey: .meta) - } catch { - meta = nil - } - } - - let links: LinksType? - if let noLinks = NoLinks() as? LinksType { - links = noLinks - } else { - do { - links = try container.decode(LinksType.self, forKey: .links) - } catch { - links = nil - } - } - - // If there are errors, there cannot be a body. Return errors and any metadata found. - if let errors = errors { - body = .errors(errors, meta: meta, links: links) - return - } - - let data: PrimaryResourceBody - if let noData = NoResourceBody() as? PrimaryResourceBody { - data = noData - } else { - data = try container.decode(PrimaryResourceBody.self, forKey: .data) - } - - let maybeIncludes = try container.decodeIfPresent(Includes.self, forKey: .included) - - // TODO come back to this and make robust - - guard let metaVal = meta else { - throw JSONAPIEncodingError.missingOrMalformedMetadata - } - guard let linksVal = links else { - throw JSONAPIEncodingError.missingOrMalformedLinks - } - - body = .data(.init(primary: data, includes: maybeIncludes ?? Includes.none, meta: metaVal, links: linksVal)) - } - - 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) - - for error in errors { - try errContainer.encode(error) - } - - 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) - } - - case .data(let data): - try container.encode(data.primary, forKey: .data) - - 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 LinksType.self != NoLinks.self { - try container.encode(data.links, forKey: .links) - } - } - - if APIDescription.self != NoAPIDescription.self { - try container.encode(apiDescription, forKey: .jsonapi) - } - } +extension Document.SuccessDocument where IncludeType == NoIncludes { + /// Create a new Document with the given includes. + public func including(_ includes: Includes) -> Document.SuccessDocument { + // 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 document.body { + case .data(let data): + return .init(apiDescription: document.apiDescription, + body: data.primary, + includes: includes, + meta: data.meta, + links: data.links) + case .errors: + fatalError("SuccessDocument cannot end up in an error state") + } + } } -// MARK: - CustomStringConvertible +// extending where _Poly1 means all non-zero _Poly arities are included +extension Document.SuccessDocument 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.SuccessDocument { + // 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 document.body { + case .data(let data): + return .init(apiDescription: document.apiDescription, + body: data.primary, + includes: data.includes + includes, + meta: data.meta, + links: data.links) + case .errors: + fatalError("SuccessDocument cannot end up in an error state") + } + } +} -extension Document: CustomStringConvertible { - public var description: String { - return "Document(\(String(describing: body)))" - } +extension Document where MetaType == NoMetadata, LinksType == NoLinks, IncludeType == NoIncludes, APIDescription == NoAPIDescription { + public init(body: PrimaryResourceBody) { + self.init( + apiDescription: .none, + body: body, + includes: .none, + meta: .none, + links: .none + ) + } + + public init(errors: [Error]) { + self.init(apiDescription: .none, errors: errors) + } } -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) - } - } +extension Document.SuccessDocument where Document.MetaType == NoMetadata, Document.LinksType == NoLinks, Document.IncludeType == NoIncludes, Document.APIDescription == NoAPIDescription { + public init(body: PrimaryResourceBody) { + self.init( + apiDescription: .none, + body: body, + includes: .none, + meta: .none, + links: .none + ) + } } -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))" - } +extension Document.ErrorDocument where Document.MetaType == NoMetadata, Document.LinksType == NoLinks, Document.IncludeType == NoIncludes, Document.APIDescription == NoAPIDescription { + public init(errors: [Error]) { + self.init(apiDescription: .none, errors: errors) + } } diff --git a/Sources/JSONAPI/Document/DocumentDecodingError.swift b/Sources/JSONAPI/Document/DocumentDecodingError.swift new file mode 100644 index 0000000..8a96054 --- /dev/null +++ b/Sources/JSONAPI/Document/DocumentDecodingError.swift @@ -0,0 +1,84 @@ +// +// DocumentDecodingError.swift +// +// +// Created by Mathew Polzin on 10/20/19. +// + +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 + case .valueNotFound(let type, let context) where Location(context) == .data && type is _ArrayType.Type && context.debugDescription.hasSuffix("found null value instead"): + self = .primaryResourcesMissing + case .valueNotFound(_, let context) where Location(context) == .data && context.debugDescription.hasSuffix("found null value instead"): + self = .primaryResourceMissing + case .typeMismatch(let type, let context) where Location(context) == .data && type is _ArrayType.Type && context.debugDescription.hasSuffix("but found null instead."): + self = .primaryResourcesMissing + case .typeMismatch(_, let context) where Location(context) == .data && context.debugDescription.hasSuffix("but found null instead."): + self = .primaryResourceMissing + 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." + } + } +} + +private protocol _ArrayType {} +extension Array: _ArrayType {} diff --git a/Sources/JSONAPI/Document/Error.swift b/Sources/JSONAPI/Document/Error.swift deleted file mode 100644 index 6d6e36e..0000000 --- a/Sources/JSONAPI/Document/Error.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Error.swift -// JSONAPI -// -// Created by Mathew Polzin on 11/10/18. -// - -public protocol JSONAPIError: Swift.Error, Equatable, Codable { - static var unknown: Self { get } -} - -public enum UnknownJSONAPIError: JSONAPIError { - case 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: UnknownJSONAPIError { - return .unknownError - } -} diff --git a/Sources/JSONAPI/Document/Includes.swift b/Sources/JSONAPI/Document/Includes.swift index 8682ee9..8eb5000 100644 --- a/Sources/JSONAPI/Document/Includes.swift +++ b/Sources/JSONAPI/Document/Includes.swift @@ -7,71 +7,113 @@ import Poly -public typealias Include = JSONPoly - -public struct Includes: Codable, Equatable { - public static var none: Includes { return .init(values: []) } - - let values: [I] - - public init(values: [I]) { - self.values = values - } - - public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - - // If not parsing includes, no need to loop over them. - guard I.self != NoIncludes.self else { - values = [] - return - } - - var valueAggregator = [I]() - while !container.isAtEnd { - valueAggregator.append(try container.decode(I.self)) - } - - values = valueAggregator - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - - 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.") - } - - for value in values { - try container.encode(value) - } - } - - public var count: Int { - return values.count - } +public typealias Include = EncodableJSONPoly + +/// A structure holding zero or more included Resource Objects. +/// The resources are accessed by their type using a subscript. +/// +/// If you have +/// +/// let includes: Includes> = ... +/// +/// then you can access all `Thing1` included resources with +/// +/// let includedThings = includes[Thing1.self] +public struct Includes: Encodable, Equatable { + public static var none: Includes { return .init(values: []) } + + public let values: [I] + + public init(values: [I]) { + self.values = values + } + + 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: Sendable where I: Sendable {} + +extension Includes: Decodable where I: Decodable { + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + // If not parsing includes, no need to loop over them. + guard I.self != NoIncludes.self else { + values = [] + return + } + + var valueAggregator = [I]() + var idx = 0 + while !container.isAtEnd { + 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, totalIncludesCount: container.count ?? 0) + } + throw IncludesDecodingError( + error: IncludeDecodingError(failures: errors), + idx: idx, + totalIncludesCount: container.count ?? 0 + ) + } catch let error { + throw IncludesDecodingError(error: error, idx: idx, totalIncludesCount: container.count ?? 0) + } + } + + values = valueAggregator + } } 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 @@ -81,71 +123,191 @@ 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(\.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(\.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(\.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(\.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(\.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(\.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(\.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(\.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(\.i) + } +} + +// MARK: - 10 includes +public typealias Include10 = Poly10 +extension Includes where I: _Poly10 { + public subscript(_ lookup: I.J.Type) -> [I.J] { + return values.compactMap(\.j) + } +} + +// MARK: - 11 includes +public typealias Include11 = Poly11 +extension Includes where I: _Poly11 { + public subscript(_ lookup: I.K.Type) -> [I.K] { + return values.compactMap(\.k) + } +} + +// MARK: - 12 includes +public typealias Include12 = Poly12 +extension Includes where I: _Poly12 { + public subscript(_ lookup: I.L.Type) -> [I.L] { + return values.compactMap(\.l) + } +} + +// MARK: - 13 includes +public typealias Include13 = Poly13 +extension Includes where I: _Poly13 { + public subscript(_ lookup: I.M.Type) -> [I.M] { + return values.compactMap(\.m) + } +} + +// MARK: - 14 includes +public typealias Include14 = Poly14 +extension Includes where I: _Poly14 { + public subscript(_ lookup: I.N.Type) -> [I.N] { + return values.compactMap(\.n) + } +} + +// MARK: - 15 includes +public typealias Include15 = Poly15 +extension Includes where I: _Poly15 { + public subscript(_ lookup: I.O.Type) -> [I.O] { + return values.compactMap(\.o) + } +} + +// MARK: - DecodingError +public struct IncludesDecodingError: Swift.Error, Equatable { + public let error: Swift.Error + /// The zero-based index of the include that failed to decode. + public let idx: Int + /// The total count of includes in the document that failed to decode. + /// + /// In other words, "of `totalIncludesCount` includes, the `(idx + 1)`th + /// include failed to decode. + public let totalIncludesCount: 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 { + let ordinalSuffix: String + if (idx % 100) + 1 > 9 && (idx % 100) + 1 < 20 { + // the teens + ordinalSuffix = "th" + } else { + switch ((idx % 10) + 1) { + case 1: + ordinalSuffix = "st" + case 2: + ordinalSuffix = "nd" + case 3: + ordinalSuffix = "rd" + default: + ordinalSuffix = "th" + } + } + let ordinalDescription = "\(idx + 1)\(ordinalSuffix)" + + return "Out of the \(totalIncludesCount) includes in the document, the \(ordinalDescription) one failed to parse: \(error)" + } +} + +public struct IncludeDecodingError: Swift.Error, Equatable, CustomStringConvertible { + public let failures: [ResourceObjectDecodingError] + + public var description: String { + // concise error when all failures are mismatched JSON:API types: + if case let .jsonTypeMismatch(foundType: foundType)? = failures.first?.cause, + failures.allSatisfy({ $0.cause.isTypeMismatch }) { + let expectedTypes = failures + .compactMap { "'\($0.resourceObjectJsonAPIType)'" } + .joined(separator: ", ") + + return "Found JSON:API type '\(foundType)' but expected one of \(expectedTypes)" + } + + // concise error when all but failures but one are type mismatches because + // we can assume the correct type was found but there was some other error: + let nonTypeMismatches = failures.filter({ !$0.cause.isTypeMismatch}) + if nonTypeMismatches.count == 1, let nonTypeMismatch = nonTypeMismatches.first { + return String(describing: nonTypeMismatch) + } + + // fall back to just describing all of the reasons it could not have been any of the available + // types: + return failures + .enumerated() + .map { + "\nCould not have been Include Type `\($0.element.resourceObjectJsonAPIType)` because:\n\($0.element)" + }.joined(separator: "\n") + } } diff --git a/Sources/JSONAPI/Document/ResourceBody.swift b/Sources/JSONAPI/Document/ResourceBody.swift index eed341e..88b8d44 100644 --- a/Sources/JSONAPI/Document/ResourceBody.swift +++ b/Sources/JSONAPI/Document/ResourceBody.swift @@ -5,110 +5,176 @@ // Created by Mathew Polzin on 11/10/18. // -public protocol MaybePrimaryResource: Equatable, Codable {} +/// This protocol allows for a `SingleResourceBody` to contain a `null` +/// data object where `ManyResourceBody` cannot (because an empty +/// array should be used for no results). +public protocol OptionalEncodablePrimaryResource: Equatable, Encodable {} -/// A PrimaryResource is a type that can be used in the body of a JSON API +/// 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 OptionalCodablePrimaryResource: OptionalEncodablePrimaryResource, Decodable {} + +/// A `CodablePrimaryResource` is a type that can be used in the body of a JSON API /// document as the primary resource. -public protocol PrimaryResource: MaybePrimaryResource {} +public protocol CodablePrimaryResource: EncodablePrimaryResource, OptionalCodablePrimaryResource {} + +extension Optional: OptionalEncodablePrimaryResource where Wrapped: EncodablePrimaryResource {} -extension Optional: MaybePrimaryResource 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 { + 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: Codable, Equatable { +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 ResourceBodyAppendable { + func appending(_ other: Self) -> Self } -public protocol AppendableResourceBody: ResourceBody { - 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) +public protocol SingleResourceBodyProtocol: EncodableResourceBody { + var value: PrimaryResource { get } + + init(resourceObject: PrimaryResource) +} + +/// 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: SingleResourceBodyProtocol { + public let value: PrimaryResource + + public init(resourceObject: PrimaryResource) { + self.value = resourceObject + } } -public struct SingleResourceBody: ResourceBody { - public let value: Entity +extension SingleResourceBody: Sendable where PrimaryResource: Sendable {} - public init(resourceObject: Entity) { - self.value = resourceObject - } +public protocol ManyResourceBodyProtocol: EncodableResourceBody { + var values: [PrimaryResource] { get } + + init(resourceObjects: [PrimaryResource]) } -public struct ManyResourceBody: AppendableResourceBody { - public let values: [Entity] +/// A type allowing for a document body containing 0 or more primary resources. +public struct ManyResourceBody: ManyResourceBodyProtocol, 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) + } } +extension ManyResourceBody: Sendable where PrimaryResource: Sendable {} + /// 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() } } +extension NoResourceBody: Sendable {} + // MARK: Codable extension SingleResourceBody { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + let anyNil: Any? = nil + let nilValue = anyNil as? PrimaryResource + guard value != nilValue else { + try container.encodeNil() + return + } + + try container.encode(value) + } +} - let anyNil: Any? = nil - if container.decodeNil(), - let val = anyNil as? Entity { - value = val - return - } +extension SingleResourceBody: Decodable, CodableResourceBody where PrimaryResource: OptionalCodablePrimaryResource { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() - value = try container.decode(Entity.self) - } + let anyNil: Any? = nil + if container.decodeNil(), + let val = anyNil as? PrimaryResource { + value = val + return + } - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() + value = try container.decode(PrimaryResource.self) + } +} - if (value as Any?) == nil { - try container.encodeNil() - return - } +extension ManyResourceBody { + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() - try container.encode(value) - } + for value in values { + try container.encode(value) + } + } } -extension ManyResourceBody { - public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - var valueAggregator = [Entity]() - while !container.isAtEnd { - valueAggregator.append(try container.decode(Entity.self)) - } - values = valueAggregator - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - - for value in values { - try container.encode(value) - } - } +extension ManyResourceBody: Decodable, CodableResourceBody where PrimaryResource: CodablePrimaryResource { + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var valueAggregator = [PrimaryResource]() + var idx = 0 + while !container.isAtEnd { + do { + valueAggregator.append(try container.decode(PrimaryResource.self)) + } catch let error as ResourceObjectDecodingError { + throw ManyResourceBodyDecodingError( + error: error, + idx: idx + ) + } + idx = idx + 1 + } + values = valueAggregator + } } // 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 new file mode 100644 index 0000000..b966229 --- /dev/null +++ b/Sources/JSONAPI/Error/BasicJSONAPIError.swift @@ -0,0 +1,105 @@ +// +// BasicJSONAPIError.swift +// JSONAPI +// +// Created by Mathew Polzin on 9/29/19. +// + +/// Most of the JSON:API Spec defined Error fields. +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. + + /// the HTTP status code applicable to this problem + public let status: String? + /// an application-specific error code + public let code: String? + /// a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization + public let title: String? + /// a human-readable explanation specific to this occurrence of the problem. Like `title`, this field’s value can be localized + 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 init(id: IdType? = nil, + status: String? = nil, + code: String? = nil, + title: String? = nil, + detail: String? = nil, + source: Source? = nil) { + self.id = id + self.status = status + self.code = code + self.title = title + self.detail = detail + self.source = source + } + + public struct Source: Codable, Equatable, Sendable { + /// a JSON Pointer [RFC6901] to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. + public let pointer: String? + /// which URI query parameter caused the error + public let parameter: String? + + public init(pointer: String? = nil, + parameter: String? = nil) { + self.pointer = pointer + self.parameter = parameter + } + } + + public var definedFields: [String: String] { + let keysAndValues = [ + id.map { ("id", String(describing: $0)) }, + status.map { ("status", $0) }, + code.map { ("code", $0) }, + title.map { ("title", $0) }, + detail.map { ("detail", $0) }, + source.flatMap { $0.pointer.map { ("pointer", $0) } }, + source.flatMap { $0.parameter.map { ("parameter", $0) } } + ].compactMap { $0 } + return Dictionary(uniqueKeysWithValues: keysAndValues) + } + + public var description: String { + return definedFields.map { "\($0.key): \($0.value)" }.sorted().joined(separator: ", ") + } +} + +extension BasicJSONAPIErrorPayload: Sendable where IdType: Sendable {} + +/// `BasicJSONAPIError` optionally decodes many possible fields +/// specified by the JSON:API 1.0 Spec. It gives no type-guarantees of what +/// will be non-nil, but could provide good diagnostic information when +/// you do not know what error structure to expect. +/// +/// ``` +/// Fields: +/// - id +/// - status +/// - code +/// - title +/// - detail +/// - source +/// - pointer +/// - parameter +/// ``` +/// +/// The JSON:API Spec does not dictate the type of this particular Id field, +/// so you must specify whether to expect, for example, an `Int` or a `String` +/// in the id field. +/// +/// Something like `AnyCodable` from *Flight-School* could be +/// a good option if you do not know what to expect. You could also use +/// `Either` (provided by the `Poly` package that is +/// already a dependency of `JSONAPI`). +/// +/// - Important: The `definedFields` property will include fields +/// with non-nil values in a flattened way. There will be no `source` key +/// but there will be `pointer` and `parameter` keys (if those values +/// are non-nil). +public typealias BasicJSONAPIError = GenericJSONAPIError> diff --git a/Sources/JSONAPI/Error/GenericJSONAPIError.swift b/Sources/JSONAPI/Error/GenericJSONAPIError.swift new file mode 100644 index 0000000..457e4f7 --- /dev/null +++ b/Sources/JSONAPI/Error/GenericJSONAPIError.swift @@ -0,0 +1,74 @@ +// +// GenericJSONAPIError.swift +// JSONAPI +// +// Created by Mathew Polzin on 9/29/19. +// + +/// `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, CustomStringConvertible { + case unknownError + case error(ErrorPayload) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + do { + self = .error(try container.decode(ErrorPayload.self)) + } catch { + self = .unknown + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .error(let payload): + try container.encode(payload) + case .unknownError: + try container.encode("unknown") + } + } + + 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 { + var payload: ErrorPayload? { + switch self { + case .unknownError: + return nil + case .error(let payload): + return payload + } + } +} + +public protocol ErrorDictType { + var definedFields: [String: String] { get } +} + +extension GenericJSONAPIError: ErrorDictType where ErrorPayload: ErrorDictType { + /// Get a dictionary of all defined fields and their values. + public var definedFields: [String: String] { + switch self { + case .unknownError: + return [:] + case .error(let basicPayload): + return basicPayload.definedFields + } + } +} diff --git a/Sources/JSONAPI/Error/JSONAPIError.swift b/Sources/JSONAPI/Error/JSONAPIError.swift new file mode 100644 index 0000000..6f19105 --- /dev/null +++ b/Sources/JSONAPI/Error/JSONAPIError.swift @@ -0,0 +1,33 @@ +// +// JSONAPIError.swift +// JSONAPI +// +// Created by Mathew Polzin on 11/10/18. +// + +public protocol JSONAPIError: Swift.Error, Equatable, Codable { + static var unknown: Self { get } +} + +/// `UnknownJSONAPIError` can actually be used in any sitaution +/// where you don't know what errors are possible _or_ you just don't +/// care what errors might show up. If you don't know how the error +/// will be structured but you would like to have access to more +/// 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 + } + + 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..8cf889b --- /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, Sendable { + 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..bbe7335 100644 --- a/Sources/JSONAPI/Meta/Links.swift +++ b/Sources/JSONAPI/Meta/Links.swift @@ -5,63 +5,65 @@ // 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 struct NoLinks: Links, CustomStringConvertible, Sendable { + 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: Sendable where Meta: Sendable, URL: Sendable {} + 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 587dc9c..51aec42 100644 --- a/Sources/JSONAPI/Meta/Meta.swift +++ b/Sources/JSONAPI/Meta/Meta.swift @@ -6,23 +6,32 @@ // /// 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 {} -public struct NoMetadata: Meta, CustomStringConvertible { - public static var none: NoMetadata { return NoMetadata() } +/// Use this type when you want to specify not to encode or decode any metadata +/// for a type. +public struct NoMetadata: Meta, CustomStringConvertible, Sendable { + 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" } } + +/// The type of metadata found in a Resource Identifier Object. +/// +/// It is sometimes more legible to differentiate between types of metadata +/// even when the underlying type is the same. This typealias is only here +/// to make code more easily understandable. +public typealias NoIdMetadata = NoMetadata 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 a43c368..e39b884 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -5,50 +5,60 @@ // 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 } +} + +public protocol AttributeType: Codable, AbstractAttributeType { + associatedtype RawValue: Codable + associatedtype ValueType - var value: ValueType { get } + 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 value: Transformer.To - - public init(rawValue: RawValue) throws { - self.rawValue = rawValue - value = try Transformer.transform(rawValue) - } + public let rawValue: RawValue + + public let value: Transformer.To + + public init(rawValue: RawValue) throws { + self.rawValue = rawValue + value = try Transformer.transform(rawValue) + } } +extension TransformedAttribute: Sendable where RawValue: Sendable, Transformer.To: Sendable {} + 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 +73,88 @@ 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: Sendable where RawValue: Sendable {} + 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() - - try container.encode(rawValue) - } + 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() + + try container.encode(rawValue) + } } extension Attribute { - 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) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - try container.encode(attribute) - } + 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) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + 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 7265a18..d8a727c 100644 --- a/Sources/JSONAPI/Resource/Id.swift +++ b/Sources/JSONAPI/Resource/Id.swift @@ -23,86 +23,101 @@ 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 {} -public struct Unidentified: MaybeRawId, CustomStringConvertible { - public init() {} - - public var description: String { return "Unidentified" } +/// A type that can be used as the `MaybeRawId` for a `ResourceObject` that does not +/// 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, Sendable { + 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 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 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 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 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) - } + public let rawValue: RawType + + public init(rawValue: RawType) { + self.rawValue = 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: Sendable where RawType: Sendable {} + +extension Id: Hashable where RawType: RawIdType { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(Self.self)) + hasher.combine(rawValue) + } } -extension Id: Hashable, CustomStringConvertible, IdType where RawType: RawIdType { - public static func id(from rawValue: RawType) -> Id { - return Id(rawValue: rawValue) - } +extension Id: 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 66aaf63..ef2a340 100644 --- a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift +++ b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift @@ -15,43 +15,288 @@ import Poly /// disparate types under one roof for /// the purposes of JSON API compliant /// encoding or decoding. -public typealias JSONPoly = Poly & PrimaryResource +public typealias EncodableJSONPoly = Poly & EncodablePrimaryResource -public typealias PolyWrapped = Codable & Equatable +public typealias EncodablePolyWrapped = Encodable & Equatable +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: @retroactive Encodable, @retroactive Decodable { + 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) + } } +extension Poly0: CodablePrimaryResource {} + // MARK: - 1 type -extension Poly1: PrimaryResource, MaybePrimaryResource where A: PolyWrapped {} +extension Poly1: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped {} + +extension Poly1: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped {} // MARK: - 2 types -extension Poly2: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped {} +extension Poly2: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped {} + +extension Poly2: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped {} // MARK: - 3 types -extension Poly3: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped {} +extension Poly3: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped {} + +extension Poly3: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped {} // MARK: - 4 types -extension Poly4: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped {} +extension Poly4: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped {} + +extension Poly4: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped {} // MARK: - 5 types -extension Poly5: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped {} +extension Poly5: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped {} + +extension Poly5: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped {} // MARK: - 6 types -extension Poly6: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped {} +extension Poly6: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where A: EncodablePolyWrapped, B: EncodablePolyWrapped, C: EncodablePolyWrapped, D: EncodablePolyWrapped, E: EncodablePolyWrapped, F: EncodablePolyWrapped {} + +extension Poly6: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped {} // MARK: - 7 types -extension Poly7: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped {} +extension Poly7: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped {} + +extension Poly7: CodablePrimaryResource, OptionalCodablePrimaryResource + where A: CodablePolyWrapped, B: CodablePolyWrapped, C: CodablePolyWrapped, D: CodablePolyWrapped, E: CodablePolyWrapped, F: CodablePolyWrapped, G: CodablePolyWrapped {} // MARK: - 8 types -extension Poly8: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped {} +extension Poly8: EncodablePrimaryResource, OptionalEncodablePrimaryResource + where + A: EncodablePolyWrapped, + B: EncodablePolyWrapped, + C: EncodablePolyWrapped, + D: EncodablePolyWrapped, + E: EncodablePolyWrapped, + F: EncodablePolyWrapped, + G: EncodablePolyWrapped, + H: EncodablePolyWrapped {} + +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: PrimaryResource, MaybePrimaryResource where A: PolyWrapped, B: PolyWrapped, C: PolyWrapped, D: PolyWrapped, E: PolyWrapped, F: PolyWrapped, G: PolyWrapped, H: PolyWrapped, I: PolyWrapped {} +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: 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: 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: 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 {} + +// MARK: - 12 types +extension Poly12: 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, +L: EncodablePolyWrapped {} + +extension Poly12: 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, +L: CodablePolyWrapped {} + +// MARK: - 13 types +extension Poly13: 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, +L: EncodablePolyWrapped, +M: EncodablePolyWrapped {} + +extension Poly13: 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, +L: CodablePolyWrapped, +M: CodablePolyWrapped {} + +// MARK: - 14 types +extension Poly14: 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, +L: EncodablePolyWrapped, +M: EncodablePolyWrapped, +N: EncodablePolyWrapped {} + +extension Poly14: 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, +L: CodablePolyWrapped, +M: CodablePolyWrapped, +N: CodablePolyWrapped {} + +// MARK: - 15 types +extension Poly15: 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, +L: EncodablePolyWrapped, +M: EncodablePolyWrapped, +N: EncodablePolyWrapped, +O: EncodablePolyWrapped {} + +extension Poly15: 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, +L: CodablePolyWrapped, +M: CodablePolyWrapped, +N: CodablePolyWrapped, +O: CodablePolyWrapped {} diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index f6a4bdc..bccf453 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -6,278 +6,490 @@ // 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 +/// A relationship with no `data` entry (it still must contain at least meta or links). +/// A server might choose to expose certain relationships as just a link that can be +/// used to retrieve the related resource(s) in some cases. +/// +/// If the server is going to deliver one or more resource's `id`/`type` in a `data` +/// entry, you want to use either the `ToOneRelationship` or the +/// `ToManyRelationship` instead. +public struct MetaRelationship: RelationshipType, Equatable { + + public let meta: MetaType + public let links: LinksType + + public init(meta: MetaType, links: LinksType) { + self.meta = meta + self.links = links + } +} + +/// A `ResourceObject` relationship that can be encoded to or decoded from /// a JSON API "Resource Linkage." +/// /// See https://jsonapi.org/format/#document-resource-object-linkage +/// /// A convenient typealias might make your code much more legible: `One` -public struct ToOneRelationship: RelationshipType, Equatable { - - public let id: Identifiable.Identifier - - public let meta: MetaType - public let links: LinksType +/// +/// The `IdMetaType` (if not `NoIdMetadata`) will be parsed out of the Resource Identifier Object. +/// (see https://jsonapi.org/format/#document-resource-identifier-objects) +/// +/// The `MetaType` (if not `NoMetadata`) will be parsed out of the Relationship Object. +/// (see https://jsonapi.org/format/#document-resource-object-relationships) +public struct ToOneRelationship: RelationshipType, Equatable { + + public let id: Identifiable.ID + + public let idMeta: IdMetaType + + public let meta: MetaType + public let links: LinksType + + public init(id: (Identifiable.ID, IdMetaType), meta: MetaType, links: LinksType) { + self.id = id.0 + self.idMeta = id.1 + 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: Sendable where + Identifiable.ID: Sendable, + IdMetaType: Sendable, + MetaType: Sendable, + LinksType: Sendable {} + +extension ToOneRelationship where IdMetaType == NoIdMetadata { + public init(id: Identifiable.ID, meta: MetaType, links: LinksType) { + self.id = id + self.idMeta = .none + 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.ID, IdMetaType)) { + self.init(id: id, meta: .none, links: .none) + } +} + +extension ToOneRelationship where IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public init(id: Identifiable.ID) { + 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.ID, IdMetaType == NoIdMetadata { + 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) - } +extension ToOneRelationship where MetaType == NoMetadata, LinksType == NoLinks, IdMetaType == NoIdMetadata { + public init(resourceObject: T) where T.Id == Identifiable.ID { + 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) - } +extension ToOneRelationship where Identifiable: OptionalRelatable, IdMetaType == NoIdMetadata { + public init(resourceObject: T?, meta: MetaType, links: LinksType) where T.Id == Identifiable.Wrapped.ID { + 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) - } +extension ToOneRelationship where Identifiable: OptionalRelatable, MetaType == NoMetadata, LinksType == NoLinks, IdMetaType == NoIdMetadata { + public init(resourceObject: T?) where T.Id == Identifiable.Wrapped.ID { + self.init(id: resourceObject?.id, meta: .none, links: .none) + } } /// An ResourceObject relationship that can be encoded to or decoded from /// a JSON API "Resource Linkage." +/// /// See https://jsonapi.org/format/#document-resource-object-linkage +/// /// A convenient typealias might make your code much more legible: `Many` -public struct ToManyRelationship: RelationshipType, Equatable { - - public let ids: [Relatable.Identifier] - - 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(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) - } - - private init(meta: MetaType, links: LinksType) { - self.init(ids: [], meta: meta, links: links) - } +/// +/// The `IdMetaType` (if not `NoIdMetadata`) will be parsed out of the Resource Identifier Object. +/// (see https://jsonapi.org/format/#document-resource-identifier-objects) +/// +/// The `MetaType` (if not `NoMetadata`) will be parsed out of the Relationship Object. +/// (see https://jsonapi.org/format/#document-resource-object-relationships) +public struct ToManyRelationship: RelationshipType, Equatable { + + public struct ID: Equatable { + public let id: Relatable.ID + public let meta: IdMetaType + + public init(id: Relatable.ID, meta: IdMetaType) { + self.id = id + self.meta = meta + } + + internal init(_ idPair: (Relatable.ID, IdMetaType)) { + self.init(id: idPair.0, meta: idPair.1) + } + } + + public let idsWithMeta: [ID] + + public var ids: [Relatable.ID] { + idsWithMeta.map(\.id) + } + + public let meta: MetaType + public let links: LinksType + + public init(idsWithMetadata ids: [(Relatable.ID, IdMetaType)], meta: MetaType, links: LinksType) { + self.idsWithMeta = ids.map { ID.init($0) } + self.meta = meta + self.links = links + } + + public init(pointers: [ToOneRelationship], meta: MetaType, links: LinksType) where T.ID == Relatable.ID, IdMetaType == NoIdMetadata { + idsWithMeta = pointers.map { .init(id: $0.id, meta: .none) } + self.meta = meta + self.links = links + } + + public init(resourceObjects: [T], meta: MetaType, links: LinksType) where T.Id == Relatable.ID, IdMetaType == NoIdMetadata { + self.init(ids: resourceObjects.map(\.id), meta: meta, links: links) + } + + private init(meta: MetaType, links: LinksType) { + self.init(idsWithMetadata: [], 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.ID: Sendable where + Relatable.ID: Sendable, + IdMetaType: Sendable {} + +extension ToManyRelationship: Sendable where + ID: Sendable, + MetaType: Sendable, + LinksType: Sendable {} + +extension ToManyRelationship where IdMetaType == NoIdMetadata { + public init(ids: [Relatable.ID], meta: MetaType, links: LinksType) { + self.idsWithMeta = ids.map { .init(id: $0, meta: .none) } + self.meta = meta + self.links = links + } } extension ToManyRelationship where MetaType == NoMetadata, LinksType == NoLinks { - public init(ids: [Relatable.Identifier]) { - self.init(ids: ids, meta: .none, links: .none) - } + public init(idsWithMetadata ids: [(Relatable.ID, IdMetaType)]) { + self.init(idsWithMetadata: ids, meta: .none, links: .none) + } + + public init(pointers: [ToOneRelationship]) where T.ID == Relatable.ID, IdMetaType == NoIdMetadata { + 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.ID, IdMetaType == NoIdMetadata { + 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) - } +extension ToManyRelationship where IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public init(ids: [Relatable.ID]) { + self.init(ids: ids, meta: .none, links: .none) + } } -public protocol Identifiable: JSONTyped { - associatedtype Identifier: Equatable +public protocol JSONAPIIdentifiable: JSONTyped { + associatedtype ID: Equatable } /// The Relatable protocol describes anything that /// has an IdType Identifier -public protocol Relatable: Identifiable where Identifier: JSONAPI.IdType { +public protocol Relatable: JSONAPIIdentifiable where ID: JSONAPI.IdType { } /// OptionalRelatable just describes an Optional /// with a Reltable Wrapped type. -public protocol OptionalRelatable: Identifiable where Identifier == Wrapped.Identifier? { - associatedtype Wrapped: JSONAPI.Relatable +public protocol OptionalRelatable: JSONAPIIdentifiable where ID == Wrapped.ID? { + associatedtype Wrapped: JSONAPI.Relatable } -extension Optional: Identifiable, OptionalRelatable, JSONTyped where Wrapped: JSONAPI.Relatable { - public typealias Identifier = Wrapped.Identifier? +extension Optional: JSONAPIIdentifiable, OptionalRelatable, JSONTyped where Wrapped: JSONAPI.Relatable { + public typealias ID = Wrapped.ID? - 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" + case metadata = "meta" } -extension ToOneRelationship: Codable where Identifiable.Identifier: OptionalId { - 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 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 - } - - 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) - - if (id as Any?) == nil { - try container.encodeNil(forKey: .data) - } - - 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 MetaRelationship: Codable { + 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 noLinks = NoLinks() as? LinksType { + links = noLinks + } else { + links = try container.decode(LinksType.self, forKey: .links) + } + } + + 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) + } + } +} + +extension ToOneRelationship: Codable where Identifiable.ID: OptionalId { + 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 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) { + guard let val = anyNil as? Identifiable.ID else { + throw DecodingError.valueNotFound( + Self.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected non-null relationship data." + ) + ) + } + // if we know we aren't getting any Resource Identifer Object at all + // (which we do inside this block) then we better either be expecting + // no Id Metadata or optional Id Metadata or else we will report an + // error. + if let noIdMeta = NoIdMetadata() as? IdMetaType { + idMeta = noIdMeta + } else if let nilMeta = anyNil as? IdMetaType { + idMeta = nilMeta + } else { + throw DecodingError.valueNotFound( + Self.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected non-null relationship data with metadata inside." + ) + ) + } + id = val + return + } + + 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 + ) + } + + let type = try identifier.decode(String.self, forKey: .entityType) + + guard type == Identifiable.jsonType else { + throw JSONAPICodingError.typeMismatch( + expected: Identifiable.jsonType, + found: type, + path: decoder.codingPath + ) + } + + let idMeta: IdMetaType + let maybeNoIdMeta: IdMetaType? = NoIdMetadata() as? IdMetaType + if let noIdMeta = maybeNoIdMeta { + idMeta = noIdMeta + } else { + idMeta = try identifier.decode(IdMetaType.self, forKey: .metadata) + } + self.idMeta = idMeta + + id = Identifiable.ID(rawValue: try identifier.decode(Identifiable.ID.RawType.self, forKey: .id)) + } + + 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.ID + 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) + if IdMetaType.self != NoMetadata.self { + try identifier.encode(idMeta, forKey: .metadata) + } + try identifier.encode(Identifiable.jsonType, forKey: .entityType) + } } extension ToManyRelationship: Codable { - 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 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) - - 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) - } - } + 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 noLinks = NoLinks() as? LinksType { + links = noLinks + } else { + links = try container.decode(LinksType.self, forKey: .links) + } + + 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) + } + + var newIds = [ID]() + 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 JSONAPICodingError.typeMismatch(expected: Relatable.jsonType, found: type, path: decoder.codingPath) + } + + let id = try identifier.decode(Relatable.ID.RawType.self, forKey: .id) + + let idMeta: IdMetaType + let maybeNoIdMeta: IdMetaType? = NoIdMetadata() as? IdMetaType + if let noIdMeta = maybeNoIdMeta { + idMeta = noIdMeta + } else { + idMeta = try identifier.decode(IdMetaType.self, forKey: .metadata) + } + + newIds.append(.init(id: Relatable.ID(rawValue: id), meta: idMeta) ) + } + idsWithMeta = 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 idsWithMeta { + var identifier = identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) + + try identifier.encode(id.id.rawValue, forKey: .id) + if IdMetaType.self != NoMetadata.self { + try identifier.encode(id.meta, forKey: .metadata) + } + try identifier.encode(Relatable.jsonType, forKey: .entityType) + } + } } // MARK: CustomStringDescribable +extension MetaRelationship: CustomStringConvertible { + public var description: String { "MetaRelationship" } +} + extension ToOneRelationship: CustomStringConvertible { - public var description: String { return "Relationship(\(String(describing: id)))" } + public var description: String { "Relationship(\(String(describing: id)))" } } extension ToManyRelationship: CustomStringConvertible { - public var description: String { return "Relationship([\(ids.map(String.init(describing:)).joined(separator: ", "))])" } + public var description: String { "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+Replacing.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObject+Replacing.swift new file mode 100644 index 0000000..98ef37c --- /dev/null +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObject+Replacing.swift @@ -0,0 +1,74 @@ +// +// ResourceObject+Replacing.swift +// JSONAPI +// +// Created by Mathew Polzin on 10/12/19. +// + +public extension JSONAPI.ResourceObject { + /// Return a new `ResourceObject`, having replaced `self`'s + /// `attributes` with the attributes returned by the given + /// replacement function. + /// + /// - important: `self` is not mutated. A copy of self is returned. + /// + /// - parameters: + /// - replacement: A function that takes the existing `attributes` and returns the replacement. + func replacingAttributes(_ replacement: (Description.Attributes) -> Description.Attributes) -> Self { + return Self(id: id, + attributes: replacement(attributes), + relationships: relationships, + meta: meta, + links: links) + } + + /// Return a new `ResourceObject`, having updated `self`'s + /// `attributes` with the tap function given. + /// + /// - important: `self` is not mutated. A copy of self is returned. + /// + /// - parameters: + /// - tap: A function that takes a copy of the existing `attributes` and mutates them. + func tappingAttributes(_ tap: (inout Description.Attributes) -> Void) -> Self { + var newAttributes = attributes + tap(&newAttributes) + return Self(id: id, + attributes: newAttributes, + relationships: relationships, + meta: meta, + links: links) + } + + /// Return a new `ResourceObject`, having replaced `self`'s + /// `relationships` with the `relationships` returned by the given + /// replacement function. + /// + /// - important: `self` is not mutated. A copy of self is returned. + /// + /// - parameters: + /// - replacement: A function that takes the existing relationships and returns the replacement. + func replacingRelationships(_ replacement: (Description.Relationships) -> Description.Relationships) -> Self { + return Self(id: id, + attributes: attributes, + relationships: replacement(relationships), + meta: meta, + links: links) + } + + /// Return a new `ResourceObject`, having updated `self`'s + /// `relationships` with the tap function given. + /// + /// - important: `self` is not mutated. A copy of self is returned. + /// + /// - parameters: + /// - tap: A function that takes a copy of the existing `relationships` and mutates them. + func tappingRelationships(_ tap: (inout Description.Relationships) -> Void) -> Self { + var newRelationships = relationships + tap(&newRelationships) + return Self(id: id, + attributes: attributes, + relationships: newRelationships, + meta: meta, + links: links) + } +} diff --git a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift new file mode 100644 index 0000000..9817d39 --- /dev/null +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift @@ -0,0 +1,471 @@ +// +// ResourceObject.swift +// JSONAPI +// +// Created by Mathew Polzin on 7/24/18. +// + + +/// A JSON API structure within an ResourceObject that contains +/// named properties of types `MetaRelationship`, `ToOneRelationship` +/// and `ToManyRelationship`. +public protocol Relationships: Codable & Equatable {} + +/// A JSON API structure within an ResourceObject that contains +/// properties of any types that are JSON encodable. +public protocol Attributes: Codable & Equatable {} + +/// CodingKeys must be `CodingKey` and `Equatable` in order +/// to support Sparse Fieldsets. +public typealias SparsableCodingKey = CodingKey & Equatable + +/// Attributes containing publicly accessible and `Equatable` +/// CodingKeys are required to support Sparse Fieldsets. +public protocol SparsableAttributes: Attributes { + associatedtype CodingKeys: SparsableCodingKey +} + +/// Can be used as `Relationships` Type for Entities that do not +/// have any Relationships. +public struct NoRelationships: Relationships, Sendable { + public static var none: NoRelationships { return .init() } +} + +extension NoRelationships: CustomStringConvertible { + 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, Sendable { + public static var none: NoAttributes { return .init() } +} + +extension NoAttributes: CustomStringConvertible { + 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 } +} + +/// A `ResourceObjectProxyDescription` is an `ResourceObjectDescription` +/// without Codable conformance. +public protocol ResourceObjectProxyDescription: JSONTyped { + associatedtype Attributes: Equatable + associatedtype Relationships: Equatable +} + +/// A `ResourceObjectDescription` describes a JSON API +/// Resource Object. The Resource Object +/// itself is encoded and decoded as an +/// `ResourceObject`, which gets specialized on an +/// `ResourceObjectDescription`. +public protocol ResourceObjectDescription: ResourceObjectProxyDescription where Attributes: JSONAPI.Attributes, Relationships: JSONAPI.Relationships {} + +/// ResourceObjectProxy is a protocol that can be used to create +/// types that _act_ like ResourceObject but cannot be encoded +/// or decoded as ResourceObjects. +@dynamicMemberLookup +public protocol ResourceObjectProxy: Equatable, JSONTyped { + associatedtype Description: ResourceObjectProxyDescription + associatedtype EntityRawIdType: JSONAPI.MaybeRawId + + typealias Id = JSONAPI.Id + + 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 JSON API compliant attributes of this `Entity`. + var attributes: Attributes { 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 } +} + +/// 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: 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 {} + +/// An `ResourceObject` is a single model type that can be +/// encoded to or decoded from a JSON API +/// "Resource Object." +/// See https://jsonapi.org/format/#document-resource-objects +public struct ResourceObject: ResourceObjectType { + + 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: JSONAPI.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 + + /// 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: Sendable where + EntityRawIdType: Sendable, + Description.Attributes: Sendable, + Description.Relationships: Sendable, + MetaType: Sendable, + LinksType: Sendable {} + +// `ResourceObject` is hashable as an identifiable resource which semantically +// means that two different resources with the same ID should yield the same +// hash value. +// +// "equatability" in this context will determine if two resources have _all_ the same +// properties, whereas hash value will determine if two resources have the same Id. +extension ResourceObject: Hashable where EntityRawIdType: RawIdType { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension ResourceObject: JSONAPIIdentifiable, IdentifiableResourceObjectType, Relatable where EntityRawIdType: JSONAPI.RawIdType { + public typealias ID = ResourceObject.Id +} + +@available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension ResourceObject: Swift.Identifiable where EntityRawIdType: JSONAPI.RawIdType {} + +extension ResourceObject: CustomStringConvertible { + 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 + } +} + +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 + } +} + +// 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 + + /// `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 (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) + } +} + +// 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 the given Id. + func identified(by id: RawIdType) -> ResourceObject { + return .init(id: ResourceObject.ID(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) + } +} + +// MARK: - Attribute Access +public extension ResourceObjectProxy { + // MARK: Dynaminc Member Keypath Lookup + /// Access the attribute at the given keypath. This just + /// allows you to write `resourceObject[\.propertyName]` instead + /// of `resourceObject.attributes.propertyName.value`. + subscript(dynamicMember 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(dynamicMember 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(dynamicMember path: KeyPath) -> U? where T.ValueType == U? { + return attributes[keyPath: path].flatMap(\.value) + } + + // MARK: Direct Keypath Subscript Lookup + /// Access the storage of the attribute at the given keypath. This just + /// allows you to write `resourceObject[direct: \.propertyName]` instead + /// 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] + } +} + +// MARK: - Meta-Attribute Access +public extension ResourceObjectProxy { + // MARK: Dynamic Member Keypath Lookup + /// Access an attribute requiring a transformation on the RawValue _and_ + /// a secondary transformation on this entity (self). + subscript(dynamicMember path: KeyPath T>) -> T { + return attributes[keyPath: path](self) + } +} + +// 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.ID { + 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.ID { + // 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.ID? { + // 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.ID] { + 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.ID]? { + 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 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 ~> + +// MARK: - Codable +private enum ResourceObjectCodingKeys: String, CodingKey { + case type = "type" + case id = "id" + case attributes = "attributes" + case relationships = "relationships" + case meta = "meta" + case links = "links" +} + +public extension ResourceObject { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ResourceObjectCodingKeys.self) + + try container.encode(ResourceObject.jsonType, forKey: .type) + + if EntityRawIdType.self != Unidentified.self { + try container.encode(id, forKey: .id) + } + + if Description.Attributes.self != NoAttributes.self { + let nestedEncoder = container.superEncoder(forKey: .attributes) + try attributes.encode(to: nestedEncoder) + } + + if Description.Relationships.self != NoRelationships.self { + try container.encode(relationships, forKey: .relationships) + } + + if MetaType.self != NoMetadata.self { + try container.encode(meta, forKey: .meta) + } + + if LinksType.self != NoLinks.self { + try container.encode(links, forKey: .links) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ResourceObjectCodingKeys.self) + + let type: String + do { + type = try container.decode(String.self, forKey: .type) + } catch let error as DecodingError { + throw ResourceObjectDecodingError(error, jsonAPIType: Self.jsonType) + ?? error + } + + guard ResourceObject.jsonType == type else { + 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) + + 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, jsonAPIType: Self.jsonType) + ?? decodingError + } catch _ as EmptyObjectDecodingError { + throw ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .attributes, + jsonAPIType: Self.jsonType + ) + } + + 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, jsonAPIType: Self.jsonType) + ?? decodingError + } catch let decodingError as JSONAPICodingError { + throw ResourceObjectDecodingError(decodingError, jsonAPIType: Self.jsonType) + ?? decodingError + } catch _ as EmptyObjectDecodingError { + throw ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .relationships, + jsonAPIType: Self.jsonType + ) + } + + do { + meta = try (NoMetadata() as? MetaType) ?? container.decode(MetaType.self, forKey: .meta) + } catch let decodingError as DecodingError { + let anyNil: Any? = nil + + guard case .keyNotFound = decodingError, + let omittedMeta = anyNil as? MetaType else { + throw decodingError + } + meta = omittedMeta + } + + links = try (NoLinks() as? LinksType) ?? container.decode(LinksType.self, forKey: .links) + } +} diff --git a/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift new file mode 100644 index 0000000..d458605 --- /dev/null +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift @@ -0,0 +1,171 @@ +// +// ResourceObjectDecodingError.swift +// +// +// Created by Mathew Polzin on 11/10/19. +// + +public struct ResourceObjectDecodingError: Swift.Error, Equatable { + public let resourceObjectJsonAPIType: String + public let subjectName: String + public let cause: Cause + public let location: Location + + static let entireObject = "entire object" + + public enum Cause: Equatable, Sendable { + case keyNotFound + case valueNotFound + case typeMismatch(expectedTypeName: String) + case jsonTypeMismatch(foundType: String) + case quantityMismatch(expected: JSONAPICodingError.Quantity) + + internal var isTypeMismatch: Bool { + guard case .jsonTypeMismatch = self else { return false} + return true + } + } + + public enum Location: String, Equatable, Sendable { + case attributes + case relationships + case relationshipType + case relationshipId + case type + + var singular: String { + switch self { + case .attributes: return "attribute" + case .relationships: return "relationship" + case .relationshipType: return "relationship type" + case .relationshipId: return "relationship Id" + case .type: return "type" + } + } + } + + init?(_ decodingError: DecodingError, jsonAPIType: String) { + self.resourceObjectJsonAPIType = jsonAPIType + 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): + let (location, name) = Self.context(ctx) + let missingKeyString = missingKey.stringValue + + if location == .relationships && missingKeyString == "type" { + self.location = .relationshipType + subjectName = name + } else if location == .relationships && missingKeyString == "id" { + self.location = .relationshipId + subjectName = name + } else { + self.location = location + subjectName = missingKey.stringValue + } + cause = .keyNotFound + default: + return nil + } + } + + init?(_ jsonAPIError: JSONAPICodingError, jsonAPIType: String) { + self.resourceObjectJsonAPIType = jsonAPIType + switch jsonAPIError { + case .typeMismatch(expected: _, found: let found, path: let path): + (location, subjectName) = Self.context(path: path) + cause = .jsonTypeMismatch(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) { + resourceObjectJsonAPIType = expectedJSONAPIType + location = .type + subjectName = "self" + cause = .jsonTypeMismatch(foundType: found) + } + + init(subjectName: String, cause: Cause, location: Location, jsonAPIType: String) { + self.resourceObjectJsonAPIType = jsonAPIType + 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 + } + + let subjectPath: [CodingKey] + if location == .relationships && path.last?.stringValue == "data" { + subjectPath = path.dropLast() + } else { + subjectPath = path + } + + return ( + location, + name: subjectPath.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. the JSON:API type name) is required and missing." + case .keyNotFound where location == .relationshipType: + return "'\(subjectName)' relationship does not have a 'type'." + case .keyNotFound where location == .relationshipId: + return "'\(subjectName)' relationship does not have an 'id'." + case .keyNotFound: + return "'\(subjectName)' \(location.singular) is required and missing." + case .valueNotFound where location == .type: + return "'\(location.singular)' (a.k.a. the 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(foundType: let found) where location == .type: + return "found JSON:API type \"\(found)\" but expected \"\(resourceObjectJsonAPIType)\"" + case .jsonTypeMismatch(foundType: let found): + return "'\(subjectName)' \(location.singular) is of JSON:API type \"\(found)\" but it was expected to be \"\(resourceObjectJsonAPIType)\"" + 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/Resource/ResourceObject.swift b/Sources/JSONAPI/Resource/ResourceObject.swift deleted file mode 100644 index 83cef3e..0000000 --- a/Sources/JSONAPI/Resource/ResourceObject.swift +++ /dev/null @@ -1,608 +0,0 @@ -// -// ResourceObject.swift -// JSONAPI -// -// Created by Mathew Polzin on 7/24/18. -// - - -/// A JSON API structure within an ResourceObject that contains -/// named properties of types `ToOneRelationship` and -/// `ToManyRelationship`. -public protocol Relationships: Codable & Equatable {} - -/// A JSON API structure within an ResourceObject that contains -/// properties of any types that are JSON encodable. -public protocol Attributes: Codable & Equatable {} - -/// 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() } -} - -extension NoRelationships: CustomStringConvertible { - 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() } -} - -extension NoAttributes: CustomStringConvertible { - 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 } -} - -/// A `ResourceObjectProxyDescription` is an `ResourceObjectDescription` -/// without Codable conformance. -public protocol ResourceObjectProxyDescription: JSONTyped { - associatedtype Attributes: Equatable - associatedtype Relationships: Equatable -} - -/// An `ResourceObjectDescription` describes a JSON API -/// Resource Object. The Resource Object -/// itself is encoded and decoded as an -/// `ResourceObject`, which gets specialized on an -/// `ResourceObjectDescription`. -public protocol ResourceObjectDescription: ResourceObjectProxyDescription where Attributes: JSONAPI.Attributes, Relationships: JSONAPI.Relationships {} - -/// ResourceObjectProxy is a protocol that can be used to create -/// types that _act_ like Entities but cannot be encoded -/// or decoded as Entities. -public protocol ResourceObjectProxy: Equatable, JSONTyped { - associatedtype Description: ResourceObjectProxyDescription - associatedtype EntityRawIdType: JSONAPI.MaybeRawId - - typealias Id = JSONAPI.Id - - 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 JSON API compliant attributes of this `Entity`. - var attributes: Attributes { get } - - /// The JSON API compliant relationships of this `Entity`. - var relationships: Relationships { get } -} - -extension ResourceObjectProxy { - /// The JSON API compliant "type" of this `Entity`. - public static var jsonType: String { return Description.jsonType } -} - -/// 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 IdentifiableResourceObjectType: ResourceObjectType, Relatable where EntityRawIdType: JSONAPI.RawIdType {} - -/// An `ResourceObject` is a single model type that can be -/// encoded to or decoded from a JSON API -/// "Resource Object." -/// See https://jsonapi.org/format/#document-resource-objects -public struct ResourceObject: ResourceObjectType { - - 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 - - /// 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 -} - -extension ResourceObject: CustomStringConvertible { - 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 - } -} - -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 - } -} - -/* -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 { - - /// An ResourceObject.Pointer is a `ToOneRelationship` with no metadata or links. - /// This is just a convenient way to reference an ResourceObject so that - /// other Entities' 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 Entities so - /// that other Entities' 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) - } - - 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 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) - } -} - -// MARK: Attribute Access -public extension ResourceObjectProxy { - /// Access the attribute at the given keypath. This just - /// allows you to write `resourceObject[\.propertyName]` instead - /// of `resourceObject.attributes.propertyName`. - 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`. - 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`. - 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 storage of the attribute at the given keypath. This just - /// allows you to write `resourceObject[direct: \.propertyName]` instead - /// 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] - } -} - -// MARK: Meta-Attribute Access -public extension ResourceObjectProxy { - /// 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) - } -} - -// 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 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 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 - } -} - -// 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 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 ~> - -// MARK: - Codable -private enum ResourceObjectCodingKeys: String, CodingKey { - case type = "type" - case id = "id" - case attributes = "attributes" - case relationships = "relationships" - case meta = "meta" - case links = "links" -} - -public extension ResourceObject { - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: ResourceObjectCodingKeys.self) - - try container.encode(ResourceObject.jsonType, forKey: .type) - - if EntityRawIdType.self != Unidentified.self { - try container.encode(id, forKey: .id) - } - - if Description.Attributes.self != NoAttributes.self { - try container.encode(attributes, forKey: .attributes) - } - - if Description.Relationships.self != NoRelationships.self { - try container.encode(relationships, forKey: .relationships) - } - - if MetaType.self != NoMetadata.self { - try container.encode(meta, forKey: .meta) - } - - if LinksType.self != NoLinks.self { - try container.encode(links, forKey: .links) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ResourceObjectCodingKeys.self) - - let type = try container.decode(String.self, forKey: .type) - - guard ResourceObject.jsonType == type else { - throw JSONAPIEncodingError.typeMismatch(expected: Description.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) - - relationships = try (NoRelationships() as? Description.Relationships) - ?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships) - ?? Description.Relationships(from: EmptyObjectDecoder()) - - meta = try (NoMetadata() as? MetaType) ?? container.decode(MetaType.self, forKey: .meta) - - links = try (NoLinks() as? LinksType) ?? container.decode(LinksType.self, forKey: .links) - } -} diff --git a/Sources/JSONAPI/Resource/Transformer.swift b/Sources/JSONAPI/Resource/Transformer.swift index 920b9d4..203b9b5 100644 --- a/Sources/JSONAPI/Resource/Transformer.swift +++ b/Sources/JSONAPI/Resource/Transformer.swift @@ -7,22 +7,26 @@ /// A Transformer simply defines a static function that transforms a value. public protocol Transformer { - associatedtype From - associatedtype To + associatedtype From + associatedtype To - static func transform(_ value: From) throws -> To + /// Turn value of type `From` into a value of type `To` or + /// throw an error on failure. + static func transform(_ value: From) throws -> To } /// ReversibleTransformers define a function that reverses the transform /// operation. public protocol ReversibleTransformer: Transformer { - static func reverse(_ value: To) throws -> From + /// Turn a value of type `To` into a value of type `From` or + /// throw an error on failure. + static func reverse(_ value: To) throws -> From } /// The IdentityTransformer does not perform any transformation on a value. public enum IdentityTransformer: ReversibleTransformer { - public static func transform(_ value: T) throws -> T { return value } - public static func reverse(_ value: T) throws -> T { return value } + public static func transform(_ value: T) throws -> T { return value } + public static func reverse(_ value: T) throws -> T { return value } } // MARK: - Validator @@ -37,13 +41,16 @@ public protocol Validator: ReversibleTransformer where From == To { } extension Validator { - public static func reverse(_ value: To) throws -> To { - let _ = try transform(value) - return value - } - - public static func validate(_ value: To) throws -> To { - let _ = try transform(value) - return value - } + public static func reverse(_ value: To) throws -> To { + let _ = try transform(value) + return value + } + + /// Validate the given value and then return it if valid. + /// throws an erro if invalid. + /// - returns: The same value passed in, if it was valid. + public static func validate(_ value: To) throws -> To { + let _ = try transform(value) + return value + } } diff --git a/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift b/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift new file mode 100644 index 0000000..2c555bd --- /dev/null +++ b/Sources/JSONAPI/SparseFields/SparseFieldEncoder.swift @@ -0,0 +1,204 @@ +// +// SparseEncoder.swift +// +// +// Created by Mathew Polzin on 8/4/19. +// + +class SparseFieldEncoder: Encoder { + private let wrappedEncoder: Encoder + private let allowedKeys: [SparseKey] + + public var codingPath: [CodingKey] { + return wrappedEncoder.codingPath + } + + public var userInfo: [CodingUserInfoKey : Any] { + return wrappedEncoder.userInfo + } + + public init(wrapping encoder: Encoder, encoding allowedKeys: [SparseKey]) { + wrappedEncoder = encoder + self.allowedKeys = allowedKeys + } + + public func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { + let container = SparseFieldKeyedEncodingContainer(wrapping: wrappedEncoder.container(keyedBy: type), + encoding: allowedKeys) + return KeyedEncodingContainer(container) + } + + public func unkeyedContainer() -> UnkeyedEncodingContainer { + return wrappedEncoder.unkeyedContainer() + } + + public func singleValueContainer() -> SingleValueEncodingContainer { + return wrappedEncoder.singleValueContainer() + } +} + +struct SparseFieldKeyedEncodingContainer: KeyedEncodingContainerProtocol where SparseKey: CodingKey, SparseKey: Equatable, Key: CodingKey { + private var wrappedContainer: KeyedEncodingContainer + private let allowedKeys: [SparseKey] + + public var codingPath: [CodingKey] { + return wrappedContainer.codingPath + } + + public init(wrapping container: KeyedEncodingContainer, encoding allowedKeys: [SparseKey]) { + wrappedContainer = container + self.allowedKeys = allowedKeys + } + + /// Ask the container whether the given key should be encoded. + public func shouldAllow(key: Key) -> Bool { + if let key = key as? SparseKey { + return allowedKeys.contains(key) + } + return true + } + + public mutating func encodeNil(forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encodeNil(forKey: key) + } + + public mutating func encode(_ value: Bool, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: String, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Double, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Float, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Int, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Int8, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Int16, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Int32, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: Int64, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: UInt, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: UInt8, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: UInt16, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: UInt32, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: UInt64, forKey key: Key) throws { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func encode(_ value: T, forKey key: Key) throws where T : Encodable { + guard shouldAllow(key: key) else { return } + + try wrappedContainer.encode(value, forKey: key) + } + + public mutating func nestedContainer(keyedBy keyType: NestedKey.Type, + forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { + guard shouldAllow(key: key) else { + return KeyedEncodingContainer( + // 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, + forKey: key), + encoding: []) + ) + } + + return KeyedEncodingContainer( + SparseFieldKeyedEncodingContainer(wrapping: wrappedContainer.nestedContainer(keyedBy: keyType, + forKey: key), + encoding: allowedKeys) + ) + } + + public mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + guard shouldAllow(key: key) else { + // 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) + } + + return wrappedContainer.nestedUnkeyedContainer(forKey: key) + } + + public mutating func superEncoder() -> Encoder { + return SparseFieldEncoder(wrapping: wrappedContainer.superEncoder(), + encoding: allowedKeys) + } + + public mutating func superEncoder(forKey key: Key) -> Encoder { + guard shouldAllow(key: key) else { + // NOTE: We are creating a sparse field encoder with no allowed keys + // here because the given key should not be allowed. + return SparseFieldEncoder(wrapping: wrappedContainer.superEncoder(forKey: key), + encoding: [SparseKey]()) + } + + return SparseFieldEncoder(wrapping: wrappedContainer.superEncoder(forKey: key), + encoding: allowedKeys) + } +} diff --git a/Sources/JSONAPI/SparseFields/SparseFieldset.swift b/Sources/JSONAPI/SparseFields/SparseFieldset.swift new file mode 100644 index 0000000..f7fca89 --- /dev/null +++ b/Sources/JSONAPI/SparseFields/SparseFieldset.swift @@ -0,0 +1,47 @@ +// +// SparseFieldset.swift +// +// +// Created by Mathew Polzin on 8/4/19. +// + +/// A SparseFieldset represents an `Encodable` subset of the fields +/// a `ResourceObject` would normally encode. Currently, you can +/// only apply sparse fieldset's to `ResourceObject.Attributes`. +public struct SparseFieldset< + Description: JSONAPI.ResourceObjectDescription, + MetaType: JSONAPI.Meta, + LinksType: JSONAPI.Links, + EntityRawIdType: JSONAPI.MaybeRawId +>: EncodablePrimaryResource where Description.Attributes: SparsableAttributes { + + /// The `ResourceObject` type this `SparseFieldset` is capable of modifying. + public typealias Resource = JSONAPI.ResourceObject + + public let resourceObject: Resource + public let fields: [Description.Attributes.CodingKeys] + + public init(_ resourceObject: Resource, fields: [Description.Attributes.CodingKeys]) { + self.resourceObject = resourceObject + self.fields = fields + } + + public func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + + try resourceObject.encode(to: sparseEncoder) + } +} + +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]) -> SparseType { + return SparseFieldset(self, fields: fields) + } +} diff --git a/Sources/JSONAPITesting/Attribute+Literal.swift b/Sources/JSONAPITesting/Attribute+Literal.swift index 706b6e4..f817a32 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 +extension Optional: @retroactive ExpressibleByFloatLiteral where Wrapped: ExpressibleByFloatLiteral { + 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 +extension Optional: @retroactive ExpressibleByBooleanLiteral where Wrapped: ExpressibleByBooleanLiteral { + 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 +extension Optional: @retroactive ExpressibleByIntegerLiteral where Wrapped: ExpressibleByIntegerLiteral { + 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..966b0ae --- /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(\.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..be2cad6 --- /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 { + 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..481016e --- /dev/null +++ b/Sources/JSONAPITesting/Comparisons/RelationshipsCompare.swift @@ -0,0 +1,161 @@ +// +// 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 MetaRelationship: AbstractRelationship { + var abstractDescription: String { + return String(describing: + ( + 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 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..a7b5ee9 --- /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(\.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..f0e03d6 100644 --- a/Sources/JSONAPITesting/Optional+Literal.swift +++ b/Sources/JSONAPITesting/Optional+Literal.swift @@ -5,26 +5,26 @@ // Created by Mathew Polzin on 11/29/18. // -extension Optional: ExpressibleByUnicodeScalarLiteral where Wrapped: ExpressibleByUnicodeScalarLiteral { - public typealias UnicodeScalarLiteralType = Wrapped.UnicodeScalarLiteralType +extension Optional: @retroactive ExpressibleByUnicodeScalarLiteral where Wrapped: ExpressibleByUnicodeScalarLiteral { + 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 +extension Optional: @retroactive ExpressibleByExtendedGraphemeClusterLiteral where Wrapped: ExpressibleByExtendedGraphemeClusterLiteral { + 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 +extension Optional: @retroactive ExpressibleByStringLiteral where Wrapped: ExpressibleByStringLiteral { + 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..c97ccd1 100644 --- a/Sources/JSONAPITesting/Relationship+Literal.swift +++ b/Sources/JSONAPITesting/Relationship+Literal.swift @@ -7,41 +7,41 @@ import JSONAPI -extension ToOneRelationship: ExpressibleByNilLiteral where Identifiable.Identifier: ExpressibleByNilLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public init(nilLiteral: ()) { +extension ToOneRelationship: ExpressibleByNilLiteral where Identifiable.ID: ExpressibleByNilLiteral, IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public init(nilLiteral: ()) { - self.init(id: Identifiable.Identifier(nilLiteral: ())) - } + self.init(id: Identifiable.ID(nilLiteral: ())) + } } -extension ToOneRelationship: ExpressibleByUnicodeScalarLiteral where Identifiable.Identifier: ExpressibleByUnicodeScalarLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias UnicodeScalarLiteralType = Identifiable.Identifier.UnicodeScalarLiteralType +extension ToOneRelationship: ExpressibleByUnicodeScalarLiteral where Identifiable.ID: ExpressibleByUnicodeScalarLiteral, IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public typealias UnicodeScalarLiteralType = Identifiable.ID.UnicodeScalarLiteralType - public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { - self.init(id: Identifiable.Identifier(unicodeScalarLiteral: value)) - } + public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { + self.init(id: Identifiable.ID(unicodeScalarLiteral: value)) + } } -extension ToOneRelationship: ExpressibleByExtendedGraphemeClusterLiteral where Identifiable.Identifier: ExpressibleByExtendedGraphemeClusterLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias ExtendedGraphemeClusterLiteralType = Identifiable.Identifier.ExtendedGraphemeClusterLiteralType +extension ToOneRelationship: ExpressibleByExtendedGraphemeClusterLiteral where Identifiable.ID: ExpressibleByExtendedGraphemeClusterLiteral, IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public typealias ExtendedGraphemeClusterLiteralType = Identifiable.ID.ExtendedGraphemeClusterLiteralType - public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { - self.init(id: Identifiable.Identifier(extendedGraphemeClusterLiteral: value)) - } + public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { + self.init(id: Identifiable.ID(extendedGraphemeClusterLiteral: value)) + } } -extension ToOneRelationship: ExpressibleByStringLiteral where Identifiable.Identifier: ExpressibleByStringLiteral, MetaType == NoMetadata, LinksType == NoLinks { - public typealias StringLiteralType = Identifiable.Identifier.StringLiteralType +extension ToOneRelationship: ExpressibleByStringLiteral where Identifiable.ID: ExpressibleByStringLiteral, IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public typealias StringLiteralType = Identifiable.ID.StringLiteralType - public init(stringLiteral value: StringLiteralType) { - self.init(id: Identifiable.Identifier(stringLiteral: value)) - } + public init(stringLiteral value: StringLiteralType) { + self.init(id: Identifiable.ID(stringLiteral: value)) + } } -extension ToManyRelationship: ExpressibleByArrayLiteral where MetaType == NoMetadata, LinksType == NoLinks { - public typealias ArrayLiteralElement = Relatable.Identifier +extension ToManyRelationship: ExpressibleByArrayLiteral where IdMetaType == NoIdMetadata, MetaType == NoMetadata, LinksType == NoLinks { + public typealias ArrayLiteralElement = Relatable.ID - 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..0ff0a2c 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 {} @@ -47,6 +47,7 @@ private protocol OptionalRelationshipType {} extension Optional: OptionalRelationshipType where Wrapped: RelationshipType {} private protocol _RelationshipType {} +extension MetaRelationship: _RelationshipType {} extension ToOneRelationship: _RelationshipType {} extension ToManyRelationship: _RelationshipType {} @@ -55,40 +56,40 @@ extension TransformedAttribute: _AttributeType {} extension Attribute: _AttributeType {} public extension ResourceObject { - static func check(_ entity: ResourceObject) throws { - var problems = [ResourceObjectCheckError]() - - let attributesMirror = Mirror(reflecting: entity.attributes) - - 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")) - } - } - - let relationshipsMirror = Mirror(reflecting: entity.relationships) - - 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")) - } - } - - guard problems.count == 0 else { - throw ResourceObjectCheckErrors(problems: problems) - } - } + static func check(_ entity: ResourceObject) throws { + var problems = [ResourceObjectCheckError]() + + let attributesMirror = Mirror(reflecting: entity.attributes) + + 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")) + } + } + + let relationshipsMirror = Mirror(reflecting: entity.relationships) + + 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")) + } + } + + 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..cbc8ffe --- /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(\.description), ["same", "same", "same"]) + + XCTAssertEqual(comparison.map(BasicComparison.init(reducing:)), [.same, .same, .same]) + + XCTAssertEqual(comparison.map(BasicComparison.init(reducing:)).map(\.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(\.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(\.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(\.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..a06c7d8 --- /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, Sendable { + let total: Int + + var description: String { + "total: \(total)" + } +} + +fileprivate struct TestLinks: JSONAPI.Links, Sendable { + 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..bd5ca90 --- /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 & Sendable { + let name: Attribute + let age: Attribute + let favoriteColor: Attribute + } + + struct Relationships: JSONAPI.Relationships & Sendable { + 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..b747c5a --- /dev/null +++ b/Tests/JSONAPITestingTests/Comparisons/RelationshipsCompareTests.swift @@ -0,0 +1,231 @@ +// +// 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, + e: t5, + f: t6, + g: t7 + ) + 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, + e: t5_differentLinks, + f: t6_differentMeta, + g: t7_differentMetaAndLinks + ) + let r4 = r3 + + XCTAssertTrue(r3.compare(to: r4).allSatisfy { $0.value == .same }) + + let r5 = TestRelationships( + a: nil, + b: nil, + c: nil, + d: nil, + e: nil, + f: nil, + g: 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, + e: nil, + f: nil, + g: nil + ) + + let r2 = TestRelationships( + a: t1_differentId, + b: nil, + c: t3_differentId, + d: nil, + e: nil, + f: nil, + g: nil + ) + + XCTAssertEqual(r1.compare(to: r2), [ + "a": .different("Id(123)", "Id(999)"), + "b": .same, + "c": .different("123, 456", "999, 1010"), + "d": .same, + "e": .same, + "f": .same, + "g": .same + ]) + } + + func test_differentMetadata() { + let r1 = TestRelationships( + a: nil, + b: t2, + c: nil, + d: t4, + e: nil, + f: t6, + g: t7 + ) + + let r2 = TestRelationships( + a: nil, + b: t2_differentMeta, + c: nil, + d: t4_differentMeta, + e: nil, + f: t6_differentMeta, + g: t7_differentMetaAndLinks + ) + + 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")"#), + "e": .same, + "f": .different(#"("hello: hi", "No Links")"#, #"("hello: there", "No Links")"#), + "g": .different(#"("hello: hi", "link: http://google.com")"#, #"("hello: there", "link: http://hi.com")"#) + ]) + } + + func test_differentLinks() { + let r1 = TestRelationships( + a: nil, + b: t2, + c: nil, + d: t4, + e: t5, + f: nil, + g: nil + ) + + let r2 = TestRelationships( + a: nil, + b: t2_differentLinks, + c: nil, + d: t4_differentLinks, + e: t5_differentLinks, + f: nil, + g: nil + ) + + 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")"#), + "e": .different(#"("No Metadata", "link: http://google.com")"#, #"("No Metadata", "link: http://hi.com")"#), + "f": .same, + "g": .same + ]) + } + + 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 t5 = MetaRelationship(meta: .none, links: .init(link: .init(url: "http://google.com"))) + let t6 = MetaRelationship(meta: .init(hello: "hi"), links: .none) + let t7 = MetaRelationship(meta: .init(hello: "hi"), 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"))) + + let t5_differentLinks = MetaRelationship(meta: .none, links: .init(link: .init(url: "http://hi.com"))) + let t6_differentMeta = MetaRelationship(meta: .init(hello: "there"), links: .none) + let t7_differentMetaAndLinks = MetaRelationship(meta: .init(hello: "there"), links: .init(link: .init(url: "http://hi.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? + let e: MetaRelationship? + let f: MetaRelationship? + let g: MetaRelationship? + } + + 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..abb9cbd --- /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/EntityCheckTests.swift b/Tests/JSONAPITestingTests/EntityCheckTests.swift index 2710d83..861d0b7 100644 --- a/Tests/JSONAPITestingTests/EntityCheckTests.swift +++ b/Tests/JSONAPITestingTests/EntityCheckTests.swift @@ -115,7 +115,7 @@ extension EntityCheckTests { public typealias Attributes = NoAttributes public struct Relationships: JSONAPI.Relationships { - let x: ToOneRelationship + let x: ToOneRelationship let y: Id } } diff --git a/Tests/JSONAPITestingTests/Relationship+LiteralTests.swift b/Tests/JSONAPITestingTests/Relationship+LiteralTests.swift index 4dff645..e0753aa 100644 --- a/Tests/JSONAPITestingTests/Relationship+LiteralTests.swift +++ b/Tests/JSONAPITestingTests/Relationship+LiteralTests.swift @@ -12,16 +12,16 @@ import JSONAPITesting class Relationship_LiteralTests: XCTestCase { func test_NilLiteral() { - XCTAssertEqual(ToOneRelationship(id: nil), nil) + XCTAssertEqual(ToOneRelationship(id: nil), nil) } func test_ArrayLiteral() { - XCTAssertEqual(ToManyRelationship(ids: ["1", "2", "3"]), ["1", "2", "3"]) + XCTAssertEqual(ToManyRelationship(ids: ["1", "2", "3"]), ["1", "2", "3"]) } func test_StringLiteral() { - XCTAssertEqual(ToOneRelationship(id: "123"), "123") - XCTAssertEqual(ToOneRelationship(id: "123"), "123") + XCTAssertEqual(ToOneRelationship(id: "123"), "123") + XCTAssertEqual(ToOneRelationship(id: "123"), "123") } } 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/Test Helpers/String+CreatableRawIdType.swift b/Tests/JSONAPITestingTests/Test Helpers/String+CreatableRawIdType.swift index dd3c8f7..282325a 100644 --- a/Tests/JSONAPITestingTests/Test Helpers/String+CreatableRawIdType.swift +++ b/Tests/JSONAPITestingTests/Test Helpers/String+CreatableRawIdType.swift @@ -7,7 +7,7 @@ import JSONAPI -private var uniqueStringCounter = 0 +nonisolated(unsafe) private var uniqueStringCounter = 0 extension String: CreatableRawIdType { public static func unique() -> String { diff --git a/Tests/JSONAPITestingTests/XCTestManifests.swift b/Tests/JSONAPITestingTests/XCTestManifests.swift deleted file mode 100644 index 3da4318..0000000 --- a/Tests/JSONAPITestingTests/XCTestManifests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import XCTest - -extension Attribute_LiteralTests { - static let __allTests = [ - ("test_ArrayLiteral", test_ArrayLiteral), - ("test_BooleanLiteral", test_BooleanLiteral), - ("test_DictionaryLiteral", test_DictionaryLiteral), - ("test_FloatLiteral", test_FloatLiteral), - ("test_IntegerLiteral", test_IntegerLiteral), - ("test_NilLiteral", test_NilLiteral), - ("test_NullableArrayLiteral", test_NullableArrayLiteral), - ("test_NullableBooleanLiteral", test_NullableBooleanLiteral), - ("test_NullableDictionaryLiteral", test_NullableDictionaryLiteral), - ("test_NullableFloatLiteral", test_NullableFloatLiteral), - ("test_NullableIntegerLiteral", test_NullableIntegerLiteral), - ("test_NullableOptionalArrayLiteral", test_NullableOptionalArrayLiteral), - ("test_NullableOptionalBooleanLiteral", test_NullableOptionalBooleanLiteral), - ("test_NullableOptionalDictionaryLiteral", test_NullableOptionalDictionaryLiteral), - ("test_NullableOptionalFloatLiteral", test_NullableOptionalFloatLiteral), - ("test_NullableOptionalIntegerLiteral", test_NullableOptionalIntegerLiteral), - ("test_NullableOptionalStringLiteral", test_NullableOptionalStringLiteral), - ("test_NullableStringLiteral", test_NullableStringLiteral), - ("test_OptionalArrayLiteral", test_OptionalArrayLiteral), - ("test_OptionalBooleanLiteral", test_OptionalBooleanLiteral), - ("test_OptionalDictionaryLiteral", test_OptionalDictionaryLiteral), - ("test_OptionalFloatLiteral", test_OptionalFloatLiteral), - ("test_OptionalIntegerLiteral", test_OptionalIntegerLiteral), - ("test_OptionalNilLiteral", test_OptionalNilLiteral), - ("test_OptionalStringLiteral", test_OptionalStringLiteral), - ("test_StringLiteral", test_StringLiteral), - ] -} - -extension EntityCheckTests { - static let __allTests = [ - ("test_failsWithBadAttribute", test_failsWithBadAttribute), - ("test_failsWithBadRelationship", test_failsWithBadRelationship), - ("test_failsWithEnumAttributes", test_failsWithEnumAttributes), - ("test_failsWithEnumRelationships", test_failsWithEnumRelationships), - ("test_failsWithOptionalArrayAttribute", test_failsWithOptionalArrayAttribute), - ] -} - -extension Id_LiteralTests { - static let __allTests = [ - ("test_IntegerLiteral", test_IntegerLiteral), - ("test_StringLiteral", test_StringLiteral), - ] -} - -extension Relationship_LiteralTests { - static let __allTests = [ - ("test_ArrayLiteral", test_ArrayLiteral), - ("test_NilLiteral", test_NilLiteral), - ("test_StringLiteral", test_StringLiteral), - ] -} - -#if !os(macOS) -public func __allTests() -> [XCTestCaseEntry] { - return [ - testCase(Attribute_LiteralTests.__allTests), - testCase(EntityCheckTests.__allTests), - testCase(Id_LiteralTests.__allTests), - testCase(Relationship_LiteralTests.__allTests), - ] -} -#endif diff --git a/Tests/JSONAPITests/APIDescription/APIDescriptionTests.swift b/Tests/JSONAPITests/APIDescription/APIDescriptionTests.swift index d5706b6..9640352 100644 --- a/Tests/JSONAPITests/APIDescription/APIDescriptionTests.swift +++ b/Tests/JSONAPITests/APIDescription/APIDescriptionTests.swift @@ -10,6 +10,17 @@ import JSONAPI class APIDescriptionTests: XCTestCase { + func test_init() { + let _ = APIDescription(version: "hello", + meta: .none) + let _ = APIDescription(version: "world", + meta: .init(hello: "there", + number: 2)) + let _ = NoAPIDescription() + + XCTAssertEqual(NoAPIDescription(), NoAPIDescription.none) + } + func test_NoDescriptionString() { XCTAssertEqual(String(describing: NoAPIDescription()), "No JSON:API Object") } @@ -18,12 +29,18 @@ class APIDescriptionTests: XCTestCase { let description = decoded(type: APIDescription.self, data: api_description_empty) XCTAssertEqual(description.version, "1.0") + + test_DecodeEncodeEquality(type: APIDescription.self, + data: api_description_empty) } func test_WithVersion() { let description = decoded(type: APIDescription.self, data: api_description_with_version) XCTAssertEqual(description.version, "1.5") + + test_DecodeEncodeEquality(type: APIDescription.self, + data: api_description_with_version) } func test_WithMeta() { @@ -32,6 +49,9 @@ class APIDescriptionTests: XCTestCase { XCTAssertEqual(description.version, "1.0") XCTAssertEqual(description.meta.hello, "world") XCTAssertEqual(description.meta.number, 10) + + test_DecodeEncodeEquality(type: APIDescription.self, + data: api_description_with_meta) } func test_WithVersionAndMeta() { @@ -40,6 +60,9 @@ class APIDescriptionTests: XCTestCase { XCTAssertEqual(description.version, "2.0") XCTAssertEqual(description.meta.hello, "world") XCTAssertEqual(description.meta.number, 10) + + test_DecodeEncodeEquality(type: APIDescription.self, + data: api_description_with_version_and_meta) } func test_failsMissingMeta() { diff --git a/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift b/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift index 4386d9f..49d4257 100644 --- a/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift +++ b/Tests/JSONAPITests/Attribute/Attribute+FunctorTests.swift @@ -15,7 +15,8 @@ class Attribute_FunctorTests: XCTestCase { XCTAssertNotNil(entity) - XCTAssertEqual(entity?[\.computedString], "Frankie2") + + XCTAssertEqual(entity?.computedString, "Frankie2") } func test_mapOptionalSuccess() { @@ -23,7 +24,7 @@ class Attribute_FunctorTests: XCTestCase { XCTAssertNotNil(entity) - XCTAssertEqual(entity?[\.computedNumber], 22) + XCTAssertEqual(entity?.computedNumber, 22) } func test_mapOptionalFailure() { @@ -31,7 +32,7 @@ class Attribute_FunctorTests: XCTestCase { XCTAssertNotNil(entity) - XCTAssertNil(entity?[\.computedNumber]) + XCTAssertNil(entity?.computedNumber) } } diff --git a/Tests/JSONAPITests/Attribute/AttributeTests.swift b/Tests/JSONAPITests/Attribute/AttributeTests.swift index 22acd71..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)) @@ -77,12 +85,37 @@ extension AttributeTests { } } - enum IntToString: Transformer { + enum IntToString: ReversibleTransformer { public static func transform(_ from: Int) -> String { return String(from) } + + public static func reverse(_ value: String) throws -> Int { + guard let intValue = Int(value) else { + fatalError("Reversed IntToString with invalid String value.") + } + return intValue + } } + enum OptionalIntToOptionalString: ReversibleTransformer { + public static func transform(_ from: Int?) -> String? { + return from.map(String.init) + } + + public static func reverse(_ value: String?) throws -> Int? { + guard let stringValue = value else { + return nil + } + + guard let intValue = Int(stringValue) else { + fatalError("Reversed IntToString with invalid String value.") + } + + return intValue + } + } + enum IntToInt: Transformer { public static func transform(_ from: Int) -> Int { return from + 100 diff --git a/Tests/JSONAPITests/Attribute/TransformerTests.swift b/Tests/JSONAPITests/Attribute/TransformerTests.swift new file mode 100644 index 0000000..1988f40 --- /dev/null +++ b/Tests/JSONAPITests/Attribute/TransformerTests.swift @@ -0,0 +1,51 @@ +// +// TransformerTests.swift +// +// +// Created by Mathew Polzin on 8/2/19. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +class TransformerTests: XCTestCase { + func testIdentityTransform() { + let inString = "hello world" + + XCTAssertNoThrow(try IdentityTransformer.transform(inString)) + XCTAssertEqual(inString, try? IdentityTransformer.transform(inString)) + + XCTAssertNoThrow(try IdentityTransformer.reverse(inString)) + XCTAssertEqual(inString, try? IdentityTransformer.reverse(inString)) + } + + func testValidator() { + let string1 = "hello" + let string2 = "hello world" + + XCTAssertThrowsError(try MoreThanFiveCharValidator.validate(string1)) + XCTAssertThrowsError(try MoreThanFiveCharValidator.transform(string1)) + XCTAssertThrowsError(try MoreThanFiveCharValidator.reverse(string1)) + + XCTAssertNoThrow(try MoreThanFiveCharValidator.validate(string2)) + XCTAssertNoThrow(try MoreThanFiveCharValidator.transform(string2)) + XCTAssertNoThrow(try MoreThanFiveCharValidator.reverse(string2)) + + XCTAssertEqual(string2, try MoreThanFiveCharValidator.transform(string2)) + XCTAssertEqual(string2, try MoreThanFiveCharValidator.reverse(string2)) + } +} + +enum MoreThanFiveCharValidator: Validator { + public static func transform(_ value: String) throws -> String { + guard value.count > 5 else { + throw Error.fewerThanFiveChars + } + return value + } + + enum Error: Swift.Error { + case fewerThanFiveChars + } +} diff --git a/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift b/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift index c7ebbf4..5fca21c 100644 --- a/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift +++ b/Tests/JSONAPITests/Computed Properties/ComputedPropertiesTests.swift @@ -14,7 +14,7 @@ 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)) } @@ -26,7 +26,7 @@ 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") } @@ -66,9 +66,9 @@ extension ComputedPropertiesTests { } public struct Relationships: JSONAPI.Relationships { - public let other: ToOneRelationship + public let other: ToOneRelationship - public var computed: ToOneRelationship { + public var computed: ToOneRelationship { return other } } diff --git a/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift b/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift index 7d6e8f0..8160cbe 100644 --- a/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift +++ b/Tests/JSONAPITests/Custom Attributes Tests/CustomAttributesTests.swift @@ -13,8 +13,8 @@ class CustomAttributesTests: XCTestCase { func test_customDecode() { let entity = decoded(type: CustomAttributeEntity.self, data: customAttributeEntityData) - XCTAssertEqual(entity[\.firstName], "Cool") - XCTAssertEqual(entity[\.name], "Cool Name") + XCTAssertEqual(entity.firstName, "Cool") + XCTAssertEqual(entity.name, "Cool Name") XCTAssertNoThrow(try CustomAttributeEntity.check(entity)) } @@ -26,8 +26,8 @@ class CustomAttributesTests: XCTestCase { func test_customKeysDecode() { let entity = decoded(type: CustomKeysEntity.self, data: customAttributeEntityData) - XCTAssertEqual(entity[\.firstNameSilly], "Cool") - XCTAssertEqual(entity[\.lastNameSilly], "Name") + XCTAssertEqual(entity.firstNameSilly, "Cool") + XCTAssertEqual(entity.lastNameSilly, "Name") XCTAssertNoThrow(try CustomKeysEntity.check(entity)) } diff --git a/Tests/JSONAPITests/Document/DocumentCompoundResourceTests.swift b/Tests/JSONAPITests/Document/DocumentCompoundResourceTests.swift new file mode 100644 index 0000000..f42bff3 --- /dev/null +++ b/Tests/JSONAPITests/Document/DocumentCompoundResourceTests.swift @@ -0,0 +1,502 @@ +// +// DocumentCompoundResourceTests.swift +// +// +// Created by Mathew Polzin on 5/25/20. +// + +import Foundation +import JSONAPITesting +import JSONAPI +import XCTest + +final class DocumentCompoundResourceTests: XCTestCase { + func test_singleDocumentNoIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + + let compoundAuthor = Document.CompoundResource(primary: author, relatives: []) + + let document = Document( + apiDescription: .none, + resource: compoundAuthor, + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.value, author) + XCTAssertEqual(document.body.includes!, .none) + } + + func test_singleDocumentEmptyIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource(primary: book, relatives: []) + + let document = Document( + apiDescription: .none, + resource: compoundBook, + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.value, book) + XCTAssertEqual(document.body.includes!, .none) + } + + func test_singleDocumentWithIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author)] + ) + + let document = Document( + apiDescription: .none, + resource: compoundBook, + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.value, book) + XCTAssertEqual(document.body.includes?.values, [.init(author)]) + } + + func test_singleDocumentWithFilteredIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let ids = [ + DocumentTests.Book.Id(), + DocumentTests.Book.Id(), + DocumentTests.Book.Id() + ] + + let book = DocumentTests.Book( + id: ids[0], + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: [ids[1], ids[2]]), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + id: ids[1], + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: [ids[0], ids[2]]), + collection: nil + ), + meta: .none, + links: .none + ) + + let book3 = DocumentTests.Book( + id: ids[2], + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: [ids[0], ids[1]]), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author), .init(book2), .init(book3)] + ) + + let document = Document( + apiDescription: .none, + resource: compoundBook.filteringRelatives { $0.value is DocumentTests.Author }, + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.value, book) + XCTAssertEqual(document.body.includes?.values, [.init(author)]) + + let document2 = Document( + apiDescription: .none, + resource: compoundBook.filteringRelatives { $0.value is DocumentTests.Book }, + meta: .none, + links: .none + ) + + XCTAssertEqual(document2.body.primaryResource?.value, book) + XCTAssertEqual(document2.body.includes?.values, [.init(book2), .init(book3)]) + + let document3 = Document( + apiDescription: .none, + resource: compoundBook, + meta: .none, + links: .none + ) + + XCTAssertEqual(document3.body.primaryResource?.value, book) + XCTAssertEqual(document3.body.includes?.values, [.init(author), .init(book2), .init(book3)]) + } + + func test_batchDocumentNoIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let author2 = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + + let compoundAuthor = Document.CompoundResource(primary: author, relatives: []) + let compoundAuthor2 = Document.CompoundResource(primary: author2, relatives: []) + + let document = Document( + apiDescription: .none, + resources: [compoundAuthor, compoundAuthor2], + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [author, author2]) + XCTAssertEqual(document.body.includes!, .none) + } + + func test_batchDocumentEmptyIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource(primary: book, relatives: []) + let compoundBook2 = Document.CompoundResource(primary: book2, relatives: []) + + let document = Document( + apiDescription: .none, + resources: [compoundBook, compoundBook2], + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [book, book2]) + XCTAssertEqual(document.body.includes!, .none) + } + + func test_batchDocumentWithIncldues() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author)] + ) + + let compoundBook2 = Document.CompoundResource( + primary: book2, + relatives: [] + ) + + let document = Document( + apiDescription: .none, + resources: [compoundBook, compoundBook2], + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [book, book2]) + XCTAssertEqual(document.body.includes?.values, [.init(author)]) + } + + func test_batchDocumentWithSameIncludeTwice() { + // should only add each unique include once + + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author)] + ) + + // the key in this test case is that both compound resources + // contain the same included relative. + let compoundBook2 = Document.CompoundResource( + primary: book2, + relatives: [.init(author)] + ) + + let document = Document( + apiDescription: .none, + resources: [compoundBook, compoundBook2], + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [book, book2]) + XCTAssertEqual(document.body.includes?.values, [.init(author)]) + } + + func test_batchDocumentWithTwoDifferentIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let author2 = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author2), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author)] + ) + + let compoundBook2 = Document.CompoundResource( + primary: book2, + relatives: [.init(author2)] + ) + + let document = Document( + apiDescription: .none, + resources: [compoundBook, compoundBook2], + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [book, book2]) + XCTAssertEqual(document.body.includes?.values, [.init(author), .init(author2)]) + } + + func test_batchDocumentWithFilteredIncludes() { + let author = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let author2 = DocumentTests.Author( + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let book = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + let book2 = DocumentTests.Book( + attributes: .init(pageCount: 10), + relationships: .init( + author: .init(resourceObject: author2), + series: .init(ids: []), + collection: nil + ), + meta: .none, + links: .none + ) + + typealias Document = JSONAPI.Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError> + + let compoundBook = Document.CompoundResource( + primary: book, + relatives: [.init(author)] + ) + + let compoundBook2 = Document.CompoundResource( + primary: book2, + relatives: [.init(author2)] + ) + + let document = Document( + apiDescription: .none, + resources: [compoundBook, compoundBook2].filteringRelatives { $0.a?.id == author.id }, + meta: .none, + links: .none + ) + + XCTAssertEqual(document.body.primaryResource?.values, [book, book2]) + XCTAssertEqual(document.body.includes?.values, [.init(author)]) + } +} diff --git a/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift b/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift new file mode 100644 index 0000000..8783794 --- /dev/null +++ b/Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift @@ -0,0 +1,254 @@ +// +// 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 resources 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() { + // test that if there is only one possible include, we just find out on one line what expecation failed. + 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), #"Out of the 3 includes in the document, the 3rd one failed to parse: found JSON:API type "not_an_author" but expected "authors""#) + } + } + + func test_include_failure2() { + // test that if there are two possiblie includes, we find out why each of them was not possible to decode. + 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), + "Out of the 3 includes in the document, the 3rd one failed to parse: Found JSON:API type 'not_an_author' but expected one of 'articles', 'authors'" + ) + } + } + + func test_include_failure3() { + // test that if the failed include is at a different index, the other index is reported correctly. + XCTAssertThrowsError( + try testDecoder.decode( + Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError>.self, + from: single_document_some_includes_wrong_type2 + ) + ) { 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), + "Out of the 3 includes in the document, the 2nd one failed to parse: Found JSON:API type 'not_an_author' but expected one of 'articles', '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, Sendable { + 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 2aa8229..559a257 100644 --- a/Tests/JSONAPITests/Document/DocumentTests.swift +++ b/Tests/JSONAPITests/Document/DocumentTests.swift @@ -11,6 +11,108 @@ import Poly class DocumentTests: XCTestCase { + func test_genericDocFunc() { + func test(_ doc: Doc) { + let _ = encoded(value: doc) + + XCTAssert(Doc.PrimaryResourceBody.self == NoResourceBody.self) + XCTAssert(Doc.MetaType.self == NoMetadata.self) + XCTAssert(Doc.LinksType.self == NoLinks.self) + XCTAssert(Doc.IncludeType.self == NoIncludes.self) + XCTAssert(Doc.APIDescription.self == NoAPIDescription.self) + XCTAssert(Doc.Error.self == UnknownJSONAPIError.self) + } + + // Document + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >( + apiDescription: .none, + body: .none, + includes: .none, + meta: .none, + links: .none + )) + + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >( + body: .none + )) + + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >( + errors: [] + )) + + // Document.SuccessDocument + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.SuccessDocument( + apiDescription: .none, + body: .none, + includes: .none, + meta: .none, + links: .none + )) + + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.SuccessDocument( + body: .none + )) + + // Document.ErrorDocument + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.ErrorDocument( + apiDescription: .none, + errors: [] + )) + + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.ErrorDocument( + errors: [] + )) + } + func test_singleDocumentNull() { let document = decoded(type: Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, data: single_document_null) @@ -23,11 +125,34 @@ class DocumentTests: XCTestCase { XCTAssertEqual(document.body.includes?.count, 0) XCTAssertEqual(document.body.links, NoLinks()) XCTAssertEqual(document.apiDescription, .none) + + // SuccessDocument + let document2 = decoded(type: Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.SuccessDocument.self, + data: single_document_null) + + XCTAssert(document == document2) + + XCTAssertFalse(document2.body.isError) + XCTAssertNil(document2.body.errors) + XCTAssertNotNil(document2.body.primaryResource) + XCTAssertEqual(document2.body.meta, NoMetadata()) + XCTAssertNil(document2.body.primaryResource?.value) + XCTAssertEqual(document2.body.includes?.count, 0) + XCTAssertEqual(document2.body.links, NoLinks()) + XCTAssertEqual(document2.apiDescription, .none) + + // ErrorDocument + XCTAssertThrowsError(try JSONDecoder().decode(Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.ErrorDocument.self, + from: single_document_null)) } func test_singleDocumentNull_encode() { test_DecodeEncodeEquality(type: Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.self, data: single_document_null) + + // SuccessDocument + test_DecodeEncodeEquality(type: Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.SuccessDocument.self, + data: single_document_null) } func test_singleDocumentNullWithAPIDescription() { @@ -66,6 +191,14 @@ extension DocumentTests { func test_errorDocumentFailsWithNoAPIDescription() { XCTAssertThrowsError(try JSONDecoder().decode(Document.self, from: error_document_no_metadata)) + + // SuccessDocument + XCTAssertThrowsError(try JSONDecoder().decode(Document.SuccessDocument.self, + from: error_document_no_metadata)) + + // ErrorDocument + XCTAssertThrowsError(try JSONDecoder().decode(Document.ErrorDocument.self, + from: error_document_no_metadata)) } func test_unknownErrorDocumentNoMeta() { @@ -78,15 +211,43 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], .unknown) - XCTAssertEqual(errors.meta, NoMetadata()) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], .unknown) + XCTAssertEqual(meta, NoMetadata()) + XCTAssertEqual(links, NoLinks()) + + // SuccessDocument + XCTAssertThrowsError(try JSONDecoder().decode(Document.SuccessDocument.self, + from: error_document_no_metadata)) + + // ErrorDocument + let document2 = decoded(type: Document.ErrorDocument.self, + data: error_document_no_metadata) + + XCTAssert(document == document2) + + XCTAssertTrue(document2.body.isError) + XCTAssertEqual(document2.body.meta, NoMetadata()) + XCTAssertNil(document2.body.data) + XCTAssertNil(document2.body.primaryResource) + XCTAssertNil(document2.body.includes) + + guard case let .errors(errors2, meta2, links2) = document2.body else { + XCTFail("Needed body to be in errors case but it was not.") + return + } + + XCTAssertEqual(errors2.count, 1) + XCTAssertEqual(errors2, document2.body.errors) + XCTAssertEqual(errors2[0], .unknown) + XCTAssertEqual(meta2, NoMetadata()) + XCTAssertEqual(links2, NoLinks()) } func test_unknownErrorDocumentAddIncludingType() { @@ -140,15 +301,16 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], .unknown) - XCTAssertEqual(errors.meta, NoMetadata()) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], .unknown) + XCTAssertEqual(meta, NoMetadata()) + XCTAssertEqual(links, NoLinks()) } func test_unknownErrorDocumentNoMetaWithAPIDescription_encode() { @@ -164,15 +326,16 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], .unknown) - XCTAssertNil(errors.meta) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], .unknown) + XCTAssertNil(meta) + XCTAssertEqual(links, NoLinks()) } func test_unknownErrorDocumentMissingMeta_encode() { @@ -188,15 +351,16 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], .unknown) - XCTAssertNil(errors.meta) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], .unknown) + XCTAssertNil(meta) + XCTAssertEqual(links, NoLinks()) } func test_unknownErrorDocumentMissingMetaWithAPIDescription_encode() { @@ -212,15 +376,16 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], TestError.basic(.init(code: 1, description: "Boooo!"))) - XCTAssertEqual(errors.meta, NoMetadata()) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], TestError.basic(.init(code: 1, description: "Boooo!"))) + XCTAssertEqual(meta, NoMetadata()) + XCTAssertEqual(links, NoLinks()) } func test_errorDocumentNoMeta_encode() { @@ -238,15 +403,16 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.0[0], TestError.basic(.init(code: 1, description: "Boooo!"))) - XCTAssertEqual(errors.meta, NoMetadata()) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(errors[0], TestError.basic(.init(code: 1, description: "Boooo!"))) + XCTAssertEqual(meta, NoMetadata()) + XCTAssertEqual(links, NoLinks()) } func test_errorDocumentNoMetaWithAPIDescription_encode() { @@ -263,14 +429,15 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(links, NoLinks()) } func test_unknownErrorDocumentWithMeta_encode() { @@ -288,14 +455,15 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(links, NoLinks()) } func test_unknownErrorDocumentWithMetaWithAPIDescription_encode() { @@ -312,14 +480,21 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual( + links, + TestLinks( + link: Link(url: "https://website.com", meta: NoMetadata()), + link2: Link(url: "https://othersite.com", meta: TestLinks.TestMetadata(hello: "world")) + ) + ) XCTAssertEqual(document.body.links?.link.url, "https://website.com") XCTAssertEqual(document.body.links?.link.meta, NoMetadata()) XCTAssertEqual(document.body.links?.link2.url, "https://othersite.com") @@ -341,14 +516,15 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) - XCTAssertEqual(errors.meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertEqual(meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + XCTAssertEqual(links, document.body.links) XCTAssertEqual(document.body.links?.link.url, "https://website.com") XCTAssertEqual(document.body.links?.link.meta, NoMetadata()) XCTAssertEqual(document.body.links?.link2.url, "https://othersite.com") @@ -368,13 +544,15 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertNil(meta) + XCTAssertEqual(links, document.body.links) XCTAssertEqual(document.body.links?.link.url, "https://website.com") XCTAssertEqual(document.body.links?.link.meta, NoMetadata()) XCTAssertEqual(document.body.links?.link2.url, "https://othersite.com") @@ -395,13 +573,15 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertNil(meta) + XCTAssertEqual(links, document.body.links) XCTAssertEqual(document.body.links?.link.url, "https://website.com") XCTAssertEqual(document.body.links?.link.meta, NoMetadata()) XCTAssertEqual(document.body.links?.link2.url, "https://othersite.com") @@ -421,13 +601,15 @@ extension DocumentTests { XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertNil(meta) + XCTAssertNil(links) XCTAssertNil(document.body.links) } @@ -445,13 +627,15 @@ extension DocumentTests { XCTAssertNil(document.body.includes) XCTAssertEqual(document.apiDescription.version, "1.0") - guard case let .errors(errors) = document.body else { + guard case let .errors(errors, meta, links) = document.body else { XCTFail("Needed body to be in errors case but it was not.") return } - XCTAssertEqual(errors.0.count, 1) - XCTAssertEqual(errors.0, document.body.errors) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors, document.body.errors) + XCTAssertNil(meta) + XCTAssertNil(links) XCTAssertNil(document.body.links) } @@ -592,6 +776,27 @@ extension DocumentTests { XCTAssertEqual(documentWithIncludes.body.includes?[Author.self], [author]) } + func test_singleSuccessDocumentNoIncludesAddIncludingType() { + // NOTE distinct from above for being Document.SuccessDocument + let author = Author(id: "1", + attributes: .none, + relationships: .none, + meta: .none, + links: .none) + + let document = decoded(type: Document.SuccessDocument.self, + data: single_document_no_includes) + + let documentWithIncludes = document.including(Includes>(values: [.init(author)])) + + XCTAssert(type(of: documentWithIncludes) == Document, NoAPIDescription, UnknownJSONAPIError>.SuccessDocument.self) + XCTAssertEqual(document.body.errors, documentWithIncludes.body.errors) + XCTAssertEqual(document.body.meta, documentWithIncludes.body.meta) + XCTAssertEqual(document.body.links, documentWithIncludes.body.links) + XCTAssertEqual(document.body.includes, Includes.none) + XCTAssertEqual(documentWithIncludes.body.includes?[Author.self], [author]) + } + func test_singleDocumentNoIncludesWithAPIDescription() { let document = decoded(type: Document, NoMetadata, NoLinks, NoIncludes, TestAPIDescription, UnknownJSONAPIError>.self, data: single_document_no_includes_with_api_description) @@ -820,6 +1025,31 @@ extension DocumentTests { XCTAssertEqual(documentWithIncludes.body.includes?[Author.self], [existingAuthor, newAuthor]) } + func test_singleSuccessDocumentSomeIncludesAddIncludes() { + // NOTE distinct from above for being Document.SuccessDocument + let existingAuthor = Author(id: "33", + attributes: .none, + relationships: .none, + meta: .none, + links: .none) + + let newAuthor = Author(id: "1", + attributes: .none, + relationships: .none, + meta: .none, + links: .none) + + let document = decoded(type: Document, NoMetadata, NoLinks, Include1, NoAPIDescription, UnknownJSONAPIError>.SuccessDocument.self, + data: single_document_some_includes) + + let documentWithIncludes = document.including(.init(values: [.init(newAuthor)])) + + XCTAssertEqual(document.body.errors, documentWithIncludes.body.errors) + XCTAssertEqual(document.body.meta, documentWithIncludes.body.meta) + XCTAssertEqual(document.body.links, documentWithIncludes.body.links) + XCTAssertEqual(documentWithIncludes.body.includes?[Author.self], [existingAuthor, newAuthor]) + } + func test_singleDocumentSomeIncludesWithAPIDescription() { let document = decoded(type: Document, NoMetadata, NoLinks, Include1, TestAPIDescription, UnknownJSONAPIError>.self, data: single_document_some_includes_with_api_description) @@ -926,6 +1156,242 @@ extension DocumentTests { } } +// MARK: Sparse Fieldset Documents + +extension DocumentTests { + func test_sparsePrimaryResource() { + let primaryResource = Book( + attributes: .init(pageCount: 100), + relationships: .init( + author: "1234", + series: [], + collection: .init(meta: .none, links: .init(link: .init(url: "https://more.books.com"), link2: .init(url: "http://extra.com/books", meta: .init(hello: "world")))) + ), + meta: .none, + links: .none + ) + .sparse(with: [.pageCount]) + + let document = Document< + SingleResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >(apiDescription: .none, + body: .init(resourceObject: primaryResource), + includes: .none, + meta: .none, + links: .none) + + let encoded = try! JSONEncoder().encode(document) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [String: Any] + + guard let deserializedObj1 = deserializedObj?["data"] as? [String: Any] else { + XCTFail("Expected to deserialize one object from document data") + return + } + + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, primaryResource.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, Book.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["pageCount"] as? Int, 100) + + XCTAssertNotNil(deserializedObj1["relationships"]) + } + + func test_sparsePrimaryResourceOptionalAndNil() { + let document = Document< + SingleResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >(apiDescription: .none, + body: .init(resourceObject: nil), + includes: .none, + meta: .none, + links: .none) + + let encoded = try! JSONEncoder().encode(document) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [String: Any] + + XCTAssertNotNil(deserializedObj?["data"] as? NSNull) + } + + func test_sparseIncludeFullPrimaryResource() { + let bookInclude = Book( + id: "444", + attributes: .init(pageCount: 113), + relationships: .init( + author: "1234", + series: ["443"], + collection: nil + ), + meta: .none, + links: .none + ) + .sparse(with: []) + + let primaryResource = Book( + id: "443", + attributes: .init(pageCount: 100), + relationships: .init( + author: "1234", + series: ["444"], + collection: nil + ), + meta: .none, + links: .none + ) + + let document = Document< + SingleResourceBody, + NoMetadata, + NoLinks, + Include1, + NoAPIDescription, + UnknownJSONAPIError + >(apiDescription: .none, + body: .init(resourceObject: primaryResource), + includes: .init(values: [.init(bookInclude)]), + meta: .none, + links: .none) + + let encoded = try! JSONEncoder().encode(document) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [String: Any] + + guard let deserializedObj1 = deserializedObj?["data"] as? [String: Any] else { + XCTFail("Expected to deserialize one object from document data") + return + } + + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, primaryResource.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, Book.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["pageCount"] as? Int, 100) + + XCTAssertNotNil(deserializedObj1["relationships"]) + + guard let deserializedIncludes = deserializedObj?["included"] as? [Any], + let deserializedObj2 = deserializedIncludes.first as? [String: Any] else { + XCTFail("Expected to deserialize one incude object") + return + } + + XCTAssertNotNil(deserializedObj2["id"]) + XCTAssertEqual(deserializedObj2["id"] as? String, bookInclude.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj2["type"]) + XCTAssertEqual(deserializedObj2["type"] as? String, Book.jsonType) + + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?.count, 0) + + XCTAssertNotNil(deserializedObj2["relationships"]) + } + + func test_sparseIncludeSparsePrimaryResource() { + let bookInclude = Book( + id: "444", + attributes: .init(pageCount: 113), + relationships: .init( + author: "1234", + series: ["443"], + collection: nil + ), + meta: .none, + links: .none + ) + .sparse(with: []) + + let primaryResource = Book( + id: "443", + attributes: .init(pageCount: 100), + relationships: .init( + author: "1234", + series: ["444"], + collection: nil + ), + meta: .none, + links: .none + ) + .sparse(with: []) + + let document = Document< + SingleResourceBody, + NoMetadata, + NoLinks, + Include1, + NoAPIDescription, + UnknownJSONAPIError + >(apiDescription: .none, + body: .init(resourceObject: primaryResource), + includes: .init(values: [.init(bookInclude)]), + meta: .none, + links: .none) + + let encoded = try! JSONEncoder().encode(document) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [String: Any] + + guard let deserializedObj1 = deserializedObj?["data"] as? [String: Any] else { + XCTFail("Expected to deserialize one object from document data") + return + } + + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, primaryResource.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, Book.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 0) + + XCTAssertNotNil(deserializedObj1["relationships"]) + + guard let deserializedIncludes = deserializedObj?["included"] as? [Any], + let deserializedObj2 = deserializedIncludes.first as? [String: Any] else { + XCTFail("Expected to deserialize one incude object") + return + } + + XCTAssertNotNil(deserializedObj2["id"]) + XCTAssertEqual(deserializedObj2["id"] as? String, bookInclude.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj2["type"]) + XCTAssertEqual(deserializedObj2["type"] as? String, Book.jsonType) + + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?.count, 0) + + XCTAssertNotNil(deserializedObj2["relationships"]) + } +} + // MARK: Poly PrimaryResource Tests extension DocumentTests { func test_singleDocument_PolyPrimaryResource() { @@ -1062,6 +1528,25 @@ extension DocumentTests { XCTAssertEqual(combined.primary.values, bodyData1.primary.values + bodyData2.primary.values) } + public func test_MergeBodyDataMixedMetaLinksErrorAndAPI(){ + let entity1 = Article(attributes: .none, relationships: .init(author: "2"), meta: .none, links: .none) + let entity2 = Article(attributes: .none, relationships: .init(author: "3"), meta: .none, links: .none) + + let bodyData1 = Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError>.Body.Data(primary: .init(resourceObjects: [entity1]), + includes: .none, + meta: .none, + links: .none) + let bodyData2 = Document, TestPageMetadata, TestLinks, NoIncludes, TestAPIDescription, GenericJSONAPIError>.Body.Data(primary: .init(resourceObjects: [entity2]), + includes: .none, + meta: .init(total: 5, limit: 2, offset: 2), + links: .init(link: .init(url: "one"), link2: .init(url: .init(), meta: .init(hello: "world")))) + let combined = bodyData1.merging(bodyData2) + + XCTAssertEqual(combined.primary.values, bodyData1.primary.values + bodyData2.primary.values) + XCTAssertEqual(combined.meta, bodyData2.meta) + XCTAssertEqual(combined.links, bodyData2.links) + } + public func test_MergeBodyDataWithMergeFunctions() { let article1 = Article(attributes: .none, relationships: .init(author: "2"), meta: .none, links: .none) let author1 = Author(id: "2", attributes: .none, relationships: .none, meta: .none, links: .none) @@ -1109,12 +1594,32 @@ extension DocumentTests { typealias Attributes = NoAttributes struct Relationships: JSONAPI.Relationships { - let author: ToOneRelationship + 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 + let collection: MetaRelationship? + } + } + + typealias Book = BasicEntity + struct TestPageMetadata: JSONAPI.Meta { let total: Int let limit: Int @@ -1163,5 +1668,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..09efbdb 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,68 @@ 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_wrong_type2 = """ +{ + "data": { + "id": "1", + "type": "articles", + "relationships": { + "author": { + "data": { + "type": "authors", + "id": "33" + } + } + } + }, + "included": [ + { + "id": "30", + "type": "authors" + }, + { + "id": "31", + "type": "not_an_author" + }, + { + "id": "33", + "type": "authors" + } + ] +} +""".data(using: .utf8)! + let single_document_some_includes_with_api_description = """ { "data": { @@ -452,6 +525,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/Empty Object Decoder/EmptyObjectDecoderTests.swift b/Tests/JSONAPITests/Empty Object Decoder/EmptyObjectDecoderTests.swift new file mode 100644 index 0000000..5e13165 --- /dev/null +++ b/Tests/JSONAPITests/Empty Object Decoder/EmptyObjectDecoderTests.swift @@ -0,0 +1,393 @@ +// +// File.swift +// +// +// Created by Mathew Polzin on 8/2/19. +// + +import XCTest +@testable import JSONAPI + +class EmptyObjectDecoderTests: XCTestCase { + func testEmptyStruct() { + XCTAssertNoThrow(try EmptyStruct.init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try EmptyStructWithCodingKeys.init(from: EmptyObjectDecoder())) + } + + func testEmptyArray() { + XCTAssertNoThrow(try [EmptyStruct].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [String].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Int].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Double].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Bool].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Float].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Int8].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Int16].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Int32].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [Int64].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [UInt].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [UInt8].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [UInt16].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [UInt32].init(from: EmptyObjectDecoder())) + XCTAssertNoThrow(try [UInt64].init(from: EmptyObjectDecoder())) + } + + func testNonEmptyArray() { + XCTAssertThrowsError(try NonEmptyArray.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayString.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayInt.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayDouble.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayBool.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayFloat.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayInt8.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayInt16.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayInt32.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayInt64.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayUInt.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayUInt8.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayUInt16.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayUInt32.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyArrayUInt64.init(from: EmptyObjectDecoder())) + } + + func testNonEmptyStruct() { + XCTAssertThrowsError(try NonEmptyStruct.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructString.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructInt.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructDouble.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructBool.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructFloat.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructInt8.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructInt16.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructInt32.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructInt64.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructUInt.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructUInt8.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructUInt16.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructUInt32.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try NonEmptyStructUInt64.init(from: EmptyObjectDecoder())) + } + + func testWantingNil() { + XCTAssertThrowsError(try StructWithNil.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try ArrayWithNil.init(from: EmptyObjectDecoder())) + } + + func testWantingSingleValue() { + XCTAssertThrowsError(try StructWithSingleValue.init(from: EmptyObjectDecoder())) + } + + func testWantingNestedKeyed() { + XCTAssertThrowsError(try StructWithNestedKeyedCall.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try ArrayWithNestedKeyedCall.init(from: EmptyObjectDecoder())) + } + + func testWantingNestedUnkeyed() { + XCTAssertThrowsError(try StructWithNestedUnkeyedCall.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try ArrayWithNestedUnkeyedCall.init(from: EmptyObjectDecoder())) + } + + func testWantsSuper() { + XCTAssertThrowsError(try StructWithUnkeyedSuper.init(from: EmptyObjectDecoder())) + XCTAssertThrowsError(try StructWithKeyedSuper.init(from: EmptyObjectDecoder())) + + XCTAssertThrowsError(try ArrayWithUnkeyedSuper.init(from: EmptyObjectDecoder())) + } + + func testKeysAndCodingPath() { + XCTAssertEqual(try? EmptyObjectDecoder().container(keyedBy: EmptyStructWithCodingKeys.CodingKeys.self).allKeys.count, 0) + XCTAssertEqual(try? EmptyObjectDecoder().container(keyedBy: EmptyStructWithCodingKeys.CodingKeys.self).codingPath.count, 0) + + XCTAssertEqual(try? EmptyObjectDecoder().unkeyedContainer().codingPath.count, 0) + XCTAssertEqual(try? EmptyObjectDecoder().unkeyedContainer().currentIndex, 0) + XCTAssertEqual(try? EmptyObjectDecoder().unkeyedContainer().count, 0) + } +} + +// MARK: - struct + +struct EmptyStruct: Decodable { + +} + +struct EmptyStructWithCodingKeys: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + init(from decoder: Decoder) throws {} +} + +struct NonEmptyStruct: Decodable { + let value: T +} + +struct NonEmptyStructString: Decodable { + let value: String +} + +struct NonEmptyStructInt: Decodable { + let value: Int +} + +struct NonEmptyStructDouble: Decodable { + let value: Double +} + +struct NonEmptyStructBool: Decodable { + let value: Bool +} + +struct NonEmptyStructFloat: Decodable { + let value: Float +} + +struct NonEmptyStructInt8: Decodable { + let value: Int8 +} + +struct NonEmptyStructInt16: Decodable { + let value: Int16 +} + +struct NonEmptyStructInt32: Decodable { + let value: Int32 +} + +struct NonEmptyStructInt64: Decodable { + let value: Int64 +} + +struct NonEmptyStructUInt: Decodable { + let value: UInt +} + +struct NonEmptyStructUInt8: Decodable { + let value: UInt8 +} + +struct NonEmptyStructUInt16: Decodable { + let value: UInt16 +} + +struct NonEmptyStructUInt32: Decodable { + let value: UInt32 +} + +struct NonEmptyStructUInt64: Decodable { + let value: UInt64 +} + +struct StructWithNil: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let _ = try container.decodeNil(forKey: .hello) + } +} + +struct StructWithSingleValue: Decodable { + let value: String + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + value = try container.decode(String.self) + } +} + +struct StructWithNestedKeyedCall: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + enum NestedKeys: String, CodingKey { + case world + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let _ = try container.nestedContainer(keyedBy: NestedKeys.self, forKey: .hello) + } +} + +struct StructWithNestedUnkeyedCall: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let _ = try container.nestedUnkeyedContainer(forKey: .hello) + } +} + +struct StructWithUnkeyedSuper: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + init(from decoder: Decoder) throws { + let _ = try decoder.container(keyedBy: CodingKeys.self).superDecoder() + } +} + +struct StructWithKeyedSuper: Decodable { + enum CodingKeys: String, CodingKey { + case hello + } + + init(from decoder: Decoder) throws { + let _ = try decoder.container(keyedBy: CodingKeys.self).superDecoder(forKey: .hello) + } +} + +// MARK: - array + +struct NonEmptyArray: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(T.self) + } +} + +struct NonEmptyArrayString: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(String.self) + } +} + +struct NonEmptyArrayInt: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Int.self) + } +} + +struct NonEmptyArrayDouble: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Double.self) + } +} + +struct NonEmptyArrayBool: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Bool.self) + } +} + +struct NonEmptyArrayFloat: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Float.self) + } +} + +struct NonEmptyArrayInt8: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Int8.self) + } +} + +struct NonEmptyArrayInt16: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Int16.self) + } +} + +struct NonEmptyArrayInt32: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Int32.self) + } +} + +struct NonEmptyArrayInt64: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(Int64.self) + } +} + +struct NonEmptyArrayUInt: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(UInt.self) + } +} + +struct NonEmptyArrayUInt8: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(UInt8.self) + } +} + +struct NonEmptyArrayUInt16: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(UInt16.self) + } +} + +struct NonEmptyArrayUInt32: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(UInt32.self) + } +} + +struct NonEmptyArrayUInt64: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.decode(UInt64.self) + } +} + +struct ArrayWithNil: Decodable { + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let _ = try container.decodeNil() + } +} + +struct ArrayWithNestedKeyedCall: Decodable { + + enum NestedKeys: String, CodingKey { + case world + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let _ = try container.nestedContainer(keyedBy: NestedKeys.self) + } +} + +struct ArrayWithNestedUnkeyedCall: Decodable { + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let _ = try container.nestedUnkeyedContainer() + } +} + +struct ArrayWithUnkeyedSuper: Decodable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let _ = try container.superDecoder() + } +} diff --git a/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift new file mode 100644 index 0000000..89dc188 --- /dev/null +++ b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift @@ -0,0 +1,131 @@ +// +// BasicJSONAPIErrorTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 9/29/19. +// + +import Foundation +import JSONAPI +import XCTest +import Poly + +final class BasicJSONAPIErrorTests: XCTestCase { + func test_initAndEquality() { + let unknown1 = BasicJSONAPIError.unknown + let unknown2 = BasicJSONAPIError.unknownError + XCTAssertEqual(unknown1, unknown2) + let unknown3 = BasicJSONAPIError.unknownError + XCTAssertEqual(unknown3, .unknown) + + let _ = BasicJSONAPIError.error(.init(id: nil, + status: nil, + code: nil, + title: nil, + detail: nil, + source: nil)) + let _ = BasicJSONAPIError.error(.init(id: nil, + status: nil, + code: nil, + title: nil, + detail: nil, + source: nil)) + + let intError = BasicJSONAPIError.error(.init(id: 2, + status: nil, + code: nil, + title: nil, + detail: nil, + source: nil)) + XCTAssertEqual(intError.payload?.id, 2) + XCTAssertNotEqual(intError, unknown3) + + let stringError = BasicJSONAPIError.error(.init(id: "hello", + status: nil, + code: nil, + title: nil, + detail: nil, + source: nil)) + XCTAssertEqual(stringError.payload?.id, "hello") + XCTAssertNotEqual(stringError, unknown1) + + let wellPopulatedError = BasicJSONAPIError.error(.init(id: 10, + status: "404", + code: "12", + title: "Missing", + detail: "Resource was not found", + source: .init(pointer: "/data/attributes/id", parameter: "id"))) + XCTAssertEqual(wellPopulatedError.payload?.id, 10) + XCTAssertEqual(wellPopulatedError.payload?.status, "404") + XCTAssertEqual(wellPopulatedError.payload?.code, "12") + XCTAssertEqual(wellPopulatedError.payload?.title, "Missing") + XCTAssertEqual(wellPopulatedError.payload?.detail, "Resource was not found") + XCTAssertEqual(wellPopulatedError.payload?.source?.pointer, "/data/attributes/id") + XCTAssertEqual(wellPopulatedError.payload?.source?.parameter, "id") + + XCTAssertNotEqual(wellPopulatedError, intError) + } + + func test_definedFields() { + let unpopulatedError = BasicJSONAPIError.error(.init(id: nil, + status: nil, + code: nil, + title: nil, + detail: nil, + source: nil)) + XCTAssertEqual(unpopulatedError.definedFields.count, 0) + + let wellPopulatedError = BasicJSONAPIError.error(.init(id: 10, + status: "404", + code: "12", + title: "Missing", + detail: "Resource was not found", + source: .init(pointer: "/data/attributes/id", parameter: "id"))) + XCTAssertEqual(wellPopulatedError.definedFields.count, 7) + XCTAssertEqual(wellPopulatedError.definedFields["id"], "10") + XCTAssertEqual(wellPopulatedError.definedFields["status"], "404") + XCTAssertEqual(wellPopulatedError.definedFields["code"], "12") + XCTAssertEqual(wellPopulatedError.definedFields["title"], "Missing") + XCTAssertEqual(wellPopulatedError.definedFields["detail"], "Resource was not found") + XCTAssertEqual(wellPopulatedError.definedFields["pointer"], "/data/attributes/id") + XCTAssertEqual(wellPopulatedError.definedFields["parameter"], "id") + } + + func test_decodeAFewExamples() { + let datas = [ +""" +{ + "id": "hello" +} +""", +""" +{ + "id": 1234 +} +""", +""" +{ + "status": "404", + "title": "Missing", + "links": { + "about": "https://google.com" + } +} +""", +""" +{ + "status": 404 +} +""" + ].map { $0.data(using: .utf8)! } + + let errors = datas + .map { decoded(type: BasicJSONAPIError>.self, data: $0) } + + XCTAssertEqual(errors[0].payload?.id, .init("hello")) + XCTAssertEqual(errors[1].payload?.id, .init(1234)) + XCTAssertEqual(errors[2].payload?.status, "404") + XCTAssertEqual(errors[2].payload?.title, "Missing") + XCTAssertEqual(errors[3], .unknown) + } +} diff --git a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift new file mode 100644 index 0000000..6f095cc --- /dev/null +++ b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift @@ -0,0 +1,148 @@ +// +// GenericJSONAPIErrorTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 9/29/19. +// + +import Foundation +import JSONAPI +import XCTest + +final class GenericJSONAPIErrorTests: XCTestCase { + func test_initAndEquality() { + let unknown1 = TestGenericJSONAPIError.unknown + let unknown2 = TestGenericJSONAPIError.unknownError + XCTAssertEqual(unknown1, unknown2) + + let known1 = TestGenericJSONAPIError.error(.init(hello: "there", world: 3)) + let known2 = TestGenericJSONAPIError.error(.init(hello: "there", world: nil)) + XCTAssertNotEqual(unknown1, known1) + XCTAssertNotEqual(unknown1, known2) + XCTAssertNotEqual(known1, known2) + } + + func test_decodeKnown() { + let datas = [ +""" +{ + "hello": "world" +} +""", +""" +{ + "hello": "there", + "world": 2 +} +""", +""" +{ + "hello": "three", + "world": null +} +""" + ].map { $0.data(using: .utf8)! } + + let errors = datas + .map { decoded(type: TestGenericJSONAPIError.self, data: $0) } + + XCTAssertEqual(errors[0], .error(TestPayload(hello: "world", world: nil))) + + XCTAssertEqual(errors[1], .error(TestPayload(hello: "there", world: 2))) + + XCTAssertEqual(errors[2], .error(TestPayload(hello: "three", world: nil))) + } + + func test_decodeUnknown() { + let data = +""" +{ + "world": 2 +} +""".data(using: .utf8)! + + let error = decoded(type: TestGenericJSONAPIError.self, data: data) + + XCTAssertEqual(error, .unknown) + XCTAssertEqual(String(describing: error), "unknown error") + } + + func test_encode() { + let datas = [ +""" +{ + "hello": "world" +} +""", +""" +{ + "hello": "there", + "world": 2 +} +""", +""" +{ + "hello": "three", + "world": null +} +""" + ].map { $0.data(using: .utf8)! } + + datas.forEach { data in + test_DecodeEncodeEquality(type: TestGenericJSONAPIError.self, data: data) + } + } + + func test_encodeUnknown() { + let error = TestGenericJSONAPIError.unknownError + + let encodedError = encoded(value: ["errors": [error]]) + + XCTAssertEqual(String(data: encodedError, encoding: .utf8)!, #"{"errors":["unknown"]}"#) + } + + func test_payloadAccess() { + let error1 = TestGenericJSONAPIError.error(.init(hello: "world", world: 3)) + let error2 = TestGenericJSONAPIError.error(.init(hello: "there", world: nil)) + let error3 = TestGenericJSONAPIError.unknown + + XCTAssertEqual(error1.payload?.hello, "world") + XCTAssertEqual(error1.payload?.world, 3) + XCTAssertEqual(error2.payload?.hello, "there") + XCTAssertNil(error2.payload?.world) + XCTAssertNil(error3.payload?.hello) + XCTAssertNil(error3.payload?.world) + } + + func test_definedFields() { + let error1 = TestGenericJSONAPIError.error(.init(hello: "world", world: 3)) + let error2 = TestGenericJSONAPIError.error(.init(hello: "there", world: nil)) + let error3 = TestGenericJSONAPIError.unknown + + XCTAssertEqual(error1.definedFields.count, 2) + XCTAssertEqual(error2.definedFields.count, 1) + XCTAssertEqual(error3.definedFields.count, 0) + + XCTAssertEqual(error1.definedFields["hello"], "world") + XCTAssertEqual(error1.definedFields["world"], "3") + XCTAssertEqual(error2.definedFields["hello"], "there") + XCTAssertNil(error2.definedFields["world"]) + XCTAssertNil(error3.definedFields["hello"]) + XCTAssertNil(error3.definedFields["world"]) + } +} + +private struct TestPayload: Codable, Equatable, ErrorDictType, Sendable { + let hello: String + let world: Int? + + public var definedFields: [String : String] { + let keysAndValues = [ + ("hello", hello), + world.map { ("world", String($0)) } + ].compactMap { $0 } + return Dictionary(uniqueKeysWithValues: keysAndValues) + } +} + +private typealias TestGenericJSONAPIError = GenericJSONAPIError diff --git a/Tests/JSONAPITests/Includes/IncludeTests.swift b/Tests/JSONAPITests/Includes/IncludeTests.swift index 7f8f0f8..e4a24c5 100644 --- a/Tests/JSONAPITests/Includes/IncludeTests.swift +++ b/Tests/JSONAPITests/Includes/IncludeTests.swift @@ -12,15 +12,15 @@ class IncludedTests: XCTestCase { func test_zeroIncludes() { let includes = decoded(type: Includes.self, data: two_same_type_includes) - + XCTAssertEqual(includes.count, 0) } func test_zeroIncludes_encode() { - XCTAssertThrowsError(try JSONEncoder().encode(decoded(type: Includes.self, - data: two_same_type_includes))) - } - + XCTAssertThrowsError(try JSONEncoder().encode(decoded(type: Includes.self, + data: two_same_type_includes))) + } + func test_OneInclude() { let includes = decoded(type: Includes>.self, data: one_include) @@ -32,7 +32,7 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: one_include) } - + func test_TwoSameIncludes() { let includes = decoded(type: Includes>.self, data: two_same_type_includes) @@ -57,7 +57,7 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: two_different_type_includes) } - + func test_ThreeDifferentIncludes() { let includes = decoded(type: Includes>.self, data: three_different_type_includes) @@ -71,11 +71,21 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: three_different_type_includes) } - + + func test_OneKnownAndTwoGenericIncludes() { + let includes = decoded(type: Includes>.self, + data: three_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntityOther.self].count, 2) + XCTAssert(includes[TestEntityOther.self].contains { $0.type == "test_entity2"}) + XCTAssert(includes[TestEntityOther.self].contains { $0.type == "test_entity4"}) + } + func test_FourDifferentIncludes() { let includes = decoded(type: Includes>.self, data: four_different_type_includes) - + XCTAssertEqual(includes[TestEntity.self].count, 1) XCTAssertEqual(includes[TestEntity2.self].count, 1) XCTAssertEqual(includes[TestEntity4.self].count, 1) @@ -86,11 +96,11 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: four_different_type_includes) } - + func test_FiveDifferentIncludes() { let includes = decoded(type: Includes>.self, data: five_different_type_includes) - + XCTAssertEqual(includes[TestEntity.self].count, 1) XCTAssertEqual(includes[TestEntity2.self].count, 1) XCTAssertEqual(includes[TestEntity3.self].count, 1) @@ -102,11 +112,11 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: five_different_type_includes) } - + func test_SixDifferentIncludes() { let includes = decoded(type: Includes>.self, data: six_different_type_includes) - + XCTAssertEqual(includes[TestEntity.self].count, 1) XCTAssertEqual(includes[TestEntity2.self].count, 1) XCTAssertEqual(includes[TestEntity3.self].count, 1) @@ -176,8 +186,151 @@ class IncludedTests: XCTestCase { test_DecodeEncodeEquality(type: Includes>.self, data: nine_different_type_includes) } + + func test_TenDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: ten_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + } + + func test_TenDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: ten_different_type_includes) + } + + func test_ElevenDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: eleven_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + XCTAssertEqual(includes[TestEntity11.self].count, 1) + } + + func test_ElevenDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: eleven_different_type_includes) + } + + func test_TwelveDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: twelve_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + XCTAssertEqual(includes[TestEntity11.self].count, 1) + XCTAssertEqual(includes[TestEntity12.self].count, 1) + } + + func test_TwelveDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: twelve_different_type_includes) + } + + func test_ThirteenDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: thirteen_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + XCTAssertEqual(includes[TestEntity11.self].count, 1) + XCTAssertEqual(includes[TestEntity12.self].count, 1) + XCTAssertEqual(includes[TestEntity13.self].count, 1) + } + + func test_ThirteenDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: thirteen_different_type_includes) + } + + func test_FourteenDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: fourteen_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + XCTAssertEqual(includes[TestEntity11.self].count, 1) + XCTAssertEqual(includes[TestEntity12.self].count, 1) + XCTAssertEqual(includes[TestEntity13.self].count, 1) + XCTAssertEqual(includes[TestEntity14.self].count, 1) + } + + func test_FourteenDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: fourteen_different_type_includes) + } + + func test_FifteenDifferentIncludes() { + let includes = decoded(type: Includes>.self, + data: fifteen_different_type_includes) + + XCTAssertEqual(includes[TestEntity.self].count, 1) + XCTAssertEqual(includes[TestEntity2.self].count, 1) + XCTAssertEqual(includes[TestEntity3.self].count, 1) + XCTAssertEqual(includes[TestEntity4.self].count, 1) + XCTAssertEqual(includes[TestEntity5.self].count, 1) + XCTAssertEqual(includes[TestEntity6.self].count, 1) + XCTAssertEqual(includes[TestEntity7.self].count, 1) + XCTAssertEqual(includes[TestEntity8.self].count, 1) + XCTAssertEqual(includes[TestEntity9.self].count, 1) + XCTAssertEqual(includes[TestEntity10.self].count, 1) + XCTAssertEqual(includes[TestEntity11.self].count, 1) + XCTAssertEqual(includes[TestEntity12.self].count, 1) + XCTAssertEqual(includes[TestEntity13.self].count, 1) + XCTAssertEqual(includes[TestEntity14.self].count, 1) + XCTAssertEqual(includes[TestEntity15.self].count, 1) + } + + func test_FifteenDifferentIncludes_encode() { + test_DecodeEncodeEquality(type: Includes>.self, + data: fifteen_different_type_includes) + } } +// MARK: - Appending + extension IncludedTests { func test_appending() { let include1 = Includes>(values: [.init(TestEntity8(attributes: .none, relationships: .none, meta: .none, links: .none)), .init(TestEntity9(attributes: .none, relationships: .none, meta: .none, links: .none)), .init(TestEntity8(attributes: .none, relationships: .none, meta: .none, links: .none))]) @@ -190,37 +343,203 @@ extension IncludedTests { } } +// MARK: - Sparse Fieldsets + +extension IncludedTests { + func test_OneSparseIncludeType() { + let include1 = TestEntity(attributes: .init(foo: "hello", + bar: 10), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: [.foo]) + + let includes: Includes> = .init(values: [.init(include1)]) + + let encoded = try! JSONEncoder().encode(includes) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [Any] + + XCTAssertEqual(deserializedObj?.count, 1) + + guard let deserializedObj1 = deserializedObj?.first as? [String: Any] else { + XCTFail("Expected to deserialize one object from array") + return + } + + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, include1.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, TestEntity.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["foo"] as? String, "hello") + + XCTAssertNil(deserializedObj1["relationships"]) + } + + func test_TwoSparseIncludeTypes() { + let include1 = TestEntity(attributes: .init(foo: "hello", + bar: 10), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: [.foo]) + + let include2 = TestEntity2(attributes: .init(foo: "world", + bar: 2), + relationships: .init(entity1: "1234"), + meta: .none, + links: .none) + .sparse(with: [.bar]) + + let includes: Includes> = .init(values: [.init(include1), .init(include2)]) + + let encoded = try! JSONEncoder().encode(includes) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [Any] + + XCTAssertEqual(deserializedObj?.count, 2) + + guard let deserializedObj1 = deserializedObj?.first as? [String: Any], + let deserializedObj2 = deserializedObj?.last as? [String: Any] else { + XCTFail("Expected to deserialize two objects from array") + return + } + + // first include + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, include1.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, TestEntity.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["foo"] as? String, "hello") + + XCTAssertNil(deserializedObj1["relationships"]) + + // second include + XCTAssertNotNil(deserializedObj2["id"]) + XCTAssertEqual(deserializedObj2["id"] as? String, include2.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj2["type"]) + XCTAssertEqual(deserializedObj2["type"] as? String, TestEntity2.jsonType) + + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?["bar"] as? Int, 2) + + XCTAssertNotNil(deserializedObj2["relationships"]) + XCTAssertNotNil((deserializedObj2["relationships"] as? [String: Any])?["entity1"]) + } + + func test_ComboSparseAndFullIncludeTypes() { + let include1 = TestEntity(attributes: .init(foo: "hello", + bar: 10), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: [.foo]) + + let include2 = TestEntity2(attributes: .init(foo: "world", + bar: 2), + relationships: .init(entity1: "1234"), + meta: .none, + links: .none) + + let includes: Includes> = .init(values: [.init(include1), .init(include2)]) + + let encoded = try! JSONEncoder().encode(includes) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [Any] + + XCTAssertEqual(deserializedObj?.count, 2) + + guard let deserializedObj1 = deserializedObj?.first as? [String: Any], + let deserializedObj2 = deserializedObj?.last as? [String: Any] else { + XCTFail("Expected to deserialize two objects from array") + return + } + + // first include + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, include1.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, TestEntity.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["foo"] as? String, "hello") + + XCTAssertNil(deserializedObj1["relationships"]) + + // second include + XCTAssertNotNil(deserializedObj2["id"]) + XCTAssertEqual(deserializedObj2["id"] as? String, include2.id.rawValue) + + XCTAssertNotNil(deserializedObj2["type"]) + XCTAssertEqual(deserializedObj2["type"] as? String, TestEntity2.jsonType) + + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?.count, 2) + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?["foo"] as? String, "world") + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?["bar"] as? Int, 2) + + XCTAssertNotNil(deserializedObj2["relationships"]) + XCTAssertNotNil((deserializedObj2["relationships"] as? [String: Any])?["entity1"]) + } +} + // MARK: - Test types extension IncludedTests { - enum TestEntityType: ResourceObjectDescription { + enum TestEntityType: ResourceObjectDescription { - typealias Relationships = NoRelationships + typealias Relationships = NoRelationships - public static var jsonType: String { return "test_entity1" } + public static var jsonType: String { return "test_entity1" } - public struct Attributes: JSONAPI.Attributes { - let foo: Attribute - let bar: Attribute - } - } + public struct Attributes: JSONAPI.SparsableAttributes { + let foo: Attribute + let bar: Attribute - typealias TestEntity = BasicEntity + public enum CodingKeys: String, Equatable, CodingKey { + case foo + case bar + } + } + } - enum TestEntityType2: ResourceObjectDescription { + typealias TestEntity = BasicEntity - public static var jsonType: String { return "test_entity2" } + enum TestEntityType2: ResourceObjectDescription { - public struct Relationships: JSONAPI.Relationships { - let entity1: ToOneRelationship - } + public static var jsonType: String { return "test_entity2" } - public struct Attributes: JSONAPI.Attributes { - let foo: Attribute - let bar: Attribute - } - } + public struct Relationships: JSONAPI.Relationships { + let entity1: ToOneRelationship + } + + public struct Attributes: JSONAPI.SparsableAttributes { + let foo: Attribute + let bar: Attribute - typealias TestEntity2 = BasicEntity + public enum CodingKeys: String, Equatable, CodingKey { + case foo + case bar + } + } + } + + typealias TestEntity2 = BasicEntity enum TestEntityType3: ResourceObjectDescription { @@ -229,8 +548,8 @@ extension IncludedTests { public static var jsonType: String { return "test_entity3" } public struct Relationships: JSONAPI.Relationships { - let entity1: ToOneRelationship - let entity2: ToManyRelationship + let entity1: ToOneRelationship + let entity2: ToManyRelationship } } @@ -265,7 +584,7 @@ extension IncludedTests { public static var jsonType: String { return "test_entity6" } struct Relationships: JSONAPI.Relationships { - let entity4: ToOneRelationship + let entity4: ToOneRelationship } } @@ -303,4 +622,116 @@ extension IncludedTests { } typealias TestEntity9 = BasicEntity + + enum TestEntityType10: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity10" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity10 = BasicEntity + + enum TestEntityType11: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity11" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity11 = BasicEntity + + enum TestEntityType12: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity12" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity12 = BasicEntity + + enum TestEntityType13: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity13" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity13 = BasicEntity + + enum TestEntityType14: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity14" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity14 = BasicEntity + + enum TestEntityType15: ResourceObjectDescription { + + typealias Attributes = NoAttributes + + public static var jsonType: String { return "test_entity15" } + + typealias Relationships = NoRelationships + } + + typealias TestEntity15 = BasicEntity + + enum TestEntityTypeOther: ResourceObjectProxyDescription { + public static var jsonType: String { return "_generic" } + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships + } + + struct TestEntityOther: ResourceObjectProxy, Codable { + typealias Description = TestEntityTypeOther + typealias EntityRawIdType = String + + public let type : String + public let id : JSONAPI.Id + + public let attributes = NoAttributes() + public let relationships = NoRelationships() + + enum CodingKeys: CodingKey { + case id + case type + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let type: String + do { + type = try container.decode(String.self, forKey: .type) + } catch let error as DecodingError { + throw ResourceObjectDecodingError(error, jsonAPIType: Self.jsonType) + ?? error + } + + id = .init(rawValue: try container.decode(String.self, forKey: .id)) + self.type = type + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(type, forKey: .type) + + try container.encode(id, forKey: .id) + } + } } diff --git a/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift b/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift new file mode 100644 index 0000000..c5d0d39 --- /dev/null +++ b/Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift @@ -0,0 +1,138 @@ +// +// IncludesDecodingErrorTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 11/14/19. +// + +import XCTest +import JSONAPI + +final class IncludesDecodingErrorTests: XCTestCase { + func test_unexpectedIncludeType() { + 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:)), + "Out of the 3 includes in the document, the 3rd one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'" + ) + } + + // now test that we get the same error with a different total include count from a different test stub + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: four_different_type_includes)) { (error2: Error) -> Void in + XCTAssertEqual( + (error2 as? IncludesDecodingError).map(String.init(describing:)), + "Out of the 4 includes in the document, the 3rd one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'" + ) + } + + // and with six total includes + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: six_includes_one_bad_type)) { (error2: Error) -> Void in + XCTAssertEqual( + (error2 as? IncludesDecodingError).map(String.init(describing:)), + "Out of the 6 includes in the document, the 5th one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'" + ) + } + + // and with a number of total includes between 10 and 19 + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: eleven_includes_one_bad_type)) { (error2: Error) -> Void in + XCTAssertEqual( + (error2 as? IncludesDecodingError).map(String.init(describing:)), + "Out of the 11 includes in the document, the 10th one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'" + ) + } + + // and finally with a larger number of total includes + XCTAssertThrowsError(try testDecoder.decode(Includes>.self, from: twenty_two_includes_one_bad_type)) { (error2: Error) -> Void in + XCTAssertEqual( + (error2 as? IncludesDecodingError).map(String.init(describing:)), + "Out of the 22 includes in the document, the 21st one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'" + ) + } + } + + func test_missingProperty() { + XCTAssertThrowsError( + try testDecoder.decode( + Includes>.self, + from: three_includes_one_missing_attributes + ) + ) { (error: Error) -> Void in + XCTAssertEqual( + (error as? IncludesDecodingError).map(String.init(describing:)), + "Out of the 3 includes in the document, the 3rd one failed to parse: 'foo' attribute is required and missing." + ) + } + } +} + +// 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 10fd6a3..df47120 100644 --- a/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift +++ b/Tests/JSONAPITests/Includes/stubs/IncludeStubs.swift @@ -7,65 +7,65 @@ let one_include = """ [ -{ -"type": "test_entity1", -"id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", -"attributes": { -"foo": "Hello", -"bar": 123 -} -} + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + } ] """.data(using: .utf8)! let two_same_type_includes = """ [ -{ -"type": "test_entity1", -"id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", -"attributes": { -"foo": "Hello", -"bar": 123 -} -}, -{ -"type": "test_entity1", -"id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", -"attributes": { -"foo": "World", -"bar": 456 -} -} + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity1", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + } + } ] """.data(using: .utf8)! let two_different_type_includes = """ [ -{ -"type": "test_entity1", -"id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", -"attributes": { -"foo": "Hello", -"bar": 123 -} -}, -{ -"type": "test_entity2", -"id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", -"attributes": { -"foo": "World", -"bar": 456 -}, -"relationships": { -"entity1": { -"data": { -"type": "test_entity1", -"id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" -} -} -} -} + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + } ] """.data(using: .utf8)! @@ -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)! @@ -512,3 +512,957 @@ let nine_different_type_includes = """ } ] """.data(using: .utf8)! + +let ten_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + } +] +""".data(using: .utf8)! + +let eleven_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + }, + { + "type": "test_entity11", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C0B" + } +] +""".data(using: .utf8)! + +let twelve_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + }, + { + "type": "test_entity11", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C0B" + }, + { + "type": "test_entity12", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C00" + } +] +""".data(using: .utf8)! + +let thirteen_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + }, + { + "type": "test_entity11", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C0B" + }, + { + "type": "test_entity12", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C00" + }, + { + "type": "test_entity13", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C01" + } +] +""".data(using: .utf8)! + +let fourteen_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + }, + { + "type": "test_entity11", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C0B" + }, + { + "type": "test_entity12", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C00" + }, + { + "type": "test_entity13", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C01" + }, + { + "type": "test_entity14", + "id": "264B3B69-4DF3-312F-B32E-A0C9E44F26C01" + } +] +""".data(using: .utf8)! + +let fifteen_different_type_includes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity3", + "id": "11223B69-4DF1-467F-B52E-B0C9E44FC443", + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + }, + "entity2": { + "data": [ + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333" + } + ] + } + } + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + }, + { + "type": "test_entity5", + "id": "A24B3B69-4DF1-467F-B52E-B0C9E44F436A" + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity7", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity8", + "id": "364B3B69-4DF1-222F-B52E-B0C9E44F266F" + }, + { + "type": "test_entity9", + "id": "364B3B69-4DF1-218F-B52E-B0C9E44F2661" + }, + { + "type": "test_entity10", + "id": "264B3B69-4DF1-212F-B52E-B0C9E44F2660" + }, + { + "type": "test_entity11", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C0B" + }, + { + "type": "test_entity12", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C00" + }, + { + "type": "test_entity13", + "id": "264B3B69-4DF3-212F-B32E-A0C9E44F26C01" + }, + { + "type": "test_entity14", + "id": "264B3B69-4DF3-312F-B32E-A0C9E44F26C01" + }, + { + "type": "test_entity15", + "id": "264B3B69-4DF3-312A-B32E-A0C9E44F26C01" + } +] +""".data(using: .utf8)! + + +let three_includes_one_missing_attributes = """ +[ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + } +] +""".data(using: .utf8)! + +let six_includes_one_bad_type = """ +[ +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + } +] +""".data(using: .utf8)! + +let eleven_includes_one_bad_type = """ +[ +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + } +] +""".data(using: .utf8)! + +let twenty_two_includes_one_bad_type = """ +[ +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, +{ + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "attributes": { + "foo": "Hello", + "bar": 123 + } + }, + { + "type": "test_entity2", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "attributes": { + "foo": "World", + "bar": 456 + }, + "relationships": { + "entity1": { + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" + } + } + } + }, + { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + }, + { + "type": "test_entity6", + "id": "11113B69-4DF1-467F-B52E-B0C9E44FC444", + "relationships": { + "entity4": { + "data": { + "type": "test_entity4", + "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" + } + } + } + } +] +""".data(using: .utf8)! diff --git a/Tests/JSONAPITests/Includes/stubs/one_include.json b/Tests/JSONAPITests/Includes/stubs/one_include.json deleted file mode 100644 index 9d2a3a4..0000000 --- a/Tests/JSONAPITests/Includes/stubs/one_include.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", - "attributes": { - "foo": "Hello", - "bar": 123 - } - } -] diff --git a/Tests/JSONAPITests/Includes/stubs/three_different_type_includes.json b/Tests/JSONAPITests/Includes/stubs/three_different_type_includes.json deleted file mode 100644 index edd1ba8..0000000 --- a/Tests/JSONAPITests/Includes/stubs/three_different_type_includes.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", - "attributes": { - "foo": "Hello", - "bar": 123 - } - }, - { - "type": "test_entity2", - "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", - "attributes": { - "foo": "World", - "bar": 456 - }, - "relationships": { - "entity1": { - "data": { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" - } - } - } - }, - { - "type": "test_entity4", - "id": "364B3B69-4DF1-467F-B52E-B0C9E44F666E" - } -] diff --git a/Tests/JSONAPITests/Includes/stubs/two_different_type_includes.json b/Tests/JSONAPITests/Includes/stubs/two_different_type_includes.json deleted file mode 100644 index 6ef8e14..0000000 --- a/Tests/JSONAPITests/Includes/stubs/two_different_type_includes.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", - "attributes": { - "foo": "Hello", - "bar": 123 - } - }, - { - "type": "test_entity2", - "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", - "attributes": { - "foo": "World", - "bar": 456 - }, - "relationships": { - "entity1": { - "data": { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF" - } - } - } - } -] diff --git a/Tests/JSONAPITests/Includes/stubs/two_same_type_includes.json b/Tests/JSONAPITests/Includes/stubs/two_same_type_includes.json deleted file mode 100644 index d1dfb42..0000000 --- a/Tests/JSONAPITests/Includes/stubs/two_same_type_includes.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "type": "test_entity1", - "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", - "attributes": { - "foo": "Hello", - "bar": 123 - } - }, - { - "type": "test_entity1", - "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", - "attributes": { - "foo": "World", - "bar": 456 - } - } -] diff --git a/Tests/JSONAPITests/NonJSONAPIRelatable/NonJSONAPIRelatableTests.swift b/Tests/JSONAPITests/NonJSONAPIRelatable/NonJSONAPIRelatableTests.swift index 7546a29..2a38cba 100644 --- a/Tests/JSONAPITests/NonJSONAPIRelatable/NonJSONAPIRelatableTests.swift +++ b/Tests/JSONAPITests/NonJSONAPIRelatable/NonJSONAPIRelatableTests.swift @@ -30,7 +30,7 @@ class NonJSONAPIRelatableTests: XCTestCase { XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "hello") XCTAssertEqual((entity ~> \.nullableMaybeOne)?.rawValue, "world") XCTAssertEqual((entity ~> \.maybeOne)?.rawValue, "world") - XCTAssertEqual((entity ~> \.maybeMany)?.map { $0.rawValue }, ["world", "hello"]) + XCTAssertEqual((entity ~> \.maybeMany)?.map(\.rawValue), ["world", "hello"]) } func test_initialization2_all_relationships_missing() { @@ -58,8 +58,8 @@ extension NonJSONAPIRelatableTests { typealias Attributes = NoAttributes struct Relationships: JSONAPI.Relationships { - let one: ToOneRelationship - let many: ToManyRelationship + let one: ToOneRelationship + let many: ToManyRelationship } } @@ -71,10 +71,10 @@ extension NonJSONAPIRelatableTests { typealias Attributes = NoAttributes struct Relationships: JSONAPI.Relationships { - let nullableOne: ToOneRelationship - let nullableMaybeOne: ToOneRelationship? - let maybeOne: ToOneRelationship? - let maybeMany: ToManyRelationship? + let nullableOne: ToOneRelationship + let nullableMaybeOne: ToOneRelationship? + let maybeOne: ToOneRelationship? + let maybeMany: ToManyRelationship? } } @@ -83,7 +83,7 @@ extension NonJSONAPIRelatableTests { struct NonJSONAPIEntity: Relatable, JSONTyped { static var jsonType: String { return "other" } - typealias Identifier = NonJSONAPIEntity.Id + typealias ID = NonJSONAPIEntity.Id let id: Id diff --git a/Tests/JSONAPITests/Poly/PolyProxyTests.swift b/Tests/JSONAPITests/Poly/PolyProxyTests.swift index 3d12259..e58148d 100644 --- a/Tests/JSONAPITests/Poly/PolyProxyTests.swift +++ b/Tests/JSONAPITests/Poly/PolyProxyTests.swift @@ -15,13 +15,19 @@ 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")) @@ -56,7 +62,7 @@ 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")) @@ -114,9 +120,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/Poly/PolyTests.swift b/Tests/JSONAPITests/Poly/PolyTests.swift deleted file mode 100644 index 0662ba0..0000000 --- a/Tests/JSONAPITests/Poly/PolyTests.swift +++ /dev/null @@ -1,683 +0,0 @@ -// -// PolyTests.swift -// JSONAPITests -// -// Created by Mathew Polzin on 11/23/18. -// - -import XCTest -import JSONAPI -import Poly - -// MARK: - init -class PolyTests: XCTestCase { - func test_init_Poly0() { - let _ = Poly0() - } - - func test_init_Poly1() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly1(entity) - XCTAssertEqual(poly.a, entity) - } - - func test_init_Poly2() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly2(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - - let poly2 = Poly2(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - } - - func test_init_Poly3() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly3(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - - let poly2 = Poly3(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - - let poly3 = Poly3(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - } - - func test_init_Poly4() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly4(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - - let poly2 = Poly4(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - - let poly3 = Poly4(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - - let poly4 = Poly4(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - } - - func test_init_Poly5() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly5(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - XCTAssertNil(poly.e) - - let poly2 = Poly5(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - XCTAssertNil(poly2.e) - - let poly3 = Poly5(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - XCTAssertNil(poly3.e) - - let poly4 = Poly5(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - XCTAssertNil(poly4.e) - - let poly5 = Poly5(entity) - XCTAssertEqual(poly5.e, entity) - XCTAssertNil(poly5.a) - XCTAssertNil(poly5.b) - XCTAssertNil(poly5.c) - XCTAssertNil(poly5.d) - } - - func test_init_Poly6() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly6(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - XCTAssertNil(poly.e) - XCTAssertNil(poly.f) - - let poly2 = Poly6(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - XCTAssertNil(poly2.e) - XCTAssertNil(poly2.f) - - let poly3 = Poly6(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - XCTAssertNil(poly3.e) - XCTAssertNil(poly3.f) - - let poly4 = Poly6(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - XCTAssertNil(poly4.e) - XCTAssertNil(poly4.f) - - let poly5 = Poly6(entity) - XCTAssertEqual(poly5.e, entity) - XCTAssertNil(poly5.a) - XCTAssertNil(poly5.b) - XCTAssertNil(poly5.c) - XCTAssertNil(poly5.d) - XCTAssertNil(poly5.f) - - let poly6 = Poly6(entity) - XCTAssertEqual(poly6.f, entity) - XCTAssertNil(poly6.a) - XCTAssertNil(poly6.b) - XCTAssertNil(poly6.c) - XCTAssertNil(poly6.d) - XCTAssertNil(poly6.e) - } - - func test_init_Poly7() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly7(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - XCTAssertNil(poly.e) - XCTAssertNil(poly.f) - XCTAssertNil(poly.g) - - let poly2 = Poly7(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - XCTAssertNil(poly2.e) - XCTAssertNil(poly2.f) - XCTAssertNil(poly2.g) - - let poly3 = Poly7(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - XCTAssertNil(poly3.e) - XCTAssertNil(poly3.f) - XCTAssertNil(poly3.g) - - let poly4 = Poly7(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - XCTAssertNil(poly4.e) - XCTAssertNil(poly4.f) - XCTAssertNil(poly4.g) - - let poly5 = Poly7(entity) - XCTAssertEqual(poly5.e, entity) - XCTAssertNil(poly5.a) - XCTAssertNil(poly5.b) - XCTAssertNil(poly5.c) - XCTAssertNil(poly5.d) - XCTAssertNil(poly5.f) - XCTAssertNil(poly5.g) - - let poly6 = Poly7(entity) - XCTAssertEqual(poly6.f, entity) - XCTAssertNil(poly6.a) - XCTAssertNil(poly6.b) - XCTAssertNil(poly6.c) - XCTAssertNil(poly6.d) - XCTAssertNil(poly6.e) - XCTAssertNil(poly6.g) - - let poly7 = Poly7(entity) - XCTAssertEqual(poly7.g, entity) - XCTAssertNil(poly7.a) - XCTAssertNil(poly7.b) - XCTAssertNil(poly7.c) - XCTAssertNil(poly7.d) - XCTAssertNil(poly7.e) - XCTAssertNil(poly7.f) - } - - func test_init_Poly8() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly8(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - XCTAssertNil(poly.e) - XCTAssertNil(poly.f) - XCTAssertNil(poly.g) - XCTAssertNil(poly.h) - - let poly2 = Poly8(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - XCTAssertNil(poly2.e) - XCTAssertNil(poly2.f) - XCTAssertNil(poly2.g) - XCTAssertNil(poly2.h) - - let poly3 = Poly8(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - XCTAssertNil(poly3.e) - XCTAssertNil(poly3.f) - XCTAssertNil(poly3.g) - XCTAssertNil(poly3.h) - - let poly4 = Poly8(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - XCTAssertNil(poly4.e) - XCTAssertNil(poly4.f) - XCTAssertNil(poly4.g) - XCTAssertNil(poly4.h) - - let poly5 = Poly8(entity) - XCTAssertEqual(poly5.e, entity) - XCTAssertNil(poly5.a) - XCTAssertNil(poly5.b) - XCTAssertNil(poly5.c) - XCTAssertNil(poly5.d) - XCTAssertNil(poly5.f) - XCTAssertNil(poly5.g) - XCTAssertNil(poly5.h) - - let poly6 = Poly8(entity) - XCTAssertEqual(poly6.f, entity) - XCTAssertNil(poly6.a) - XCTAssertNil(poly6.b) - XCTAssertNil(poly6.c) - XCTAssertNil(poly6.d) - XCTAssertNil(poly6.e) - XCTAssertNil(poly6.g) - XCTAssertNil(poly6.h) - - let poly7 = Poly8(entity) - XCTAssertEqual(poly7.g, entity) - XCTAssertNil(poly7.a) - XCTAssertNil(poly7.b) - XCTAssertNil(poly7.c) - XCTAssertNil(poly7.d) - XCTAssertNil(poly7.e) - XCTAssertNil(poly7.f) - XCTAssertNil(poly7.h) - - let poly8 = Poly8(entity) - XCTAssertEqual(poly8.h, entity) - XCTAssertNil(poly8.a) - XCTAssertNil(poly8.b) - XCTAssertNil(poly8.c) - XCTAssertNil(poly8.d) - XCTAssertNil(poly8.e) - XCTAssertNil(poly8.f) - XCTAssertNil(poly8.g) - } - - func test_init_Poly9() { - let entity = TestEntity5(attributes: .none, relationships: .none, meta: .none, links: .none) - let poly = Poly9(entity) - XCTAssertEqual(poly.a, entity) - XCTAssertNil(poly.b) - XCTAssertNil(poly.c) - XCTAssertNil(poly.d) - XCTAssertNil(poly.e) - XCTAssertNil(poly.f) - XCTAssertNil(poly.g) - XCTAssertNil(poly.h) - XCTAssertNil(poly.i) - - let poly2 = Poly9(entity) - XCTAssertEqual(poly2.b, entity) - XCTAssertNil(poly2.a) - XCTAssertNil(poly2.c) - XCTAssertNil(poly2.d) - XCTAssertNil(poly2.e) - XCTAssertNil(poly2.f) - XCTAssertNil(poly2.g) - XCTAssertNil(poly2.h) - XCTAssertNil(poly2.i) - - let poly3 = Poly9(entity) - XCTAssertEqual(poly3.c, entity) - XCTAssertNil(poly3.a) - XCTAssertNil(poly3.b) - XCTAssertNil(poly3.d) - XCTAssertNil(poly3.e) - XCTAssertNil(poly3.f) - XCTAssertNil(poly3.g) - XCTAssertNil(poly3.h) - XCTAssertNil(poly3.i) - - let poly4 = Poly9(entity) - XCTAssertEqual(poly4.d, entity) - XCTAssertNil(poly4.a) - XCTAssertNil(poly4.b) - XCTAssertNil(poly4.c) - XCTAssertNil(poly4.e) - XCTAssertNil(poly4.f) - XCTAssertNil(poly4.g) - XCTAssertNil(poly4.h) - XCTAssertNil(poly4.i) - - let poly5 = Poly9(entity) - XCTAssertEqual(poly5.e, entity) - XCTAssertNil(poly5.a) - XCTAssertNil(poly5.b) - XCTAssertNil(poly5.c) - XCTAssertNil(poly5.d) - XCTAssertNil(poly5.f) - XCTAssertNil(poly5.g) - XCTAssertNil(poly5.h) - XCTAssertNil(poly5.i) - - let poly6 = Poly9(entity) - XCTAssertEqual(poly6.f, entity) - XCTAssertNil(poly6.a) - XCTAssertNil(poly6.b) - XCTAssertNil(poly6.c) - XCTAssertNil(poly6.d) - XCTAssertNil(poly6.e) - XCTAssertNil(poly6.g) - XCTAssertNil(poly6.h) - XCTAssertNil(poly6.i) - - let poly7 = Poly9(entity) - XCTAssertEqual(poly7.g, entity) - XCTAssertNil(poly7.a) - XCTAssertNil(poly7.b) - XCTAssertNil(poly7.c) - XCTAssertNil(poly7.d) - XCTAssertNil(poly7.e) - XCTAssertNil(poly7.f) - XCTAssertNil(poly7.h) - XCTAssertNil(poly7.i) - - let poly8 = Poly9(entity) - XCTAssertEqual(poly8.h, entity) - XCTAssertNil(poly8.a) - XCTAssertNil(poly8.b) - XCTAssertNil(poly8.c) - XCTAssertNil(poly8.d) - XCTAssertNil(poly8.e) - XCTAssertNil(poly8.f) - XCTAssertNil(poly8.g) - XCTAssertNil(poly8.i) - - let poly9 = Poly9(entity) - XCTAssertEqual(poly9.i, entity) - XCTAssertNil(poly9.a) - XCTAssertNil(poly9.b) - XCTAssertNil(poly9.c) - XCTAssertNil(poly9.d) - XCTAssertNil(poly9.e) - XCTAssertNil(poly9.f) - XCTAssertNil(poly9.g) - XCTAssertNil(poly9.h) - } -} - -// MARK: - subscript lookup -extension PolyTests { - func test_Poly1_lookup() { - let entity = decoded(type: TestEntity.self, data: poly_entity1) - let poly = decoded(type: Poly1.self, data: poly_entity1) - - XCTAssertEqual(entity, poly[TestEntity.self]) - } - - func test_Poly2_lookup() { - let entity = decoded(type: TestEntity2.self, data: poly_entity2) - let poly = decoded(type: Poly2.self, data: poly_entity2) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertEqual(entity, poly[TestEntity2.self]) - } - - func test_Poly3_lookup() { - let entity = decoded(type: TestEntity3.self, data: poly_entity3) - let poly = decoded(type: Poly3.self, data: poly_entity3) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertEqual(entity, poly[TestEntity3.self]) - } - - func test_Poly4_lookup() { - let entity = decoded(type: TestEntity4.self, data: poly_entity4) - let poly = decoded(type: Poly4.self, data: poly_entity4) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertEqual(entity, poly[TestEntity4.self]) - } - - func test_Poly5_lookup() { - let entity = decoded(type: TestEntity5.self, data: poly_entity5) - let poly = decoded(type: Poly5.self, data: poly_entity5) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertNil(poly[TestEntity4.self]) - XCTAssertEqual(entity, poly[TestEntity5.self]) - } - - func test_Poly6_lookup() { - let entity = decoded(type: TestEntity6.self, data: poly_entity6) - let poly = decoded(type: Poly6.self, data: poly_entity6) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertNil(poly[TestEntity4.self]) - XCTAssertNil(poly[TestEntity5.self]) - XCTAssertEqual(entity, poly[TestEntity6.self]) - } - - func test_Poly7_lookup() { - let entity = decoded(type: TestEntity7.self, data: poly_entity7) - let poly = decoded(type: Poly7.self, data: poly_entity7) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertNil(poly[TestEntity4.self]) - XCTAssertNil(poly[TestEntity5.self]) - XCTAssertNil(poly[TestEntity6.self]) - XCTAssertEqual(entity, poly[TestEntity7.self]) - } - - func test_Poly8_lookup() { - let entity = decoded(type: TestEntity8.self, data: poly_entity8) - let poly = decoded(type: Poly8.self, data: poly_entity8) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertNil(poly[TestEntity4.self]) - XCTAssertNil(poly[TestEntity5.self]) - XCTAssertNil(poly[TestEntity6.self]) - XCTAssertNil(poly[TestEntity7.self]) - XCTAssertEqual(entity, poly[TestEntity8.self]) - } - - func test_Poly9_lookup() { - let entity = decoded(type: TestEntity9.self, data: poly_entity9) - let poly = decoded(type: Poly9.self, data: poly_entity9) - - XCTAssertNil(poly[TestEntity.self]) - XCTAssertNil(poly[TestEntity2.self]) - XCTAssertNil(poly[TestEntity3.self]) - XCTAssertNil(poly[TestEntity4.self]) - XCTAssertNil(poly[TestEntity5.self]) - XCTAssertNil(poly[TestEntity6.self]) - XCTAssertNil(poly[TestEntity7.self]) - XCTAssertNil(poly[TestEntity8.self]) - XCTAssertEqual(entity, poly[TestEntity9.self]) - } -} - -// MARK: - failures -extension PolyTests { - func test_Poly0_encode_throws() { - XCTAssertThrowsError(try JSONEncoder().encode(Poly0())) - } - - func test_Poly0_decode_throws() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly0.self, from: poly_entity1)) - } - - func test_Poly1_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly1.self, from: poly_entity2)) - } - - func test_Poly2_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly2.self, from: poly_entity3)) - } - - func test_Poly3_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly3.self, from: poly_entity4)) - } - - func test_Poly4_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly4.self, from: poly_entity5)) - } - - func test_Poly5_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly5.self, from: poly_entity6)) - } - - func test_Poly6_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly6.self, from: poly_entity7)) - } - - func test_Poly7_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly7.self, from: poly_entity8)) - } - - func test_Poly8_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly8.self, from: poly_entity9)) - } - - func test_Poly9_decode_throws_typeNotFound() { - XCTAssertThrowsError(try JSONDecoder().decode(Poly9.self, from: poly_entity10)) - } -} - -// MARK: - Test types -extension PolyTests { - enum TestEntityType: ResourceObjectDescription { - - typealias Relationships = NoRelationships - - public static var jsonType: String { return "test_entity1" } - - public struct Attributes: JSONAPI.Attributes { - let foo: Attribute - let bar: Attribute - } - } - - 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.Attributes { - let foo: Attribute - let bar: Attribute - } - } - - typealias TestEntity2 = BasicEntity - - enum TestEntityType3: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - public static var jsonType: String { return "test_entity3" } - - public struct Relationships: JSONAPI.Relationships { - let entity1: ToOneRelationship - let entity2: ToManyRelationship - } - } - - typealias TestEntity3 = BasicEntity - - enum TestEntityType4: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - typealias Relationships = NoRelationships - - public static var jsonType: String { return "test_entity4" } - } - - typealias TestEntity4 = BasicEntity - - enum TestEntityType5: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - typealias Relationships = NoRelationships - - public static var jsonType: String { return "test_entity5" } - } - - typealias TestEntity5 = 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 - - enum TestEntityType7: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - public static var jsonType: String { return "test_entity7" } - - typealias Relationships = NoRelationships - } - - typealias TestEntity7 = BasicEntity - - enum TestEntityType8: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - public static var jsonType: String { return "test_entity8" } - - typealias Relationships = NoRelationships - } - - typealias TestEntity8 = BasicEntity - - enum TestEntityType9: ResourceObjectDescription { - - typealias Attributes = NoAttributes - - public static var jsonType: String { return "test_entity9" } - - typealias Relationships = NoRelationships - } - - typealias TestEntity9 = BasicEntity -} diff --git a/Tests/JSONAPITests/Poly/stubs/PolyStubs.swift b/Tests/JSONAPITests/Poly/stubs/PolyStubs.swift index fa4ac4d..b1254cf 100644 --- a/Tests/JSONAPITests/Poly/stubs/PolyStubs.swift +++ b/Tests/JSONAPITests/Poly/stubs/PolyStubs.swift @@ -114,3 +114,10 @@ let poly_entity10 = """ "id": "A24B3444-4DF1-467F-B52E-B0C9E12F436A" } """.data(using: .utf8)! + +let poly_entity11 = """ +{ + "type": "test_entity11", + "id": "A24B3444-4DF1-467F-B52E-B0C9E12F4440" +} +""".data(using: .utf8)! diff --git a/Tests/JSONAPITests/Relationships/RelationshipTests.swift b/Tests/JSONAPITests/Relationships/RelationshipTests.swift index 08c1983..4a9a5fe 100644 --- a/Tests/JSONAPITests/Relationships/RelationshipTests.swift +++ b/Tests/JSONAPITests/Relationships/RelationshipTests.swift @@ -15,10 +15,10 @@ class RelationshipTests: XCTestCase { let entity2 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity3 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity4 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) - let relationship = ToManyRelationship(resourceObjects: [entity1, entity2, entity3, entity4]) + let relationship = ToManyRelationship(resourceObjects: [entity1, entity2, entity3, entity4]) XCTAssertEqual(relationship.ids.count, 4) - XCTAssertEqual(relationship.ids, [entity1, entity2, entity3, entity4].map { $0.id }) + XCTAssertEqual(relationship.ids, [entity1, entity2, entity3, entity4].map(\.id)) } func test_initToManyWithRelationships() { @@ -26,24 +26,95 @@ class RelationshipTests: XCTestCase { let entity2 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity3 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity4 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) - let relationship = ToManyRelationship(pointers: [entity1.pointer, entity2.pointer, entity3.pointer, entity4.pointer]) + let relationship = ToManyRelationship(pointers: [entity1.pointer, entity2.pointer, entity3.pointer, entity4.pointer]) XCTAssertEqual(relationship.ids.count, 4) - XCTAssertEqual(relationship.ids, [entity1, entity2, entity3, entity4].map { $0.id }) + XCTAssertEqual(relationship.ids, [entity1, entity2, entity3, entity4].map(\.id)) } + + func test_initToOneWithIdMeta() { + let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) + let relationship = ToOneWithMetaInIds( + id: (entity1.id, .init(a: "hello")) + ) + XCTAssertEqual(relationship.id, entity1.id) + XCTAssertEqual(relationship.idMeta, .init(a: "hello")) + } + + func test_initToManyWithIdMeta() { + let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) + let relationship = ToManyWithMetaInIds( + idsWithMetadata: [ + (entity1.id, .init(a: "hello")) + ] + ) + XCTAssertEqual(relationship.ids, [entity1.id]) + XCTAssertEqual(relationship.idsWithMeta, [.init(id: entity1.id, meta: .init(a: "hello"))]) + } } // MARK: - Encode/Decode extension RelationshipTests { + func test_MetaRelationshipWithMeta() { + let relationship = decoded( + type: MetaRelationship.self, + data: meta_relationship_with_meta + ) + + XCTAssertEqual(relationship.meta, TestMeta(a: "hello")) + XCTAssertEqual(relationship.links, .none) + } + + func test_MetaRelationshipWithMeta_encode() { + test_DecodeEncodeEquality( + type: MetaRelationship.self, + data: meta_relationship_with_meta + ) + } + + func test_MetaRelationshipWithLinks() { + let relationship = decoded( + type: MetaRelationship.self, + data: meta_relationship_with_links + ) + + XCTAssertEqual(relationship.meta, .none) + XCTAssertEqual(relationship.links, TestLinks(b: .init(url: "world"))) + } + + func test_MetaRelationshipWithLinks_encode() { + test_DecodeEncodeEquality( + type: MetaRelationship.self, + data: meta_relationship_with_links + ) + } + + func test_MetaRelationshipWithMetaAndLinks() { + let relationship = decoded( + type: MetaRelationship.self, + data: meta_relationship_with_meta_and_links + ) + + XCTAssertEqual(relationship.meta, TestMeta(a: "hello")) + XCTAssertEqual(relationship.links, TestLinks(b: .init(url: "world"))) + } + + func test_MetaRelationshipWithMetaAndLinks_encode() { + test_DecodeEncodeEquality( + type: MetaRelationship.self, + data: meta_relationship_with_meta_and_links + ) + } + func test_ToOneRelationship() { - let relationship = decoded(type: ToOneRelationship.self, + let relationship = decoded(type: ToOneRelationship.self, data: to_one_relationship) XCTAssertEqual(relationship.id.rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") } func test_ToOneRelationship_encode() { - test_DecodeEncodeEquality(type: ToOneRelationship.self, + test_DecodeEncodeEquality(type: ToOneRelationship.self, data: to_one_relationship) } @@ -60,6 +131,19 @@ extension RelationshipTests { data: to_one_relationship_with_meta) } + func test_ToOneRelationshipWithMetaInsideIdentifier() { + let relationship = decoded(type: ToOneWithMetaInIds.self, + data: to_one_relationship_with_meta_inside_identifier) + + XCTAssertEqual(relationship.id.rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") + XCTAssertEqual(relationship.idMeta.a, "hello") + } + + func test_ToOneRelationshipWithMetaInsideIdentifier_encode() { + test_DecodeEncodeEquality(type: ToOneWithMetaInIds.self, + data: to_one_relationship_with_meta_inside_identifier) + } + func test_ToOneRelationshipWithLinks() { let relationship = decoded(type: ToOneWithLinks.self, data: to_one_relationship_with_links) @@ -88,14 +172,14 @@ extension RelationshipTests { } func test_ToManyRelationship() { - let relationship = decoded(type: ToManyRelationship.self, + let relationship = decoded(type: ToManyRelationship.self, data: to_many_relationship) - XCTAssertEqual(relationship.ids.map { $0.rawValue }, ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) + XCTAssertEqual(relationship.ids.map(\.rawValue), ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) } func test_ToManyRelationship_encode() { - test_DecodeEncodeEquality(type: ToManyRelationship.self, + test_DecodeEncodeEquality(type: ToManyRelationship.self, data: to_many_relationship) } @@ -103,7 +187,7 @@ extension RelationshipTests { let relationship = decoded(type: ToManyWithMeta.self, data: to_many_relationship_with_meta) - XCTAssertEqual(relationship.ids.map { $0.rawValue }, ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) + XCTAssertEqual(relationship.ids.map(\.rawValue), ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) XCTAssertEqual(relationship.meta.a, "hello") } @@ -112,11 +196,24 @@ extension RelationshipTests { data: to_many_relationship_with_meta) } + func test_ToManyRelationshipWithMetaInsideIdentifier() { + let relationship = decoded(type: ToManyWithMetaInIds.self, + data: to_many_relationship_with_meta_inside_identifier) + + XCTAssertEqual(relationship.ids.map(\.rawValue), ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) + XCTAssertEqual(relationship.idsWithMeta[0].meta.a, "hello") + } + + func test_ToManyRelationshipWithMetaInsideIdentifier_encode() { + test_DecodeEncodeEquality(type: ToManyWithMetaInIds.self, + data: to_many_relationship_with_meta_inside_identifier) + } + func test_ToManyRelationshipWithLinks() { let relationship = decoded(type: ToManyWithLinks.self, data: to_many_relationship_with_links) - XCTAssertEqual(relationship.ids.map { $0.rawValue }, ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) + XCTAssertEqual(relationship.ids.map(\.rawValue), ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) XCTAssertEqual(relationship.links.b, .init(url: "world")) } @@ -129,7 +226,7 @@ extension RelationshipTests { let relationship = decoded(type: ToManyWithMetaAndLinks.self, data: to_many_relationship_with_meta_and_links) - XCTAssertEqual(relationship.ids.map { $0.rawValue }, ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) + XCTAssertEqual(relationship.ids.map(\.rawValue), ["2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", "90F03B69-4DF1-467F-B52E-B0C9E44FC333", "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF"]) XCTAssertEqual(relationship.meta.a, "hello") XCTAssertEqual(relationship.links.b, .init(url: "world")) } @@ -157,17 +254,25 @@ extension RelationshipTests { XCTAssertEqual(encoded(value: relationship1), encoded(value: relationship2)) } + + func test_nullableIdMeta() { + let _ = decoded(type: ToOneRelationship.self, data: to_one_relationship_nulled_out) + } } // MARK: Failure tests extension RelationshipTests { func test_ToManyTypeMismatch() { - XCTAssertThrowsError(try JSONDecoder().decode(ToManyRelationship.self, from: to_many_relationship_type_mismatch)) + XCTAssertThrowsError(try JSONDecoder().decode(ToManyRelationship.self, from: to_many_relationship_type_mismatch)) } func test_ToOneTypeMismatch() { - XCTAssertThrowsError(try JSONDecoder().decode(ToOneRelationship.self, from: to_one_relationship_type_mismatch)) + XCTAssertThrowsError(try JSONDecoder().decode(ToOneRelationship.self, from: to_one_relationship_type_mismatch)) } + + func test_toOneNulledOutWithExpectedIdMetadata() { + XCTAssertThrowsError(try JSONDecoder().decode(ToOneRelationship.self, from: to_one_relationship_nulled_out)) + } } // MARK: - Test types @@ -182,17 +287,22 @@ extension RelationshipTests { typealias TestEntity1 = BasicEntity - typealias ToOneWithMeta = ToOneRelationship + typealias ToOneWithMeta = ToOneRelationship + typealias ToOneWithMetaInIds = ToOneRelationship + + typealias ToOneWithLinks = ToOneRelationship + + typealias ToOneWithMetaAndLinks = ToOneRelationship + + typealias ToManyWithMeta = ToManyRelationship + typealias ToManyWithMetaInIds = ToManyRelationship - typealias ToOneWithLinks = ToOneRelationship - typealias ToOneWithMetaAndLinks = ToOneRelationship + typealias ToManyWithLinks = ToManyRelationship - typealias ToManyWithMeta = ToManyRelationship - typealias ToManyWithLinks = ToManyRelationship - typealias ToManyWithMetaAndLinks = ToManyRelationship + typealias ToManyWithMetaAndLinks = ToManyRelationship - typealias ToOneNullable = ToOneRelationship - typealias ToOneNonNullable = ToOneRelationship + typealias ToOneNullable = ToOneRelationship + typealias ToOneNonNullable = ToOneRelationship struct TestMeta: JSONAPI.Meta { let a: String diff --git a/Tests/JSONAPITests/Relationships/stubs/RelationshipStubs.swift b/Tests/JSONAPITests/Relationships/stubs/RelationshipStubs.swift index 2f5d6d5..434667f 100644 --- a/Tests/JSONAPITests/Relationships/stubs/RelationshipStubs.swift +++ b/Tests/JSONAPITests/Relationships/stubs/RelationshipStubs.swift @@ -5,6 +5,33 @@ // Created by Mathew Polzin on 11/12/18. // +let meta_relationship_with_meta = """ +{ + "meta": { + "a": "hello" + } +} +""".data(using: .utf8)! + +let meta_relationship_with_links = """ +{ + "links": { + "b": "world" + } +} +""".data(using: .utf8)! + +let meta_relationship_with_meta_and_links = """ +{ + "meta": { + "a": "hello" + }, + "links": { + "b": "world" + } +} +""".data(using: .utf8)! + let to_one_relationship = """ { "data": { @@ -14,6 +41,12 @@ let to_one_relationship = """ } """.data(using: .utf8)! +let to_one_relationship_nulled_out = """ +{ + "data": null +} +""".data(using: .utf8)! + let to_one_relationship_type_mismatch = """ { "data": { @@ -35,6 +68,18 @@ let to_one_relationship_with_meta = """ } """.data(using: .utf8)! +let to_one_relationship_with_meta_inside_identifier = """ +{ + "data": { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "meta": { + "a": "hello" + } + } +} +""".data(using: .utf8)! + let to_one_relationship_with_links = """ { "data": { @@ -103,6 +148,34 @@ let to_many_relationship_with_meta = """ } """.data(using: .utf8)! +let to_many_relationship_with_meta_inside_identifier = """ +{ + "data": [ + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "meta": { + "a": "hello" + } + }, + { + "type": "test_entity1", + "id": "90F03B69-4DF1-467F-B52E-B0C9E44FC333", + "meta": { + "a": "hello" + } + }, + { + "type": "test_entity1", + "id": "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF", + "meta": { + "a": "hello" + } + } + ] +} +""".data(using: .utf8)! + let to_many_relationship_with_links = """ { "data": [ diff --git a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift index a20db8c..ad4c3fb 100644 --- a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift +++ b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift @@ -100,16 +100,110 @@ class ResourceBodyTests: XCTestCase { XCTAssertEqual(combined.values.count, 3) XCTAssertEqual(combined.values, body1.values + body2.values) } +} - enum ArticleType: ResourceObjectDescription { - public static var jsonType: String { return "articles" } +// MARK: - Sparse Fieldsets - typealias Relationships = NoRelationships +extension ResourceBodyTests { + func test_SparseSingleBodyEncode() { + let sparseArticle = Article(attributes: .init(title: "hello world"), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: []) + let body = SingleResourceBody(resourceObject: sparseArticle) - struct Attributes: JSONAPI.Attributes { - let title: Attribute - } - } + let encoded = try! JSONEncoder().encode(body) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [String: Any] + + XCTAssertNotNil(deserializedObj?["id"]) + XCTAssertEqual(deserializedObj?["id"] as? String, sparseArticle.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj?["type"]) + XCTAssertEqual(deserializedObj?["type"] as? String, Article.jsonType) + + XCTAssertEqual((deserializedObj?["attributes"] as? [String: Any])?.count, 0) + + XCTAssertNil(deserializedObj?["relationships"]) + } + + func test_SparseManyBodyEncode() { + let fields: [Article.Attributes.CodingKeys] = [.title] + let sparseArticle1 = Article(attributes: .init(title: "hello world"), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: fields) + let sparseArticle2 = Article(attributes: .init(title: "hello two"), + relationships: .none, + meta: .none, + links: .none) + .sparse(with: fields) + + let body = ManyResourceBody(resourceObjects: [sparseArticle1, sparseArticle2]) + + let encoded = try! JSONEncoder().encode(body) + + let deserialized = try! JSONSerialization.jsonObject(with: encoded, + options: []) + + let deserializedObj = deserialized as? [Any] + + XCTAssertEqual(deserializedObj?.count, 2) + + guard let deserializedObj1 = deserializedObj?.first as? [String: Any], + let deserializedObj2 = deserializedObj?.last as? [String: Any] else { + XCTFail("Expected to deserialize two objects from array") + return + } + + // first article + XCTAssertNotNil(deserializedObj1["id"]) + XCTAssertEqual(deserializedObj1["id"] as? String, sparseArticle1.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj1["type"]) + XCTAssertEqual(deserializedObj1["type"] as? String, Article.jsonType) + + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj1["attributes"] as? [String: Any])?["title"] as? String, "hello world") + + XCTAssertNil(deserializedObj1["relationships"]) + + // second article + XCTAssertNotNil(deserializedObj2["id"]) + XCTAssertEqual(deserializedObj2["id"] as? String, sparseArticle2.resourceObject.id.rawValue) + + XCTAssertNotNil(deserializedObj2["type"]) + XCTAssertEqual(deserializedObj2["type"] as? String, Article.jsonType) + + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?.count, 1) + XCTAssertEqual((deserializedObj2["attributes"] as? [String: Any])?["title"] as? String, "hello two") + + XCTAssertNil(deserializedObj2["relationships"]) + } +} + +// MARK: - Test Types + +extension ResourceBodyTests { + + enum ArticleType: ResourceObjectDescription { + public static var jsonType: String { return "articles" } + + typealias Relationships = NoRelationships + + struct Attributes: JSONAPI.SparsableAttributes { + let title: Attribute + + public enum CodingKeys: String, Equatable, CodingKey { + case title + } + } + } - typealias Article = BasicEntity + typealias Article = BasicEntity } diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObject+HashableTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObject+HashableTests.swift new file mode 100644 index 0000000..7df88d3 --- /dev/null +++ b/Tests/JSONAPITests/ResourceObject/ResourceObject+HashableTests.swift @@ -0,0 +1,119 @@ +// +// ResourceObject+HashableTests.swift +// +// +// Created by Mathew Polzin on 5/26/20. +// + +import XCTest +import JSONAPI +import JSONAPITesting + +class ResourceObjectHashableTests: XCTestCase { + + func test_hashable_sameProeprties() { + let t1 = ResourceObject( + id: "hello", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + let t2 = ResourceObject( + id: "hello", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + let t3 = ResourceObject( + id: "world", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + XCTAssertEqual(t1, t2) + XCTAssertEqual(t1.hashValue, t2.hashValue) + + XCTAssertEqual(t1.attributes, t3.attributes) + XCTAssertEqual(t1.relationships, t3.relationships) + XCTAssertEqual(t1.meta, t3.meta) + XCTAssertEqual(t1.links, t3.links) + XCTAssertNotEqual(t1, t3) + XCTAssertNotEqual(t1.hashValue, t3.hashValue) + } + + func test_hashable_differentProeprties() { + let t1 = ResourceObject( + id: "hello", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + let t2 = ResourceObject( + id: "hello", + attributes: .init(floater: 11111), + relationships: .none, + meta: .none, + links: .none + ) + + let t3 = ResourceObject( + id: "world", + attributes: .init(floater: 1111), + relationships: .none, + meta: .none, + links: .none + ) + + XCTAssertNotEqual(t1, t2) + XCTAssertEqual(t1.hashValue, t2.hashValue) + + XCTAssertNotEqual(t1.attributes, t3.attributes) + XCTAssertEqual(t1.relationships, t3.relationships) + XCTAssertEqual(t1.meta, t3.meta) + XCTAssertEqual(t1.links, t3.links) + XCTAssertNotEqual(t1, t3) + XCTAssertNotEqual(t1.hashValue, t3.hashValue) + } + + func test_hashable_differentTypes() { + let t1 = ResourceObject( + id: "hello", + attributes: .none, + relationships: .none, + meta: .none, + links: .none + ) + + let t2 = ResourceObject( + id: "hello", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + let t3 = ResourceObject( + id: "world", + attributes: .init(floater: 1234), + relationships: .none, + meta: .none, + links: .none + ) + + XCTAssertNotEqual(t1.hashValue, t2.hashValue) + + XCTAssertNotEqual(t1.hashValue, t3.hashValue) + } +} + +extension ResourceObjectHashableTests { + +} diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObject+ReplacingTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObject+ReplacingTests.swift new file mode 100644 index 0000000..4a5fffa --- /dev/null +++ b/Tests/JSONAPITests/ResourceObject/ResourceObject+ReplacingTests.swift @@ -0,0 +1,155 @@ +// +// ResourceObject+ReplacingTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 10/12/19. +// + +import XCTest +import JSONAPI + +final class ResourceObjectReplacingTests: XCTestCase { + func test_replaceMutableAttributes() { + let testResource = MutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .replacingAttributes { + var newAttributes = $0 + newAttributes.name = .init(value: "Matt 2") + return newAttributes + } + + XCTAssertEqual(testResource.name, "Matt") + XCTAssertEqual(mutatedResource.name, "Matt 2") + } + + func test_tapMutableAttributes() { + let testResource = MutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .tappingAttributes { $0.name = .init(value: "Matt 2") } + + XCTAssertEqual(testResource.name, "Matt") + XCTAssertEqual(mutatedResource.name, "Matt 2") + } + + func test_replaceImmutableAttributes() { + let testResource = ImmutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .replacingAttributes { + return .init(name: $0.name.map { $0 + " 2" }) + } + + XCTAssertEqual(testResource.name, "Matt") + XCTAssertEqual(mutatedResource.name, "Matt 2") + } + + func test_tapImmutableAttributes() { + let testResource = ImmutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .tappingAttributes { $0 = .init(name: $0.name.map { $0 + " 2" }) } + + XCTAssertEqual(testResource.name, "Matt") + XCTAssertEqual(mutatedResource.name, "Matt 2") + } + + func test_replaceMutableRelationships() { + let testResource = MutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .replacingRelationships { + var newRelationships = $0 + newRelationships.other = .init(id: .init(rawValue: "3")) + return newRelationships + } + + XCTAssertEqual(testResource ~> \.other, "2") + XCTAssertEqual(mutatedResource ~> \.other, "3") + } + + func test_tapMutableRelationships() { + let testResource = MutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .tappingRelationships { $0.other = .init(id: .init(rawValue: "3")) } + + XCTAssertEqual(testResource ~> \.other, "2") + XCTAssertEqual(mutatedResource ~> \.other, "3") + } + + func test_replaceImmutableRelationships() { + let testResource = ImmutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .replacingRelationships { _ in + return .init(other: .init(id: .init(rawValue: "3"))) + } + + XCTAssertEqual(testResource ~> \.other, "2") + XCTAssertEqual(mutatedResource ~> \.other, "3") + } + + func test_tapImmutableRelationships() { + let testResource = ImmutableTestType(attributes: .init(name: .init(value: "Matt")), + relationships: .init(other: .init(id: .init(rawValue: "2"))), + meta: .none, + links: .none) + + let mutatedResource = testResource + .tappingRelationships { $0 = .init(other: .init(id: .init(rawValue: "3"))) } + + XCTAssertEqual(testResource ~> \.other, "2") + XCTAssertEqual(mutatedResource ~> \.other, "3") + } +} + +private enum MutableTestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test" + + struct Attributes: JSONAPI.Attributes { + var name: Attribute + } + + struct Relationships: JSONAPI.Relationships { + var other: ToOneRelationship + } +} + +private typealias MutableTestType = JSONAPI.ResourceObject + +private enum ImmutableTestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test2" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let other: ToOneRelationship + } +} + +private typealias ImmutableTestType = JSONAPI.ResourceObject diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift new file mode 100644 index 0000000..694acb9 --- /dev/null +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift @@ -0,0 +1,593 @@ +// +// 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, + jsonAPIType: TestEntity.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity.jsonType + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is required and missing." + ) + } + } + + func test_relationshipWithNoId() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_required_relationship_no_id + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .relationshipId, + jsonAPIType: TestEntity.jsonType + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship does not have an 'id'." + ) + } + } + + func test_relationshipWithNoType() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_required_relationship_no_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .relationshipType, + jsonAPIType: TestEntity.jsonType + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship does not have a 'type'." + ) + } + } + + func test_NonNullable_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_nonNullable_relationship_is_null + )) { error in + let specialError = error as? ResourceObjectDecodingError + + XCTAssertNotNil(specialError) + + // later Linux versions of Swift will catch that the value is not + // a string rather than calling null "not found." The errors are both + // effective, so we check that one of the two is the result: + if specialError?.cause == .valueNotFound { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .relationships, + jsonAPIType: TestEntity.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'required' relationship is not nullable but null was found." + ) + } else { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .typeMismatch(expectedTypeName: "Dictionary"), + location: .relationships, + jsonAPIType: TestEntity.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'required' relationship is not a Dictionary as expected." + ) + } + } + } + + 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, + jsonAPIType: TestEntity.jsonType + ) + ) + + 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(foundType: "not_the_same"), + location: .relationships, + jsonAPIType: "thirteenth_test_entities" + ) + ) + + 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, + jsonAPIType: TestEntity.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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 + let specialError = error as? ResourceObjectDecodingError + + XCTAssertNotNil(specialError) + + // later Linux versions of Swift will catch that the value is not + // a string rather than calling null "not found." The errors are both + // effective, so we check that one of the two is the result: + if specialError?.cause == .valueNotFound { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .attributes, + jsonAPIType: TestEntity2.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'required' attribute is not nullable but null was found." + ) + } else { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .typeMismatch(expectedTypeName: "String"), + location: .attributes, + jsonAPIType: TestEntity2.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'required' attribute is not a String as expected." + ) + } + } + } + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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(foundType: "not_correct_type"), + location: .type, + jsonAPIType: "fourteenth_test_entities" + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + 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, + jsonAPIType: TestEntity2.jsonType + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'type' (a.k.a. the 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 + let specialError = error as? ResourceObjectDecodingError + + XCTAssertNotNil(specialError) + + // later Linux versions of Swift will catch that the value is not + // a string rather than calling null "not found." The errors are both + // effective, so we check that one of the two is the result: + if specialError?.cause == .valueNotFound { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "type", + cause: .valueNotFound, + location: .type, + jsonAPIType: TestEntity2.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'type' (a.k.a. the JSON:API type name) is not nullable but null was found." + ) + } else { + XCTAssertEqual( + specialError, + ResourceObjectDecodingError( + subjectName: "type", + cause: .typeMismatch(expectedTypeName: "String"), + location: .type, + jsonAPIType: TestEntity2.jsonType + ) + ) + + XCTAssertEqual( + specialError?.description, + "'type' (a.k.a. the JSON:API type name) is not a String as expected." + ) + } + } + } +} + +// 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/Entity/EntityTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift similarity index 72% rename from Tests/JSONAPITests/Entity/EntityTests.swift rename to Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift index feaf9ee..77a75c7 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectTests.swift @@ -1,5 +1,5 @@ // -// EntityTests.swift +// ResourceObjectTests.swift // JSONAPITests // // Created by Mathew Polzin on 7/25/18. @@ -9,15 +9,15 @@ import XCTest import JSONAPI import JSONAPITesting -class EntityTests: XCTestCase { - +class ResourceObjectTests: XCTestCase { + func test_relationship_access() { let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity2 = TestEntity2(attributes: .none, relationships: .init(other: entity1.pointer), meta: .none, links: .none) - + XCTAssertEqual(entity2.relationships.other, entity1.pointer) } - + func test_relationship_operator_access() { let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity2 = TestEntity2(attributes: .none, relationships: .init(other: entity1.pointer), meta: .none, links: .none) @@ -27,9 +27,10 @@ class EntityTests: XCTestCase { func test_optional_relationship_operator_access() { let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) - let entity = TestEntity9(attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalNullableOne: nil, optionalMany: .init(resourceObjects: [entity1, entity1], meta: .none, links: .none)), meta: .none, links: .none) + let entity = TestEntity9(attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalNullableOne: nil, optionalMany: .init(resourceObjects: [entity1, entity1], meta: .none, links: .none)), meta: .none, links: .none) - XCTAssertEqual(entity ~> \.optionalOne, entity1.id) + XCTAssertEqual(entity ~> \.optionalOne, Optional(entity1.id)) + XCTAssertEqual((entity ~> \.optionalOne).rawValue, Optional(entity1.id.rawValue)) } func test_toMany_relationship_operator_access() { @@ -43,15 +44,15 @@ class EntityTests: XCTestCase { func test_optionalToMany_relationship_opeartor_access() { let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) - let entity = TestEntity9(attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: nil, optionalNullableOne: nil, optionalMany: .init(resourceObjects: [entity1, entity1], meta: .none, links: .none)), meta: .none, links: .none) + let entity = TestEntity9(attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: nil, optionalNullableOne: nil, optionalMany: .init(resourceObjects: [entity1, entity1], meta: .none, links: .none)), meta: .none, links: .none) XCTAssertEqual(entity ~> \.optionalMany, [entity1.id, entity1.id]) } - + func test_relationshipIds() { let entity1 = TestEntity1(attributes: .none, relationships: .none, meta: .none, links: .none) let entity2 = TestEntity2(attributes: .none, relationships: .init(other: entity1.pointer), meta: .none, links: .none) - + XCTAssertEqual(entity2.relationships.other.id, entity1.id) } @@ -68,7 +69,7 @@ class EntityTests: 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") } func test_initialization() { @@ -83,14 +84,14 @@ class EntityTests: XCTestCase { let _ = TestEntity6(id: .init(rawValue: "6"), attributes: .init(here: .init(value: "here"), maybeHere: nil, maybeNull: .init(value: nil)), relationships: .none, meta: .none, links: .none) let _ = TestEntity7(id: .init(rawValue: "7"), attributes: .init(here: .init(value: "hello"), maybeHereMaybeNull: .init(value: "world")), relationships: .none, meta: .none, links: .none) XCTAssertNoThrow(try TestEntity8(id: .init(rawValue: "8"), attributes: .init(string: .init(value: "hello"), int: .init(value: 10), stringFromInt: .init(rawValue: 20), plus: .init(rawValue: 30), doubleFromInt: .init(rawValue: 32), omitted: nil, nullToString: .init(rawValue: nil)), relationships: .none, meta: .none, links: .none)) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: .init(resourceObject: nil), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: .init(id: nil), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: entity1.pointer, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalMany: nil), meta: .none, links: .none) - let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalMany: .init(resourceObjects: [], meta: .none, links: .none)), meta: .none, links: .none) - let e10id1 = TestEntity10.Identifier(rawValue: "hello") + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: .init(meta: .init(x: "hello", y: 5), links: .none), one: entity1.pointer, nullableOne: .init(resourceObject: nil), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: .init(id: nil), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: nil, optionalOne: entity1.pointer, optionalNullableOne: nil, optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalMany: nil), meta: .none, links: .none) + let _ = TestEntity9(id: .init(rawValue: "9"), attributes: .none, relationships: .init(meta: .init(meta: .init(x: "hello", y: 5), links: .none), optionalMeta: nil, one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(resourceObject: entity1, meta: .none, links: .none), optionalMany: .init(resourceObjects: [], meta: .none, links: .none)), meta: .none, links: .none) + let e10id1 = TestEntity10.ID(rawValue: "hello") let e10id2 = TestEntity10.Id(rawValue: "world") let e10id3: TestEntity10.Id = "!" let _ = TestEntity10(id: .init(rawValue: "10"), attributes: .none, relationships: .init(selfRef: .init(id: e10id1), selfRefs: .init(ids: [e10id2, e10id3])), meta: .none, links: .none) @@ -102,7 +103,7 @@ class EntityTests: XCTestCase { } // MARK: - Identifying entity copies -extension EntityTests { +extension ResourceObjectTests { func test_copyIdentifiedByType() { let unidentifiedEntity = UnidentifiedTestEntity(attributes: .init(me: .init(value: "hello")), relationships: .none, meta: .none, links: .none) @@ -132,7 +133,7 @@ extension EntityTests { } // MARK: - Encode/Decode -extension EntityTests { +extension ResourceObjectTests { func test_EntityNoRelationshipsNoAttributes() { let entity = decoded(type: TestEntity1.self, @@ -150,13 +151,18 @@ extension EntityTests { data: entity_no_relationships_no_attributes) } + func test_EntityNoMetaDecodesAsOptionalMeta() { + let _ = decoded(type: TestEntityOptionalMeta.self, + data: entity_no_relationships_no_attributes) + } + func test_EntityNoRelationshipsSomeAttributes() { let entity = decoded(type: TestEntity5.self, data: entity_no_relationships_some_attributes) 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) @@ -172,8 +178,8 @@ extension EntityTests { data: entity_some_relationships_no_attributes) XCTAssert(type(of: entity.attributes) == NoAttributes.self) - - XCTAssertEqual((entity ~> \.others).map { $0.rawValue }, ["364B3B69-4DF1-467F-B52E-B0C9E44F666E"]) + + XCTAssertEqual((entity ~> \.others).map(\.rawValue), ["364B3B69-4DF1-467F-B52E-B0C9E44F666E"]) XCTAssertNoThrow(try TestEntity3.check(entity)) testEncoded(entity: entity) @@ -183,13 +189,13 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity3.self, data: entity_some_relationships_no_attributes) } - + func test_EntitySomeRelationshipsSomeAttributes() { let entity = decoded(type: TestEntity4.self, data: entity_some_relationships_some_attributes) - - XCTAssertEqual(entity[\.word], "coolio") - XCTAssertEqual(entity[\.number], 992299) + + XCTAssertEqual(entity.word, "coolio") + XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertNoThrow(try TestEntity4.check(entity)) @@ -203,15 +209,15 @@ extension EntityTests { } // MARK: Attribute omission and nullification -extension EntityTests { - +extension ResourceObjectTests { + func test_entityOneOmittedAttribute() { let entity = decoded(type: TestEntity6.self, data: entity_one_omitted_attribute) - - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertNil(entity[\.maybeHere]) - XCTAssertEqual(entity[\.maybeNull], "World") + + XCTAssertEqual(entity.here, "Hello") + XCTAssertNil(entity.maybeHere) + XCTAssertEqual(entity.maybeNull, "World") XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) @@ -221,14 +227,14 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_omitted_attribute) } - + func test_entityOneNullAttribute() { let entity = decoded(type: TestEntity6.self, data: entity_one_null_attribute) - - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertEqual(entity[\.maybeHere], "World") - XCTAssertNil(entity[\.maybeNull]) + + XCTAssertEqual(entity.here, "Hello") + XCTAssertEqual(entity.maybeHere, "World") + XCTAssertNil(entity.maybeNull) XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) @@ -238,14 +244,14 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_null_attribute) } - + func test_entityAllAttribute() { let entity = decoded(type: TestEntity6.self, data: entity_all_attributes) - - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertEqual(entity[\.maybeHere], "World") - XCTAssertEqual(entity[\.maybeNull], "!") + + XCTAssertEqual(entity.here, "Hello") + XCTAssertEqual(entity.maybeHere, "World") + XCTAssertEqual(entity.maybeNull, "!") XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) @@ -255,14 +261,14 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_all_attributes) } - + func test_entityOneNullAndOneOmittedAttribute() { let entity = decoded(type: TestEntity6.self, data: entity_one_null_and_one_missing_attribute) - - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertNil(entity[\.maybeHere]) - XCTAssertNil(entity[\.maybeNull]) + + XCTAssertEqual(entity.here, "Hello") + XCTAssertNil(entity.maybeHere) + XCTAssertNil(entity.maybeNull) XCTAssertNoThrow(try TestEntity6.check(entity)) testEncoded(entity: entity) @@ -272,18 +278,18 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity6.self, data: entity_one_null_and_one_missing_attribute) } - + func test_entityBrokenNullableOmittedAttribute() { XCTAssertThrowsError(try JSONDecoder().decode(TestEntity6.self, from: entity_broken_missing_nullable_attribute)) } - + func test_NullOptionalNullableAttribute() { let entity = decoded(type: TestEntity7.self, data: entity_null_optional_nullable_attribute) - - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertNil(entity[\.maybeHereMaybeNull]) + + XCTAssertEqual(entity.here, "Hello") + XCTAssertNil(entity.maybeHereMaybeNull) XCTAssertNoThrow(try TestEntity7.check(entity)) testEncoded(entity: entity) @@ -293,13 +299,13 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity7.self, data: entity_null_optional_nullable_attribute) } - + func test_NonNullOptionalNullableAttribute() { let entity = decoded(type: TestEntity7.self, data: entity_non_null_optional_nullable_attribute) - XCTAssertEqual(entity[\.here], "Hello") - XCTAssertEqual(entity[\.maybeHereMaybeNull], "World") + XCTAssertEqual(entity.here, "Hello") + XCTAssertEqual(entity.maybeHereMaybeNull, "World") XCTAssertNoThrow(try TestEntity7.check(entity)) testEncoded(entity: entity) @@ -312,17 +318,17 @@ extension EntityTests { } // MARK: Attribute Transformation -extension EntityTests { +extension ResourceObjectTests { func test_IntToString() { 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") + + 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") XCTAssertNoThrow(try TestEntity8.check(entity)) testEncoded(entity: entity) @@ -335,7 +341,7 @@ extension EntityTests { } // MARK: Attribute Validation -extension EntityTests { +extension ResourceObjectTests { func test_IntOver10_success() { XCTAssertNoThrow(decoded(type: TestEntity11.self, data: entity_valid_validated_attribute)) } @@ -350,11 +356,13 @@ extension EntityTests { } // MARK: Relationship omission and nullification -extension EntityTests { +extension ResourceObjectTests { func test_nullableRelationshipNotNullOrOmitted() { let entity = decoded(type: TestEntity9.self, data: entity_optional_not_omitted_relationship) + XCTAssertEqual(entity.relationships.meta.meta, TestEntityMeta(x: "world", y: 5)) + XCTAssertEqual(entity.relationships.optionalMeta?.meta, TestEntityMeta(x: "world", y: 5)) XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") XCTAssertEqual((entity ~> \.one).rawValue, "4459") XCTAssertNil(entity ~> \.optionalOne) @@ -390,9 +398,12 @@ extension EntityTests { let entity = decoded(type: TestEntity9.self, data: entity_optional_nullable_nulled_relationship) + XCTAssertEqual(entity.relationships.meta.meta, TestEntityMeta(x: "world", y: 5)) + XCTAssertNil(entity.relationships.optionalMeta) XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") XCTAssertEqual((entity ~> \.one).rawValue, "4459") XCTAssertNil(entity ~> \.optionalNullableOne) + XCTAssertNil((entity ~> \.optionalNullableOne).rawValue) XCTAssertNoThrow(try TestEntity9.check(entity)) testEncoded(entity: entity) @@ -451,7 +462,7 @@ extension EntityTests { // MARK: Relationships of same type as root entity -extension EntityTests { +extension ResourceObjectTests { func test_RleationshipsOfSameType() { let entity = decoded(type: TestEntity10.self, data: entity_self_ref_relationship) @@ -470,12 +481,12 @@ extension EntityTests { // MARK: Unidentified -extension EntityTests { +extension ResourceObjectTests { func test_UnidentifiedEntity() { let entity = decoded(type: UnidentifiedTestEntity.self, data: entity_unidentified) - XCTAssertNil(entity[\.me]) + XCTAssertNil(entity.me) XCTAssertEqual(entity.id, .unidentified) XCTAssertNoThrow(try UnidentifiedTestEntity.check(entity)) @@ -491,7 +502,7 @@ extension EntityTests { 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)) @@ -506,12 +517,12 @@ extension EntityTests { // MARK: With Meta and/or Links -extension EntityTests { +extension ResourceObjectTests { func test_UnidentifiedEntityWithAttributesAndMeta() { 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") XCTAssertEqual(entity.meta.y, 5) @@ -529,7 +540,7 @@ extension EntityTests { 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")) XCTAssertNoThrow(try UnidentifiedTestEntityWithLinks.check(entity)) @@ -546,7 +557,7 @@ extension EntityTests { 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") XCTAssertEqual(entity.meta.y, 5) @@ -565,8 +576,8 @@ extension EntityTests { let entity = decoded(type: TestEntity4WithMeta.self, data: entity_some_relationships_some_attributes_with_meta) - XCTAssertEqual(entity[\.word], "coolio") - XCTAssertEqual(entity[\.number], 992299) + XCTAssertEqual(entity.word, "coolio") + XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.meta.x, "world") XCTAssertEqual(entity.meta.y, 5) @@ -584,8 +595,8 @@ extension EntityTests { let entity = decoded(type: TestEntity4WithLinks.self, data: entity_some_relationships_some_attributes_with_links) - XCTAssertEqual(entity[\.word], "coolio") - XCTAssertEqual(entity[\.number], 992299) + XCTAssertEqual(entity.word, "coolio") + XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.links.link1, .init(url: "https://image.com/image.png")) XCTAssertNoThrow(try TestEntity4WithLinks.check(entity)) @@ -602,8 +613,8 @@ extension EntityTests { let entity = decoded(type: TestEntity4WithMetaAndLinks.self, data: entity_some_relationships_some_attributes_with_meta_and_links) - XCTAssertEqual(entity[\.word], "coolio") - XCTAssertEqual(entity[\.number], 992299) + XCTAssertEqual(entity.word, "coolio") + XCTAssertEqual(entity.number, 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") XCTAssertEqual(entity.meta.x, "world") XCTAssertEqual(entity.meta.y, 5) @@ -617,11 +628,29 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity4WithMetaAndLinks.self, data: entity_some_relationships_some_attributes_with_meta_and_links) } + + func test_fullLinksExample() { + let entity = decoded( + type: ResourceObjectLinksTest.Article.self, + data: ResourceObjectLinksTest.json.data(using: .utf8)! + ) + + XCTAssertEqual(entity.links.`self`.url.absoluteString, "http://example.com/articles/1") + XCTAssertEqual(entity.relationships.author.links.`self`.url.absoluteString, "http://example.com/articles/1/relationships/author") + XCTAssertEqual(entity.relationships.author.links.related.url.absoluteString, "http://example.com/articles/1/author") + } + + func test_fullLinksExample_encode() { + test_DecodeEncodeEquality( + type: ResourceObjectLinksTest.Article.self, + data: ResourceObjectLinksTest.json.data(using: .utf8)! + ) + } } // MARK: With a Meta Attribute -extension EntityTests { +extension ResourceObjectTests { func test_MetaEntityAttributeAccessWorks() { let entity1 = TestEntityWithMetaAttribute(id: "even", attributes: .init(), @@ -634,14 +663,14 @@ extension EntityTests { meta: .none, links: .none) - XCTAssertEqual(entity1[\.metaAttribute], true) - XCTAssertEqual(entity2[\.metaAttribute], false) + XCTAssertEqual(entity1.metaAttribute, true) + XCTAssertEqual(entity2.metaAttribute, false) } } // MARK: With a Meta Relationship -extension EntityTests { +extension ResourceObjectTests { func test_MetaEntityRelationshipAccessWorks() { let entity1 = TestEntityWithMetaRelationship(id: "even", attributes: .none, @@ -651,10 +680,20 @@ extension EntityTests { XCTAssertEqual(entity1 ~> \.metaRelationship, "hello") } + + func test_toManyMetaRelationshipAccessWorks() { + let entity1 = TestEntityWithMetaRelationship(id: "even", + attributes: .none, + relationships: .init(), + meta: .none, + links: .none) + + XCTAssertEqual(entity1 ~> \.toManyMetaRelationship, ["hello"]) + } } // MARK: - Test Types -extension EntityTests { +extension ResourceObjectTests { enum TestEntityType1: ResourceObjectDescription { static var jsonType: String { return "test_entities"} @@ -669,9 +708,9 @@ extension EntityTests { static var jsonType: String { return "second_test_entities"} typealias Attributes = NoAttributes - + struct Relationships: JSONAPI.Relationships { - let other: ToOneRelationship + let other: ToOneRelationship } } @@ -681,9 +720,9 @@ extension EntityTests { static var jsonType: String { return "third_test_entities"} typealias Attributes = NoAttributes - + struct Relationships: JSONAPI.Relationships { - let others: ToManyRelationship + let others: ToManyRelationship } } @@ -693,7 +732,7 @@ extension EntityTests { static var jsonType: String { return "fourth_test_entities"} struct Relationships: JSONAPI.Relationships { - let other: ToOneRelationship + let other: ToOneRelationship } struct Attributes: JSONAPI.Attributes { @@ -754,7 +793,7 @@ extension EntityTests { static var jsonType: String { return "eighth_test_entities" } typealias Relationships = NoRelationships - + struct Attributes: JSONAPI.Attributes { let string: Attribute let int: Attribute @@ -765,7 +804,7 @@ extension EntityTests { let nullToString: TransformedAttribute> } } - + typealias TestEntity8 = BasicEntity enum TestEntityType9: ResourceObjectDescription { @@ -774,15 +813,19 @@ extension EntityTests { typealias Attributes = NoAttributes public struct Relationships: JSONAPI.Relationships { - let one: ToOneRelationship + let meta: MetaRelationship + + let optionalMeta: MetaRelationship? + + let one: ToOneRelationship - let nullableOne: ToOneRelationship + let nullableOne: ToOneRelationship - let optionalOne: ToOneRelationship? + let optionalOne: ToOneRelationship? - let optionalNullableOne: ToOneRelationship? + let optionalNullableOne: ToOneRelationship? - let optionalMany: ToManyRelationship? + let optionalMany: ToManyRelationship? // a nullable many is not allowed. it should // just be an empty array. @@ -797,8 +840,8 @@ extension EntityTests { typealias Attributes = NoAttributes public struct Relationships: JSONAPI.Relationships { - let selfRef: ToOneRelationship - let selfRefs: ToManyRelationship + let selfRef: ToOneRelationship + let selfRefs: ToManyRelationship } } @@ -823,21 +866,37 @@ extension EntityTests { public struct Relationships: JSONAPI.Relationships { public init() { + optionalMeta = nil optionalOne = nil optionalNullableOne = nil optionalMany = nil } - let optionalOne: ToOneRelationship? + let optionalMeta: MetaRelationship? - let optionalNullableOne: ToOneRelationship? + let optionalOne: ToOneRelationship? - let optionalMany: ToManyRelationship? + let optionalNullableOne: ToOneRelationship? + + let optionalMany: ToManyRelationship? } } typealias TestEntity12 = BasicEntity + enum TestEntityOptionalMetaType: JSONAPI.ResourceObjectDescription { + public static var jsonType: String { return "test_entities" } + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships + } + + struct UnimportantMeta: JSONAPI.Meta { + let property1: String + } + + typealias TestEntityOptionalMeta = JSONAPI.ResourceObject + enum UnidentifiedTestEntityType: ResourceObjectDescription { public static var jsonType: String { return "unidentified_test_entities" } @@ -878,11 +937,17 @@ extension EntityTests { typealias Attributes = NoAttributes struct Relationships: JSONAPI.Relationships { - var metaRelationship: (TestEntityWithMetaRelationship) -> TestEntity1.Identifier { + var metaRelationship: (TestEntityWithMetaRelationship) -> TestEntity1.ID { return { entity in - return TestEntity1.Identifier(rawValue: "hello") + return TestEntity1.ID(rawValue: "hello") } } + + var toManyMetaRelationship: (TestEntityWithMetaRelationship) -> [TestEntity1.ID] { + return { entity in + return [TestEntity1.ID.id(from: "hello")] + } + } } } @@ -893,19 +958,19 @@ extension EntityTests { return String(from) } } - + enum IntPlusOneHundred: Transformer { public static func transform(_ from: Int) -> Int { return from + 100 } } - + enum IntToDouble: Transformer { public static func transform(_ from: Int) -> Double { return Double(from) } } - + enum OptionalToString: Transformer { public static func transform(_ from: T?) -> String { return String(describing: from) @@ -934,3 +999,61 @@ extension EntityTests { let link1: Link } } + +extension Foundation.URL : JSONAPIURL {} + +enum ResourceObjectLinksTest { + struct PersonStubDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "people" + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships + } + + typealias Person = JSONAPI.ResourceObject + + struct ArticleAuthorRelationshipLinks: JSONAPI.Links { + let `self`: JSONAPI.Link + let related: JSONAPI.Link + } + + struct ArticleLinks: JSONAPI.Links { + let `self`: JSONAPI.Link + } + + struct ArticleDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "articles" + + struct Attributes: JSONAPI.Attributes { + let title: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let author: ToOneRelationship + } + } + + typealias Article = JSONAPI.ResourceObject + + static let json = """ + { + "type": "articles", + "id": "1", + "attributes": { + "title": "Rails is Omakase" + }, + "relationships": { + "author": { + "links": { + "self": "http://example.com/articles/1/relationships/author", + "related": "http://example.com/articles/1/author" + }, + "data": { "type": "people", "id": "9" } + } + }, + "links": { + "self": "http://example.com/articles/1" + } + } + """ +} diff --git a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift similarity index 59% rename from Tests/JSONAPITests/Entity/stubs/EntityStubs.swift rename to Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift index 58343ce..1973276 100644 --- a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift +++ b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift @@ -233,6 +233,18 @@ let entity_optional_not_omitted_relationship = """ "id": "1", "type": "ninth_test_entities", "relationships": { + "meta": { + "meta": { + "x": "world", + "y": 5 + } + }, + "optionalMeta": { + "meta": { + "x": "world", + "y": 5 + } + }, "nullableOne": { "data": { "id": "3323", @@ -260,6 +272,12 @@ let entity_optional_nullable_nulled_relationship = """ "id": "1", "type": "ninth_test_entities", "relationships": { + "meta": { + "meta": { + "x": "world", + "y": 5 + } + }, "nullableOne": { "data": { "id": "3323", @@ -284,6 +302,12 @@ let entity_omitted_relationship = """ "id": "1", "type": "ninth_test_entities", "relationships": { + "meta": { + "meta": { + "x": "world", + "y": 5 + } + }, "nullableOne": { "data": { "id": "3323", @@ -305,6 +329,12 @@ let entity_optional_to_many_relationship_not_omitted = """ "id": "1", "type": "ninth_test_entities", "relationships": { + "meta": { + "meta": { + "x": "world", + "y": 5 + } + }, "nullableOne": { "data": { "id": "3323", @@ -334,6 +364,12 @@ let entity_nulled_relationship = """ "id": "1", "type": "ninth_test_entities", "relationships": { + "meta": { + "meta": { + "x": "world", + "y": 5 + } + }, "nullableOne": { "data": null }, @@ -393,6 +429,243 @@ 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_no_id = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": { + "type": "thirteenth_test_entities" + } + } + } +} +""".data(using: .utf8)! + +let entity_required_relationship_no_type = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": { + "id": "10" + } + } + } +} +""".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 new file mode 100644 index 0000000..2625989 --- /dev/null +++ b/Tests/JSONAPITests/SparseFields/SparseFieldEncoderTests.swift @@ -0,0 +1,351 @@ +// +// SparseFieldEncoderTests.swift +// +// +// Created by Mathew Polzin on 8/5/19. +// + +import XCTest +@testable import JSONAPI +import Foundation + +class SparseFieldEncoderTests: XCTestCase { + func test_AccurateCodingPath() { + let encoder = JSONEncoder() + XCTAssertThrowsError(try encoder.encode(Wrapper())) + + do { + let _ = try encoder.encode(Wrapper()) + } catch let err as Wrapper.OuterFail.FailError { + XCTAssertEqual(err.path.first as? Wrapper.OuterFail.CodingKeys, Wrapper.OuterFail.CodingKeys.inner) + } catch { + XCTFail("received unexpected error during test") + } + } + + func test_SkipsOmittedFields() { + let encoder = JSONEncoder() + + // does not throw because we omit the field that would have failed + XCTAssertNoThrow(try encoder.encode(Wrapper(fields: []))) + } + + func test_EverythingArsenal_allOn() { + let encoder = JSONEncoder() + + let allThingsOn = try! encoder.encode(EverythingWrapper(fields: EverythingWrapper.EverythingWrapped.CodingKeys.allCases)) + + let allThingsOnDeserialized = try! JSONSerialization.jsonObject(with: allThingsOn, + options: []) as! [String: Any] + + XCTAssertNil(allThingsOnDeserialized["omittable"]) + XCTAssertNotNil(allThingsOnDeserialized["nullable"] as? NSNull) + XCTAssertEqual(allThingsOnDeserialized["bool"] as? Bool, true) + XCTAssertEqual(allThingsOnDeserialized["double"] as? Double, 10.5) + XCTAssertEqual(allThingsOnDeserialized["string"] as? String, "hello") + // For latest versions of MacOS and all versions of Linux, floats + // decode as doubles. + XCTAssertEqual(allThingsOnDeserialized["float"] as? Double, 1.2) + XCTAssertEqual(allThingsOnDeserialized["int"] as? Int, 3) + XCTAssertEqual(allThingsOnDeserialized["int8"] as? Int8, 4) + XCTAssertEqual(allThingsOnDeserialized["int16"] as? Int16, 5) + XCTAssertEqual(allThingsOnDeserialized["int32"] as? Int32, 6) + XCTAssertEqual(allThingsOnDeserialized["int64"] as? Int64, 7) + XCTAssertEqual(allThingsOnDeserialized["uint"] as? UInt, 8) + XCTAssertEqual(allThingsOnDeserialized["uint8"] as? UInt8, 9) + XCTAssertEqual(allThingsOnDeserialized["uint16"] as? UInt16, 10) + XCTAssertEqual(allThingsOnDeserialized["uint32"] as? UInt32, 11) + XCTAssertEqual(allThingsOnDeserialized["uint64"] as? UInt64, 12) + XCTAssertEqual(allThingsOnDeserialized["nested"] as? String, "world") + } + + func test_EverythingArsenal_allOff() { + let encoder = JSONEncoder() + + let allThingsOn = try! encoder.encode(EverythingWrapper(fields: [])) + + let allThingsOnDeserialized = try! JSONSerialization.jsonObject(with: allThingsOn, + options: []) as! [String: Any] + + XCTAssertNil(allThingsOnDeserialized["omittable"]) + XCTAssertNil(allThingsOnDeserialized["nullable"]) + XCTAssertNil(allThingsOnDeserialized["bool"]) + XCTAssertNil(allThingsOnDeserialized["double"]) + XCTAssertNil(allThingsOnDeserialized["string"]) + XCTAssertNil(allThingsOnDeserialized["float"]) + XCTAssertNil(allThingsOnDeserialized["int"]) + XCTAssertNil(allThingsOnDeserialized["int8"]) + XCTAssertNil(allThingsOnDeserialized["int16"]) + XCTAssertNil(allThingsOnDeserialized["int32"]) + XCTAssertNil(allThingsOnDeserialized["int64"]) + XCTAssertNil(allThingsOnDeserialized["uint"]) + XCTAssertNil(allThingsOnDeserialized["uint8"]) + XCTAssertNil(allThingsOnDeserialized["uint16"]) + XCTAssertNil(allThingsOnDeserialized["uint32"]) + XCTAssertNil(allThingsOnDeserialized["uint64"]) + XCTAssertNil(allThingsOnDeserialized["nested"]) + XCTAssertEqual(allThingsOnDeserialized.count, 0) + } + + func test_NilEncode() { + let encoder = JSONEncoder() + + let nilOn = try! encoder.encode(NilWrapper(fields: [.hello])) + let nilOff = try! encoder.encode(NilWrapper(fields: [])) + + let nilOnDeserialized = try! JSONSerialization.jsonObject(with: nilOn, + options: []) as! [String: Any] + + let nilOffDeserialized = try! JSONSerialization.jsonObject(with: nilOff, + options: []) as! [String: Any] + + XCTAssertEqual(nilOnDeserialized.count, 1) + XCTAssertNotNil(nilOnDeserialized["hello"] as? NSNull) + XCTAssertEqual(nilOffDeserialized.count, 0) + } + + func test_NestedContainers() { + let encoder = JSONEncoder() + + let nestedOn = try! encoder.encode(NestedWrapper(fields: [.hello, .world])) + let nestedOff = try! encoder.encode(NestedWrapper(fields: [])) + + let nestedOnDeserialized = try! JSONSerialization.jsonObject(with: nestedOn, + options: []) as! [String: Any] + let nestedOffDeserialized = try! JSONSerialization.jsonObject(with: nestedOff, + options: []) as! [String: Any] + + XCTAssertEqual(nestedOnDeserialized.count, 2) + XCTAssertEqual((nestedOnDeserialized["hello"] as? [String: Bool])?["nestedKey"], true) + XCTAssertEqual((nestedOnDeserialized["world"] as? [Bool])?.first, false) + + // NOTE: When a nested container is explicitly requested, + // the best we can do to omit the field is to encode + // nothing _within_ the nested container. + XCTAssertEqual(nestedOffDeserialized.count, 2) + // TODO: The container currently does not encode empty object + // for the keyed nested container but I think it should. + XCTAssertEqual((nestedOffDeserialized["hello"] as? [String: Bool])?.count, 1) + // TODO: The container currently does not encode empty array + // for the unkeyed nested container but I think it should. + XCTAssertEqual((nestedOffDeserialized["world"] as? [Bool])?.count, 1) + } + + func test_SuperEncoderIsStillSparse() { + let encoder = JSONEncoder() + + let superOn = try! encoder.encode(SuperWrapper(fields: [.hello, .world])) + let superOff = try! encoder.encode(SuperWrapper(fields: [])) + + let superOnDeserialized = try! JSONSerialization.jsonObject(with: superOn, + options: []) as! [String: Any] + let superOffDeserialized = try! JSONSerialization.jsonObject(with: superOff, + options: []) as! [String: Any] + + XCTAssertEqual(superOnDeserialized.count, 2) + XCTAssertEqual((superOnDeserialized["hello"] as? [String: Bool])?["hello"], true) + XCTAssertEqual((superOnDeserialized["super"] as? [String: Bool])?["world"], false) + + // NOTE: When explicitly requesting a super encoder + // the best we can do is tell the super encoder only + // to encode the same keys + XCTAssertEqual(superOffDeserialized.count, 2) + XCTAssertEqual((superOffDeserialized["hello"] as? [String: Bool])?.count, 0) + XCTAssertEqual((superOffDeserialized["super"] as? [String: Bool])?.count, 0) + } +} + +extension SparseFieldEncoderTests { + struct Wrapper: Encodable { + + let fields: [OuterFail.CodingKeys] + + init(fields: [OuterFail.CodingKeys] = OuterFail.CodingKeys.allCases) { + self.fields = fields + } + + func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + try OuterFail(inner: .init()).encode(to: sparseEncoder) + } + + struct OuterFail: Encodable { + let inner: InnerFail + + public enum CodingKeys: String, Equatable, CaseIterable, CodingKey { + case inner + } + + struct InnerFail: Encodable { + func encode(to encoder: Encoder) throws { + + throw FailError(path: encoder.codingPath) + } + } + + struct FailError: Swift.Error { + let path: [CodingKey] + } + } + } + + struct EverythingWrapper: Encodable { + let fields: [EverythingWrapped.CodingKeys] + + func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + + try EverythingWrapped(omittable: nil, + nullable: .init(value: nil), + bool: true, + double: 10.5, + string: "hello", + float: 1.2, + int: 3, + int8: 4, + int16: 5, + int32: 6, + int64: 7, + uint: 8, + uint8: 9, + uint16: 10, + uint32: 11, + uint64: 12, + nested: .init(value: "world")) + .encode(to: sparseEncoder) + } + + struct EverythingWrapped: Encodable { + let omittable: Int? + let nullable: Attribute + let bool: Bool + let double: Double + let string: String + let float: Float + let int: Int + let int8: Int8 + let int16: Int16 + let int32: Int32 + let int64: Int64 + let uint: UInt + let uint8: UInt8 + let uint16: UInt16 + let uint32: UInt32 + let uint64: UInt64 + let nested: Attribute + + enum CodingKeys: String, Equatable, CaseIterable, CodingKey { + case omittable + case nullable + case bool + case double + case string + case float + case int + case int8 + case int16 + case int32 + case int64 + case uint + case uint8 + case uint16 + case uint32 + case uint64 + case nested + } + } + } + + struct NilWrapper: Encodable { + let fields: [NilWrapped.CodingKeys] + + func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + + try NilWrapped() + .encode(to: sparseEncoder) + } + + struct NilWrapped: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeNil(forKey: .hello) + } + + enum CodingKeys: String, Equatable, CodingKey { + case hello + } + } + } + + struct NestedWrapper: Encodable { + let fields: [NestedWrapped.CodingKeys] + + func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + + try NestedWrapped() + .encode(to: sparseEncoder) + } + + struct NestedWrapped: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + var nestedContainer1 = container.nestedContainer(keyedBy: NestedKeys.self, forKey: .hello) + + var nestedContainer2 = container.nestedUnkeyedContainer(forKey: .world) + + try nestedContainer1.encode(true, forKey: .nestedKey) + try nestedContainer2.encode(false) + } + + enum CodingKeys: String, Equatable, CodingKey { + case hello + case world + } + + enum NestedKeys: String, CodingKey { + case nestedKey + } + } + } + + struct SuperWrapper: Encodable { + let fields: [SuperWrapped.CodingKeys] + + func encode(to encoder: Encoder) throws { + let sparseEncoder = SparseFieldEncoder(wrapping: encoder, + encoding: fields) + + try SuperWrapped() + .encode(to: sparseEncoder) + } + + struct SuperWrapped: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + let superEncoder1 = container.superEncoder(forKey: .hello) + + let superEncoder2 = container.superEncoder() + + var container1 = superEncoder1.container(keyedBy: CodingKeys.self) + var container2 = superEncoder2.container(keyedBy: CodingKeys.self) + + try container1.encode(true, forKey: .hello) + try container2.encode(false, forKey: .world) + } + + enum CodingKeys: String, Equatable, CodingKey { + case hello + case world + } + } + } +} diff --git a/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift b/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift new file mode 100644 index 0000000..6090d19 --- /dev/null +++ b/Tests/JSONAPITests/SparseFields/SparseFieldsetTests.swift @@ -0,0 +1,180 @@ +// +// SparseFieldsetTests.swift +// +// +// Created by Mathew Polzin on 8/4/19. +// + +import XCTest +import Foundation +import JSONAPI +import JSONAPITesting + +class SparseFieldsetTests: XCTestCase { + func test_FullEncode() { + 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 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?.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) + } + + func test_PartialEncode() { + 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?.count, 3) + XCTAssertEqual(attributesDict?["bool"] as? Bool, + testEverythingObject.bool) + XCTAssertNil(attributesDict?["int"]) + XCTAssertNil(attributesDict?["double"]) + XCTAssertEqual(attributesDict?["string"] as? String, + testEverythingObject.string) + XCTAssertNil(attributesDict?["nestedStruct"]) + XCTAssertNil(attributesDict?["nestedEnum"]) + XCTAssertEqual(attributesDict?["array"] as? [Bool], + testEverythingObject.array) + XCTAssertNil(attributesDict?["optional"]) + XCTAssertNil(attributesDict?["nullable"]) + XCTAssertNil(attributesDict?["optionalNullable"]) + } + + func test_sparseFieldsMethod() { + 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?.count, 3) + XCTAssertEqual(attributesDict?["bool"] as? Bool, + testEverythingObject.bool) + XCTAssertNil(attributesDict?["int"]) + XCTAssertNil(attributesDict?["double"]) + XCTAssertEqual(attributesDict?["string"] as? String, + testEverythingObject.string) + XCTAssertNil(attributesDict?["nestedStruct"]) + XCTAssertNil(attributesDict?["nestedEnum"]) + XCTAssertEqual(attributesDict?["array"] as? [Bool], + testEverythingObject.array) + XCTAssertNil(attributesDict?["optional"]) + XCTAssertNil(attributesDict?["nullable"]) + XCTAssertNil(attributesDict?["optionalNullable"]) + } +} + +struct EverythingTestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "everything" + + struct Attributes: JSONAPI.SparsableAttributes { + let bool: Attribute + let int: Attribute + let double: Attribute + let string: Attribute + let nestedStruct: Attribute + let nestedEnum: Attribute + + let array: Attribute<[Bool]> + let optional: Attribute? + let nullable: Attribute + let optionalNullable: Attribute? + + struct NestedStruct: Codable, Equatable { + let hello: String + } + + enum NestedEnum: String, Codable, Equatable { + case hello + case world + } + + enum CodingKeys: String, CodingKey, Equatable, CaseIterable { + case bool + case int + case double + case string + case nestedStruct + case nestedEnum + case array + case optional + case nullable + case optionalNullable + } + } + + typealias Relationships = NoRelationships +} + +typealias EverythingTest = JSONAPI.ResourceObject + +let testEverythingObject = EverythingTest(attributes: .init(bool: true, + int: 10, + double: 10.5, + string: "hello world", + nestedStruct: .init(value: .init(hello: "world")), + nestedEnum: .init(value: .hello), + array: [true, false, false], + optional: nil, + nullable: .init(value: nil), + optionalNullable: .init(value: nil)), + relationships: .none, + meta: .none, + links: .none) diff --git a/Tests/JSONAPITests/SwiftIdentifiableTests.swift b/Tests/JSONAPITests/SwiftIdentifiableTests.swift new file mode 100644 index 0000000..70f6738 --- /dev/null +++ b/Tests/JSONAPITests/SwiftIdentifiableTests.swift @@ -0,0 +1,50 @@ +// +// SwiftIdentifiableTests.swift +// +// +// Created by Mathew Polzin on 5/29/20. +// + +import JSONAPI +import XCTest + +final class SwiftIdentifiableTests: XCTestCase { + @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) + func test_identifiableConformance() { + let t1 = TestType(attributes: .none, relationships: .none, meta: .none, links: .none) + let t2 = TestType(attributes: .none, relationships: .none, meta: .none, links: .none) + + var hash = [AnyHashable: String]() + func storeErased(_ thing: T) { + hash[thing.id] = String(describing: thing.id) + } + + storeErased(t1) + storeErased(t2) + + XCTAssertEqual(hash[t1.id], String(describing: t1.id)) + XCTAssertEqual(hash[t2.id], String(describing: t2.id)) + } + + func test_Id_ID_equivalence() { + // it's not at all great to have both of these names for + // the Id type, but I could not do better than this and + // still have a typealias for the Id type on the + // ResourceObjectProxy protocol. One protocol's typealias + // will collide with anotehr protocol's associatedtype in + // very ugly ways. + + XCTAssert(TestType.ID.self == TestType.Id.self) + + XCTAssertEqual(TestType.ID(rawValue: "hello"), TestType.Id(rawValue: "hello")) + } +} + +fileprivate enum TestDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "test" + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships +} + +fileprivate typealias TestType = ResourceObject diff --git a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift index a2eeb56..75a93e7 100644 --- a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift +++ b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift @@ -8,12 +8,24 @@ import Foundation import XCTest +let testDecoder = JSONDecoder() +let testEncoder: JSONEncoder = { + let encoder = JSONEncoder() + if #available(OSX 10.13, iOS 11.0, *) { + encoder.outputFormatting = .sortedKeys + } + #if os(Linux) + encoder.outputFormatting = .sortedKeys + #endif + return encoder +}() + 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/Test Helpers/String+CreatableRawIdType.swift b/Tests/JSONAPITests/Test Helpers/String+CreatableRawIdType.swift index dd3c8f7..282325a 100644 --- a/Tests/JSONAPITests/Test Helpers/String+CreatableRawIdType.swift +++ b/Tests/JSONAPITests/Test Helpers/String+CreatableRawIdType.swift @@ -7,7 +7,7 @@ import JSONAPI -private var uniqueStringCounter = 0 +nonisolated(unsafe) private var uniqueStringCounter = 0 extension String: CreatableRawIdType { public static func unique() -> String { diff --git a/Tests/JSONAPITests/XCTestManifests.swift b/Tests/JSONAPITests/XCTestManifests.swift deleted file mode 100644 index 32b0c0a..0000000 --- a/Tests/JSONAPITests/XCTestManifests.swift +++ /dev/null @@ -1,385 +0,0 @@ -import XCTest - -extension APIDescriptionTests { - static let __allTests = [ - ("test_empty", test_empty), - ("test_failsMissingMeta", test_failsMissingMeta), - ("test_NoDescriptionString", test_NoDescriptionString), - ("test_WithMeta", test_WithMeta), - ("test_WithVersion", test_WithVersion), - ("test_WithVersionAndMeta", test_WithVersionAndMeta), - ] -} - -extension AttributeTests { - static let __allTests = [ - ("test_AttributeConstructor", test_AttributeConstructor), - ("test_EncodedPrimitives", test_EncodedPrimitives), - ("test_NullableIsEqualToNonNullableIfNotNil", test_NullableIsEqualToNonNullableIfNotNil), - ("test_NullableIsNullIfNil", test_NullableIsNullIfNil), - ("test_TransformedAttributeNoThrow", test_TransformedAttributeNoThrow), - ("test_TransformedAttributeReversNoThrow", test_TransformedAttributeReversNoThrow), - ("test_TransformedAttributeThrows", test_TransformedAttributeThrows), - ] -} - -extension Attribute_FunctorTests { - static let __allTests = [ - ("test_mapGuaranteed", test_mapGuaranteed), - ("test_mapOptionalFailure", test_mapOptionalFailure), - ("test_mapOptionalSuccess", test_mapOptionalSuccess), - ] -} - -extension ComputedPropertiesTests { - static let __allTests = [ - ("test_ComputedAttributeAccess", test_ComputedAttributeAccess), - ("test_ComputedNonAttributeAccess", test_ComputedNonAttributeAccess), - ("test_ComputedRelationshipAccess", test_ComputedRelationshipAccess), - ("test_DecodeIgnoresComputed", test_DecodeIgnoresComputed), - ("test_EncodeIgnoresComputed", test_EncodeIgnoresComputed), - ] -} - -extension CustomAttributesTests { - static let __allTests = [ - ("test_customDecode", test_customDecode), - ("test_customEncode", test_customEncode), - ("test_customKeysDecode", test_customKeysDecode), - ("test_customKeysEncode", test_customKeysEncode), - ] -} - -extension DocumentTests { - static let __allTests = [ - ("test_errorDocumentFailsWithNoAPIDescription", test_errorDocumentFailsWithNoAPIDescription), - ("test_errorDocumentNoMeta", test_errorDocumentNoMeta), - ("test_errorDocumentNoMeta_encode", test_errorDocumentNoMeta_encode), - ("test_errorDocumentNoMetaWithAPIDescription", test_errorDocumentNoMetaWithAPIDescription), - ("test_errorDocumentNoMetaWithAPIDescription_encode", test_errorDocumentNoMetaWithAPIDescription_encode), - ("test_manyDocumentNoIncludes", test_manyDocumentNoIncludes), - ("test_manyDocumentNoIncludes_encode", test_manyDocumentNoIncludes_encode), - ("test_manyDocumentNoIncludesWithAPIDescription", test_manyDocumentNoIncludesWithAPIDescription), - ("test_manyDocumentNoIncludesWithAPIDescription_encode", test_manyDocumentNoIncludesWithAPIDescription_encode), - ("test_manyDocumentSomeIncludes", test_manyDocumentSomeIncludes), - ("test_manyDocumentSomeIncludes_encode", test_manyDocumentSomeIncludes_encode), - ("test_manyDocumentSomeIncludesWithAPIDescription", test_manyDocumentSomeIncludesWithAPIDescription), - ("test_manyDocumentSomeIncludesWithAPIDescription_encode", test_manyDocumentSomeIncludesWithAPIDescription_encode), - ("test_MergeBodyDataBasic", test_MergeBodyDataBasic), - ("test_MergeBodyDataWithMergeFunctions", test_MergeBodyDataWithMergeFunctions), - ("test_metaDataDocument", test_metaDataDocument), - ("test_metaDataDocument_encode", test_metaDataDocument_encode), - ("test_metaDataDocumentFailsIfMissingAPIDescription", test_metaDataDocumentFailsIfMissingAPIDescription), - ("test_metaDataDocumentWithAPIDescription", test_metaDataDocumentWithAPIDescription), - ("test_metaDataDocumentWithAPIDescription_encode", test_metaDataDocumentWithAPIDescription_encode), - ("test_metaDataDocumentWithLinks", test_metaDataDocumentWithLinks), - ("test_metaDataDocumentWithLinks_encode", test_metaDataDocumentWithLinks_encode), - ("test_metaDataDocumentWithLinksWithAPIDescription", test_metaDataDocumentWithLinksWithAPIDescription), - ("test_metaDataDocumentWithLinksWithAPIDescription_encode", test_metaDataDocumentWithLinksWithAPIDescription_encode), - ("test_metaDocumentMissingMeta", test_metaDocumentMissingMeta), - ("test_singleDocument_PolyPrimaryResource", test_singleDocument_PolyPrimaryResource), - ("test_singleDocument_PolyPrimaryResource_encode", test_singleDocument_PolyPrimaryResource_encode), - ("test_singleDocument_PolyPrimaryResourceWithAPIDescription", test_singleDocument_PolyPrimaryResourceWithAPIDescription), - ("test_singleDocument_PolyPrimaryResourceWithAPIDescription_encode", test_singleDocument_PolyPrimaryResourceWithAPIDescription_encode), - ("test_singleDocumentNoIncludes", test_singleDocumentNoIncludes), - ("test_singleDocumentNoIncludes_encode", test_singleDocumentNoIncludes_encode), - ("test_singleDocumentNoIncludesAddIncludingType", test_singleDocumentNoIncludesAddIncludingType), - ("test_singleDocumentNoIncludesMissingAPIDescription", test_singleDocumentNoIncludesMissingAPIDescription), - ("test_singleDocumentNoIncludesMissingMetadata", test_singleDocumentNoIncludesMissingMetadata), - ("test_singleDocumentNoIncludesOptionalNotNull", test_singleDocumentNoIncludesOptionalNotNull), - ("test_singleDocumentNoIncludesOptionalNotNull_encode", test_singleDocumentNoIncludesOptionalNotNull_encode), - ("test_singleDocumentNoIncludesOptionalNotNullWithAPIDescription", test_singleDocumentNoIncludesOptionalNotNullWithAPIDescription), - ("test_singleDocumentNoIncludesOptionalNotNullWithAPIDescription_encode", test_singleDocumentNoIncludesOptionalNotNullWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithAPIDescription", test_singleDocumentNoIncludesWithAPIDescription), - ("test_singleDocumentNoIncludesWithAPIDescription_encode", test_singleDocumentNoIncludesWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithLinks", test_singleDocumentNoIncludesWithLinks), - ("test_singleDocumentNoIncludesWithLinks_encode", test_singleDocumentNoIncludesWithLinks_encode), - ("test_singleDocumentNoIncludesWithLinksWithAPIDescription", test_singleDocumentNoIncludesWithLinksWithAPIDescription), - ("test_singleDocumentNoIncludesWithLinksWithAPIDescription_encode", test_singleDocumentNoIncludesWithLinksWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithMetadata", test_singleDocumentNoIncludesWithMetadata), - ("test_singleDocumentNoIncludesWithMetadata_encode", test_singleDocumentNoIncludesWithMetadata_encode), - ("test_singleDocumentNoIncludesWithMetadataMissingLinks", test_singleDocumentNoIncludesWithMetadataMissingLinks), - ("test_singleDocumentNoIncludesWithMetadataWithAPIDescription", test_singleDocumentNoIncludesWithMetadataWithAPIDescription), - ("test_singleDocumentNoIncludesWithMetadataWithAPIDescription_encode", test_singleDocumentNoIncludesWithMetadataWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithMetadataWithLinks", test_singleDocumentNoIncludesWithMetadataWithLinks), - ("test_singleDocumentNoIncludesWithMetadataWithLinks_encode", test_singleDocumentNoIncludesWithMetadataWithLinks_encode), - ("test_singleDocumentNoIncludesWithMetadataWithLinksWithAPIDescription", test_singleDocumentNoIncludesWithMetadataWithLinksWithAPIDescription), - ("test_singleDocumentNoIncludesWithMetadataWithLinksWithAPIDescription_encode", test_singleDocumentNoIncludesWithMetadataWithLinksWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithSomeIncludesMetadataWithLinks_encode", test_singleDocumentNoIncludesWithSomeIncludesMetadataWithLinks_encode), - ("test_singleDocumentNoIncludesWithSomeIncludesMetadataWithLinksWithAPIDescription_encode", test_singleDocumentNoIncludesWithSomeIncludesMetadataWithLinksWithAPIDescription_encode), - ("test_singleDocumentNoIncludesWithSomeIncludesWithMetadataWithLinks", test_singleDocumentNoIncludesWithSomeIncludesWithMetadataWithLinks), - ("test_singleDocumentNoIncludesWithSomeIncludesWithMetadataWithLinksWithAPIDescription", test_singleDocumentNoIncludesWithSomeIncludesWithMetadataWithLinksWithAPIDescription), - ("test_singleDocumentNonOptionalFailsOnNull", test_singleDocumentNonOptionalFailsOnNull), - ("test_singleDocumentNull", test_singleDocumentNull), - ("test_singleDocumentNull_encode", test_singleDocumentNull_encode), - ("test_singleDocumentNullFailsWithNoAPIDescription", test_singleDocumentNullFailsWithNoAPIDescription), - ("test_singleDocumentNullWithAPIDescription", test_singleDocumentNullWithAPIDescription), - ("test_singleDocumentNullWithAPIDescription_encode", test_singleDocumentNullWithAPIDescription_encode), - ("test_singleDocumentSomeIncludes", test_singleDocumentSomeIncludes), - ("test_singleDocumentSomeIncludes_encode", test_singleDocumentSomeIncludes_encode), - ("test_singleDocumentSomeIncludesAddIncludes", test_singleDocumentSomeIncludesAddIncludes), - ("test_singleDocumentSomeIncludesWithAPIDescription", test_singleDocumentSomeIncludesWithAPIDescription), - ("test_singleDocumentSomeIncludesWithAPIDescription_encode", test_singleDocumentSomeIncludesWithAPIDescription_encode), - ("test_singleDocumentSomeIncludesWithMetadata", test_singleDocumentSomeIncludesWithMetadata), - ("test_singleDocumentSomeIncludesWithMetadata_encode", test_singleDocumentSomeIncludesWithMetadata_encode), - ("test_singleDocumentSomeIncludesWithMetadataWithAPIDescription", test_singleDocumentSomeIncludesWithMetadataWithAPIDescription), - ("test_singleDocumentSomeIncludesWithMetadataWithAPIDescription_encode", test_singleDocumentSomeIncludesWithMetadataWithAPIDescription_encode), - ("test_unknownErrorDocumentAddIncludes", test_unknownErrorDocumentAddIncludes), - ("test_unknownErrorDocumentAddIncludingType", test_unknownErrorDocumentAddIncludingType), - ("test_unknownErrorDocumentMissingLinks", test_unknownErrorDocumentMissingLinks), - ("test_unknownErrorDocumentMissingLinks_encode", test_unknownErrorDocumentMissingLinks_encode), - ("test_unknownErrorDocumentMissingLinksWithAPIDescription", test_unknownErrorDocumentMissingLinksWithAPIDescription), - ("test_unknownErrorDocumentMissingLinksWithAPIDescription_encode", test_unknownErrorDocumentMissingLinksWithAPIDescription_encode), - ("test_unknownErrorDocumentMissingMeta", test_unknownErrorDocumentMissingMeta), - ("test_unknownErrorDocumentMissingMeta_encode", test_unknownErrorDocumentMissingMeta_encode), - ("test_unknownErrorDocumentMissingMetaWithAPIDescription", test_unknownErrorDocumentMissingMetaWithAPIDescription), - ("test_unknownErrorDocumentMissingMetaWithAPIDescription_encode", test_unknownErrorDocumentMissingMetaWithAPIDescription_encode), - ("test_unknownErrorDocumentNoMeta", test_unknownErrorDocumentNoMeta), - ("test_unknownErrorDocumentNoMeta_encode", test_unknownErrorDocumentNoMeta_encode), - ("test_unknownErrorDocumentNoMetaWithAPIDescription", test_unknownErrorDocumentNoMetaWithAPIDescription), - ("test_unknownErrorDocumentNoMetaWithAPIDescription_encode", test_unknownErrorDocumentNoMetaWithAPIDescription_encode), - ("test_unknownErrorDocumentWithLinks", test_unknownErrorDocumentWithLinks), - ("test_unknownErrorDocumentWithLinks_encode", test_unknownErrorDocumentWithLinks_encode), - ("test_unknownErrorDocumentWithLinksWithAPIDescription", test_unknownErrorDocumentWithLinksWithAPIDescription), - ("test_unknownErrorDocumentWithLinksWithAPIDescription_encode", test_unknownErrorDocumentWithLinksWithAPIDescription_encode), - ("test_unknownErrorDocumentWithMeta", test_unknownErrorDocumentWithMeta), - ("test_unknownErrorDocumentWithMeta_encode", test_unknownErrorDocumentWithMeta_encode), - ("test_unknownErrorDocumentWithMetaWithAPIDescription", test_unknownErrorDocumentWithMetaWithAPIDescription), - ("test_unknownErrorDocumentWithMetaWithAPIDescription_encode", test_unknownErrorDocumentWithMetaWithAPIDescription_encode), - ("test_unknownErrorDocumentWithMetaWithLinks", test_unknownErrorDocumentWithMetaWithLinks), - ("test_unknownErrorDocumentWithMetaWithLinks_encode", test_unknownErrorDocumentWithMetaWithLinks_encode), - ("test_unknownErrorDocumentWithMetaWithLinksWithAPIDescription", test_unknownErrorDocumentWithMetaWithLinksWithAPIDescription), - ("test_unknownErrorDocumentWithMetaWithLinksWithAPIDescription_encode", test_unknownErrorDocumentWithMetaWithLinksWithAPIDescription_encode), - ] -} - -extension EntityTests { - static let __allTests = [ - ("test_copyIdentifiedByType", test_copyIdentifiedByType), - ("test_copyIdentifiedByValue", test_copyIdentifiedByValue), - ("test_copyWithNewId", test_copyWithNewId), - ("test_entityAllAttribute", test_entityAllAttribute), - ("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_encode", test_EntityNoRelationshipsSomeAttributes_encode), - ("test_entityOneNullAndOneOmittedAttribute", test_entityOneNullAndOneOmittedAttribute), - ("test_entityOneNullAndOneOmittedAttribute_encode", test_entityOneNullAndOneOmittedAttribute_encode), - ("test_entityOneNullAttribute", test_entityOneNullAttribute), - ("test_entityOneNullAttribute_encode", test_entityOneNullAttribute_encode), - ("test_entityOneOmittedAttribute", test_entityOneOmittedAttribute), - ("test_entityOneOmittedAttribute_encode", test_entityOneOmittedAttribute_encode), - ("test_EntitySomeRelationshipsNoAttributes", test_EntitySomeRelationshipsNoAttributes), - ("test_EntitySomeRelationshipsNoAttributes_encode", test_EntitySomeRelationshipsNoAttributes_encode), - ("test_EntitySomeRelationshipsSomeAttributes", test_EntitySomeRelationshipsSomeAttributes), - ("test_EntitySomeRelationshipsSomeAttributes_encode", test_EntitySomeRelationshipsSomeAttributes_encode), - ("test_EntitySomeRelationshipsSomeAttributesWithLinks", test_EntitySomeRelationshipsSomeAttributesWithLinks), - ("test_EntitySomeRelationshipsSomeAttributesWithLinks_encode", test_EntitySomeRelationshipsSomeAttributesWithLinks_encode), - ("test_EntitySomeRelationshipsSomeAttributesWithMeta", test_EntitySomeRelationshipsSomeAttributesWithMeta), - ("test_EntitySomeRelationshipsSomeAttributesWithMeta_encode", test_EntitySomeRelationshipsSomeAttributesWithMeta_encode), - ("test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks", test_EntitySomeRelationshipsSomeAttributesWithMetaAndLinks), - ("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_encode", test_IntToString_encode), - ("test_MetaEntityAttributeAccessWorks", test_MetaEntityAttributeAccessWorks), - ("test_MetaEntityRelationshipAccessWorks", test_MetaEntityRelationshipAccessWorks), - ("test_NonNullOptionalNullableAttribute", test_NonNullOptionalNullableAttribute), - ("test_NonNullOptionalNullableAttribute_encode", test_NonNullOptionalNullableAttribute_encode), - ("test_nullableRelationshipIsNull", test_nullableRelationshipIsNull), - ("test_nullableRelationshipIsNull_encode", test_nullableRelationshipIsNull_encode), - ("test_nullableRelationshipNotNull", test_nullableRelationshipNotNull), - ("test_nullableRelationshipNotNull_encode", test_nullableRelationshipNotNull_encode), - ("test_nullableRelationshipNotNullOrOmitted", test_nullableRelationshipNotNullOrOmitted), - ("test_nullableRelationshipNotNullOrOmitted_encode", test_nullableRelationshipNotNullOrOmitted_encode), - ("test_NullOptionalNullableAttribute", test_NullOptionalNullableAttribute), - ("test_NullOptionalNullableAttribute_encode", test_NullOptionalNullableAttribute_encode), - ("test_optional_relationship_operator_access", test_optional_relationship_operator_access), - ("test_optionalNullableRelationshipNulled", test_optionalNullableRelationshipNulled), - ("test_optionalNullableRelationshipNulled_encode", test_optionalNullableRelationshipNulled_encode), - ("test_optionalToMany_relationship_opeartor_access", test_optionalToMany_relationship_opeartor_access), - ("test_optionalToManyIsNotOmitted", test_optionalToManyIsNotOmitted), - ("test_optionalToManyIsNotOmitted_encode", test_optionalToManyIsNotOmitted_encode), - ("test_pointerWithMetaAndLinks", test_pointerWithMetaAndLinks), - ("test_relationship_access", test_relationship_access), - ("test_relationship_operator_access", test_relationship_operator_access), - ("test_relationshipIds", test_relationshipIds), - ("test_RleationshipsOfSameType", test_RleationshipsOfSameType), - ("test_RleationshipsOfSameType_encode", test_RleationshipsOfSameType_encode), - ("test_toMany_relationship_operator_access", test_toMany_relationship_operator_access), - ("test_UnidentifiedEntity", test_UnidentifiedEntity), - ("test_UnidentifiedEntity_encode", test_UnidentifiedEntity_encode), - ("test_unidentifiedEntityAttributeAccess", test_unidentifiedEntityAttributeAccess), - ("test_UnidentifiedEntityWithAttributes", test_UnidentifiedEntityWithAttributes), - ("test_UnidentifiedEntityWithAttributes_encode", test_UnidentifiedEntityWithAttributes_encode), - ("test_UnidentifiedEntityWithAttributesAndLinks", test_UnidentifiedEntityWithAttributesAndLinks), - ("test_UnidentifiedEntityWithAttributesAndLinks_encode", test_UnidentifiedEntityWithAttributesAndLinks_encode), - ("test_UnidentifiedEntityWithAttributesAndMeta", test_UnidentifiedEntityWithAttributesAndMeta), - ("test_UnidentifiedEntityWithAttributesAndMeta_encode", test_UnidentifiedEntityWithAttributesAndMeta_encode), - ("test_UnidentifiedEntityWithAttributesAndMetaAndLinks", test_UnidentifiedEntityWithAttributesAndMetaAndLinks), - ("test_UnidentifiedEntityWithAttributesAndMetaAndLinks_encode", test_UnidentifiedEntityWithAttributesAndMetaAndLinks_encode), - ] -} - -extension IncludedTests { - static let __allTests = [ - ("test_appending", test_appending), - ("test_EightDifferentIncludes", test_EightDifferentIncludes), - ("test_EightDifferentIncludes_encode", test_EightDifferentIncludes_encode), - ("test_FiveDifferentIncludes", test_FiveDifferentIncludes), - ("test_FiveDifferentIncludes_encode", test_FiveDifferentIncludes_encode), - ("test_FourDifferentIncludes", test_FourDifferentIncludes), - ("test_FourDifferentIncludes_encode", test_FourDifferentIncludes_encode), - ("test_NineDifferentIncludes", test_NineDifferentIncludes), - ("test_NineDifferentIncludes_encode", test_NineDifferentIncludes_encode), - ("test_OneInclude", test_OneInclude), - ("test_OneInclude_encode", test_OneInclude_encode), - ("test_SevenDifferentIncludes", test_SevenDifferentIncludes), - ("test_SevenDifferentIncludes_encode", test_SevenDifferentIncludes_encode), - ("test_SixDifferentIncludes", test_SixDifferentIncludes), - ("test_SixDifferentIncludes_encode", test_SixDifferentIncludes_encode), - ("test_ThreeDifferentIncludes", test_ThreeDifferentIncludes), - ("test_ThreeDifferentIncludes_encode", test_ThreeDifferentIncludes_encode), - ("test_TwoDifferentIncludes", test_TwoDifferentIncludes), - ("test_TwoDifferentIncludes_encode", test_TwoDifferentIncludes_encode), - ("test_TwoSameIncludes", test_TwoSameIncludes), - ("test_TwoSameIncludes_encode", test_TwoSameIncludes_encode), - ("test_zeroIncludes", test_zeroIncludes), - ("test_zeroIncludes_encode", test_zeroIncludes_encode), - ("test_zeroIncludes_init", test_zeroIncludes_init), - ] -} - -extension LinksTests { - static let __allTests = [ - ("test_linkFailsIfMetaNotFound", test_linkFailsIfMetaNotFound), - ("test_linkWithMetadata", test_linkWithMetadata), - ("test_linkWithMetadata_encode", test_linkWithMetadata_encode), - ("test_linkWithNoMeta", test_linkWithNoMeta), - ("test_linkWithNoMeta_encode", test_linkWithNoMeta_encode), - ("test_linkWithNoMetaWithoutOptionalLink", test_linkWithNoMetaWithoutOptionalLink), - ("test_linkWithNoMetaWithoutOptionalLink_encode", test_linkWithNoMetaWithoutOptionalLink_encode), - ("test_linkWithNullMetadata", test_linkWithNullMetadata), - ("test_linkWithNullMetadata_encode", test_linkWithNullMetadata_encode), - ] -} - -extension NonJSONAPIRelatableTests { - static let __allTests = [ - ("test_initialization1", test_initialization1), - ("test_initialization2_all_relationships_missing", test_initialization2_all_relationships_missing), - ("test_initialization2_all_relationships_there", test_initialization2_all_relationships_there), - ] -} - -extension PolyProxyTests { - static let __allTests = [ - ("test_AsymmetricEncodeDecodeUserA", test_AsymmetricEncodeDecodeUserA), - ("test_AsymmetricEncodeDecodeUserB", test_AsymmetricEncodeDecodeUserB), - ("test_generalReasonableness", test_generalReasonableness), - ("test_UserAAndBEncodeEquality", test_UserAAndBEncodeEquality), - ("test_UserADecode", test_UserADecode), - ("test_UserBDecode", test_UserBDecode), - ] -} - -extension PolyTests { - static let __allTests = [ - ("test_init_Poly0", test_init_Poly0), - ("test_init_Poly1", test_init_Poly1), - ("test_init_Poly2", test_init_Poly2), - ("test_init_Poly3", test_init_Poly3), - ("test_init_Poly4", test_init_Poly4), - ("test_init_Poly5", test_init_Poly5), - ("test_init_Poly6", test_init_Poly6), - ("test_init_Poly7", test_init_Poly7), - ("test_init_Poly8", test_init_Poly8), - ("test_init_Poly9", test_init_Poly9), - ("test_Poly0_decode_throws", test_Poly0_decode_throws), - ("test_Poly0_encode_throws", test_Poly0_encode_throws), - ("test_Poly1_decode_throws_typeNotFound", test_Poly1_decode_throws_typeNotFound), - ("test_Poly1_lookup", test_Poly1_lookup), - ("test_Poly2_decode_throws_typeNotFound", test_Poly2_decode_throws_typeNotFound), - ("test_Poly2_lookup", test_Poly2_lookup), - ("test_Poly3_decode_throws_typeNotFound", test_Poly3_decode_throws_typeNotFound), - ("test_Poly3_lookup", test_Poly3_lookup), - ("test_Poly4_decode_throws_typeNotFound", test_Poly4_decode_throws_typeNotFound), - ("test_Poly4_lookup", test_Poly4_lookup), - ("test_Poly5_decode_throws_typeNotFound", test_Poly5_decode_throws_typeNotFound), - ("test_Poly5_lookup", test_Poly5_lookup), - ("test_Poly6_decode_throws_typeNotFound", test_Poly6_decode_throws_typeNotFound), - ("test_Poly6_lookup", test_Poly6_lookup), - ("test_Poly7_decode_throws_typeNotFound", test_Poly7_decode_throws_typeNotFound), - ("test_Poly7_lookup", test_Poly7_lookup), - ("test_Poly8_decode_throws_typeNotFound", test_Poly8_decode_throws_typeNotFound), - ("test_Poly8_lookup", test_Poly8_lookup), - ("test_Poly9_decode_throws_typeNotFound", test_Poly9_decode_throws_typeNotFound), - ("test_Poly9_lookup", test_Poly9_lookup), - ] -} - -extension RelationshipTests { - static let __allTests = [ - ("test_initToManyWithEntities", test_initToManyWithEntities), - ("test_initToManyWithRelationships", test_initToManyWithRelationships), - ("test_ToManyRelationship", test_ToManyRelationship), - ("test_ToManyRelationship_encode", test_ToManyRelationship_encode), - ("test_ToManyRelationshipWithLinks", test_ToManyRelationshipWithLinks), - ("test_ToManyRelationshipWithLinks_encode", test_ToManyRelationshipWithLinks_encode), - ("test_ToManyRelationshipWithMeta", test_ToManyRelationshipWithMeta), - ("test_ToManyRelationshipWithMeta_encode", test_ToManyRelationshipWithMeta_encode), - ("test_ToManyRelationshipWithMetaAndLinks", test_ToManyRelationshipWithMetaAndLinks), - ("test_ToManyRelationshipWithMetaAndLinks_encode", test_ToManyRelationshipWithMetaAndLinks_encode), - ("test_ToManyTypeMismatch", test_ToManyTypeMismatch), - ("test_ToOneNullableIsEqualToNonNullableIfNotNil", test_ToOneNullableIsEqualToNonNullableIfNotNil), - ("test_ToOneNullableIsNullIfNil", test_ToOneNullableIsNullIfNil), - ("test_ToOneRelationship", test_ToOneRelationship), - ("test_ToOneRelationship_encode", test_ToOneRelationship_encode), - ("test_ToOneRelationshipWithLinks", test_ToOneRelationshipWithLinks), - ("test_ToOneRelationshipWithLinks_encode", test_ToOneRelationshipWithLinks_encode), - ("test_ToOneRelationshipWithMeta", test_ToOneRelationshipWithMeta), - ("test_ToOneRelationshipWithMeta_encode", test_ToOneRelationshipWithMeta_encode), - ("test_ToOneRelationshipWithMetaAndLinks", test_ToOneRelationshipWithMetaAndLinks), - ("test_ToOneRelationshipWithMetaAndLinks_encode", test_ToOneRelationshipWithMetaAndLinks_encode), - ("test_ToOneTypeMismatch", test_ToOneTypeMismatch), - ] -} - -extension ResourceBodyTests { - static let __allTests = [ - ("test_initializers", test_initializers), - ("test_manyResourceBody", test_manyResourceBody), - ("test_manyResourceBody_encode", test_manyResourceBody_encode), - ("test_manyResourceBodyEmpty", test_manyResourceBodyEmpty), - ("test_manyResourceBodyEmpty_encode", test_manyResourceBodyEmpty_encode), - ("test_manyResourceBodyMerge", test_manyResourceBodyMerge), - ("test_singleResourceBody", test_singleResourceBody), - ("test_singleResourceBody_encode", test_singleResourceBody_encode), - ] -} - -#if !os(macOS) -public func __allTests() -> [XCTestCaseEntry] { - return [ - testCase(APIDescriptionTests.__allTests), - testCase(AttributeTests.__allTests), - testCase(Attribute_FunctorTests.__allTests), - testCase(ComputedPropertiesTests.__allTests), - testCase(CustomAttributesTests.__allTests), - testCase(DocumentTests.__allTests), - testCase(EntityTests.__allTests), - testCase(IncludedTests.__allTests), - testCase(LinksTests.__allTests), - testCase(NonJSONAPIRelatableTests.__allTests), - testCase(PolyProxyTests.__allTests), - testCase(PolyTests.__allTests), - testCase(RelationshipTests.__allTests), - testCase(ResourceBodyTests.__allTests), - ] -} -#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index 18367a8..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest - -import JSONAPITests -import JSONAPITestingTests - -var tests = [XCTestCaseEntry]() -tests += JSONAPITests.__allTests() -tests += JSONAPITestingTests.__allTests() - -XCTMain(tests) diff --git a/documentation/examples/basic-example.md b/documentation/examples/basic-example.md new file mode 100644 index 0000000..2b1e9fb --- /dev/null +++ b/documentation/examples/basic-example.md @@ -0,0 +1,174 @@ + +# JSONAPI Basic Example + +We are about to walk through a basic example to show how easy it is to set up a +simple model. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +The `JSONAPI` framework relies heavily on generic types so the first step will +be to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. +For our simple example, let's create a `Person` and a `Dog`. + +```swift +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + // we will define "Dog" next + let pets: ToManyRelationship + } +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + // we could relate dogs back to their owners, but for the sake of this example + // we will instead show how you would create a resource with no relationships. + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +At this point we have two objects that can decode JSON:API responses. To +illustrate we can mock up a dog response and parse it. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +let mockBatchDogResponse = +""" +{ + "data": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() + +let dogsDocument = try! decoder.decode(BatchDocument.self, from: mockBatchDogResponse) + +let dogs = dogsDocument.body.primaryResource!.values + +print("dogs parsed: \(dogs.count ?? 0)") +``` + +To illustrate `ResourceObject` property access, we can loop over the dogs and +print their names. + +```swift +for dog in dogs { + print(dog.name) +} +``` + +Now let's parse a mocked `Person` response. + +```swift +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + } +} +""".data(using: .utf8)! + +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let personDocument = try! decoder.decode(SingleDocument.self, from: mockSinglePersonResponse) +``` + +Our `Person` object has both attributes and relationships. Generally what we care +about when accessing relationships is actually the Id(s) of the resource(s); the +loop below shows off how to access those Ids. + +```swift +let person = personDocument.body.primaryResource!.value + +let relatedDogIds = person ~> \.pets + +print("related dog Ids: \(relatedDogIds)") +``` + +To wrap things up, let's throw our dog resources into a local cache and tie +things together a bit. There are many ways to go about storing or caching +resources clientside. For additional examples of more robust solutions, take a +look at [JSONAPI-ResourceStorage](https://github.com/mattpolzin/JSONAPI-ResourceStorage). + +```swift +let dogCache = Dictionary(uniqueKeysWithValues: zip(dogs.map { $0.id }, dogs)) + +func cachedDog(_ id: Dog.Id) -> Dog? { return dogCache[id] } + +print("Our person's name is \(person.firstName) \(person.lastName).") +print("They have \((person ~> \.pets).count) pets named \((person ~> \.pets).compactMap(cachedDog).map { $0.name }.joined(separator: " and ")).") +``` + diff --git a/documentation/examples/client-server-example.md b/documentation/examples/client-server-example.md new file mode 100644 index 0000000..5bf5d83 --- /dev/null +++ b/documentation/examples/client-server-example.md @@ -0,0 +1,186 @@ +## 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. + +### Preamble (Setup shared by server and client) +```swift +// Make String a CreatableRawIdType. +var globalStringId: Int = 0 +extension String: CreatableRawIdType { + public static func unique() -> String { + globalStringId += 1 + return String(globalStringId) + } +} + +// Create a typealias because we do not expect JSON:API Resource +// Objects for this particular API to have Metadata or Links associated +// with them. We also expect them to have String Identifiers. +typealias JSONEntity = JSONAPI.ResourceObject + +// Similarly, create a typealias for unidentified entities. JSON:API +// only allows unidentified entities (i.e. no "id" field) for client +// requests that create new entities. In these situations, the server +// is expected to assign the new entity a unique ID. +typealias UnidentifiedJSONEntity = JSONAPI.ResourceObject + +// Create relationship typealiases because we do not expect +// JSON:API Relationships for this particular API to have +// Metadata or Links associated with them. +typealias ToOneRelationship = JSONAPI.ToOneRelationship +typealias ToManyRelationship = JSONAPI.ToManyRelationship + +// Create a typealias for a Document because we do not expect +// JSON:API Documents for this particular API to have Metadata, Links, +// useful Errors, or an APIDescription (The *SPEC* calls this +// "API Description" the "JSON:API Object"). +typealias Document = JSONAPI.Document> + +// MARK: Entity Definitions + +enum AuthorDescription: ResourceObjectDescription { + public static var jsonType: String { return "authors" } + + public struct Attributes: JSONAPI.Attributes { + public let name: Attribute + } + + public typealias Relationships = NoRelationships +} + +typealias Author = JSONEntity + +enum ArticleDescription: ResourceObjectDescription { + public static var jsonType: String { return "articles" } + + public struct Attributes: JSONAPI.Attributes { + public let title: Attribute + public let abstract: Attribute + } + + public struct Relationships: JSONAPI.Relationships { + public let author: ToOneRelationship + } +} + +typealias Article = JSONEntity + +// MARK: Document Definitions + +// We create a typealias to represent a document containing one Article +// and including its Author +typealias SingleArticleDocument = Document, Include1> + +// ... and a typealias to represent a batch document containing any number of Articles +typealias ManyArticleDocument = Document, Include1> +``` + +### Server Pseudo-example +```swift +// Skipping over all the API and database stuff, here's a chunk of code +// that creates a document. Note that this document is the entirety +// of a JSON:API response body. +func article(includeAuthor: Bool) -> CompoundResource { + // Let's pretend all of this is coming from a database: + + let authorId = Author.Id(rawValue: "1234") + + let article = Article( + id: .init(rawValue: "5678"), + attributes: .init( + title: .init(value: "JSON:API in Swift"), + abstract: .init(value: "Not yet written") + ), + relationships: .init(author: .init(id: authorId)), + meta: .none, + links: .none + ) + + let authorInclude: SingleArticleDocument.Include? + if includeAuthor { + let author = Author( + id: authorId, + attributes: .init(name: .init(value: "Janice Bluff")), + relationships: .none, + meta: .none, + links: .none + ) + authorInclude = .init(author) + } else { + authorInclude = nil + } + + return CompoundResource( + primary: article, + relatives: authorInclude.map { [$0] } ?? [] + ) +} + +func articleDocument(includeAuthor: Bool) -> SingleArticleDocument { + + let compoundResource = article(includeAuthor: includeAuthor) + + return SingleArticleDocument( + apiDescription: .none, + resource: compoundResource, + meta: .none, + links: .none + ) +} + +let encoder = JSONEncoder() +encoder.keyEncodingStrategy = .convertToSnakeCase +encoder.outputFormatting = .prettyPrinted + +let responseBody = articleDocument(includeAuthor: true) +let responseData = try! encoder.encode(responseBody) + +// Next step would be setting the HTTP body of a response. +// We will just print it out instead: +print("-----") +print(String(data: responseData, encoding: .utf8)!) + +// ... and if we had received a request for an article without +// including the author: +let otherResponseBody = articleDocument(includeAuthor: false) +let otherResponseData = try! encoder.encode(otherResponseBody) +print("-----") +print(String(data: otherResponseData, encoding: .utf8)!) +``` + +### Client Pseudo-example +```swift +enum NetworkError: Swift.Error { + case serverError + case quantityMismatch +} + +// Skipping over all the API stuff, here's a chunk of code that will +// decode a document. We will assume we have made a request for a +// single article including the author. +func docode(articleResponseData: Data) throws -> (article: Article, author: Author) { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let articleDocument = try decoder.decode(SingleArticleDocument.self, from: articleResponseData) + + switch articleDocument.body { + case .data(let data): + let authors = data.includes[Author.self] + + guard authors.count == 1 else { + throw NetworkError.quantityMismatch + } + + return (article: data.primary.value, author: authors[0]) + case .errors(let errors, meta: _, links: _): + throw NetworkError.serverError + } +} + +let response = try! docode(articleResponseData: responseData) + +// Next step would be to do something useful with the article and author but we will print them instead. +print("-----") +print(response.article) +print(response.author) +``` diff --git a/documentation/examples/compound-example.md b/documentation/examples/compound-example.md new file mode 100644 index 0000000..18988dd --- /dev/null +++ b/documentation/examples/compound-example.md @@ -0,0 +1,134 @@ +# JSONAPI Compound Example + +We are about to walk through an example to show how easy it is to parse JSON:API +includes. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +Next we will create similar `typealiases` for single and batch documents as we did +in the **Basic Example**, but we will allow for an include type to be specified. + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> +``` + +Now let's define a mock response containing a single person and including any +dogs that are related to that person. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + }, + "included": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! +``` + +Parsing the above response looks almost identical to in the **Basic Example**. The +key thing to note is that instead of specifying `NoIncludes` we specify +`Include1` below. This does not mean "include one dog," it means "include one +type of thing, with that type being `Dog`." The `JSONAPI` framework comes with +built-in support for `Include2<...>`, `Include3<...>` and many more. If you wanted to include +both `Person` and `Dog` resources (perhaps because your primary `Person` resource had +a "friends" relationship), you would use `Include2`. + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let includeDocument = try! decoder.decode(SingleDocument>.self, from: mockSinglePersonResponse) +``` + +The `Person` is pulled out as before with `Document.body.primaryResource`. The dogs +can be accessed from `Document.body.includes`; note that because multiple types of +things can be included, we must specify that we want things of type `Dog` by using +the `JSONAPI.Includes` subscript operator. + +```swift +let person = includeDocument.body.primaryResource!.value +let dogs = includeDocument.body.includes![Dog.self] + +print("Parsed person named \(person.firstName) \(person.lastName)") +print("Parsed dogs named \(dogs.map { $0.name }.joined(separator: " and "))") +``` + diff --git a/documentation/examples/custom-errors-example.md b/documentation/examples/custom-errors-example.md new file mode 100644 index 0000000..c575ed3 --- /dev/null +++ b/documentation/examples/custom-errors-example.md @@ -0,0 +1,153 @@ + +# JSONAPI Custom Errors Example + +We are about to walk through an example of parsing a JSON:API errors response. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +First we will define a structure that can parse each of the errors we might +expect to get back from the server. This is the one type for which the framework +does not offer a generic option but we can pretty easily pick the relevant +properties from the **Error Object** description given by JSON:API +[here](https://www.google.com/url?q=https%3A%2F%2Fjsonapi.org%2Fformat%2F%23error-objects). +We will choose only to distinguish between server and client errors for this +example but that is the tip of the iceberg if you wish to make more robust error +handling for yourself. + +```swift +enum OurExampleError: JSONAPIError { + case unknownError + case server(code: Int, description: String) + case client(code: Int, description: String) + + static var unknown: OurExampleError { return .unknownError } + + // Example decoder just switches on the status code + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let code = try Int(container.decode(String.self, forKey: .status)) + + guard let statusCode = code else { + throw DecodingError.typeMismatch(Int.self, + .init(codingPath: decoder.codingPath, + debugDescription: "Expected an integer HTTP status code.")) + } + + switch statusCode { + case 400..<500: + self = try .client(code: statusCode, description: container.decode(String.self, forKey: .detail)) + case 500..<600: + self = try .server(code: statusCode, description: container.decode(String.self, forKey: .detail)) + default: + self = .unknown + } + } + + // naturally, opposite of decoding except for needing to put something down + // for the unknown case. We choose 500 here; client won't need to encode errors + // and 500 is fitting for this situation on the server side. + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + let status: String + let detail: String + switch self { + case .server(let code, let description), + .client(let code, let description): + status = String(code) + detail = description + default: + status = "500" + detail = "Unknown problem occurred" + } + + try container.encode(status, forKey: .status) + try container.encode(detail, forKey: .detail) + } + + private enum CodingKeys: String, CodingKey { + case status + case detail + } +} +``` + +Next we will define some utility `typealiases` like we did in the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). +This time, we will specify that our `Document` type expects to parse +`OurExampleError`. + +```swift +typealias Resource = JSONAPI.ResourceObject + +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, OurExampleError> +``` + +We will reuse the `Dog` type from the **Basic Example**. We won't actually be +parsing this type because we are showing off error parsing. + +```swift +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +Now let's mock up an error response. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockErrorResponse = +""" +{ + "errors": [ + { + "status": "400", + "detail": "You made a bad request" + }, + { + "status": "500", + "detail": "The server fell over because it tried to handle your bad request" + } + ] +} +""".data(using: .utf8)! +``` + +Now we can parse the response data and switch on the response body to see if we +are dealing with an error or successful request (although we know in this case +it will be an error, of course). + +```swift +let decoder = JSONDecoder() + +let dogDocument = try! decoder.decode(SingleDocument.self, from: mockErrorResponse) + +switch dogDocument.body { +case .data(let response): + print("this would be unexpected given our mock data!") + +case .errors(let errors, meta: _, links: _): + print("The server returned the following errors:") + print(errors.map { error -> String in + switch error { + case .client(let code, let description), + .server(let code, let description): + return "\(code): \(description)" + default: + return "unknown" + } + }) +} +``` + diff --git a/documentation/examples/metadata-example.md b/documentation/examples/metadata-example.md new file mode 100644 index 0000000..d1b7ca6 --- /dev/null +++ b/documentation/examples/metadata-example.md @@ -0,0 +1,129 @@ + +# JSONAPI Metadata Example + +We are about to walk through an example of parsing JSON:API metadata. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +We will additionally define a structure that can parse some pagination metadata. + +```swift +struct PaginationMetadata: JSONAPI.Meta { + + let page: Page + + /// The total count of all resources of the primary type of a given response. + let total: Int + + struct Page: Codable, Equatable { + let index: Int + let size: Int + } +} +``` + +Next we will create similar `typealiases` for single and batch documents as we did +in the **Basic Example**, but we will specify that we expect the `BatchDocument` to +include our `PaginationMetadata`. + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, PaginationMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +Now let's define a mock response containing a batch of dogs and pagination +metadata. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockBatchDogResponse = +""" +{ + "data": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ], + "meta": { + "total": 10, + "page": { + "index": 2, + "size": 2 + } + } +} +""".data(using: .utf8)! +``` + +Parsing the above response looks identical to in the **Basic Example**. + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let metadataDocument = try! decoder.decode(BatchDocument.self, from: mockBatchDogResponse) +``` + +The `Dogs` are pulled out as before with `Document.body.primaryResource`. The +metadata is accessed by the `Document.body.metadata` property. + +```swift +let dogs = metadataDocument.body.primaryResource!.values +let metadata = metadataDocument.body.meta! + +print("Parsed dogs named \(dogs.map { $0.name }.joined(separator: " and "))") +print("Page \(metadata.page.index) out of \(metadata.total / metadata.page.size) at \(metadata.page.size) resources per page.") +``` + diff --git a/documentation/examples/patch-example.md b/documentation/examples/patch-example.md new file mode 100644 index 0000000..d7a8629 --- /dev/null +++ b/documentation/examples/patch-example.md @@ -0,0 +1,157 @@ + +# JSONAPI PATCH Example + +We are about to walk through an example to show how to take an existing resource +object and create a copy with different attributes. Additional information on +the features used here can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, let's create a `Dog`. We will choose to make the properties of +our `Attributes` struct `vars` to facilitate updating them via the `ResourceObject` +`tapping` functions; an alternative approach with an immutable structure is found +later in this tutorial. + +```swift +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + var name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +At this point we have two objects that can decode JSON:API responses. To +illustrate we can mock up a dog response we might receive from the server and +parse it. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +let mockDogResponse = +""" +{ + "data": { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + } +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() + +let dogDocument = try! decoder.decode(SingleDocument.self, from: mockDogResponse) + +let dog = dogDocument.body.primaryResource!.value +``` + +We'll demonstrate renaming the dog using `ResourceObject`'s `tappingAttributes()` +function. + +```swift +let updatedDog = dog + .tappingAttributes { $0.name = .init(value: "Charlie") } +``` + +Now we can prepare a document to be used as the request body of a `PATCH` request. + +```swift +let patchRequestDocument = SingleDocument(apiDescription: .none, + body: .init(resourceObject: updatedDog), + includes: .none, + meta: .none, + links: .none) + +let requestBody = JSONEncoder().encode(patchRequestDocument) +``` + +Instead of actually sending off a `PATCH` request, we will just print the request +body out to prove to ourselves that the name was updated. + +```swift +print(String(data: requestBody, encoding: .utf8)!) +``` + +---- + +Alternatively, the `Attributes` struct could have been defined with `let` +properties. Not much changes, but the `name` cannot be mutated so the entire +struct must be recreated. We will take this opportunity to use the +`ResourceObject` `replacingAttributes()` method to contrast it to the +`tappingAttributes()` method. + +The `ImmutableDogDescription` below is almost identical to `DogDescription`, but the `name` +is a `let` constant. + +```swift +struct ImmutableDogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog2 = Resource +``` + +We can use the same mock data for a single dog document and parse it as a `Dog2`. + +```swift +let dogDocument2 = try! decoder.decode(SingleDocument.self, from: mockDogResponse) + +let dog2 = dogDocument2.body.primaryResource!.value +``` + +We could still use `tappingAttributes()` but we cannot mutate the name property of +the new `Attributes` struct, so we will use `replacingAttributes()` instead. This +method takes as its parameter a function that returns the new attributes. + +```swift +let updatedDog2 = dog2 + .replacingAttributes { _ in + return .init(name: .init(value: "Toby")) +} +``` + +Now create a request document. + +```swift +let patchRequestDocument2 = SingleDocument(apiDescription: .none, + body: .init(resourceObject: updatedDog2), + includes: .none, + meta: .none, + links: .none) + +let requestBody2 = JSONEncoder().encode(patchRequestDocument2) +``` + +Once again, we'll print the request body out instead of sending it with a `PATCH` +request. + +```swift +print(String(data: requestBody2, encoding: .utf8)!) +``` + diff --git a/documentation/examples/resource-storage-example.md b/documentation/examples/resource-storage-example.md new file mode 100644 index 0000000..4e63ae7 --- /dev/null +++ b/documentation/examples/resource-storage-example.md @@ -0,0 +1,204 @@ + +# JSONAPI Resource Storage Example + +We are about to walk through an example to show one possible way to handle +resource caching on the clientside. This example depends on both +[JSONAPI](https://github.com/mattpolzin/JSONAPI) and +[JSONAPI-ResourceStorage](https://github.com/mattpolzin/JSONAPI-ResourceStorage). + +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + /// User is not required to specify their age. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +We can borrow the `Document` `typealiases` from the +[Compound Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/compound-example.md). + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> +``` + +We define a resource cache capable of storing `Person` and `Dog` types. As a +convenience, we define what it means to merge two `Caches`. The `merge` method is +not a requirement of `ResourceCache` but it will allow us to easily add resources +from our JSON:API document to our cache. + +We are going to use a value type for the cache. A reference type (like the one +in the `JSONAPIResourceStore` module in this package) could also be used, but an +equatable value type works well when you want your app state to be comparable so +your logic can determine when the cache has changed. + +```swift +struct Cache: Equatable, ResourceCache { + var people: ResourceHash = [:] + var dogs: ResourceHash = [:] + + mutating func merge(_ other: Cache) { + // we merge and resolve conflicts with `other`'s versions so we effectively + // "add or update" each resource. + people.merge(other.people, uniquingKeysWith: { $1 }) + dogs.merge(other.dogs, uniquingKeysWith: { $1 }) + } +} +``` + +We need to tell people and dogs where to find themselves in the cache. + +```swift +extension PersonDescription: Materializable { + public static var cachePath: WritableKeyPath> { \.people } +} + +extension DogDescription: Materializable { + public static var cachePath: WritableKeyPath> { \.dogs } +} +``` + +Let's create our app-wide cache of resources. We are going to use a value type; +a reference type could be used just as well, but a value type that is equatable. + +```swift +var cache = Cache() +``` + +Now let's define a mock response containing a single person and including any +dogs that are related to that person. + +```swift +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + }, + "included": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! +``` + +We decode a document like the one mocked above as a `SingleDocument` specialized +on a primary resource type of `Person` and an include type of `Include1` +(a.k.a. all included resources will be of the same type: `Dog`). + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let document = try decoder.decode(SingleDocument>.self, from: mockSinglePersonResponse) +``` + +We can ask `document` for a cache of resources it contains. Then we can merge that +into our app-wide cache. + +```swift +if let documentResources = document.resourceCache() { + cache.merge(documentResources) +} else { + // probably time to check for an error response. +} +``` + +We can access all people in the cache. + +```swift +for person in cache.people.values { + print("\(person.firstName) \(person.lastName) has \((person ~> \.pets).count) dogs.") +} +``` + +We can access those dogs via the cache using the cache's subscript operator. + +```swift +for person in cache.people.values { + print("\(person.firstName) \(person.lastName) has pets named:") + for dogId in (person ~> \.pets) { + print(cache[dogId]?.name ?? "missing dog info") + } +} +``` + +We can also map the dog ids to materialized dogs. + +```swift +for person in cache.people.values { + let dogs = (person ~> \.pets).compactMap { $0.materialized(from: cache) } + let dogNames = dogs.map(\.name).joined(separator: ", ") + + print("\(person.firstName) \(person.lastName) has pets named: \(dogNames)") +} +``` + diff --git a/documentation/examples/serverside-get-example.md b/documentation/examples/serverside-get-example.md new file mode 100644 index 0000000..a3dc390 --- /dev/null +++ b/documentation/examples/serverside-get-example.md @@ -0,0 +1,179 @@ + +# JSONAPI Serverside GET Example + +We are about to walk through a basic example of serializing a simple model. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +Note that the first two steps here are almost identical to the first two steps +in the +[Clientside Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). +The same Swift resource types you create with this framework can be used by the +client and the server. + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, let's create a `Person` and a `Dog`. + +```swift +enum API {} + +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + // we will define "Dog" next + let pets: ToManyRelationship + } +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +extension API { + typealias Person = Resource +} + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + // we could relate dogs back to their owners, but for the sake of this example + // we will instead show how you would create a resource with no relationships. + typealias Relationships = NoRelationships +} + +extension API { + typealias Dog = Resource +} +``` + +At this point we have two objects that can encode JSON:API responses. To +illustrate we will skip over the details of reading from the database and assume +we have some data ready to be turned into a JSON:API response for a collection +of `Dogs`. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +// This is just a standin for whatever models you've got coming out of the database. +enum DB { + struct Dog { + let id: Int + let name: String + } +} + +// you could handle this any number of ways, but here we will write an extension +// that gets you the `JSONAPI` models from the database models. +extension DB.Dog { + var serializable: API.Dog { + let attributes = API.Dog.Attributes(name: .init(value: name)) + return API.Dog(id: .init(rawValue: String(id)), + attributes: attributes, + relationships: .none, + meta: .none, + links: .none) + } +} + +let dogs = [ + DB.Dog(id: 123, name: "Sparky"), + DB.Dog(id: 456, name: "Charlie Dog") +].map { $0.serializable } + + +let encoder = JSONEncoder() +encoder.outputFormatting = .prettyPrinted + +let primaryResources = ManyResourceBody(resourceObjects: dogs) + +let dogsDocument = BatchDocument(apiDescription: .none, + body: primaryResources, + includes: .none, + meta: .none, + links: .none) + +let dogsResponse = try! encoder.encode(dogsDocument) + +// At this point you can send the response data to the client in whatever way you like. + +print("dogs response: \(String(data: dogsResponse, encoding: .utf8)!)") +``` + +Let's look at a single `Person` response as well. + +```swift +extension DB { + struct Person { + let id: Int + let firstName: String + let lastName: String + let age: Int? + + /// relationship to dogs created as array of String Ids + let dogs: [Int] + } +} + +extension DB.Person { + var serializable: API.Person { + let attributes = API.Person.Attributes(firstName: .init(value: firstName), + lastName: .init(value: lastName), + age: .init(value: age)) + let relationships = API.Person.Relationships(pets: .init(ids: dogs.map { API.Dog.Id(rawValue: String($0)) })) + + return API.Person(id: .init(rawValue: String(id)), + attributes: attributes, + relationships: relationships, + meta: .none, + links: .none) + } +} + +let person = DB.Person(id: 9876, + firstName: "Julie", + lastName: "Stone", + age: nil, + dogs: [123, 456]).serializable + +let personDocument = SingleDocument(apiDescription: .none, + body: .init(resourceObject: person), + includes: .none, + meta: .none, + links: .none) + +let personResponse = try! encoder.encode(personDocument) + +// At this point you can send the response data to the client in whatever way you like. + +print("person response: \(String(data: personResponse, encoding: .utf8)!)") +``` + diff --git a/documentation/examples/serverside-post-example.md b/documentation/examples/serverside-post-example.md new file mode 100644 index 0000000..5be338f --- /dev/null +++ b/documentation/examples/serverside-post-example.md @@ -0,0 +1,152 @@ + +# JSONAPI Serverside POST Example + +We are about to walk through an example handling a POST (resource creation) +request. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will identify our resources using `UUID`s. + +```swift +// If we wanted to, we could just make `UUID` a `RawIdType` +// extension UUID: RawIdType {} + +// We will go a step further and make it a `CreatableRawIdType` and let `JSONAPI` +// create new unique Ids for people in the POST handling code farther down in this +// example. +extension UUID: CreatableRawIdType { + public static func unique() -> UUID { + return UUID() + } +} +``` + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by UUIDs. +typealias Resource = JSONAPI.ResourceObject + +/// The client will send us a POST request with an unidenfitied resource object. We will call this a "new resource object" +typealias New = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, we will handle a `POST` request for a `Person` resource. + +```swift +enum API {} + +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + typealias Relationships = NoRelationships +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +extension API { + typealias Person = Resource +} +``` + +To illustrate using the `JSONAPI` framework, we will skip over the details of +database reading/writing. Let's mock up a database model for a `Person`. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +// This is just a standin for whatever models you've got coming out of the database. +enum DB { + struct Person { + let id: String + let firstName: String + let lastName: String + let age: Int? + } +} + +// you could handle this any number of ways, but here we will write an initializer +// that gets you a database model from the `JSONAPI` model. +extension DB.Person { + + init(_ person: API.Person) { + id = "\(person.id.rawValue)" + firstName = person.firstName + lastName = person.lastName + age = person.age + } +} +``` + +Now we'll handle a `POST` request by creating a new database record (we'll skip +this detail) and responding with a `Person` resource. + +```swift +// NOTE this request has no Id because the client is requesting this new `Person` be created. +let mockPersonRequest = +""" +{ + "data": { + "type": "people", + "attributes": { + "first_name": "Jimmie", + "last_name": "Glows", + "age": 53 + } + } +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +// We will decode a "new" resource (see typealiases earlier in this example) +let requestedPersonDocument = try! decoder.decode(SingleDocument>.self, from: mockPersonRequest) +let requestedPerson = requestedPersonDocument.body.primaryResource!.value + +// Our DB.Person initializer expects an identified `Person`, not a `New` +// but we can let the `JSONAPI` framework create a new `UUID` for us: +let identifiedPerson = requestedPerson.identified(byType: UUID.self) + +let dbPerson = DB.Person(identifiedPerson) + +// Here's where we would save our `dbPerson` to te database, if we had an +// actualy database connection in this example. We'd also create our response from +// the result of that database save, ideally. We are going to skip those details +// and pretend the database write was successful. + +// finally, let's create a response +let encoder = JSONEncoder() +encoder.keyEncodingStrategy = .convertToSnakeCase +encoder.outputFormatting = .prettyPrinted + +let responseData = try! encoder.encode(SingleDocument(apiDescription: .none, + body: .init(resourceObject: identifiedPerson), + includes: .none, + meta: .none, + links: .none)) + +// Send it off to the client! + +print("response body:") +print("\(String(data: responseData, encoding: .utf8)!)") +``` + diff --git a/documentation/project-status.md b/documentation/project-status.md new file mode 100644 index 0000000..1760289 --- /dev/null +++ b/documentation/project-status.md @@ -0,0 +1,51 @@ +## Project Status + +### JSON:API +#### Document +- [x] `data` +- [x] `included` +- [x] `errors` +- [x] `meta` +- [x] `jsonapi` (i.e. API Information) +- [x] `links` + +#### Resource Object +- [x] `id` +- [x] `type` +- [x] `attributes` +- [x] `relationships` +- [x] `links` +- [x] `meta` + +#### Relationship Object +- [x] `data` +- [x] `links` +- [x] `meta` + +##### Resource Identifier Object +- [x] `id` +- [x] `type` +- [x] `meta` + +#### Links Object +- [x] `href` +- [x] `meta` + +### Misc +- [x] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) +- [x] Support validation on `Attributes`. +- [x] Support sparse fieldsets (encoding only). A client can likely just define a new model to represent a sparse population of another model in a very specific use case for decoding purposes. On the server side, sparse fieldsets of Resource Objects can be encoded without creating one model for every possible sparse fieldset. + +### Testing +#### Resource Object Validator +- [x] Disallow optional array in `Attribute` (should be empty array, not `null`). +- [x] Only allow `TransformedAttribute` and its derivatives as stored properties within `Attributes` struct. Computed properties can still be any type because they do not get encoded or decoded. +- [x] Only allow `MetaRelationship`, `ToManyRelationship` and `ToOneRelationship` within `Relationships` struct. + +### Potential Improvements +These ideas could be implemented in future versions. + +- [ ] (Maybe) Use `KeyPath` to specify `Includes` thus creating type safety around the relationship between a primary resource type and the types of included resources. +- [ ] (Maybe) Replace `SingleResourceBody` and `ManyResourceBody` with support at the `Document` level to just interpret `PrimaryResource`, `PrimaryResource?`, or `[PrimaryResource]` as the same decoding/encoding strategies. +- [ ] 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). diff --git a/documentation/usage.md b/documentation/usage.md new file mode 100644 index 0000000..e02c797 --- /dev/null +++ b/documentation/usage.md @@ -0,0 +1,807 @@ + +## 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) + - [Relationship Metadata](#relationship-metadata) +- [`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) + - [`SuccessDocument` and `ErrorDocument`](#successdocument-and-errordocument) +- [`CompoundResource`](#compoundresource) +- [`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 definition of the attributes and relationships 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: ResourceObjectDescription { + static let jsonType: String = "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` (or `let`) "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 was used above for the `PersonDescription`; it could have been a `struct`, but `ResourceObjectDescriptions` do not ever need to be created so an `enum` with no `cases` 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` + +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` + +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`; it is useful to create unidentified resources 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 `typealiases` 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 calling an unidentified person is a "new" person. This is generally an acceptable conflation in naming 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 three types of `Relationships`: `MetaRelationship`, `ToOneRelationship` and `ToManyRelationship`. A `ResourceObjectDescription`'s `Relationships` type can contain any number of `Relationship` properties of any of these types. Do not store anything other than `Relationship` properties in the `Relationships` struct of a `ResourceObjectDescription`. + +The `MetaRelationship` is special in that it represents a Relationship Object with no `data` (it must contain at least one of `meta` or `links`). The other two relationship types are Relationship Objects with either singular resource linkages (`ToOneRelationship`) or arrays of resource linkages (`ToManyRelationship`). + +To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire `MetaRelationship`, `ToOneRelationship` or `ToManyRelationship` optional. +```swift +// note the question mark at the very end of the line. +let optionalRelative: ToOneRelationship? +``` + +A `ToOneRelationship` can be marked as nullable (i.e. the value could be either `null` or a resource identifier) like this: +```swift +// note the question mark just after `Person`. +let nullableRelative: ToOneRelationship +``` + +A `ToManyRelationship` can naturally represent the absence of related values with an empty array, so `ToManyRelationship` do not support nullability. + +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.Id] = person ~> \.friends +``` + +🗒You will likely find relationship types more ergonomic and easier to read if you create typealiases. For example, if your project never uses Relationship metadata or links, you can create a typealias like `typealias ToOne = JSONAPI.ToOneRelationship`. + +#### Relationship Metadata +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. + +Metadata can be specified both in the Relationship Object and in the Resource Identifier Object. You specify the two types of metadata differently. As always, you can use `NoMetadata` to indicate you do not intend the JSON:API relationship to contain metadata. + +```swift +// No metadata in the Resource Identifer or the Relationship: +// { +// "data" : { +// "id" : "1234", +// "type": "people" +// } +// } +let relationship1: ToOneRelationship + +// No metadata in the Resource Identifier but some metadata in the Relationship: +// { +// "data" : { +// "id" : "1234", +// "type": "people" +// }, +// "meta": { ... } +// } +let relationship2: ToOneRelationship +// ^ assumes `RelMetadata` is a `Codable` struct defined elsewhere + +// Metadata in the Resource Identifier but not the Relationship: +// { +// "data" : { +// "id" : "1234", +// "type": "people", +// "meta": { ... } +// } +// } +let relationship3: ToOneRelationship +// ^ assumes `CoolMetadata` is a `Codable` struct defined elsewhere +``` + +When you need metadata out of a to-one relationship, you can access the Relationship Object metadata with the `meta` property and the Resource Identifer metadata with the `idMeta` property. When you need metadata out of a to-many relationship, you can access the Relationship Object metadata with the `meta` property (there is only one such metadata object) and you can access the Resource Identifier metadata (of which there is one per related resource) by asking each element of the `idsWithMeta` property for its `meta` property. + +```swift +// to-one +let relation = entity.relationships.home +let idMeta = relation.idMeta + +// to-many +let relations = entity.relationships.friends +let idMeta = relations.idsWithMeta.map { $0.meta } +``` + +### `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 +``` + +#### `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 three common mutations you might want to make when copying a `ResourceObject`: +1. Assigning a new `Id` to the copy of an identified `ResourceObject`. +2. Assigning a new `Id` to the copy of an unidentified `ResourceObject`. +3. Change attribute or relationship values. + +The first two 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") +``` + +The third use-case is described in [Replacing and Tapping Attributes/Relationships](#replacing-and-tapping-attributesrelationships). + +### `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 `JSONAPI.Document` 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 `Poly11`) 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. + +Examples: +```swift +typealias SingleDog = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError> + +typealias ManyCats = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError> + +typealias ManyDogsOrCats = JSONAPI.Document>, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError> +``` + +##### 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 `JSONAPI.Document` 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 `JSONAPI.Document` is a `Links` struct. `Links` are described in their own section [below](#jsonapilinks). + +#### `IncludeType` + +The fourth generic type of a `JSONAPI.Document` 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_. + +Decoding a JSON:API Document will fail if you specify an `IncludeType` that does not cover all of the types of includes you expect a response to contain. + +To specify that we expect friends of a person to be included in the above example `JSONAPI.Document`, we would use `Include1` instead of `NoIncludes`. + +#### `APIDescriptionType` + +The fifth generic type of a `JSONAPI.Document` 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 `JSONAPI.Document` is the `Error`. + +You can either create an error type that can handle all the errors you expect your `JSONAPI.Document` 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`. + +#### `SuccessDocument` and `ErrorDocument` +The `Document` type also supplies two nested types that guarantee either a successful data document or error an error document. + +In general, if you want to encode or decode a document you will want the flexibility of representing either success or errors. When you know you will be working with one or the other in a particular context, `Document.SuccessDocument` and `Document.ErrorDocument` will provide additional convenience: they only expose relevant initializers (a success document cannot be initialized with errors), they only succeed to decode given the expected result, and success documents provide non-optional access to the `data` property that is normally optional on the `body`. + +For example: +```swift +typealias Response = JSONAPI.Document<...> + +let decoder = JSONDecoder() +let document = try decoder.decode(Response.SuccessDocument.self, from: ...) + +// the following are non-optional because we know that if the document did not +// contain a `data` body (i.e. if it was an error response) then it would have +// failed to decode above. +let primaryResource = document.primaryResource +let includes = document.includes +``` + +### `CompoundResource` +`CompoundResource` packages a primary resource with relatives (stored using the same `Include` types that `Document` uses). The `CompoundResource` type can be a convenient way to package a resource and its relatives to be later turned into a `Document`; A single resource body for a document is a straight forward representation of a `CompoundResource`, but `Document` will take an array of `CompoundResources` and create a batch ("many") resource body containing all the primary resources and uniquely including each relative as required by the **SPEC**. + +A single resource document: +```swift +typealias SinglePersonDocument = Document, NoMetadata, NoLinks, Include1, NoAPIDescription, BasicJSONAPIError> + +let person: Person = ... +let friends: [Person] = ... +let friendIncludes = friends.map(SinglePersonDocument.Include.init) + +let compoundResource = CompoundResource(primary: person, relatives: friendIncludes) + +let document = SinglePersonDocument( + apiDescription: .none, + resource: compoundResource, + meta: .none, + links: .none +) +``` + +A batch resource document: +```swift +typealias ManyPersonDocument = Document, NoMetadata, NoLinks, Include1, NoAPIDescription, BasicJSONAPIError> + +let people: [Person] = ... +let compoundResources = people.map { person in + let friends: [Person] = ... + let friendIncludes = friends.map(SinglePersonDocument.Include.init) + + return CompoundResource(primary: person, relatives: friendIncludes) +} + +let document = ManyPersonDocument( + apiDescription: .none, + resources: compoundResources, + meta: .none, + links: .none +) +``` + +### `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`. + +**IMPORTANT:** The URL type used in links is a type conforming to `JSONAPIURL`. Any type that is both `Codable` and `Equatable` is eligible, but it must be conformed explicitly. + +For example, +```swift +extension Foundation.URL: JSONAPIURL {} +extension String: JSONAPIURL {} +``` + +Here's an example of an "article" resource object with some links and the JSON it would be capable of parsing: +```swift +struct PersonStubDescription: JSONAPI.ResourceObjectDescription { + // this is just a pretend model to be used in a relationship below. + static let jsonType: String = "people" + + typealias Attributes = NoAttributes + typealias Relationships = NoRelationships +} + +typealias Person = JSONAPI.ResourceObject + +struct ArticleAuthorRelationshipLinks: JSONAPI.Links { + let `self`: JSONAPI.Link + let related: JSONAPI.Link +} + +struct ArticleLinks: JSONAPI.Links { + let `self`: JSONAPI.Link +} + +struct ArticleDescription: JSONAPI.ResourceObjectDescription { + static let jsonType: String = "articles" + + struct Attributes: JSONAPI.Attributes { + let title: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let author: ToOneRelationship + } +} + +typealias Article = JSONAPI.ResourceObject +``` +```json +{ + "type": "articles", + "id": "1", + "attributes": { + "title": "Rails is Omakase" + }, + "relationships": { + "author": { + "links": { + "self": "http://example.com/articles/1/relationships/author", + "related": "http://example.com/articles/1/author" + }, + "data": { "type": "people", "id": "9" } + } + }, + "links": { + "self": "http://example.com/articles/1" + } +} +``` + +### `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.CodableResourceBody`): +```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 +**NOTE** this section describes an ability to create computed relationships, not to be confused with the similarly named `MetaRelationship` type which is used to create relationships that do not have an `id`/`type` (they only have `links` and/or `meta`). + +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.Id { + return { user in + return User.Id(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}) -> {Id}` where `{ResourceObject}` is the `JSONAPI.ResourceObject` described by the `ResourceObjectDescription` containing the meta-relationship.