From 88c5d400aa8a9e3c5249bbc1383297d374e812aa Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 29 Sep 2019 14:56:04 -0700 Subject: [PATCH 1/5] Add generic and basic error types. add tests for generic type. --- Sources/JSONAPI/Error/BasicJSONAPIError.swift | 73 +++++++++ .../JSONAPI/Error/GenericJSONAPIError.swift | 67 +++++++++ .../Error.swift => Error/JSONAPIError.swift} | 7 +- .../Error/GenericJSONAPIErrorTests.swift | 139 ++++++++++++++++++ 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 Sources/JSONAPI/Error/BasicJSONAPIError.swift create mode 100644 Sources/JSONAPI/Error/GenericJSONAPIError.swift rename Sources/JSONAPI/{Document/Error.swift => Error/JSONAPIError.swift} (69%) create mode 100644 Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift diff --git a/Sources/JSONAPI/Error/BasicJSONAPIError.swift b/Sources/JSONAPI/Error/BasicJSONAPIError.swift new file mode 100644 index 0000000..345af26 --- /dev/null +++ b/Sources/JSONAPI/Error/BasicJSONAPIError.swift @@ -0,0 +1,73 @@ +// +// BasicError.swift +// JSONAPI +// +// Created by Mathew Polzin on 9/29/19. +// + +import Foundation + +/// Most of the JSON:API Spec defined Error fields. +public struct BasicJSONAPIErrorPayload: Codable, Equatable, ErrorDictType { + /// a unique identifier for this particular occurrence of the problem + let id: IdType? +// 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 + let status: String? + /// an application-specific error code + 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 + let title: String? + /// a human-readable explanation specific to this occurrence of the problem. Like `title`, this field’s value can be localized + let detail: String? + /// an object containing references to the source of the error + let source: Source? +// let meta: Meta? // we skip this for now to avoid adding complexity to using this basic type + + public struct Source: Codable, Equatable { + /// 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]. + let pointer: String? + /// which URI query parameter caused the error + let parameter: String? + } + + 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) + } +} + +/// `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`). +public typealias BasicJSONAPIError = GenericJSONAPIError> diff --git a/Sources/JSONAPI/Error/GenericJSONAPIError.swift b/Sources/JSONAPI/Error/GenericJSONAPIError.swift new file mode 100644 index 0000000..e151311 --- /dev/null +++ b/Sources/JSONAPI/Error/GenericJSONAPIError.swift @@ -0,0 +1,67 @@ +// +// GenericError.swift +// JSONAPI +// +// Created by Mathew Polzin on 9/29/19. +// + +import Foundation + +/// `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 { + 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 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/Document/Error.swift b/Sources/JSONAPI/Error/JSONAPIError.swift similarity index 69% rename from Sources/JSONAPI/Document/Error.swift rename to Sources/JSONAPI/Error/JSONAPIError.swift index ab6e3ca..f1dbfe9 100644 --- a/Sources/JSONAPI/Document/Error.swift +++ b/Sources/JSONAPI/Error/JSONAPIError.swift @@ -11,7 +11,10 @@ public protocol JSONAPIError: Swift.Error, Equatable, Codable { /// `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. +/// 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 @@ -24,7 +27,7 @@ public enum UnknownJSONAPIError: JSONAPIError { try container.encode("unknown") } - public static var unknown: UnknownJSONAPIError { + public static var unknown: Self { return .unknownError } } diff --git a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift new file mode 100644 index 0000000..e1fca6e --- /dev/null +++ b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift @@ -0,0 +1,139 @@ +// +// 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) + } + + 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_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 { + 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 From b0801f7cee7aa2127501ebb4ba1070b1200b2766 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 29 Sep 2019 15:20:08 -0700 Subject: [PATCH 2/5] Add tests for BasicJSONAPIError and tweak documentation --- Sources/JSONAPI/Error/BasicJSONAPIError.swift | 5 + .../Error/BasicJSONAPIErrorTests.swift | 92 +++++++++++++++++++ .../Error/GenericJSONAPIErrorTests.swift | 8 ++ 3 files changed, 105 insertions(+) create mode 100644 Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift diff --git a/Sources/JSONAPI/Error/BasicJSONAPIError.swift b/Sources/JSONAPI/Error/BasicJSONAPIError.swift index 345af26..fa0158d 100644 --- a/Sources/JSONAPI/Error/BasicJSONAPIError.swift +++ b/Sources/JSONAPI/Error/BasicJSONAPIError.swift @@ -70,4 +70,9 @@ public struct BasicJSONAPIErrorPayload: Codable, Eq /// 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/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift new file mode 100644 index 0000000..e726143 --- /dev/null +++ b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift @@ -0,0 +1,92 @@ +// +// BasicJSONAPIErrorTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 9/29/19. +// + +import Foundation +@testable import JSONAPI +import XCTest + +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") + } +} diff --git a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift index e1fca6e..21172ed 100644 --- a/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift +++ b/Tests/JSONAPITests/Error/GenericJSONAPIErrorTests.swift @@ -92,6 +92,14 @@ final class GenericJSONAPIErrorTests: XCTestCase { } } + 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)) From d4806ff557dfe9ceff0a6b350d17db5b29ccf225 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 29 Sep 2019 15:36:16 -0700 Subject: [PATCH 3/5] Add a few decode examples --- .../Error/BasicJSONAPIErrorTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift index e726143..05bcf2f 100644 --- a/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift +++ b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift @@ -8,6 +8,7 @@ import Foundation @testable import JSONAPI import XCTest +import Poly final class BasicJSONAPIErrorTests: XCTestCase { func test_initAndEquality() { @@ -89,4 +90,42 @@ final class BasicJSONAPIErrorTests: XCTestCase { 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) + } } From 305799234898f9d43636df8eae7c8f96f3c2d5d1 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 29 Sep 2019 15:57:54 -0700 Subject: [PATCH 4/5] bump podspec version, update Playground examples --- .../Contents.swift | 2 +- .../Contents.swift | 2 +- JSONAPI.playground/Sources/Entities.swift | 4 ++-- JSONAPI.podspec | 2 +- README.md | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) 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 e68aaa6..eabd561 100644 --- a/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Full Client & Server Example.xcplaygroundpage/Contents.swift @@ -37,7 +37,7 @@ typealias ToManyRelationship = JSONAPI.ToManyRelationship = JSONAPI.Document +typealias Document = JSONAPI.Document> // MARK: Entity Definitions diff --git a/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift index e34667b..5a9dc6c 100644 --- a/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Sparse Fieldsets Example.xcplaygroundpage/Contents.swift @@ -35,7 +35,7 @@ typealias ThingWithProperties = JSONAPI.ResourceObject = JSONAPI.Document +typealias Document = JSONAPI.Document> // // NOTE: Using `JSONAPI.EncodablePrimaryResource` which means the `ResourceBody` will be `Encodable` but not `Decodable. diff --git a/JSONAPI.playground/Sources/Entities.swift b/JSONAPI.playground/Sources/Entities.swift index d01f4f8..4ae56c9 100644 --- a/JSONAPI.playground/Sources/Entities.swift +++ b/JSONAPI.playground/Sources/Entities.swift @@ -139,6 +139,6 @@ 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 BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError> +public typealias BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, BasicJSONAPIError> diff --git a/JSONAPI.podspec b/JSONAPI.podspec index 852317d..b84fef0 100644 --- a/JSONAPI.podspec +++ b/JSONAPI.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |spec| # spec.name = "MP-JSONAPI" - spec.version = "2.1.0" + spec.version = "2.2.0" spec.summary = "Swift Codable JSON API framework." # This description is used to generate tags and improve search results. diff --git a/README.md b/README.md index 3d0eb2b..733e9ca 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ See the JSON API Spec here: https://jsonapi.org/format/ ## Quick Start ### Clientside -- [Basic Example](https://colab.research.google.com/drive/1IS7lRSBGoiW02Vd1nN_rfdDbZvTDj6Te) -- [Compound Example](https://colab.research.google.com/drive/1BdF0Kc7l2ixDfBZEL16FY6palweDszQU) -- [Metadata Example](https://colab.research.google.com/drive/10dEESwiE9I3YoyfzVeOVwOKUTEgLT3qr) +- [Basic Example](https://colab.research.google.com/drive/1IS7lRSBGoiW02Vd1nN_rfdDbZvTDj6Te) +- [Compound Example](https://colab.research.google.com/drive/1BdF0Kc7l2ixDfBZEL16FY6palweDszQU) +- [Metadata Example](https://colab.research.google.com/drive/10dEESwiE9I3YoyfzVeOVwOKUTEgLT3qr) - [Errors Example](https://colab.research.google.com/drive/1TIv6STzlHrkTf_-9Eu8sv8NoaxhZcFZH) ### Serverside @@ -108,7 +108,7 @@ If you find something wrong with this library and it isn't already mentioned und ### Swift Package Manager Just include the following in your package's dependencies and add `JSONAPI` to the dependencies for any of your targets. ``` - .package(url: "https://github.com/mattpolzin/JSONAPI.git", .upToNextMajor(from: "2.0.0")) + .package(url: "https://github.com/mattpolzin/JSONAPI.git", .upToNextMajor(from: "2.2.0")) ``` ### CocoaPods From f1d6b22f61edc6f09d10e7dda5fa8b7050a17939 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 29 Sep 2019 16:49:38 -0700 Subject: [PATCH 5/5] Add playground example, add/update documentation, correct visibility of new error payload properties to public. --- .../Usage.xcplaygroundpage/Contents.swift | 30 +++++++++- README.md | 59 +++++++++++++++---- Sources/JSONAPI/Error/BasicJSONAPIError.swift | 42 +++++++++---- .../JSONAPI/Error/GenericJSONAPIError.swift | 2 - .../Error/BasicJSONAPIErrorTests.swift | 2 +- 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift index a50f24f..e5434e5 100644 --- a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift @@ -24,7 +24,7 @@ let dogOwner: Person.Identifier? = dogFromData.flatMap { $0 ~> \.owner } // MARK: - Parse a request or response body with one Dog in it using an alternative model -typealias AltSingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +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 } @@ -63,7 +63,7 @@ if case let .data(bodyData) = peopleResponse.body { // MARK: - Work in the abstract - +print("-----") func process(document: T) { guard case let .data(body) = document.body else { return @@ -71,3 +71,29 @@ func process(document: T) { let x: T.Body.Data = 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/README.md b/README.md index 733e9ca..91a58f8 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,8 @@ See the JSON API Spec here: https://jsonapi.org/format/ This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](#example) further down in this README. ## Table of Contents - - [JSONAPI](#jsonapi) - - [Table of Contents](#table-of-contents) - [Primary Goals](#primary-goals) - [Caveat](#caveat) - [Dev Environment](#dev-environment) @@ -67,6 +65,9 @@ This library works well when used by both the server responsible for serializati - [`IncludeType`](#includetype) - [`APIDescriptionType`](#apidescriptiontype) - [`Error`](#error) + - [`UnknownJSONAPIError`](#unknownjsonapierror) + - [`BasicJSONAPIError`](#basicjsonapierror) + - [`GenericJSONAPIError`](#genericjsonapierror) - [`JSONAPI.Meta`](#jsonapimeta) - [`JSONAPI.Links`](#jsonapilinks) - [`JSONAPI.RawIdType`](#jsonapirawidtype) @@ -85,8 +86,6 @@ This library works well when used by both the server responsible for serializati - [JSONAPI+Arbitrary](#jsonapiarbitrary) - [JSONAPI+OpenAPI](#jsonapiopenapi) - - ## Primary Goals The primary goals of this framework are: @@ -122,6 +121,8 @@ To use this framework in your project via Cocoapods, add the following dependenc To create an Xcode project for JSONAPI, run `swift package generate-xcodeproj` +With Xcode 11+ you can also just open the folder containing your clone of this repository and begin working. + ### Running the Playground To run the included Playground files, create an Xcode project using Swift Package Manager, then create an Xcode Workspace in the root of the repository and add both the generated Xcode project and the playground to the Workspace. @@ -330,7 +331,7 @@ let favoriteColor: String = person.favoriteColor let favoriteColor: String = person[\.favoriteColor] ``` -In both cases you retain type-safety, although neither plays particularly nicely with code autocompletion. It is best practice to pick an attribute access syntax and stick with it. At some point in the future the syntax deemed less desirable may be deprecated. +In both cases you retain type-safety. It is best practice to pick an attribute access syntax and stick with it. At some point in the future the syntax deemed less desirable may be deprecated. #### `Transformer` @@ -403,7 +404,7 @@ The entirety of a JSON API request or response is encoded or decoded from- or to ```swift let decoder = JSONDecoder() -let responseStructure = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, UnknownJSONAPIError>.self +let responseStructure = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, BasicJSONAPIError>.self let document = try decoder.decode(responseStructure, from: data) ``` @@ -470,7 +471,45 @@ You can supply any `JSONAPI.Meta` type as the metadata type of the API descripti #### `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`. +The final generic type of a `JSONAPIDocument` is the `Error`. + +You can either create an error type that can handle all the errors you expect your `JSONAPIDocument` to be able to encode/decode or use an out-of-box error type described here. As prescribed by the **SPEC**, these errors will be found under the root document key `errors`. + +##### `UnknownJSONAPIError` +The `UnknownJSONAPIError` type will always succeed in parsing errors but it will not give you any information about what error occurred. You will generally get more bang for your buck out of the next error type described. + +##### `BasicJSONAPIError` +The `BasicJSONAPIError` type will always succeed unless it is faced with an `id` field of an unexpected type, although it still "succeeds" in falling back to its `.unknown` case when that happens. This type extracts _most_ of the fields the **SPEC** describes [here](https://jsonapi.org/format/#error-objects). Because all of these fields are optional in the **SPEC**, they are optional on the `BasicJSONAPIError` type. You will have to create your own error type if you want to define certain fields as non-optional or parse metadata or links out of error objects. + +🗒Metadata and links are supported at the Document level for error responses, the are just not supported hanging off of the individual errors in the `errors` array of the response when using this error type. + +The `BasicJSONAPIError` type is generic on one thing: The type it expects for the `id` field. If you expect integer `ids` back, you use `BasicJSONAPIError`. The same can be done for `String` or any other type that is both `Codable` and `Equatable`. You can even employ something like `AnyCodable` from *Flight-School* as your id field type. If you only need to handle a small subset of possible `id` field types, you can also use the `Poly` library that is already a dependency of `JSONAPI`. For example, you might expect a mix of `String` and `Int` ids for some reason: `BasicJSONAPIError>`. + +The two easiest ways to access the available properties of an error response are under the `payload` property of the error (this property is `nil` if the error was parsed as `.unknown`) or by asking the error for its `definedFields` dictionary. + +As an example, let's say you have the following `Document` type that is destined for errors: +```swift +typealias ErrorDoc = JSONAPI.Document> +``` +And you've parsed an error response +```swift +let errorResponse = try! JSONDecoder().decode(ErrorDoc.self, from: mockErrorData) +``` +You can get at the `Document` body and errors in a couple of different ways, but for one you can switch on the body: +```swift +switch errorResponse.body { +case .data: + print("cool, data!") + +case .errors(let errors, let meta, let links): + let errorDetails = errors.compactMap { $0.payload?.detail } + + print("error details: \(errorDetails)") +} +``` + +##### `GenericJSONAPIError` +This type makes it simple to use your own error payload structures as `JSONAPIError` types. Simply define a `Codable` and `Equatable` struct and then use `GenericJSONAPIError` as the error type for a `Document`. ### `JSONAPI.Meta` @@ -520,12 +559,12 @@ There is a sparse fieldsets example included with this repository as a Playgroun #### Sparse Fieldset `typealias` comparisons You might have found a `typealias` like the following for encoding/decoding `JSONAPI.Document`s (note the primary resource body is a `JSONAPI.ResourceBody`): ```swift -typealias Document = JSONAPI.Document +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 +typealias SparseDocument = JSONAPI.Document> ``` ### Custom Attribute or Relationship Key Mapping @@ -713,7 +752,7 @@ typealias ToManyRelationship = JSONAPI.ToManyRelationship = JSONAPI.Document +typealias Document = JSONAPI.Document> // MARK: Entity Definitions diff --git a/Sources/JSONAPI/Error/BasicJSONAPIError.swift b/Sources/JSONAPI/Error/BasicJSONAPIError.swift index fa0158d..d3859eb 100644 --- a/Sources/JSONAPI/Error/BasicJSONAPIError.swift +++ b/Sources/JSONAPI/Error/BasicJSONAPIError.swift @@ -5,30 +5,48 @@ // Created by Mathew Polzin on 9/29/19. // -import Foundation - /// Most of the JSON:API Spec defined Error fields. public struct BasicJSONAPIErrorPayload: Codable, Equatable, ErrorDictType { /// a unique identifier for this particular occurrence of the problem - let id: IdType? -// let links: Links? // we skip this for now to avoid adding complexity to using this basic type. + 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 - let status: String? + public let status: String? /// an application-specific error code - let code: String? + 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 - let title: String? + public let title: String? /// a human-readable explanation specific to this occurrence of the problem. Like `title`, this field’s value can be localized - let detail: String? + public let detail: String? /// an object containing references to the source of the error - let source: Source? -// let meta: Meta? // we skip this for now to avoid adding complexity to using this basic type + 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 { /// 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]. - let pointer: String? + public let pointer: String? /// which URI query parameter caused the error - let parameter: String? + public let parameter: String? + + public init(pointer: String? = nil, + parameter: String? = nil) { + self.pointer = pointer + self.parameter = parameter + } } public var definedFields: [String: String] { diff --git a/Sources/JSONAPI/Error/GenericJSONAPIError.swift b/Sources/JSONAPI/Error/GenericJSONAPIError.swift index e151311..91ce2b8 100644 --- a/Sources/JSONAPI/Error/GenericJSONAPIError.swift +++ b/Sources/JSONAPI/Error/GenericJSONAPIError.swift @@ -5,8 +5,6 @@ // Created by Mathew Polzin on 9/29/19. // -import Foundation - /// `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`. diff --git a/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift index 05bcf2f..89dc188 100644 --- a/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift +++ b/Tests/JSONAPITests/Error/BasicJSONAPIErrorTests.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import JSONAPI +import JSONAPI import XCTest import Poly