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:
Mathew Polzin
2019-01-24 00:47:24 -08:00
parent 7045373708
commit dc30cb3b9e
4 changed files with 226 additions and 4 deletions
@@ -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
}
}