diff --git a/Package.resolved b/Package.resolved index 3bb77f2..1a48e92 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "package": "OpenAPIKit", "repositoryURL": "https://github.com/mattpolzin/OpenAPIKit.git", "state": { - "branch": null, - "revision": "d96f819964a665438c15134465d334d4d3446034", + "branch": "release/3_0", + "revision": "b069168ebd9bac3704beab3aadff07b589aadeb2", "version": null } }, diff --git a/Package.swift b/Package.swift index f238af4..799edfb 100644 --- a/Package.swift +++ b/Package.swift @@ -10,14 +10,19 @@ let package = Package( targets: ["OpenAPIReflection"]), ], dependencies: [ -// .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "2.0.0"), - .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", .revision("d96f819964a665438c15134465d334d4d3446034")), + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", .branch("release/3_0")), .package(url: "https://github.com/mattpolzin/Sampleable.git", from: "2.1.0") ], targets: [ .target( - name: "OpenAPIReflection", + name: "OpenAPIReflection30", dependencies: [.product(name: "OpenAPIKit30", package: "OpenAPIKit"), "Sampleable"]), + .testTarget( + name: "OpenAPIReflection30Tests", + dependencies: ["OpenAPIReflection30"]), + .target( + name: "OpenAPIReflection", + dependencies: [.product(name: "OpenAPIKit", package: "OpenAPIKit"), "Sampleable"]), .testTarget( name: "OpenAPIReflectionTests", dependencies: ["OpenAPIReflection"]), diff --git a/README.md b/README.md index a8660f5..0b8830b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -[![Swift 5.1+](http://img.shields.io/badge/Swift-5.2+-blue.svg)](https://swift.org) +[![Swift 5.2+](http://img.shields.io/badge/Swift-5.2+-blue.svg)](https://swift.org) [![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) ![Tests](https://github.com/mattpolzin/OpenAPIReflection/workflows/Tests/badge.svg) # OpenAPI support -See parent library at https://github.com/mattpolzin/OpenAPIKit +See parent library at https://github.com/mattpolzin/OpenAPIKit. + +To generate OpenAPI 3.1.x types, use the `OpenAPIReflection` module. To generate OpenAPI 3.0.x types, use the `OpenAPIReflection30` module. # OpenAPIReflection diff --git a/Sources/OpenAPIReflection30/AnyJSONCaseIterable.swift b/Sources/OpenAPIReflection30/AnyJSONCaseIterable.swift new file mode 100644 index 0000000..15ec9ac --- /dev/null +++ b/Sources/OpenAPIReflection30/AnyJSONCaseIterable.swift @@ -0,0 +1,75 @@ +// +// AnyJSONCaseIterable.swift +// OpenAPI +// +// Created by Mathew Polzin on 6/22/19. +// + +import Foundation +import OpenAPIKit30 + +public protocol AnyRawRepresentable { + /// The `RawValue` type of this type. + static var rawValueType: Any.Type { get } +} + +extension AnyRawRepresentable where Self: RawRepresentable { + /// The default `rawValueType` of a `RawRepresentable` is just the + /// type of `Self.RawValue`. + public static var rawValueType: Any.Type { return Self.RawValue.self } +} + +/// Anything conforming to `AnyJSONCaseIterable` can provide a +/// list of its possible values. +public protocol AnyJSONCaseIterable: AnyRawRepresentable { + static func allCases(using encoder: JSONEncoder) -> [AnyCodable] +} + +extension AnyJSONCaseIterable where Self: RawRepresentable { + /// The default `rawValueType` of a `RawRepresentable` is just the + /// type of `Self.RawValue`. + public static var rawValueType: Any.Type { return Self.RawValue.self } +} + +public extension AnyJSONCaseIterable { + /// Given an array of Codable values, retrieve an array of AnyCodables. + static func allCases(from input: [T], using encoder: JSONEncoder) throws -> [AnyCodable] { + return try OpenAPIReflection30.allCases(from: input, using: encoder) + } +} + +public extension AnyJSONCaseIterable where Self: CaseIterable, Self: Codable { + static func caseIterableOpenAPISchemaGuess(using encoder: JSONEncoder) throws -> JSONSchema { + guard let first = allCases.first else { + throw OpenAPI.EncodableError.exampleNotCodable + } + let itemSchema = try OpenAPIReflection30.nestedGenericOpenAPISchemaGuess(for: first, using: encoder) + + return itemSchema.with(allowedValues: allCases.map { AnyCodable($0) }) + } +} + +extension CaseIterable where Self: Encodable { + public static func allCases(using encoder: JSONEncoder) -> [AnyCodable] { + return (try? OpenAPIReflection30.allCases(from: Array(Self.allCases), using: encoder)) ?? [] + } +} + +fileprivate func allCases(from input: [T], using encoder: JSONEncoder) throws -> [AnyCodable] { + if let alreadyGoodToGo = input as? [AnyCodable] { + return alreadyGoodToGo + } + + // The following is messy, but it does get us the intended result: + // Given any array of things that can be encoded, we want + // to map to an array of AnyCodable so we can store later. We need to + // muck with JSONSerialization because something like an `enum` may + // very well be encoded as a string, and therefore representable + // by AnyCodable, but AnyCodable wants it to actually BE a String + // upon initialization. + + guard let arrayOfCodables = try JSONSerialization.jsonObject(with: encoder.encode(input), options: []) as? [Any] else { + throw OpenAPI.EncodableError.allCasesArrayNotCodable + } + return arrayOfCodables.map(AnyCodable.init) +} diff --git a/Sources/OpenAPIReflection30/Date+OpenAPI.swift b/Sources/OpenAPIReflection30/Date+OpenAPI.swift new file mode 100644 index 0000000..f105362 --- /dev/null +++ b/Sources/OpenAPIReflection30/Date+OpenAPI.swift @@ -0,0 +1,48 @@ +// +// Date+OpenAPI.swift +// OpenAPI +// +// Created by Mathew Polzin on 1/24/19. +// + +import Foundation +import OpenAPIKit30 + +extension Date: DateOpenAPISchemaType { + public static func dateOpenAPISchemaGuess(using encoder: JSONEncoder) -> JSONSchema? { + + switch encoder.dateEncodingStrategy { + case .deferredToDate, .custom: + // I don't know if we can say anything about this case without + // encoding the Date and looking at it, which is what `primitiveGuess()` + // does. + return nil + + case .secondsSince1970, + .millisecondsSince1970: + return .number(format: .double) + + case .iso8601: + return .string(format: .dateTime) + + case .formatted(let formatter): + let hasTime = formatter.timeStyle != .none + let format: JSONTypeFormat.StringFormat = hasTime ? .dateTime : .date + + return .string(format: format) + + @unknown default: + return nil + } + } +} + +extension Date: OpenAPIEncodedSchemaType { + public static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { + guard let dateSchema: JSONSchema = try openAPISchemaGuess(for: Date(), using: encoder) else { + throw OpenAPI.TypeError.unknownSchemaType(type(of: self)) + } + + return dateSchema + } +} diff --git a/Sources/OpenAPIReflection30/OpenAPI+Errors.swift b/Sources/OpenAPIReflection30/OpenAPI+Errors.swift new file mode 100644 index 0000000..0657de9 --- /dev/null +++ b/Sources/OpenAPIReflection30/OpenAPI+Errors.swift @@ -0,0 +1,32 @@ +// +// OpenAPI+Errors.swift +// +// +// Created by Mathew Polzin on 4/21/20. +// + +import Foundation +import OpenAPIKit30 + +extension OpenAPI { + public enum TypeError: Swift.Error, CustomDebugStringConvertible { + case invalidSchema + case unknownSchemaType(Any.Type) + + public var debugDescription: String { + switch self { + case .invalidSchema: + return "Invalid Schema" + case .unknownSchemaType(let type): + return "Could not determine OpenAPI schema type of \(String(describing: type))" + } + } + } + + public enum EncodableError: Swift.Error, Equatable { + case allCasesArrayNotCodable + case exampleNotCodable + case primitiveGuessFailed + case exampleNotSupported(String) + } +} diff --git a/Sources/OpenAPIReflection30/Optional+ZipWith.swift b/Sources/OpenAPIReflection30/Optional+ZipWith.swift new file mode 100644 index 0000000..36b6a69 --- /dev/null +++ b/Sources/OpenAPIReflection30/Optional+ZipWith.swift @@ -0,0 +1,13 @@ +// +// Optional+ZipWith.swift +// OpenAPIKit +// +// Created by Mathew Polzin on 1/19/19. +// + +/// Zip two optionals together with the given operation performed on +/// the unwrapped contents. If either optional is nil, the zip +/// yields nil. +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/OpenAPIReflection30/Sampleable+OpenAPI.swift b/Sources/OpenAPIReflection30/Sampleable+OpenAPI.swift new file mode 100644 index 0000000..5045951 --- /dev/null +++ b/Sources/OpenAPIReflection30/Sampleable+OpenAPI.swift @@ -0,0 +1,182 @@ +// +// Sampleable+OpenAPI.swift +// OpenAPIReflection +// +// Created by Mathew Polzin on 1/24/19. +// + +import Foundation +import Sampleable +import OpenAPIKit30 + +extension Sampleable where Self: Encodable { + public static func genericOpenAPISchemaGuess(using encoder: JSONEncoder) throws -> JSONSchema { + return try OpenAPIReflection30.genericOpenAPISchemaGuess(for: Self.sample, using: encoder) + } +} + +public func genericOpenAPISchemaGuess(for value: T, using encoder: JSONEncoder) throws -> JSONSchema { + // short circuit for dates + if let date = value as? Date, + let node = try type(of: date) + .dateOpenAPISchemaGuess(using: encoder) + ?? reencodedSchemaGuess(for: date, using: encoder) { + + return node + } + + let mirror = Mirror(reflecting: value) + let properties: [(String, JSONSchema)] = try mirror.children.compactMap { child in + + // see if we can enumerate the possible values + let maybeAllCases: [AnyCodable]? = { + switch type(of: child.value) { + case let valType as AnyJSONCaseIterable.Type: + return valType.allCases(using: encoder) + default: + return nil + } + }() + + // try to snag an OpenAPI Schema + let openAPINode: JSONSchema = try openAPISchemaGuess(for: child.value, using: encoder) + ?? nestedGenericOpenAPISchemaGuess(for: child.value, using: encoder) + + // put it all together + let newNode: JSONSchema + if let allCases = maybeAllCases { + newNode = openAPINode.with(allowedValues: allCases) + } else { + newNode = openAPINode + } + + return zip(child.label, newNode) { ($0, $1) } + } + + if properties.count != mirror.children.count { + throw OpenAPI.TypeError.unknownSchemaType(type(of: value)) + } + + // 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 = OrderedDictionary(properties) { _, value2 in value2 } + + return .object(required: true, properties: propertiesDict) +} + +/// Same as genericOpenAPISchemaGuess() except it checks if there's an easy +/// way out via an explicit conformance to one of the OpenAPISchema protocols. +internal func nestedGenericOpenAPISchemaGuess(for value: T, using encoder: JSONEncoder) throws -> JSONSchema { + if let schema = try openAPISchemaGuess(for: value, using: encoder) { + return schema + } + + return try genericOpenAPISchemaGuess(for: value, using: encoder) +} + +internal func reencodedSchemaGuess(for value: T, using encoder: JSONEncoder) throws -> JSONSchema? { + let data = try encoder.encode(PrimitiveWrapper(primitive: value)) + let wrappedValue = try JSONSerialization.jsonObject(with: data, options: [.allowFragments]) + + guard let wrapperDict = wrappedValue as? [String: Any], + wrapperDict.contains(where: { $0.key == "primitive" }) else { + throw OpenAPI.EncodableError.primitiveGuessFailed + } + + let value = (wrappedValue as! [String: Any])["primitive"]! + + return try openAPISchemaGuess(for: value, using: encoder) +} + +internal func openAPISchemaGuess(for type: Any.Type, using encoder: JSONEncoder) throws -> JSONSchema? { + let nodeGuess: JSONSchema? = try { + switch type { + case let valType as OpenAPISchemaType.Type: + return valType.openAPISchema + + case let valType as RawOpenAPISchemaType.Type: + return try valType.rawOpenAPISchema() + + case let valType as DateOpenAPISchemaType.Type: + return valType.dateOpenAPISchemaGuess(using: encoder) + + case let valType as OpenAPIEncodedSchemaType.Type: + return try valType.openAPISchema(using: encoder) + + case let valType as AnyRawRepresentable.Type: + if valType.rawValueType != valType { + let guess = try openAPISchemaGuess(for: valType.rawValueType, using: + encoder) + return valType is _Optional.Type + ? guess?.optionalSchemaObject() + : guess + } else { + return nil + } + + default: + return nil + } + }() + + return nodeGuess +} + +internal func openAPISchemaGuess(for value: Any, using encoder: JSONEncoder) throws -> JSONSchema? { + // ideally the type specifies how to get an OpenAPI node from itself. + let nodeGuess: JSONSchema? = try openAPISchemaGuess(for: type(of: value), using: encoder) + + if nodeGuess != nil { + return nodeGuess + } + + // Second we can try for a few primitive types. + // This is only necessary because when decoding + // types like `NSTaggedPointerString` will be recognized + // by the following switch statement even though + // `NSTaggedPointerString` does not conform to + // `OpenAPISchemaType` like `String` does. + let primitiveGuess: JSONSchema? = try { + switch value { + case is String: + return .string + + case is Int: + return .integer + + case is Double: + return .number( + format: .double + ) + + case is Bool: + return .boolean + + case is Data: + return .string( + format: .binary + ) + + case is DateOpenAPISchemaType: + // we don't know what Date will end up looking like without + // trying it out. Most likely a `.string` or `.number(format: .double)` + return try OpenAPIReflection30.reencodedSchemaGuess(for: Date(), using: encoder) + + default: + return nil + } + }() + + return primitiveGuess +} + +// 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 +} + +private protocol _Optional {} +extension Optional: _Optional {} diff --git a/Sources/OpenAPIReflection30/SchemaProtocols.swift b/Sources/OpenAPIReflection30/SchemaProtocols.swift new file mode 100644 index 0000000..a234ac1 --- /dev/null +++ b/Sources/OpenAPIReflection30/SchemaProtocols.swift @@ -0,0 +1,47 @@ +// +// SchemaProtocols.swift +// +// +// Created by Mathew Polzin on 3/4/20. +// + +import Foundation +import OpenAPIKit30 +import Sampleable + +/// Anything conforming to `OpenAPIEncodedSchemaType` can provide an +/// OpenAPI schema representing itself but it may need an Encoder +/// to do its job. +public protocol OpenAPIEncodedSchemaType { + static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema +} + +extension OpenAPIEncodedSchemaType where Self: Sampleable, Self: Encodable { + public static func openAPISchemaWithExample(using encoder: JSONEncoder = JSONEncoder()) throws -> JSONSchema { + let exampleData = try encoder.encode(Self.successSample ?? Self.sample) + let example = try JSONDecoder().decode(AnyCodable.self, from: exampleData) + return try openAPISchema(using: encoder).with(example: example) + } +} + +/// Anything conforming to `RawOpenAPISchemaType` 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 RawOpenAPISchemaType { + static func rawOpenAPISchema() throws -> JSONSchema +} + +extension RawOpenAPISchemaType where Self: RawRepresentable, RawValue: OpenAPISchemaType { + public static func rawOpenAPISchema() throws -> JSONSchema { + return RawValue.openAPISchema + } +} + +/// Anything conforming to `DateOpenAPISchemaType` is +/// able to attempt to represent itself as a date `JSONSchema` +public protocol DateOpenAPISchemaType { + static func dateOpenAPISchemaGuess(using encoder: JSONEncoder) -> JSONSchema? +} diff --git a/Sources/OpenAPIReflection30/SwiftPrimitiveExtensions.swift b/Sources/OpenAPIReflection30/SwiftPrimitiveExtensions.swift new file mode 100644 index 0000000..4f28774 --- /dev/null +++ b/Sources/OpenAPIReflection30/SwiftPrimitiveExtensions.swift @@ -0,0 +1,75 @@ +// +// SwiftPrimitiveExtensions.swift +// +// +// Created by Mathew Polzin on 3/4/20. +// + +import Foundation +import OpenAPIKit30 + +extension Optional: AnyRawRepresentable where Wrapped: AnyRawRepresentable { + public static var rawValueType: Any.Type { Wrapped.rawValueType } +} + +extension Optional: AnyJSONCaseIterable where Wrapped: AnyJSONCaseIterable { + public static func allCases(using encoder: JSONEncoder) -> [AnyCodable] { + return Wrapped.allCases(using: encoder) + } +} + +extension Optional: RawOpenAPISchemaType where Wrapped: RawOpenAPISchemaType { + static public func rawOpenAPISchema() throws -> JSONSchema { + return try Wrapped.rawOpenAPISchema().optionalSchemaObject() + } +} + +extension Optional: DateOpenAPISchemaType where Wrapped: DateOpenAPISchemaType { + static public func dateOpenAPISchemaGuess(using encoder: JSONEncoder) -> JSONSchema? { + return Wrapped.dateOpenAPISchemaGuess(using: encoder)?.optionalSchemaObject() + } +} + +extension Array: OpenAPIEncodedSchemaType where Element: OpenAPIEncodedSchemaType { + public static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { + return .array( + .init( + format: .generic, + required: true + ), + .init( + items: try Element.openAPISchema(using: encoder) + ) + ) + } +} + +extension Dictionary: RawOpenAPISchemaType where Key: RawRepresentable, Key.RawValue == String, Value: OpenAPISchemaType { + static public func rawOpenAPISchema() throws -> JSONSchema { + return .object( + .init( + format: .generic, + required: true + ), + .init( + properties: [:], + additionalProperties: .init(Value.openAPISchema) + ) + ) + } +} + +extension Dictionary: OpenAPIEncodedSchemaType where Key == String, Value: OpenAPIEncodedSchemaType { + public static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { + return .object( + .init( + format: .generic, + required: true + ), + .init( + properties: [:], + additionalProperties: .init(try Value.openAPISchema(using: encoder)) + ) + ) + } +} diff --git a/Tests/OpenAPIReflection30Tests/AnyJSONCaseIterableTests.swift b/Tests/OpenAPIReflection30Tests/AnyJSONCaseIterableTests.swift new file mode 100644 index 0000000..a2c447c --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/AnyJSONCaseIterableTests.swift @@ -0,0 +1,37 @@ +// +// AnyJSONCaseIterableTests.swift +// +// +// Created by Mathew Polzin on 8/4/19. +// + +import XCTest +import OpenAPIKit30 +import OpenAPIReflection + +class AnyJSONCaseIterableTests: XCTestCase { + func test_CodableToAllCases() { + let testEncoder = JSONEncoder() + + let allCases = CodableEnum.allCases(using: testEncoder) + + XCTAssertEqual(allCases.count, 2) + XCTAssertTrue(allCases.contains("one")) + XCTAssertTrue(allCases.contains("two")) + } + + func testAnyCodableToAllCases() { + let testEncoder = JSONEncoder() + + let allCases = try! CodableEnum.allCases(from: CodableEnum.allCases(using: testEncoder), using: testEncoder) + + XCTAssertEqual(allCases.count, 2) + XCTAssertTrue(allCases.contains("one")) + XCTAssertTrue(allCases.contains("two")) + } +} + +enum CodableEnum: String, CaseIterable, AnyJSONCaseIterable, Codable { + case one + case two +} diff --git a/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaInternalTests.swift b/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaInternalTests.swift new file mode 100644 index 0000000..fad8fd9 --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaInternalTests.swift @@ -0,0 +1,44 @@ +// +// GenericOpenAPISchemaInternalTests.swift +// +// +// Created by Mathew Polzin on 1/11/20. +// + +import XCTest +@testable import OpenAPIReflection + +final class GenericOpenAPISchemaInternalTests: XCTestCase { + func test_reencodedSchemaGuess() throws { + XCTAssertEqual(try reencodedSchemaGuess(for: "hello", using: testEncoder), .string) + XCTAssertEqual(try reencodedSchemaGuess(for: 10, using: testEncoder), .integer) + XCTAssertEqual(try reencodedSchemaGuess(for: 11.5, using: testEncoder), .number(format: .double)) + XCTAssertEqual(try reencodedSchemaGuess(for: true, using: testEncoder), .integer) + XCTAssertEqual(try reencodedSchemaGuess(for: TestEnum.one, using: testEncoder), .string) + } + + func test_openAPINodeGuessForType() { + XCTAssertEqual(try openAPISchemaGuess(for: String.self, using: testEncoder), .string) + XCTAssertEqual(try openAPISchemaGuess(for: Int.self, using: testEncoder), .integer) + XCTAssertEqual(try openAPISchemaGuess(for: Float.self, using: testEncoder), .number(format: .float)) + XCTAssertEqual(try openAPISchemaGuess(for: Double.self, using: testEncoder), .number(format: .double)) + XCTAssertEqual(try openAPISchemaGuess(for: Bool.self, using: testEncoder), .boolean) + XCTAssertEqual(try openAPISchemaGuess(for: TestEnum.self, using: testEncoder), .string) + } + + func test_openAPINodeGuessForValue() { + XCTAssertEqual(try openAPISchemaGuess(for: "hello", using: testEncoder), .string) + XCTAssertEqual(try openAPISchemaGuess(for: 10, using: testEncoder), .integer) + XCTAssertEqual(try openAPISchemaGuess(for: 11.5 as Float, using: testEncoder), .number(format: .float)) + XCTAssertEqual(try openAPISchemaGuess(for: 11.5, using: testEncoder), .number(format: .double)) + XCTAssertEqual(try openAPISchemaGuess(for: true, using: testEncoder), .boolean) + XCTAssertEqual(try openAPISchemaGuess(for: TestEnum.one, using: testEncoder), .string) + } +} + +extension GenericOpenAPISchemaInternalTests { + enum TestEnum: String, Codable, AnyRawRepresentable { + case one + case two + } +} diff --git a/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaTests.swift b/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaTests.swift new file mode 100644 index 0000000..9e28d57 --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/GenericOpenAPISchemaTests.swift @@ -0,0 +1,511 @@ +// +// GenericOpenAPINodeTests.swift +// OpenAPIKitTests +// +// Created by Mathew Polzin on 12/15/19. +// + +import XCTest +import OpenAPIKit30 +import OpenAPIReflection +import Sampleable + +final class GenericOpenAPISchemaTests: XCTestCase { + + func test_emptyObject() throws { + let node = try EmptyObjectType.genericOpenAPISchemaGuess(using: JSONEncoder()) + + XCTAssertEqual( + node, + JSONSchema.object( + properties: [ + "empty": .object + ] + ) + ) + } + + func test_basicTypes() throws { + let node = try BasicTypes.genericOpenAPISchemaGuess(using: JSONEncoder()) + + XCTAssertEqual( + node, + JSONSchema.object( + properties: [ + "string": .string, + "int": .integer, + "double": .number(format: .double), + "float": .number(format: .float), + "bool": .boolean + ] + ) + ) + } + + func test_dateType() throws { + let node = try DateType.genericOpenAPISchemaGuess(using: JSONEncoder()) + + XCTAssertEqual( + node, + JSONSchema.object( + properties: [ + "date": .number(format: .double) + ] + ) + ) + } + + func test_dateTypeFormats() throws { + let e1 = JSONEncoder() + #if os(Linux) + e1.dateEncodingStrategy = .iso8601 + #else + if #available(macOS 10.12, *) { + e1.dateEncodingStrategy = .iso8601 + } + #endif + + let node1 = try DateType.genericOpenAPISchemaGuess(using: e1) + + XCTAssertEqual( + node1, + JSONSchema.object( + properties: [ + "date": .string(format: .dateTime) + ] + ) + ) + + let e2 = JSONEncoder() + e2.dateEncodingStrategy = .secondsSince1970 + let e3 = JSONEncoder() + e3.dateEncodingStrategy = .millisecondsSince1970 + + let node2 = try DateType.genericOpenAPISchemaGuess(using: e2) + let node3 = try DateType.genericOpenAPISchemaGuess(using: e3) + + XCTAssertEqual(node2, node3) + XCTAssertEqual( + node2, + JSONSchema.object( + properties: [ + "date": .number(format: .double) + ] + ) + ) + + let e4 = JSONEncoder() + let df1 = DateFormatter() + df1.timeStyle = .none + e4.dateEncodingStrategy = .formatted(df1) + + let node4 = try DateType.genericOpenAPISchemaGuess(using: e4) + + XCTAssertEqual( + node4, + JSONSchema.object( + properties: [ + "date": .string(format: .date) + ] + ) + ) + + let e5 = JSONEncoder() + let df2 = DateFormatter() + df2.timeStyle = .full + e5.dateEncodingStrategy = .formatted(df2) + + let node5 = try DateType.genericOpenAPISchemaGuess(using: e5) + + XCTAssertEqual( + node5, + JSONSchema.object( + properties: [ + "date": .string(format: .dateTime) + ] + ) + ) + } + + func test_nested() throws { + let node = try Nested.genericOpenAPISchemaGuess(using: JSONEncoder()) + + XCTAssertEqual( + node, + JSONSchema.object( + properties: [ + "array1": .array(items: .string), + "array2": .array(items: .number(format: .double)), + "array3": .array(items: .number(format: .double)), + "dict1": .object( + additionalProperties: .init(.string) + ), + "dict2": .object( + additionalProperties: .init(.boolean) + ), + "dictArray": .object( + additionalProperties: .init(.array(items: .integer)) + ), + "arrayDict": .array( + items: .object( + additionalProperties: .init(.number(format: .double)) + ) + ), + "structure": .object( + properties: [ + "bool": .boolean, + "array": .array(items: .string), + "dict": .object( + additionalProperties: .init(.integer) + ) + ] + ) + ] + ) + ) + } + + func test_enumTypes() throws { + let node = try EnumTypes.genericOpenAPISchemaGuess(using: JSONEncoder()) + let schema = node.value + + XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) + + guard case .object(_, let ctx) = schema else { + XCTFail("Expected object") + return + } + + XCTAssertEqual(ctx.properties["stringEnum"], .string) + XCTAssertEqual(ctx.properties["intEnum"], .integer) + XCTAssertEqual(ctx.properties["doubleEnum"], .number(format: .double)) + XCTAssertEqual(ctx.properties["boolEnum"], .boolean) + XCTAssertEqual(ctx.properties["optionalStringEnum"], .string(required: false)) + XCTAssertEqual(ctx.properties["optionalIntEnum"], .integer(required: false)) + XCTAssertEqual(ctx.properties["optionalDoubleEnum"], .number(format: .double, required: false)) + XCTAssertEqual(ctx.properties["optionalBoolEnum"], .boolean(required: false)) + } + + func test_allowedValues() throws { + let node = try AllowedValues.genericOpenAPISchemaGuess(using: JSONEncoder()) + let schema = node.value + + guard case let .object(_, objCtx) = schema else { + XCTFail("Expected object") + return + } + + guard case let .string(ctx2, _) = objCtx.properties["stringEnum"]?.value else { + XCTFail("Expected stringEnum property to be a .string") + return + } + + XCTAssert(ctx2.allowedValues?.count == 2) + XCTAssert(ctx2.allowedValues?.contains("hello") ?? false) + XCTAssert(ctx2.allowedValues?.contains("world") ?? false) + + guard case let .string(ctx3, _) = objCtx.properties["optionalStringEnum"]?.value else { + XCTFail("Expected optionalStringEnum property to be a .string") + return + } + + XCTAssert(ctx3.allowedValues?.count == 2) + XCTAssert(ctx3.allowedValues?.contains("hello") ?? false) + XCTAssert(ctx3.allowedValues?.contains("world") ?? false) + XCTAssertFalse(ctx3.required) + + guard case let .string(ctx4, _) = objCtx.properties["stringStruct"]?.value else { + XCTFail("Expected stringStruct property to be a .string") + return + } + + XCTAssert(ctx4.allowedValues?.count == 2) + XCTAssert(ctx4.allowedValues?.contains("hi") ?? false) + XCTAssert(ctx4.allowedValues?.contains("there") ?? false) + + guard case let .string(ctx5, _) = objCtx.properties["optionalStringStruct"]?.value else { + XCTFail("Expected optionalStringStruct property to be a .string") + return + } + + XCTAssert(ctx5.allowedValues?.count == 2) + XCTAssert(ctx5.allowedValues?.contains("hi") ?? false) + XCTAssert(ctx5.allowedValues?.contains("there") ?? false) + XCTAssertFalse(ctx5.required) + } + + func test_enumDirectly() throws { + let schemaGuess = try AllowedValues.StringEnum.caseIterableOpenAPISchemaGuess(using: JSONEncoder()) + guard case let .string(ctx, _) = schemaGuess.value else { + XCTFail("Expected string.") + return + } + + XCTAssertEqual(ctx.allowedValues?.first?.value as? AllowedValues.StringEnum, .hello) + XCTAssertEqual(ctx.allowedValues?.last?.value as? AllowedValues.StringEnum, .world) + + XCTAssertThrowsError(try CaselessEnum.caseIterableOpenAPISchemaGuess(using: JSONEncoder())) { err in + XCTAssertEqual(err as? OpenAPI.EncodableError, .exampleNotCodable) + } + } + + func test_sampleableInSampleable() throws { + XCTAssertEqual( + try SampleableInSampleable.genericOpenAPISchemaGuess(using: JSONEncoder()), + .object( + properties: [ + "sampleable": .string + ] + ) + ) + } + + func test_customCallsGeneric() throws { + let schema = try CustomImplementationCallsGeneric.openAPISchema(using: JSONEncoder()) + + XCTAssertEqual( + schema, + .object( + properties: [ + "stringValue": .string + ] + ) + ) + } +} + +// MARK: - Test Types + +extension GenericOpenAPISchemaTests { + struct BasicTypes: Codable, Sampleable { + let string: String + let int: Int + let double: Double + let float: Float + let bool: Bool + + static let sample: BasicTypes = .init(string: "hello", int: 10, double: 2.3, float: 1.1, bool: true) + } + + struct DateType: Codable, Sampleable { + let date: Date + + static let sample: DateType = .init(date: Date()) + } + + struct Nested: Codable, Sampleable { + let array1: [String] + let array2: [Double] + let array3: [Date] + + let dict1: [String: String] + let dict2: [String: Bool] + + let dictArray: [String: [Int]] + + let arrayDict: [[String: Date]] + + let structure: Structure + + struct Structure: Codable { + let bool: Bool + let array: [String] + let dict: [String: Int] + } + + static let sample: Nested = .init( + array1: [], + array2: [], + array3: [], + dict1: [:], + dict2: [:], + dictArray: [:], + arrayDict: [], + structure: .init(bool: true, array: [], dict: [:]) + ) + } + + struct EnumTypes: Codable, Sampleable { + let stringEnum: StringEnum + let intEnum: IntEnum + let doubleEnum: DoubleEnum + let boolEnum: BoolEnum + + let optionalStringEnum: StringEnum? + let optionalIntEnum: IntEnum? + let optionalDoubleEnum: DoubleEnum? + let optionalBoolEnum: BoolEnum? + + enum StringEnum: String, Codable, AnyRawRepresentable { + case hello + case world + } + + enum IntEnum: Int, Codable, AnyRawRepresentable { + case zero + case one + } + + enum DoubleEnum: Double, Codable, AnyRawRepresentable { + case twoPointFive = 2.5 + case onePointTwo = 1.2 + } + + enum BoolEnum: RawRepresentable, Codable, AnyRawRepresentable { + case `true` + case `false` + + init?(rawValue: Bool) { + self = rawValue ? .true : .false + } + + var rawValue: Bool { + switch self { + case .true: return true + case .false: return false + } + } + } + + static let sample: EnumTypes = .init( + stringEnum: .hello, + intEnum: .one, + doubleEnum: .onePointTwo, + boolEnum: .true, + optionalStringEnum: nil, + optionalIntEnum: nil, + optionalDoubleEnum: nil, + optionalBoolEnum: nil + ) + } + + struct AllowedValues: Codable, Sampleable { + let stringEnum: StringEnum + let optionalStringEnum: StringEnum? + + let stringStruct: StringStruct + let optionalStringStruct: StringStruct? + + enum StringEnum: String, Codable, CaseIterable, AnyJSONCaseIterable { + case hello + case world + } + + struct StringStruct: RawRepresentable, Codable, AnyJSONCaseIterable { + + let val: String + + var rawValue: String { val } + + static func allCases(using encoder: JSONEncoder) -> [AnyCodable] { + return ["hi", "there"] + } + + init(val: String) { + self.val = val + } + + init?(rawValue: String) { + self.val = rawValue + } + } + + static let sample: AllowedValues = .init( + stringEnum: .hello, + optionalStringEnum: nil, + stringStruct: .init(val: "hi"), + optionalStringStruct: nil + ) + } + + struct EmptyObjectType: Codable, Sampleable { + let empty: EmptyObject + + struct EmptyObject: Codable {} + + static let sample: EmptyObjectType = .init(empty: .init()) + } + + enum CaselessEnum: RawRepresentable, Codable, CaseIterable, AnyJSONCaseIterable { + init?(rawValue: String) { + return nil + } + var rawValue: String { "" } + + typealias RawValue = String + + static func allCases(using encoder: JSONEncoder) -> [AnyCodable] { + [] + } + } + + struct SampleableInSampleable: Codable, Sampleable { + let sampleable: NestedSampleable + + static let sample: Self = .init(sampleable: .sample) + + enum NestedSampleable: String, Codable, CaseIterable, Sampleable, AnyRawRepresentable { + case one + case two + + static let sample: Self = .one + } + } + + struct CustomImplementationCallsGeneric: Codable, Sampleable, OpenAPIEncodedSchemaType { + + let stringValue: String + + static let sample: Self = .init(stringValue: "hello") + + static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { + try OpenAPIReflection.genericOpenAPISchemaGuess(for: sample, using: encoder) + } + } + +// struct EncodesAsPrimitive: Codable, SampleableOpenAPIType { +// let asString: AsString +// let asInt: AsInt +// let asDouble: AsDouble +// let asBool: AsBool +// +// struct AsString: Codable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.singleValueContainer() +// +// try container.encode("hello world") +// } +// } +// +// struct AsInt: Codable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.singleValueContainer() +// +// try container.encode(10) +// } +// } +// +// struct AsDouble: Codable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.singleValueContainer() +// +// try container.encode(5.5) +// } +// } +// +// struct AsBool: Codable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.singleValueContainer() +// +// try container.encode(true) +// } +// } +// +// static let sample: EncodesAsPrimitive = .init( +// asString: .init(), +// asInt: .init(), +// asDouble: .init(), +// asBool: .init() +// ) +// } +} diff --git a/Tests/OpenAPIReflection30Tests/SchemaWithExampleTests.swift b/Tests/OpenAPIReflection30Tests/SchemaWithExampleTests.swift new file mode 100644 index 0000000..5d97d4b --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/SchemaWithExampleTests.swift @@ -0,0 +1,84 @@ +// +// SchemaWithExampleTests.swift +// +// +// Created by Mathew Polzin on 9/10/20. +// + +import XCTest +import Foundation +import OpenAPIKit30 +import OpenAPIReflection +import Sampleable + +final class SchemaWithExampleTests: XCTestCase { + func test_structWithExample() throws { + + let schemaGuess = try Test.openAPISchemaWithExample(using: testEncoder) + let expectedSchema = try JSONSchema.object( + properties: [ + "string": .string, + "int": .integer, + "bool": .boolean, + "double": .number(format: .double) + ] + ).with(example: + [ + "bool" : true, + "double" : 2.34, + "int" : 10, + "string" : "hello" + ] + ) + + XCTAssertNotNil(schemaGuess.jsonType) + + XCTAssertEqual( + schemaGuess.jsonType, + expectedSchema.jsonType + ) + + XCTAssertNotNil(schemaGuess.objectContext) + + XCTAssertEqual( + schemaGuess.objectContext, + expectedSchema.objectContext + ) + + XCTAssertNotNil(schemaGuess.example) + + // equality checks on AnyCodable are finicky but + // they compare equally when encoded to data. + XCTAssertEqual( + try testEncoder.encode(schemaGuess.example), + try testEncoder.encode(expectedSchema.example) + ) + } +} + +extension SchemaWithExampleTests { + struct Test: Codable, Sampleable, OpenAPIEncodedSchemaType { + let string: String + let int: Int + let bool: Bool + let double: Double + + public static let sample: SchemaWithExampleTests.Test = .init( + string: "hello", + int: 10, + bool: true, + double: 2.34 + ) + + static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { + return .object( + properties: [ + "string": .string, + "int": .integer, + "bool": .boolean, + "double": .number(format: .double) + ] + ) + } + } +} diff --git a/Tests/OpenAPIReflection30Tests/SwiftPrimitiveExtensionsTests.swift b/Tests/OpenAPIReflection30Tests/SwiftPrimitiveExtensionsTests.swift new file mode 100644 index 0000000..911ec5f --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/SwiftPrimitiveExtensionsTests.swift @@ -0,0 +1,62 @@ +// +// SwiftPrimitiveExtensionsTests.swift +// +// +// Created by Mathew Polzin on 3/4/20. +// + +import XCTest +import Foundation +import OpenAPIReflection +import OpenAPIKit30 + +class SwiftPrimitiveTypesTests: XCTestCase { + func test_OptionalCaseIterableNodeAllCases() { + XCTAssertTrue(RawRepStringEnum?.allCases(using: SwiftPrimitiveTypesTests.localTestEncoder).contains("hello")) + XCTAssertTrue(RawRepStringEnum?.allCases(using: SwiftPrimitiveTypesTests.localTestEncoder).contains("world")) + XCTAssertEqual(RawRepStringEnum?.allCases(using: SwiftPrimitiveTypesTests.localTestEncoder).count, 2) + } + + func test_OptionalDateNodeType() { + XCTAssertEqual(Date?.dateOpenAPISchemaGuess(using: testEncoder), .string(format: .dateTime, required: false)) + } + + func test_RawNodeType() { + XCTAssertEqual(try! RawRepStringEnum.rawOpenAPISchema(), .string) + XCTAssertEqual(try! RawRepIntEnum.rawOpenAPISchema(), .integer) + } + + func test_OptionalRawRepresentable() { + XCTAssertEqual(try! RawRepStringEnum?.rawOpenAPISchema(), .string(required: false)) + + XCTAssertEqual(try! RawRepIntEnum?.rawOpenAPISchema(), .integer(required: false)) + } + + func test_OptionalRawNodeType() { + XCTAssertEqual(try! RawRepStringEnum?.rawOpenAPISchema(), .string(required: false)) + + XCTAssertEqual(try! RawRepIntEnum?.rawOpenAPISchema(), .integer(required: false)) + } + + func test_DoubleWrappedRawNodeType() { + XCTAssertEqual(try! RawRepStringEnum??.rawOpenAPISchema(), .string(required: false)) + + XCTAssertEqual(try! RawRepIntEnum??.rawOpenAPISchema(), .integer(required: false)) + + XCTAssertEqual(try! RawRepStringEnum??.rawOpenAPISchema(), .string(required: false)) + + XCTAssertEqual(try! RawRepIntEnum??.rawOpenAPISchema(), .integer(required: false)) + } + + static let localTestEncoder = JSONEncoder() +} + +fileprivate enum RawRepStringEnum: String, RawOpenAPISchemaType, CaseIterable, Codable, AnyJSONCaseIterable { + case hello + case world +} + +fileprivate enum RawRepIntEnum: Int, RawOpenAPISchemaType { + case one + case two +} diff --git a/Tests/OpenAPIReflection30Tests/TestHelpers.swift b/Tests/OpenAPIReflection30Tests/TestHelpers.swift new file mode 100644 index 0000000..fe16e58 --- /dev/null +++ b/Tests/OpenAPIReflection30Tests/TestHelpers.swift @@ -0,0 +1,62 @@ +// +// TestHelpers.swift +// +// +// Created by Mathew Polzin on 6/23/19. +// + +import Foundation +import XCTest + +let testEncoder = { () -> JSONEncoder in + let encoder = JSONEncoder() + if #available(macOS 10.13, *) { + encoder.dateEncodingStrategy = .iso8601 + encoder.keyEncodingStrategy = .useDefaultKeys + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + #if os(Linux) + encoder.dateEncodingStrategy = .iso8601 + encoder.keyEncodingStrategy = .useDefaultKeys + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + #endif + return encoder +}() + +func testStringFromEncoding(of entity: T) throws -> String? { + return String(data: try testEncoder.encode(entity), encoding: .utf8) +} + +let testDecoder = { () -> JSONDecoder in + let decoder = JSONDecoder() + if #available(macOS 10.12, *) { + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .useDefaultKeys + } + #if os(Linux) + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .useDefaultKeys + #endif + return decoder +}() + +func assertJSONEquivalent(_ str1: String?, _ str2: String?, file: StaticString = #file, line: UInt = #line) { + + // when testing on Linux, pretty printing has slightly different + // meaning so the tests pass on OS X as written but need whitespace + // stripped to pass on Linux + #if os(Linux) + var str1 = str1 + var str2 = str2 + + str1?.removeAll { $0.isWhitespace } + str2?.removeAll { $0.isWhitespace } + #endif + + XCTAssertEqual( + str1, + str2, + file: (file), + line: line + ) +}