mirror of
https://github.com/encounter/JSONAPI.git
synced 2026-03-30 11:18:38 -07:00
Added meta data to JSONAPIDocument. Added tests around JSONAPIDocument error finally. Needs a few more tests around metadata.
This commit is contained in:
@@ -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<ResourceBody: JSONAPI.ResourceBody, IncludeType: JSONAPI.Include, Error: JSONAPIError>: Equatable {
|
||||
public struct JSONAPIDocument<ResourceBody: JSONAPI.ResourceBody, MetaType: JSONAPI.Meta, IncludeType: JSONAPI.Include, Error: JSONAPIError>: 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<Include>)
|
||||
case errors([Error], meta: MetaType?)
|
||||
case data(primary: ResourceBody, included: Includes<Include>, 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<Include>)? {
|
||||
guard case let .data(primary: body, included: includes) = self else { return nil }
|
||||
return (primary: body, included: includes)
|
||||
public var data: (primary: ResourceBody, included: Includes<Include>, 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<Include>) {
|
||||
self.body = .data(primary: body, included: includes)
|
||||
public init(body: ResourceBody, includes: Includes<Include>, 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<Include>) {
|
||||
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<Include>.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<Include>.none)
|
||||
body = .data(primary: data, included: maybeIncludes ?? Includes<Include>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ public struct ManyResourceBody<Entity: JSONAPI.EntityType>: 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 {
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,22 +11,106 @@ import JSONAPI
|
||||
class DocumentTests: XCTestCase {
|
||||
|
||||
func test_singleDocumentNull() {
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, 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<SingleResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
data: single_document_null)
|
||||
test_DecodeEncodeEquality(type: JSONAPIDocument<SingleResourceBody<Article>, NoMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: single_document_null)
|
||||
}
|
||||
|
||||
func test_unknownErrorDocumentNoMeta() {
|
||||
let document = decoded(type: JSONAPIDocument<NoResourceBody, NoMetadata, NoIncludes, BasicJSONAPIError>.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<NoResourceBody, NoMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: error_document_no_metadata)
|
||||
}
|
||||
|
||||
func test_errorDocumentNoMeta() {
|
||||
let document = decoded(type: JSONAPIDocument<NoResourceBody, NoMetadata, NoIncludes, TestError>.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<NoResourceBody, NoMetadata, NoIncludes, TestError>.self,
|
||||
data: error_document_no_metadata)
|
||||
}
|
||||
|
||||
func test_errorDocumentWithMeta() {
|
||||
let document = decoded(type: JSONAPIDocument<NoResourceBody, TestPageMetadata, NoIncludes, BasicJSONAPIError>.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<NoResourceBody, TestPageMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: error_document_with_metadata)
|
||||
}
|
||||
|
||||
func test_metaDataDocument() {
|
||||
let document = decoded(type: JSONAPIDocument<NoResourceBody, TestPageMetadata, NoIncludes, BasicJSONAPIError>.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<NoResourceBody, TestPageMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: metadata_document)
|
||||
}
|
||||
|
||||
func test_singleDocumentNoIncludes() {
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, 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<SingleResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
test_DecodeEncodeEquality(type: JSONAPIDocument<SingleResourceBody<Article>, NoMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: single_document_no_includes)
|
||||
}
|
||||
|
||||
func test_singleDocumentSomeIncludes() {
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, Include1<Author>, BasicJSONAPIError>.self,
|
||||
let document = decoded(type: JSONAPIDocument<SingleResourceBody<Article>, NoMetadata, Include1<Author>, 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<SingleResourceBody<Article>, Include1<Author>, BasicJSONAPIError>.self,
|
||||
test_DecodeEncodeEquality(type: JSONAPIDocument<SingleResourceBody<Article>, NoMetadata, Include1<Author>, BasicJSONAPIError>.self,
|
||||
data: single_document_some_includes)
|
||||
}
|
||||
|
||||
func test_manyDocumentNoIncludes() {
|
||||
let document = decoded(type: JSONAPIDocument<ManyResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
let document = decoded(type: JSONAPIDocument<ManyResourceBody<Article>, 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<ManyResourceBody<Article>, Include0, BasicJSONAPIError>.self,
|
||||
test_DecodeEncodeEquality(type: JSONAPIDocument<ManyResourceBody<Article>, NoMetadata, NoIncludes, BasicJSONAPIError>.self,
|
||||
data: many_document_no_includes)
|
||||
}
|
||||
|
||||
func test_manyDocumentSomeIncludes() {
|
||||
let document = decoded(type: JSONAPIDocument<ManyResourceBody<Article>, Include1<Author>, BasicJSONAPIError>.self,
|
||||
let document = decoded(type: JSONAPIDocument<ManyResourceBody<Article>, NoMetadata, Include1<Author>, 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<ManyResourceBody<Article>, Include1<Author>, BasicJSONAPIError>.self,
|
||||
test_DecodeEncodeEquality(type: JSONAPIDocument<ManyResourceBody<Article>, NoMetadata, Include1<Author>, 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<ArticleType>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
|
||||
Reference in New Issue
Block a user