mirror of
https://github.com/encounter/JSONAPI.git
synced 2026-03-30 11:18:38 -07:00
626 lines
21 KiB
Swift
626 lines
21 KiB
Swift
//
|
|
// Document.swift
|
|
// JSONAPI
|
|
//
|
|
// Created by Mathew Polzin on 11/5/18.
|
|
//
|
|
|
|
import Poly
|
|
|
|
public protocol DocumentBodyDataContext {
|
|
associatedtype PrimaryResourceBody: JSONAPI.EncodableResourceBody
|
|
associatedtype MetaType: JSONAPI.Meta
|
|
associatedtype LinksType: JSONAPI.Links
|
|
associatedtype IncludeType: JSONAPI.Include
|
|
}
|
|
|
|
public protocol DocumentBodyContext: DocumentBodyDataContext {
|
|
associatedtype Error: JSONAPIError
|
|
associatedtype BodyData: DocumentBodyData
|
|
where
|
|
BodyData.PrimaryResourceBody == PrimaryResourceBody,
|
|
BodyData.MetaType == MetaType,
|
|
BodyData.LinksType == LinksType,
|
|
BodyData.IncludeType == IncludeType
|
|
}
|
|
|
|
public protocol DocumentBodyData: DocumentBodyDataContext {
|
|
/// The document's primary resource body
|
|
/// (contains one or many resource objects)
|
|
var primary: PrimaryResourceBody { get }
|
|
|
|
/// The document's included objects
|
|
var includes: Includes<IncludeType> { get }
|
|
var meta: MetaType { get }
|
|
var links: LinksType { get }
|
|
}
|
|
|
|
public protocol DocumentBody: DocumentBodyContext {
|
|
/// `true` if the document represents one or more errors. `false` if the
|
|
/// document represents JSON:API data and/or metadata.
|
|
var isError: Bool { get }
|
|
|
|
/// Get all errors in the document, if any.
|
|
///
|
|
/// `nil` if the Document is _not_ an error response. Otherwise,
|
|
/// an array containing all errors.
|
|
var errors: [Error]? { get }
|
|
|
|
/// Get the document data
|
|
///
|
|
/// `nil` if the Document is an error response. Otherwise,
|
|
/// a structure containing the primary resource, any included
|
|
/// resources, metadata, and links.
|
|
var data: BodyData? { get }
|
|
|
|
/// Quick access to the `data`'s primary resource.
|
|
///
|
|
/// `nil` if the Document is an error document. Otherwise,
|
|
/// the primary resource body, which will contain zero/one, one/many
|
|
/// resources dependening on the `PrimaryResourceBody` type.
|
|
///
|
|
/// See `SingleResourceBody` and `ManyResourceBody`.
|
|
var primaryResource: PrimaryResourceBody? { get }
|
|
|
|
/// Quick access to the `data`'s includes.
|
|
///
|
|
/// `nil` if the Document is an error document. Otherwise,
|
|
/// zero or more includes.
|
|
var includes: Includes<IncludeType>? { get }
|
|
|
|
/// The metadata for the error or data document or `nil` if
|
|
/// no metadata is found.
|
|
var meta: MetaType? { get }
|
|
|
|
/// The links for the error or data document or `nil` if
|
|
/// no links are found.
|
|
var links: LinksType? { get }
|
|
}
|
|
|
|
/// An `EncodableJSONAPIDocument` supports encoding but not decoding.
|
|
/// It is actually more restrictive than `JSONAPIDocument` which supports both
|
|
/// encoding and decoding.
|
|
public protocol EncodableJSONAPIDocument: Equatable, Encodable, DocumentBodyContext {
|
|
associatedtype APIDescription: APIDescriptionType
|
|
associatedtype Body: DocumentBody
|
|
where
|
|
Body.PrimaryResourceBody == PrimaryResourceBody,
|
|
Body.MetaType == MetaType,
|
|
Body.LinksType == LinksType,
|
|
Body.IncludeType == IncludeType,
|
|
Body.Error == Error,
|
|
Body.BodyData == BodyData
|
|
|
|
var body: Body { get }
|
|
}
|
|
|
|
/// A `CodableJSONAPIDocument` supports encoding and decoding of a JSON:API
|
|
/// compliant Document.
|
|
public protocol CodableJSONAPIDocument: EncodableJSONAPIDocument, Decodable where PrimaryResourceBody: JSONAPI.ResourceBody, IncludeType: Decodable {}
|
|
|
|
/// A JSON API Document represents the entire body
|
|
/// of a JSON API request or the entire body of
|
|
/// a JSON API response.
|
|
/// Note that this type uses Camel case. If your
|
|
/// API uses snake case, you will want to use
|
|
/// a conversion such as the one offerred by the
|
|
/// Foundation JSONEncoder/Decoder: `KeyDecodingStrategy`
|
|
public struct Document<PrimaryResourceBody: JSONAPI.EncodableResourceBody, MetaType: JSONAPI.Meta, LinksType: JSONAPI.Links, IncludeType: JSONAPI.Include, APIDescription: APIDescriptionType, Error: JSONAPIError>: EncodableJSONAPIDocument {
|
|
public typealias Include = IncludeType
|
|
public typealias BodyData = Body.Data
|
|
|
|
/// The JSON API Spec calls this the JSON:API Object. It contains version
|
|
/// and metadata information about the API itself.
|
|
public let apiDescription: APIDescription
|
|
|
|
/// The Body of the Document. This body is either one or more errors
|
|
/// with links and metadata attempted to parse but not guaranteed or
|
|
/// it is a successful data struct containing all the primary and
|
|
/// included resources, the metadata, and the links that this
|
|
/// document type specifies.
|
|
public let body: Body
|
|
|
|
public init(apiDescription: APIDescription,
|
|
errors: [Error],
|
|
meta: MetaType? = nil,
|
|
links: LinksType? = nil) {
|
|
body = .errors(errors, meta: meta, links: links)
|
|
self.apiDescription = apiDescription
|
|
}
|
|
|
|
public init(apiDescription: APIDescription,
|
|
body: PrimaryResourceBody,
|
|
includes: Includes<Include>,
|
|
meta: MetaType,
|
|
links: LinksType) {
|
|
self.body = .data(
|
|
.init(
|
|
primary: body,
|
|
includes: includes,
|
|
meta: meta,
|
|
links: links
|
|
)
|
|
)
|
|
self.apiDescription = apiDescription
|
|
}
|
|
}
|
|
|
|
extension Document {
|
|
public enum Body: DocumentBody, Equatable {
|
|
case errors([Error], meta: MetaType?, links: LinksType?)
|
|
case data(Data)
|
|
|
|
public typealias BodyData = Data
|
|
|
|
public struct Data: DocumentBodyData, Equatable {
|
|
/// The document's Primary Resource object(s)
|
|
public let primary: PrimaryResourceBody
|
|
/// The document's included objects
|
|
public let includes: Includes<Include>
|
|
public let meta: MetaType
|
|
public let links: LinksType
|
|
|
|
public init(primary: PrimaryResourceBody, includes: Includes<Include>, meta: MetaType, links: LinksType) {
|
|
self.primary = primary
|
|
self.includes = includes
|
|
self.meta = meta
|
|
self.links = links
|
|
}
|
|
}
|
|
|
|
/// `true` if the document represents one or more errors. `false` if the
|
|
/// document represents JSON:API data and/or metadata.
|
|
public var isError: Bool {
|
|
guard case .errors = self else { return false }
|
|
return true
|
|
}
|
|
|
|
public var errors: [Error]? {
|
|
guard case let .errors(errors, meta: _, links: _) = self else { return nil }
|
|
return errors
|
|
}
|
|
|
|
public var data: Data? {
|
|
guard case let .data(data) = self else { return nil }
|
|
return data
|
|
}
|
|
|
|
public var primaryResource: PrimaryResourceBody? {
|
|
guard case let .data(data) = self else { return nil }
|
|
return data.primary
|
|
}
|
|
|
|
public var includes: Includes<Include>? {
|
|
guard case let .data(data) = self else { return nil }
|
|
return data.includes
|
|
}
|
|
|
|
public var meta: MetaType? {
|
|
switch self {
|
|
case .data(let data):
|
|
return data.meta
|
|
case .errors(_, meta: let metadata?, links: _):
|
|
return metadata
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public var links: LinksType? {
|
|
switch self {
|
|
case .data(let data):
|
|
return data.links
|
|
case .errors(_, meta: _, links: let links?):
|
|
return links
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Document.Body.Data where PrimaryResourceBody: ResourceBodyAppendable {
|
|
public func merging(_ other: Document.Body.Data,
|
|
combiningMetaWith metaMerge: (MetaType, MetaType) -> MetaType,
|
|
combiningLinksWith linksMerge: (LinksType, LinksType) -> LinksType) -> Document.Body.Data {
|
|
return Document.Body.Data(primary: primary.appending(other.primary),
|
|
includes: includes.appending(other.includes),
|
|
meta: metaMerge(meta, other.meta),
|
|
links: linksMerge(links, other.links))
|
|
}
|
|
}
|
|
|
|
extension Document.Body.Data where PrimaryResourceBody: ResourceBodyAppendable, MetaType == NoMetadata, LinksType == NoLinks {
|
|
public func merging(_ other: Document.Body.Data) -> Document.Body.Data {
|
|
return merging(other,
|
|
combiningMetaWith: { _, _ in .none },
|
|
combiningLinksWith: { _, _ in .none })
|
|
}
|
|
}
|
|
|
|
extension Document where IncludeType == NoIncludes {
|
|
/// Create a new Document with the given includes.
|
|
public func including<I: JSONAPI.Include>(_ includes: Includes<I>) -> Document<PrimaryResourceBody, MetaType, LinksType, I, APIDescription, Error> {
|
|
// 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 body {
|
|
case .data(let data):
|
|
return .init(apiDescription: apiDescription,
|
|
body: data.primary,
|
|
includes: includes,
|
|
meta: data.meta,
|
|
links: data.links)
|
|
case .errors(let errors, meta: let meta, links: let links):
|
|
return .init(apiDescription: apiDescription,
|
|
errors: errors,
|
|
meta: meta,
|
|
links: links)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extending where _Poly1 means all non-zero _Poly arities are included
|
|
extension Document 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<IncludeType>) -> 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 body {
|
|
case .data(let data):
|
|
return .init(apiDescription: apiDescription,
|
|
body: data.primary,
|
|
includes: data.includes + includes,
|
|
meta: data.meta,
|
|
links: data.links)
|
|
case .errors(let errors, meta: let meta, links: let links):
|
|
return .init(apiDescription: apiDescription,
|
|
errors: errors,
|
|
meta: meta,
|
|
links: links)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Codable
|
|
extension Document {
|
|
private enum RootCodingKeys: String, CodingKey {
|
|
case data
|
|
case errors
|
|
case included
|
|
case meta
|
|
case links
|
|
case jsonapi
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: RootCodingKeys.self)
|
|
|
|
switch body {
|
|
case .errors(let errors, meta: let meta, links: let links):
|
|
var errContainer = container.nestedUnkeyedContainer(forKey: .errors)
|
|
|
|
for error in errors {
|
|
try errContainer.encode(error)
|
|
}
|
|
|
|
if MetaType.self != NoMetadata.self,
|
|
let metaVal = meta {
|
|
try container.encode(metaVal, forKey: .meta)
|
|
}
|
|
|
|
if LinksType.self != NoLinks.self,
|
|
let linksVal = links {
|
|
try container.encode(linksVal, forKey: .links)
|
|
}
|
|
|
|
case .data(let data):
|
|
try container.encode(data.primary, forKey: .data)
|
|
|
|
if Include.self != NoIncludes.self {
|
|
try container.encode(data.includes, forKey: .included)
|
|
}
|
|
|
|
if MetaType.self != NoMetadata.self {
|
|
try container.encode(data.meta, forKey: .meta)
|
|
}
|
|
|
|
if LinksType.self != NoLinks.self {
|
|
try container.encode(data.links, forKey: .links)
|
|
}
|
|
}
|
|
|
|
if APIDescription.self != NoAPIDescription.self {
|
|
try container.encode(apiDescription, forKey: .jsonapi)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Document: Decodable, CodableJSONAPIDocument where PrimaryResourceBody: ResourceBody, IncludeType: Decodable {
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: RootCodingKeys.self)
|
|
|
|
if let noData = NoAPIDescription() as? APIDescription {
|
|
apiDescription = noData
|
|
} else {
|
|
apiDescription = try container.decode(APIDescription.self, forKey: .jsonapi)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
let links: LinksType?
|
|
if let noLinks = NoLinks() as? LinksType {
|
|
links = noLinks
|
|
} else {
|
|
do {
|
|
links = try container.decode(LinksType.self, forKey: .links)
|
|
} catch {
|
|
links = nil
|
|
}
|
|
}
|
|
|
|
// If there are errors, there cannot be a body. Return errors and any metadata found.
|
|
if let errors = errors {
|
|
body = .errors(errors, meta: meta, links: links)
|
|
return
|
|
}
|
|
|
|
let data: PrimaryResourceBody
|
|
if let noData = NoResourceBody() as? PrimaryResourceBody {
|
|
data = noData
|
|
} else {
|
|
data = try container.decode(PrimaryResourceBody.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
|
|
}
|
|
guard let linksVal = links else {
|
|
throw JSONAPIEncodingError.missingOrMalformedLinks
|
|
}
|
|
|
|
body = .data(.init(primary: data, includes: maybeIncludes ?? Includes<Include>.none, meta: metaVal, links: linksVal))
|
|
}
|
|
}
|
|
|
|
// MARK: - CustomStringConvertible
|
|
|
|
extension Document: CustomStringConvertible {
|
|
public var description: String {
|
|
return "Document(\(String(describing: body)))"
|
|
}
|
|
}
|
|
|
|
extension Document.Body: CustomStringConvertible {
|
|
public var description: String {
|
|
switch self {
|
|
case .errors(let errors, meta: let meta, links: let links):
|
|
return "errors: \(String(describing: errors)), meta: \(String(describing: meta)), links: \(String(describing: links))"
|
|
case .data(let data):
|
|
return String(describing: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Document.Body.Data: CustomStringConvertible {
|
|
public var description: String {
|
|
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.
|
|
public struct ErrorDocument: EncodableJSONAPIDocument {
|
|
public typealias BodyData = Document.BodyData
|
|
|
|
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)
|
|
}
|
|
|
|
/// The JSON API Spec calls this the JSON:API Object. It contains version
|
|
/// and metadata information about the API itself.
|
|
public var apiDescription: APIDescription {
|
|
return document.apiDescription
|
|
}
|
|
|
|
/// Get all errors in the document, if any.
|
|
public var errors: [Error] {
|
|
return document.body.errors ?? []
|
|
}
|
|
|
|
/// The metadata for the error or data document or `nil` if
|
|
/// no metadata is found.
|
|
public var meta: MetaType? {
|
|
return document.body.meta
|
|
}
|
|
|
|
/// The links for the error or data document or `nil` if
|
|
/// no links are found.
|
|
public var links: LinksType? {
|
|
return document.body.links
|
|
}
|
|
|
|
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.
|
|
public struct SuccessDocument: EncodableJSONAPIDocument {
|
|
public typealias BodyData = Document.BodyData
|
|
|
|
public var body: Document.Body { return document.body }
|
|
|
|
private let document: Document
|
|
|
|
public init(apiDescription: APIDescription,
|
|
body: PrimaryResourceBody,
|
|
includes: Includes<Include>,
|
|
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)
|
|
}
|
|
|
|
/// The JSON API Spec calls this the JSON:API Object. It contains version
|
|
/// and metadata information about the API itself.
|
|
public var apiDescription: APIDescription {
|
|
return document.apiDescription
|
|
}
|
|
|
|
/// Get the document data
|
|
///
|
|
/// `nil` if the Document is an error response. Otherwise,
|
|
/// a structure containing the primary resource, any included
|
|
/// resources, metadata, and links.
|
|
var data: BodyData? {
|
|
return document.body.data
|
|
}
|
|
|
|
/// Quick access to the `data`'s primary resource.
|
|
///
|
|
/// `nil` if the Document is an error document. Otherwise,
|
|
/// the primary resource body, which will contain zero/one, one/many
|
|
/// resources dependening on the `PrimaryResourceBody` type.
|
|
///
|
|
/// See `SingleResourceBody` and `ManyResourceBody`.
|
|
var primaryResource: PrimaryResourceBody? {
|
|
return document.body.primaryResource
|
|
}
|
|
|
|
/// Quick access to the `data`'s includes.
|
|
///
|
|
/// `nil` if the Document is an error document. Otherwise,
|
|
/// zero or more includes.
|
|
var includes: Includes<IncludeType>? {
|
|
return document.body.includes
|
|
}
|
|
|
|
/// The metadata for the error or data document or `nil` if
|
|
/// no metadata is found.
|
|
var meta: MetaType? {
|
|
return document.body.meta
|
|
}
|
|
|
|
/// The links for the error or data document or `nil` if
|
|
/// no links are found.
|
|
var links: LinksType? {
|
|
return document.body.links
|
|
}
|
|
|
|
public static func ==(lhs: Document, rhs: SuccessDocument) -> Bool {
|
|
return lhs == rhs.document
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Document.ErrorDocument: Decodable, CodableJSONAPIDocument
|
|
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, CodableJSONAPIDocument
|
|
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<I: JSONAPI.Include>(_ includes: Includes<I>) -> Document<PrimaryResourceBody, MetaType, LinksType, I, APIDescription, Error>.SuccessDocument {
|
|
// 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<IncludeType>) -> Document.SuccessDocument {
|
|
// 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")
|
|
}
|
|
}
|
|
}
|