Files
JSONAPI/Sources/JSONAPI/Document/Document.swift
T
2019-11-05 20:04:47 -08:00

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")
}
}
}