diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index aedb87a..2cbf4e7 100644 --- a/Sources/JSONAPI/Document/Document.swift +++ b/Sources/JSONAPI/Document/Document.swift @@ -414,3 +414,137 @@ extension Document.Body.Data: CustomStringConvertible { return "primary: \(String(describing: primary)), includes: \(String(describing: includes)), meta: \(String(describing: meta)), links: \(String(describing: links))" } } + +// 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. + @dynamicMemberLookup + public struct ErrorDocument: EncodableJSONAPIDocument { + 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) + } + + public subscript(dynamicMember path: KeyPath) -> T { + return document[keyPath: path] + } + + public static func ==(lhs: Document, rhs: ErrorDocument) -> Bool { + return lhs == rhs.document + } + } + + /// A Document that only supports success bodies. This is useful if you wish to pass around a + /// Document type but you wish to constrain it to success values. + @dynamicMemberLookup + public struct SuccessDocument: EncodableJSONAPIDocument { + public var body: Document.Body { return document.body } + + private let document: Document + + public init(apiDescription: APIDescription, + body: PrimaryResourceBody, + includes: Includes, + meta: MetaType, + links: LinksType) { + document = .init(apiDescription: apiDescription, + body: body, + includes: includes, + meta: meta, + links: links) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(document) + } + + public subscript(dynamicMember path: KeyPath) -> T { + return document[keyPath: path] + } + + public static func ==(lhs: Document, rhs: SuccessDocument) -> Bool { + return lhs == rhs.document + } + } +} + +extension Document.ErrorDocument: Decodable, JSONAPIDocument + where PrimaryResourceBody: ResourceBody, IncludeType: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + document = try container.decode(Document.self) + + guard document.body.isError else { + throw JSONAPIDocumentDecodingError.foundSuccessDocumentWhenExpectingError + } + } +} + +extension Document.SuccessDocument: Decodable, JSONAPIDocument + where PrimaryResourceBody: ResourceBody, IncludeType: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + document = try container.decode(Document.self) + + guard !document.body.isError else { + throw JSONAPIDocumentDecodingError.foundErrorDocumentWhenExpectingSuccess + } + } +} + +extension Document.SuccessDocument 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 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") + } + } +} + +// 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 { + // 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") + } + } +} diff --git a/Sources/JSONAPI/Document/DocumentDecodingError.swift b/Sources/JSONAPI/Document/DocumentDecodingError.swift new file mode 100644 index 0000000..912a8f8 --- /dev/null +++ b/Sources/JSONAPI/Document/DocumentDecodingError.swift @@ -0,0 +1,11 @@ +// +// DocumentDecodingErro.swift +// +// +// Created by Mathew Polzin on 10/20/19. +// + +public enum JSONAPIDocumentDecodingError: Swift.Error { + case foundErrorDocumentWhenExpectingSuccess + case foundSuccessDocumentWhenExpectingError +} diff --git a/Tests/JSONAPITests/Document/DocumentTests.swift b/Tests/JSONAPITests/Document/DocumentTests.swift index be10f7c..48ed1c8 100644 --- a/Tests/JSONAPITests/Document/DocumentTests.swift +++ b/Tests/JSONAPITests/Document/DocumentTests.swift @@ -23,6 +23,7 @@ class DocumentTests: XCTestCase { XCTAssert(Doc.Error.self == UnknownJSONAPIError.self) } + // Document test(JSONAPI.Document< NoResourceBody, NoMetadata, @@ -37,6 +38,35 @@ class DocumentTests: XCTestCase { meta: .none, links: .none )) + + // Document.SuccessDocument + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.SuccessDocument( + apiDescription: .none, + body: .none, + includes: .none, + meta: .none, + links: .none + )) + + // Document.ErrorDocument + test(JSONAPI.Document< + NoResourceBody, + NoMetadata, + NoLinks, + NoIncludes, + NoAPIDescription, + UnknownJSONAPIError + >.ErrorDocument( + apiDescription: .none, + errors: [] + )) } func test_singleDocumentNull() { @@ -51,11 +81,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() { @@ -94,6 +147,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() { @@ -115,6 +176,32 @@ extension DocumentTests { XCTAssertEqual(errors.0, document.body.errors) XCTAssertEqual(errors.0[0], .unknown) XCTAssertEqual(errors.meta, NoMetadata()) + + // 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) = document2.body else { + XCTFail("Needed body to be in errors case but it was not.") + return + } + + XCTAssertEqual(errors2.0.count, 1) + XCTAssertEqual(errors2.0, document2.body.errors) + XCTAssertEqual(errors2.0[0], .unknown) + XCTAssertEqual(errors2.meta, NoMetadata()) } func test_unknownErrorDocumentAddIncludingType() { @@ -620,6 +707,26 @@ 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)])) + + 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) @@ -848,6 +955,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)