From c4032eb35106806c162f8913420a9165f27faa24 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 22 Nov 2018 21:04:58 -0800 Subject: [PATCH] Added meta data to JSONAPIDocument. Added tests around JSONAPIDocument error finally. Needs a few more tests around metadata. --- Sources/JSONAPI/Document/Document.swift | 96 +++++++++-- Sources/JSONAPI/Document/ResourceBody.swift | 6 + Sources/JSONAPI/Meta/Meta.swift | 24 +++ Sources/JSONAPI/Resource/Relationship.swift | 1 + .../JSONAPITests/Document/DocumentTests.swift | 149 ++++++++++++++++-- .../Document/stubs/DocumentStubs.swift | 37 +++++ 6 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 Sources/JSONAPI/Meta/Meta.swift diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index 464b137..6f15dc4 100644 --- a/Sources/JSONAPI/Document/Document.swift +++ b/Sources/JSONAPI/Document/Document.swift @@ -12,41 +12,58 @@ /// API uses snake case, you will want to use /// a conversion such as the one offerred by the /// Foundation JSONEncoder/Decoder: `KeyDecodingStrategy` -public struct JSONAPIDocument: Equatable { +public struct JSONAPIDocument: Equatable { public typealias Include = IncludeType public let body: Body -// public let meta: Meta? // public let jsonApi: APIDescription? // public let links: Links? public enum Body: Equatable { - case errors([Error]) - case data(primary: ResourceBody, included: Includes) + case errors([Error], meta: MetaType?) + case data(primary: ResourceBody, included: Includes, meta: MetaType) + case meta(MetaType) public var isError: Bool { guard case .errors = self else { return false } return true } - public var data: (primary: ResourceBody, included: Includes)? { - guard case let .data(primary: body, included: includes) = self else { return nil } - return (primary: body, included: includes) + public var data: (primary: ResourceBody, included: Includes, meta: MetaType)? { + guard case let .data(primary: body, included: includes, meta: meta) = self else { return nil } + return (primary: body, included: includes, meta: meta) + } + + public var meta: MetaType? { + guard case let .meta(metadata) = self else { return nil } + return metadata } } - public init(errors: [Error]) { - body = .errors(errors) + public init(errors: [Error], meta: MetaType? = nil) { + body = .errors(errors, meta: meta) } - public init(body: ResourceBody, includes: Includes) { - self.body = .data(primary: body, included: includes) + public init(body: ResourceBody, includes: Includes, meta: MetaType) { + self.body = .data(primary: body, included: includes, meta: meta) } } extension JSONAPIDocument where IncludeType == NoIncludes { + public init(body: ResourceBody, meta: MetaType) { + self.body = .data(primary: body, included: .none, meta: meta) + } +} + +extension JSONAPIDocument where MetaType == NoMetadata { + public init(body: ResourceBody, includes: Includes) { + self.body = .data(primary: body, included: includes, meta: .none) + } +} + +extension JSONAPIDocument where IncludeType == NoIncludes, MetaType == NoMetadata { public init(body: ResourceBody) { - self.body = .data(primary: body, included: .none) + self.body = .data(primary: body, included: .none, meta: .none) } } @@ -65,36 +82,81 @@ extension JSONAPIDocument: Codable { 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 + } + } + + // If there are errors, there cannot be a body. Return errors and any metadata found. if let errors = errors { - body = .errors(errors) + body = .errors(errors, meta: meta) + return + } + + let maybeData: ResourceBody? + if ResourceBody.self == NoResourceBody.self { + maybeData = nil + } else { + maybeData = try container.decode(ResourceBody.self, forKey: .data) + } + + // If there were not errors but there is also no data, try to find metadata. + // No metadata is against JSON API Spec, but otherwise we can form a + // metadat-only document. + guard let data = maybeData else { + guard let metaVal = meta else { + throw JSONAPIEncodingError.missingOrMalformedMetadata + } + body = .meta(metaVal) return } - let data = try container.decode(ResourceBody.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 + } - body = .data(primary: data, included: maybeIncludes ?? Includes.none) + body = .data(primary: data, included: maybeIncludes ?? Includes.none, meta: metaVal) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: RootCodingKeys.self) switch body { - case .errors(let errors): + case .errors(let errors, let meta): var errContainer = container.nestedUnkeyedContainer(forKey: .errors) for error in errors { try errContainer.encode(error) } - case .data(primary: let resourceBody, included: let includes): + if MetaType.self != NoMetadata.self, + let metaVal = meta { + try container.encode(metaVal, forKey: .meta) + } + + case .data(primary: let resourceBody, included: let includes, let meta): try container.encode(resourceBody, forKey: .data) if Include.self != NoIncludes.self { try container.encode(includes, forKey: .included) } + + if MetaType.self != NoMetadata.self { + try container.encode(meta, forKey: .meta) + } + + case .meta(let metadata): + try container .encode(metadata, forKey: .meta) } } } diff --git a/Sources/JSONAPI/Document/ResourceBody.swift b/Sources/JSONAPI/Document/ResourceBody.swift index 5c7598c..bdcb55d 100644 --- a/Sources/JSONAPI/Document/ResourceBody.swift +++ b/Sources/JSONAPI/Document/ResourceBody.swift @@ -24,6 +24,12 @@ public struct ManyResourceBody: ResourceBody { } } +/// 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() } +} + // MARK: Decodable extension SingleResourceBody { public init(from decoder: Decoder) throws { diff --git a/Sources/JSONAPI/Meta/Meta.swift b/Sources/JSONAPI/Meta/Meta.swift new file mode 100644 index 0000000..8d035fe --- /dev/null +++ b/Sources/JSONAPI/Meta/Meta.swift @@ -0,0 +1,24 @@ +// +// Meta.swift +// JSONAPI +// +// Created by Mathew Polzin on 11/21/18. +// + +import Foundation + +/// 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 +/// 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 +/// the server and client agree upon. +public protocol Meta: Codable, Equatable { +} + +public struct NoMetadata: Meta { + public static var none: NoMetadata { return NoMetadata() } + + public init() { } +} diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index c07c362..b2073de 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -96,6 +96,7 @@ private enum ResourceIdentifierCodingKeys: String, CodingKey { public enum JSONAPIEncodingError: Swift.Error { case typeMismatch(expected: String, found: String) case illegalEncoding(String) + case missingOrMalformedMetadata } extension ToOneRelationship { diff --git a/Tests/JSONAPITests/Document/DocumentTests.swift b/Tests/JSONAPITests/Document/DocumentTests.swift index 081e1e9..6d1c290 100644 --- a/Tests/JSONAPITests/Document/DocumentTests.swift +++ b/Tests/JSONAPITests/Document/DocumentTests.swift @@ -11,22 +11,106 @@ import JSONAPI class DocumentTests: XCTestCase { func test_singleDocumentNull() { - let document = decoded(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, + let document = decoded(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, data: single_document_null) XCTAssertFalse(document.body.isError) XCTAssertNotNil(document.body.data) + XCTAssertNil(document.body.meta) XCTAssertNil(document.body.data?.primary.value) XCTAssertEqual(document.body.data?.included.count, 0) } func test_singleDocumentNull_encode() { - test_DecodeEncodeEquality(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, - data: single_document_null) + test_DecodeEncodeEquality(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, + data: single_document_null) + } + + func test_unknownErrorDocumentNoMeta() { + let document = decoded(type: JSONAPIDocument.self, + data: error_document_no_metadata) + + XCTAssertTrue(document.body.isError) + XCTAssertNil(document.body.meta) + XCTAssertNil(document.body.data) + + guard case let .errors(errors) = document.body else { + XCTFail("Needed body to be in errors case but it was not.") + return + } + + XCTAssertEqual(errors.0.count, 1) + XCTAssertEqual(errors.0[0], .unknown) + XCTAssertEqual(errors.meta, NoMetadata()) + } + + func test_unknownErrorDocumentNoMeta_encode() { + test_DecodeEncodeEquality(type: JSONAPIDocument.self, + data: error_document_no_metadata) + } + + func test_errorDocumentNoMeta() { + let document = decoded(type: JSONAPIDocument.self, + data: error_document_no_metadata) + + XCTAssertTrue(document.body.isError) + XCTAssertNil(document.body.meta) + XCTAssertNil(document.body.data) + + guard case let .errors(errors) = document.body else { + XCTFail("Needed body to be in errors case but it was not.") + return + } + + XCTAssertEqual(errors.0.count, 1) + XCTAssertEqual(errors.0[0], TestError.basic(.init(code: 1, description: "Boooo!"))) + XCTAssertEqual(errors.meta, NoMetadata()) + } + + func test_errorDocumentNoMeta_encode() { + test_DecodeEncodeEquality(type: JSONAPIDocument.self, + data: error_document_no_metadata) + } + + func test_errorDocumentWithMeta() { + let document = decoded(type: JSONAPIDocument.self, + data: error_document_with_metadata) + + XCTAssertTrue(document.body.isError) + XCTAssertNil(document.body.meta) + XCTAssertNil(document.body.data) + + guard case let .errors(errors) = document.body else { + XCTFail("Needed body to be in errors case but it was not.") + return + } + + XCTAssertEqual(errors.0.count, 1) + XCTAssertEqual(errors.meta, TestPageMetadata(total: 70, limit: 40, offset: 10)) + } + + func test_errorDocumentWithMeta_encode() { + test_DecodeEncodeEquality(type: JSONAPIDocument.self, + data: error_document_with_metadata) + } + + func test_metaDataDocument() { + let document = decoded(type: JSONAPIDocument.self, + data: metadata_document) + + XCTAssertFalse(document.body.isError) + XCTAssertEqual(document.body.meta?.total, 100) + XCTAssertEqual(document.body.meta?.limit, 50) + XCTAssertEqual(document.body.meta?.offset, 0) + } + + func test_metaDataDocument_encode() { + test_DecodeEncodeEquality(type: JSONAPIDocument.self, + data: metadata_document) } func test_singleDocumentNoIncludes() { - let document = decoded(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, + let document = decoded(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, data: single_document_no_includes) XCTAssertFalse(document.body.isError) @@ -36,12 +120,12 @@ class DocumentTests: XCTestCase { } func test_singleDocumentNoIncludes_encode() { - test_DecodeEncodeEquality(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, + test_DecodeEncodeEquality(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, data: single_document_no_includes) } func test_singleDocumentSomeIncludes() { - let document = decoded(type: JSONAPIDocument, Include1, BasicJSONAPIError>.self, + let document = decoded(type: JSONAPIDocument, NoMetadata, Include1, BasicJSONAPIError>.self, data: single_document_some_includes) XCTAssertFalse(document.body.isError) @@ -53,12 +137,12 @@ class DocumentTests: XCTestCase { } func test_singleDocumentSomeIncludes_encode() { - test_DecodeEncodeEquality(type: JSONAPIDocument, Include1, BasicJSONAPIError>.self, + test_DecodeEncodeEquality(type: JSONAPIDocument, NoMetadata, Include1, BasicJSONAPIError>.self, data: single_document_some_includes) } func test_manyDocumentNoIncludes() { - let document = decoded(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, + let document = decoded(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, data: many_document_no_includes) XCTAssertFalse(document.body.isError) @@ -71,12 +155,12 @@ class DocumentTests: XCTestCase { } func test_manyDocumentNoIncludes_encode() { - test_DecodeEncodeEquality(type: JSONAPIDocument, Include0, BasicJSONAPIError>.self, + test_DecodeEncodeEquality(type: JSONAPIDocument, NoMetadata, NoIncludes, BasicJSONAPIError>.self, data: many_document_no_includes) } func test_manyDocumentSomeIncludes() { - let document = decoded(type: JSONAPIDocument, Include1, BasicJSONAPIError>.self, + let document = decoded(type: JSONAPIDocument, NoMetadata, Include1, BasicJSONAPIError>.self, data: many_document_some_includes) XCTAssertFalse(document.body.isError) @@ -93,10 +177,14 @@ class DocumentTests: XCTestCase { } func test_manyDocumentSomeIncludes_encode() { - test_DecodeEncodeEquality(type: JSONAPIDocument, Include1, BasicJSONAPIError>.self, + test_DecodeEncodeEquality(type: JSONAPIDocument, NoMetadata, Include1, BasicJSONAPIError>.self, data: many_document_some_includes) } - + +} + +// MARK: - Test Types +extension DocumentTests { enum AuthorType: EntityDescription { static var type: String { return "authors" } @@ -117,4 +205,41 @@ class DocumentTests: XCTestCase { } typealias Article = Entity + + struct TestPageMetadata: JSONAPI.Meta { + let total: Int + let limit: Int + let offset: Int + } + + enum TestError: JSONAPIError { + case unknownError + case basic(BasicError) + + struct BasicError: Codable, Equatable { + let code: Int + let description: String + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + self = (try? .basic(container.decode(BasicError.self))) ?? .unknown + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .unknownError: + try container.encode("unknown") + case .basic(let error): + try container.encode(error) + } + } + + public static var unknown: DocumentTests.TestError { + return .unknownError + } + } } diff --git a/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift b/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift index d12d188..a7fb104 100644 --- a/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift +++ b/Tests/JSONAPITests/Document/stubs/DocumentStubs.swift @@ -150,3 +150,40 @@ let many_document_some_includes = """ ] } """.data(using: .utf8)! + +let error_document_no_metadata = """ +{ + "errors": [ + { + "description": "Boooo!", + "code": 1 + } + ] +} +""".data(using: .utf8)! + +let error_document_with_metadata = """ +{ + "errors": [ + { + "description": "Boooo!", + "code": 1 + } + ], + "meta": { + "total": 70, + "limit": 40, + "offset": 10 + } +} +""".data(using: .utf8)! + +let metadata_document = """ +{ + "meta": { + "total": 100, + "limit": 50, + "offset": 0 + } +} +""".data(using: .utf8)!