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