mirror of
https://github.com/encounter/JSONAPI.git
synced 2026-03-30 11:18:38 -07:00
Make Attribute Sampleable where its RawValue is Sampleable. Allow Sampleable things to provide a best guess for their node type based on the result of encoding and then deserializing them.
This commit is contained in:
@@ -12,6 +12,11 @@ import AnyCodable
|
||||
private protocol _Optional {}
|
||||
extension Optional: _Optional {}
|
||||
|
||||
private protocol Wrapper {
|
||||
associatedtype Wrapped
|
||||
}
|
||||
extension Optional: Wrapper {}
|
||||
|
||||
extension Attribute: OpenAPINodeType where RawValue: OpenAPINodeType {
|
||||
static public func openAPINode() throws -> JSONNode {
|
||||
// If the RawValue is not required, we actually consider it
|
||||
@@ -72,6 +77,8 @@ extension TransformedAttribute: OpenAPINodeType where RawValue: OpenAPINodeType
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: conform TransformedAttribute to all of the above protocols that Attribute conforms to.
|
||||
|
||||
extension RelationshipType {
|
||||
static func relationshipNode(nullable: Bool, jsonType: String) -> JSONNode {
|
||||
let propertiesDict: [String: JSONNode] = [
|
||||
@@ -142,13 +149,13 @@ extension Entity: OpenAPIEncodedNodeType, OpenAPINodeType where Description.Attr
|
||||
|
||||
let attributesNode: JSONNode? = Description.Attributes.self == NoAttributes.self
|
||||
? nil
|
||||
: try Description.Attributes.genericObjectOpenAPINode(using: encoder)
|
||||
: try Description.Attributes.genericOpenAPINode(using: encoder)
|
||||
|
||||
let attributesProperty = attributesNode.map { ("attributes", $0) }
|
||||
|
||||
let relationshipsNode: JSONNode? = Description.Relationships.self == NoRelationships.self
|
||||
? nil
|
||||
: try Description.Relationships.genericObjectOpenAPINode(using: encoder)
|
||||
: try Description.Relationships.genericOpenAPINode(using: encoder)
|
||||
|
||||
let relationshipsProperty = relationshipsNode.map { ("relationships", $0) }
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ public protocol DoubleWrappedRawOpenAPIType {
|
||||
static func wrappedOpenAPINode() throws -> JSONNode
|
||||
}
|
||||
|
||||
/// A GenericOpenAPINodeType can take a stab at
|
||||
/// determining its OpenAPINode because it is sampleable.
|
||||
public protocol GenericOpenAPINodeType {
|
||||
static func genericOpenAPINode(using encoder: JSONEncoder) throws -> JSONNode
|
||||
}
|
||||
|
||||
/// Anything conforming to `AnyJSONCaseIterable` can provide a
|
||||
/// list of its possible values.
|
||||
public protocol AnyJSONCaseIterable {
|
||||
@@ -594,6 +600,7 @@ public enum JSONNode: Equatable {
|
||||
public enum OpenAPICodableError: Swift.Error, Equatable {
|
||||
case allCasesArrayNotCodable
|
||||
case exampleNotCodable
|
||||
case primitiveGuessFailed
|
||||
}
|
||||
|
||||
public enum OpenAPITypeError: Swift.Error, Equatable {
|
||||
|
||||
@@ -37,6 +37,8 @@ public protocol Sampleable {
|
||||
static var samples: [Self] { get }
|
||||
}
|
||||
|
||||
public typealias SampleableOpenAPIType = Sampleable & GenericOpenAPINodeType
|
||||
|
||||
public extension Sampleable {
|
||||
// default implementation:
|
||||
public static var successSample: Self? { return nil }
|
||||
@@ -48,8 +50,8 @@ public extension Sampleable {
|
||||
public static var samples: [Self] { return [Self.sample] }
|
||||
}
|
||||
|
||||
extension Sampleable {
|
||||
public static func genericObjectOpenAPINode(using encoder: JSONEncoder) throws -> JSONNode {
|
||||
extension Sampleable where Self: Encodable {
|
||||
public static func genericOpenAPINode(using encoder: JSONEncoder) throws -> JSONNode {
|
||||
let mirror = Mirror(reflecting: Self.sample)
|
||||
let properties: [(String, JSONNode)] = try mirror.children.compactMap { child in
|
||||
|
||||
@@ -80,6 +82,9 @@ extension Sampleable {
|
||||
case let valType as DoubleWrappedRawOpenAPIType.Type:
|
||||
return try valType.wrappedOpenAPINode()
|
||||
|
||||
case let valType as GenericOpenAPINodeType.Type:
|
||||
return try valType.genericOpenAPINode(using: encoder)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -97,6 +102,13 @@ extension Sampleable {
|
||||
return zip(child.label, newNode) { ($0, $1) }
|
||||
}
|
||||
|
||||
// if there are no properties, let's see if we are dealing
|
||||
// with a primitive.
|
||||
if properties.count == 0,
|
||||
let primitive = try primitiveGuess(using: encoder) {
|
||||
return primitive
|
||||
}
|
||||
|
||||
// There should not be any duplication of keys since these are
|
||||
// property names, but rather than risk runtime exception, we just
|
||||
// fail to the newer value arbitrarily
|
||||
@@ -106,6 +118,71 @@ extension Sampleable {
|
||||
required: true),
|
||||
.init(properties: propertiesDict))
|
||||
}
|
||||
|
||||
private static func primitiveGuess(using encoder: JSONEncoder) throws -> JSONNode? {
|
||||
let data = try encoder.encode(PrimitiveWrapper(primitive: Self.sample))
|
||||
let wrappedValue = try JSONSerialization.jsonObject(with: data, options: [.allowFragments])
|
||||
|
||||
guard let wrapperDict = wrappedValue as? [String: Any],
|
||||
wrapperDict.contains(where: { $0.key == "primitive" }) else {
|
||||
throw OpenAPICodableError.primitiveGuessFailed
|
||||
}
|
||||
|
||||
let value = (wrappedValue as! [String: Any])["primitive"]!
|
||||
|
||||
return try {
|
||||
switch type(of: value) {
|
||||
case let valType as OpenAPINodeType.Type:
|
||||
return try valType.openAPINode()
|
||||
|
||||
case let valType as RawOpenAPINodeType.Type:
|
||||
return try valType.rawOpenAPINode()
|
||||
|
||||
case let valType as WrappedRawOpenAPIType.Type:
|
||||
return try valType.wrappedOpenAPINode()
|
||||
|
||||
case let valType as DoubleWrappedRawOpenAPIType.Type:
|
||||
return try valType.wrappedOpenAPINode()
|
||||
|
||||
case let valType as GenericOpenAPINodeType.Type:
|
||||
return try valType.genericOpenAPINode(using: encoder)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}() ?? {
|
||||
switch value {
|
||||
case is String:
|
||||
return .string(.init(format: .generic,
|
||||
required: true),
|
||||
.init())
|
||||
|
||||
case is Int:
|
||||
return .integer(.init(format: .generic,
|
||||
required: true),
|
||||
.init())
|
||||
|
||||
case is Double:
|
||||
return .number(.init(format: .double,
|
||||
required: true),
|
||||
.init())
|
||||
|
||||
case is Bool:
|
||||
return .boolean(.init(format: .generic,
|
||||
required: true))
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// The following wrapper is only needed because JSONEncoder cannot yet encode
|
||||
// JSON fragments. It is a very unfortunate limitation that requires silly
|
||||
// workarounds in edge cases like this.
|
||||
private struct PrimitiveWrapper<Wrapped: Encodable>: Encodable {
|
||||
let primitive: Wrapped
|
||||
}
|
||||
|
||||
extension Sampleable {
|
||||
@@ -199,6 +276,12 @@ extension UnknownJSONAPIError: Sampleable {
|
||||
}
|
||||
}
|
||||
|
||||
extension Attribute: Sampleable where RawValue: Sampleable {
|
||||
public static var sample: Attribute<RawValue> {
|
||||
return .init(value: RawValue.sample)
|
||||
}
|
||||
}
|
||||
|
||||
extension SingleResourceBody: Sampleable where Entity: Sampleable {
|
||||
public static var sample: SingleResourceBody<Entity> {
|
||||
return .init(entity: Entity.sample)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import XCTest
|
||||
import JSONAPI
|
||||
import JSONAPIOpenAPI
|
||||
import SwiftCheck
|
||||
import AnyCodable
|
||||
|
||||
class JSONAPIAttributeOpenAPITests: XCTestCase {
|
||||
@@ -504,6 +505,124 @@ extension JSONAPIAttributeOpenAPITests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date
|
||||
extension JSONAPIAttributeOpenAPITests {
|
||||
func test_DateStringAttribute() {
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
dateFormatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
encoder.dateEncodingStrategy = .formatted(dateFormatter)
|
||||
|
||||
let node = try! Attribute<Date>.genericOpenAPINode(using: encoder)
|
||||
|
||||
XCTAssertTrue(node.required)
|
||||
XCTAssertEqual(node.jsonTypeFormat, .string(.generic))
|
||||
|
||||
guard case .string(let contextA, let stringContext) = node else {
|
||||
XCTFail("Expected string Node")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(contextA, .init(format: .generic,
|
||||
required: true,
|
||||
nullable: false,
|
||||
allowedValues: nil))
|
||||
|
||||
XCTAssertEqual(stringContext, .init())
|
||||
}
|
||||
|
||||
func test_DateNumberAttribute() {
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
dateFormatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
|
||||
let node = try! Attribute<Date>.genericOpenAPINode(using: encoder)
|
||||
|
||||
XCTAssertTrue(node.required)
|
||||
XCTAssertEqual(node.jsonTypeFormat, .number(.double))
|
||||
|
||||
guard case .number(let contextA, let numberContext) = node else {
|
||||
XCTFail("Expected string Node")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(contextA, .init(format: .double,
|
||||
required: true,
|
||||
nullable: false,
|
||||
allowedValues: nil))
|
||||
|
||||
XCTAssertEqual(numberContext, .init())
|
||||
}
|
||||
|
||||
// func test_NullableEnumAttribute() {
|
||||
// let node = try! Attribute<EnumAttribute?>.wrappedOpenAPINode()
|
||||
//
|
||||
// XCTAssertTrue(node.required)
|
||||
// XCTAssertEqual(node.jsonTypeFormat, .string(.generic))
|
||||
//
|
||||
// guard case .string(let contextA, let stringContext) = node else {
|
||||
// XCTFail("Expected string Node")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(contextA, .init(format: .generic,
|
||||
// required: true,
|
||||
// nullable: true,
|
||||
// allowedValues: nil))
|
||||
//
|
||||
// XCTAssertEqual(stringContext, .init())
|
||||
// }
|
||||
//
|
||||
// func test_OptionalEnumAttribute() {
|
||||
// let node = try! Attribute<EnumAttribute>?.wrappedOpenAPINode()
|
||||
//
|
||||
// XCTAssertFalse(node.required)
|
||||
// XCTAssertEqual(node.jsonTypeFormat, .string(.generic))
|
||||
//
|
||||
// guard case .string(let contextA, let stringContext) = node else {
|
||||
// XCTFail("Expected string Node")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(contextA, .init(format: .generic,
|
||||
// required: false,
|
||||
// nullable: false,
|
||||
// allowedValues: nil))
|
||||
//
|
||||
// XCTAssertEqual(stringContext, .init())
|
||||
// }
|
||||
//
|
||||
// func test_OptionalNullableEnumAttribute() {
|
||||
// let node = try! Attribute<EnumAttribute?>?.wrappedOpenAPINode()
|
||||
//
|
||||
// XCTAssertFalse(node.required)
|
||||
// XCTAssertEqual(node.jsonTypeFormat, .string(.generic))
|
||||
//
|
||||
// guard case .string(let contextA, let stringContext) = node else {
|
||||
// XCTFail("Expected string Node")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(contextA, .init(format: .generic,
|
||||
// required: false,
|
||||
// nullable: true,
|
||||
// allowedValues: nil))
|
||||
//
|
||||
// XCTAssertEqual(stringContext, .init())
|
||||
// }
|
||||
}
|
||||
|
||||
// MARK: - Test Types
|
||||
extension JSONAPIAttributeOpenAPITests {
|
||||
enum EnumAttribute: String, Codable, CaseIterable {
|
||||
@@ -511,3 +630,9 @@ extension JSONAPIAttributeOpenAPITests {
|
||||
case two
|
||||
}
|
||||
}
|
||||
|
||||
extension Date: Sampleable {
|
||||
public static var sample: Date {
|
||||
return TimeInterval.arbitrary.map { Date(timeIntervalSince1970: $0) }.generate
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user