From cf746e182f3fae7a14ecd006e4ba6088b7bb51f9 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 19 Jan 2019 15:30:09 -0800 Subject: [PATCH] currently in a pretty broken state with support for enumerations being turned into allowed values via reflection. I think I am going to have to give up type safety if I want to use reflection and keep things open ended --- .../JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift | 108 +++++++++++----- Sources/JSONAPIOpenAPI/OpenAPITypes.swift | 101 ++++++++++++++- Sources/JSONAPIOpenAPI/Optional+ZipWith.swift | 10 ++ Sources/JSONAPIOpenAPI/Sampleable.swift | 122 ++++++++++++++++++ .../JSONAPIOpenAPI/SwiftPrimitiveTypes.swift | 34 +++-- .../JSONAPIEntityOpenAPITests.swift | 84 ++++++++++++ .../JSONAPIRelationshipsOpenAPITests.swift | 12 +- 7 files changed, 422 insertions(+), 49 deletions(-) create mode 100644 Sources/JSONAPIOpenAPI/Optional+ZipWith.swift create mode 100644 Sources/JSONAPIOpenAPI/Sampleable.swift create mode 100644 Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift diff --git a/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift b/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift index 97865df..aa36007 100644 --- a/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift +++ b/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift @@ -11,48 +11,78 @@ private protocol _Optional {} extension Optional: _Optional {} extension Attribute: OpenAPINodeType where RawValue: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { // If the RawValue is not required, we actually consider it // nullable. To be not required is for the Attribute itself // to be optional. - if !RawValue.openAPINode.required { - return RawValue.openAPINode.requiredNode().nullableNode() + if try !RawValue.openAPINode().required { + return try RawValue.openAPINode().requiredNode().nullableNode() } - return RawValue.openAPINode + return try RawValue.openAPINode() + } +} + +extension Attribute: RawOpenAPINodeType where RawValue: RawRepresentable, RawValue.RawValue: OpenAPINodeType { + static public func openAPINode() throws -> JSONNode { + + if try !RawValue.RawValue.openAPINode().required { + return try RawValue.RawValue.openAPINode().requiredNode().nullableNode() + } + return try RawValue.RawValue.openAPINode() + } +} + +extension Attribute: AnyJSONCaseIterable where RawValue: CaseIterable { + public static var allCases: [Any] { + return Array(RawValue.allCases) + } +} + +extension Attribute: AnyWrappedJSONCaseIterable where RawValue: AnyJSONCaseIterable { + public static var allCases: [Any] { + return RawValue.allCases } } extension TransformedAttribute: OpenAPINodeType where RawValue: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { // If the RawValue is not required, we actually consider it // nullable. To be not required is for the Attribute itself // to be optional. - if !RawValue.openAPINode.required { - return RawValue.openAPINode.requiredNode().nullableNode() + if try !RawValue.openAPINode().required { + return try RawValue.openAPINode().requiredNode().nullableNode() } - return RawValue.openAPINode + return try RawValue.openAPINode() + } +} + +extension RelationshipType { + static func relationshipNode(nullable: Bool) -> JSONNode { + let propertiesDict: [String: JSONNode] = [ + "id": .string(.init(format: .generic, + required: true), + .init()), + "type": .string(.init(format: .generic, + required: true), + .init()) + ] + + return .object(.init(format: .generic, + required: true, + nullable: nullable), + .init(properties: propertiesDict)) } } extension ToOneRelationship: OpenAPINodeType { // TODO: const for json `type` // TODO: metadata & links - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { let nullable = Identifiable.self is _Optional.Type return .object(.init(format: .generic, required: true), .init(properties: [ - "data": .object(.init(format: .generic, - required: true, - nullable: nullable), - .init(properties: [ - "id": .string(.init(format: .generic, - required: true), - .init()), - "type": .string(.init(format: .generic, - required: true), - .init()) - ])) + "data": ToOneRelationship.relationshipNode(nullable: nullable) ])) } } @@ -60,22 +90,38 @@ extension ToOneRelationship: OpenAPINodeType { extension ToManyRelationship: OpenAPINodeType { // TODO: const for json `type` // TODO: metadata & links - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .object(.init(format: .generic, required: true), .init(properties: [ "data": .array(.init(format: .generic, required: true), - .init(items: .object(.init(format: .generic, - required: true), - .init(properties: [ - "id": .string(.init(format: .generic, - required: true), - .init()), - "type": .string(.init(format: .generic, - required: true), - .init()) - ])))) + .init(items: ToManyRelationship.relationshipNode(nullable: false))) ])) } } + +extension Entity: OpenAPINodeType where Description.Attributes: Sampleable, Description.Relationships: Sampleable { + public static func openAPINode() throws -> JSONNode { + let attributesNode: JSONNode? = Description.Attributes.self == NoAttributes.self + ? nil + : try Description.Attributes.genericObjectOpenAPINode() + + let attributesProperty = attributesNode.map { ("attributes", $0) } + + let relationshipsNode: JSONNode? = Description.Relationships.self == NoRelationships.self + ? nil + : try Description.Relationships.genericObjectOpenAPINode() + + let relationshipsProperty = relationshipsNode.map { ("relationships", $0) } + + let propertiesDict = Dictionary([ + attributesProperty, + relationshipsProperty + ].compactMap { $0 }) { _, value in value } + + return .object(.init(format: .generic, + required: true), + .init(properties: propertiesDict)) + } +} diff --git a/Sources/JSONAPIOpenAPI/OpenAPITypes.swift b/Sources/JSONAPIOpenAPI/OpenAPITypes.swift index c039975..1be08c4 100644 --- a/Sources/JSONAPIOpenAPI/OpenAPITypes.swift +++ b/Sources/JSONAPIOpenAPI/OpenAPITypes.swift @@ -7,8 +7,39 @@ import AnyCodable +// MARK: Node (i.e. schema) Protocols + +/// Anything conforming to `OpenAPINodeType` can provide an +/// OpenAPI schema representing itself. public protocol OpenAPINodeType { - static var openAPINode: JSONNode { get } + static func openAPINode() throws -> JSONNode +} + +/// Anything conforming to `RawOpenAPINodeType` can provide an +/// OpenAPI schema representing itself. This second protocol is +/// necessary so that one type can conditionally provide a +/// schema and then (under different conditions) provide a +/// different schema. The "different" conditions have to do +/// with Raw Representability, hence the name of this protocol. +public protocol RawOpenAPINodeType { + static func openAPINode() throws -> JSONNode +} + +/// Anything conforming to `AnyJSONCaseIterable` can provide a +/// list of its possible values. +public protocol AnyJSONCaseIterable { + static var allCases: [Any] { get } +} + +/// Anything conforming to `AnyJSONCaseIterable` can provide a +/// list of its possible values. This second protocol is +/// necessary so that one type can conditionally provide a +/// list of possible values and then (under different conditions) +/// provide a different list of possible values. +/// The "different" conditions have to do +/// with Optionality, hence the name of this protocol. +public protocol AnyWrappedJSONCaseIterable { + static var allCases: [Any] { get } } public protocol SwiftTyped { @@ -210,6 +241,14 @@ public enum JSONNode { nullable: true, allowedValues: allowedValues) } + + /// Return this context with the given list of possible values + public func with(allowedValues: [Format.SwiftType]?) -> Context { + return .init(format: format, + required: required, + nullable: nullable, + allowedValues: allowedValues) + } } public struct NumericContext { @@ -393,4 +432,64 @@ public enum JSONNode { return self } } + + public func with(allowedValues: [T]) throws -> JSONNode where T: RawRepresentable, T.RawValue == String { + return try with(allowedValues: allowedValues.map { $0.rawValue }) + } + + public func with(allowedValues: [JSONTypeFormat.BooleanFormat.SwiftType]) throws -> JSONNode { + guard case let .boolean(contextA) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.BooleanFormat.SwiftType.self) + } + return .boolean(contextA.with(allowedValues: allowedValues)) + } + + public func with(allowedValues: [JSONTypeFormat.ObjectFormat.SwiftType]) throws -> JSONNode { + guard case let .object(contextA, contextB) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.ObjectFormat.SwiftType.self) + } + return .object(contextA.with(allowedValues: allowedValues), contextB) + } + + public func with(allowedValues: [JSONTypeFormat.ArrayFormat.SwiftType]) throws -> JSONNode { + guard case let .array(contextA, contextB) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.ArrayFormat.SwiftType.self) + } + return .array(contextA.with(allowedValues: allowedValues), contextB) + } + + public func with(allowedValues: [JSONTypeFormat.NumberFormat.SwiftType]) throws -> JSONNode { + guard case let .number(contextA, contextB) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.NumberFormat.SwiftType.self) + } + return .number(contextA.with(allowedValues: allowedValues), contextB) + } + + public func with(allowedValues: [JSONTypeFormat.IntegerFormat.SwiftType]) throws -> JSONNode { + guard case let .integer(contextA, contextB) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.IntegerFormat.SwiftType.self) + } + return .integer(contextA.with(allowedValues: allowedValues), contextB) + } + + public func with(allowedValues: [JSONTypeFormat.StringFormat.SwiftType]) throws -> JSONNode { + guard case let .string(contextA, contextB) = self else { + throw AllowedValueError(expectation: jsonTypeFormat?.jsonType, receivedType: JSONTypeFormat.StringFormat.SwiftType.self) + } + return .string(contextA.with(allowedValues: allowedValues), contextB) + } +} + +public struct AllowedValueError: Swift.Error, CustomStringConvertible { + public let expectation: JSONType? + public let receivedType: Any.Type + + public init(expectation: JSONType?, receivedType: Any.Type) { + self.expectation = expectation + self.receivedType = receivedType + } + + public var description: String { + return "Expected type compatible with JSON Type \(String(describing: expectation)) but found \(receivedType)" + } } diff --git a/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift b/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift new file mode 100644 index 0000000..cbe113e --- /dev/null +++ b/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift @@ -0,0 +1,10 @@ +// +// Optional+ZipWith.swift +// JSONAPIOpenAPI +// +// Created by Mathew Polzin on 1/19/19. +// + +func zip(_ left: X?, _ right: Y?, with fn: (X, Y) -> Z) -> Z? { + return left.flatMap { lft in right.map { rght in fn(lft, rght) }} +} diff --git a/Sources/JSONAPIOpenAPI/Sampleable.swift b/Sources/JSONAPIOpenAPI/Sampleable.swift new file mode 100644 index 0000000..5e700a3 --- /dev/null +++ b/Sources/JSONAPIOpenAPI/Sampleable.swift @@ -0,0 +1,122 @@ +// +// Sampleable.swift +// JSONAPIOpenAPI +// +// Created by Mathew Polzin on 1/15/19. +// + +import JSONAPI +import AnyCodable + +/// A Sampleable type can provide a sample value. +/// This is useful for reflection. +public protocol Sampleable { + static var sample: Self { get } +} + +extension Sampleable { + public static func genericObjectOpenAPINode() throws -> JSONNode { + let mirror = Mirror(reflecting: Self.sample) + let properties: [(String, JSONNode)] = try mirror.children.compactMap { child in + + // see if we can enumerate the possible values + let maybeAllCases: [Any]? = { + switch type(of: child.value) { + case let valType as AnyJSONCaseIterable.Type: + return valType.allCases + case let valType as AnyWrappedJSONCaseIterable.Type: + return valType.allCases + default: + return nil + } + }() + + // try to snag an OpenAPI Node + let maybeOpenAPINode: JSONNode? = try { + switch type(of: child.value) { + case let valType as OpenAPINodeType.Type: + return try valType.openAPINode() + + case let valType as RawOpenAPINodeType.Type: + return try valType.openAPINode() + + default: + return nil + } + }() + + // put it all together + let newNode: JSONNode? + if let allCases = maybeAllCases, + let openAPINode = maybeOpenAPINode { + newNode = try { + if let cases = allCases as? [JSONTypeFormat.BooleanFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if let cases = allCases as? [JSONTypeFormat.ArrayFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if let cases = allCases as? [JSONTypeFormat.ObjectFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if let cases = allCases as? [JSONTypeFormat.NumberFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if let cases = allCases as? [JSONTypeFormat.IntegerFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if let cases = allCases as? [JSONTypeFormat.StringFormat.SwiftType] { + return try openAPINode.with(allowedValues: cases) + + } else if allCases.compactMap({ $0 as? RawStringRepresentable }).count == allCases.count { + return try openAPINode.with(allowedValues: allCases.compactMap { ($0 as? RawStringRepresentable)?.rawValue }) + + } else { + throw SampleableError.allowedValuesNotOfExpectedType(forNode: openAPINode, allowedValues: allCases) + } + }() + } else { + newNode = maybeOpenAPINode + } + + return zip(child.label, newNode) { ($0, $1) } + } + + // 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 + let propertiesDict = Dictionary(properties) { _, value2 in value2 } + + return .object(.init(format: .generic, + required: true), + .init(properties: propertiesDict)) + } +} + +extension NoAttributes: Sampleable { + public static var sample: NoAttributes { + return .none + } +} + +extension NoRelationships: Sampleable { + public static var sample: NoRelationships { + return .none + } +} + +extension NoMetadata: Sampleable { + public static var sample: NoMetadata { + return .none + } +} + +extension NoLinks: Sampleable { + public static var sample: NoLinks { + return .none + } +} + +public enum SampleableError: Swift.Error { + case allowedValuesNotOfExpectedType(forNode: JSONNode, allowedValues: [Any]) +} diff --git a/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift b/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift index ce7fe6b..1fe6559 100644 --- a/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift +++ b/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift @@ -30,13 +30,25 @@ Any object: **/ extension Optional: OpenAPINodeType where Wrapped: OpenAPINodeType { - static public var openAPINode: JSONNode { - return Wrapped.openAPINode.optionalNode() + static public func openAPINode() throws -> JSONNode { + return try Wrapped.openAPINode().optionalNode() + } +} + +extension Optional: RawOpenAPINodeType where Wrapped: RawRepresentable, Wrapped.RawValue: OpenAPINodeType { + static public func openAPINode() throws -> JSONNode { + return try Wrapped.RawValue.openAPINode().optionalNode() + } +} + +extension Optional: AnyJSONCaseIterable where Wrapped: CaseIterable { + public static var allCases: [Any] { + return Array(Wrapped.allCases) } } extension String: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .string(.init(format: .generic, required: true), .init()) @@ -44,22 +56,22 @@ extension String: OpenAPINodeType { } extension Bool: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .boolean(.init(format: .generic, required: true)) } } extension Array: OpenAPINodeType where Element: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .array(.init(format: .generic, required: true), - .init(items: Element.openAPINode)) + .init(items: try Element.openAPINode())) } } extension Double: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .number(.init(format: .double, required: true), .init()) @@ -67,7 +79,7 @@ extension Double: OpenAPINodeType { } extension Float: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .number(.init(format: .float, required: true), .init()) @@ -75,7 +87,7 @@ extension Float: OpenAPINodeType { } extension Int: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .integer(.init(format: .generic, required: true), .init()) @@ -83,7 +95,7 @@ extension Int: OpenAPINodeType { } extension Int32: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .integer(.init(format: .int32, required: true), .init()) @@ -91,7 +103,7 @@ extension Int32: OpenAPINodeType { } extension Int64: OpenAPINodeType { - static public var openAPINode: JSONNode { + static public func openAPINode() throws -> JSONNode { return .integer(.init(format: .int64, required: true), .init()) diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift new file mode 100644 index 0000000..c7d325f --- /dev/null +++ b/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift @@ -0,0 +1,84 @@ +// +// JSONAPIEntityOpenAPITests.swift +// JSONAPIOpenAPITests +// +// Created by Mathew Polzin on 1/15/19. +// + +import XCTest +import JSONAPI +import JSONAPIOpenAPI + +class JSONAPIEntityOpenAPITests: XCTestCase { + func test_EmptyEntity() { + let node = try! TestType1.openAPINode() + + // TODO: Write test + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let string = String(data: try! encoder.encode(node), encoding: .utf8)! + print(string) + } + + func test_AttributesEntity() { + + let tmp = ["hello"] as [Any] + let tmp2 = tmp as! [String] + let tmp3 = tmp as? RawStringArrayRepresentable + let tmp4 = tmp2 as? RawStringArrayRepresentable + + let y = TestType2Description.EnumType.one + let z = y as Any + + let x = [y as? TestType2Description.EnumType] + + let node = try! TestType2.openAPINode() + + // TODO: Write test + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let string = String(data: try! encoder.encode(node), encoding: .utf8)! + print(string) + } +} + +// MARK: Test Types +extension JSONAPIEntityOpenAPITests { + enum TestType1Description: EntityDescription { + public static var jsonType: String { return "test1" } + + public typealias Attributes = NoAttributes + + public typealias Relationships = NoRelationships + } + + typealias TestType1 = BasicEntity + + enum TestType2Description: EntityDescription { + public static var jsonType: String { return "test1" } + + public enum EnumType: String, CaseIterable, Codable, Equatable { + case one + case two + } + + public struct Attributes: JSONAPI.Attributes, Sampleable { + let stringProperty: Attribute + let enumProperty: Attribute + var computedProperty: Attribute { + return enumProperty + } + + public static var sample: Attributes { + return Attributes(stringProperty: .init(value: "hello"), + enumProperty: .init(value: .one)) + } + } + + public typealias Relationships = NoRelationships + } + + typealias TestType2 = BasicEntity +} diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIRelationshipsOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIRelationshipsOpenAPITests.swift index 019af16..ca51f8d 100644 --- a/Tests/JSONAPIOpenAPITests/JSONAPIRelationshipsOpenAPITests.swift +++ b/Tests/JSONAPIOpenAPITests/JSONAPIRelationshipsOpenAPITests.swift @@ -14,7 +14,7 @@ import JSONAPIOpenAPI class JSONAPIRelationshipsOpenAPITests: XCTestCase { func test_ToOne() { - let node = ToOneRelationship.openAPINode + let node = try! ToOneRelationship.openAPINode() XCTAssertTrue(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) @@ -47,7 +47,7 @@ class JSONAPIRelationshipsOpenAPITests: XCTestCase { } func test_OptionalToOne() { - let node = ToOneRelationship?.openAPINode + let node = try! ToOneRelationship?.openAPINode() XCTAssertFalse(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) @@ -80,7 +80,7 @@ class JSONAPIRelationshipsOpenAPITests: XCTestCase { } func test_NullableToOne() { - let node = ToOneRelationship.openAPINode + let node = try! ToOneRelationship.openAPINode() XCTAssertTrue(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) @@ -113,7 +113,7 @@ class JSONAPIRelationshipsOpenAPITests: XCTestCase { } func test_OptionalNullableToOne() { - let node = ToOneRelationship?.openAPINode + let node = try! ToOneRelationship?.openAPINode() XCTAssertFalse(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) @@ -146,7 +146,7 @@ class JSONAPIRelationshipsOpenAPITests: XCTestCase { } func test_ToMany() { - let node = ToManyRelationship.openAPINode + let node = try! ToManyRelationship.openAPINode() XCTAssertTrue(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) @@ -189,7 +189,7 @@ class JSONAPIRelationshipsOpenAPITests: XCTestCase { } func test_OptionalToMany() { - let node = ToManyRelationship?.openAPINode + let node = try! ToManyRelationship?.openAPINode() XCTAssertFalse(node.required) XCTAssertEqual(node.jsonTypeFormat, .object(.generic))