diff --git a/Sources/JSONAPIOpenAPI/JSONAPI/JSONAPITypes+OpenAPI.swift b/Sources/JSONAPIOpenAPI/JSONAPI/JSONAPITypes+OpenAPI.swift index 91c6068..6c07525 100644 --- a/Sources/JSONAPIOpenAPI/JSONAPI/JSONAPITypes+OpenAPI.swift +++ b/Sources/JSONAPIOpenAPI/JSONAPI/JSONAPITypes+OpenAPI.swift @@ -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) } diff --git a/Sources/JSONAPIOpenAPI/OpenAPI/OpenAPITypes.swift b/Sources/JSONAPIOpenAPI/OpenAPI/OpenAPITypes.swift index a7723ec..14a3a24 100644 --- a/Sources/JSONAPIOpenAPI/OpenAPI/OpenAPITypes.swift +++ b/Sources/JSONAPIOpenAPI/OpenAPI/OpenAPITypes.swift @@ -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 { diff --git a/Sources/JSONAPIOpenAPI/Sampleable/Sampleable.swift b/Sources/JSONAPIOpenAPI/Sampleable/Sampleable.swift index e186222..ae7062b 100644 --- a/Sources/JSONAPIOpenAPI/Sampleable/Sampleable.swift +++ b/Sources/JSONAPIOpenAPI/Sampleable/Sampleable.swift @@ -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: Encodable { + let primitive: Wrapped } extension Sampleable { @@ -199,6 +276,12 @@ extension UnknownJSONAPIError: Sampleable { } } +extension Attribute: Sampleable where RawValue: Sampleable { + public static var sample: Attribute { + return .init(value: RawValue.sample) + } +} + extension SingleResourceBody: Sampleable where Entity: Sampleable { public static var sample: SingleResourceBody { return .init(entity: Entity.sample) diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift index 642b95e..ebb1085 100644 --- a/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift +++ b/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift @@ -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.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.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.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?.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?.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 + } +}