From aa89bcdf1a1a8298360a00ef110805375abd2e03 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 12:35:50 -0800 Subject: [PATCH 1/6] Add lookup and unwrapAndLookup functions for validation and add filteringPaths method for OpenAPI.PathItem.Map --- Sources/OpenAPIKit/Path Item/PathItem.swift | 7 ++ .../Validator/Validator+Convenience.swift | 69 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index 5bc75bac4..576267162 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -166,6 +166,13 @@ extension OpenAPI.PathItem { public typealias Map = OrderedDictionary } +extension OrderedDictionary where Key == OpenAPI.Path { + public func filteringPaths(with predicate: (OpenAPI.Path) -> Bool) -> OrderedDictionary { + let filteredPaths = filter { (path, _) in predicate(path) } + return OrderedDictionary(filteredPaths, uniquingKeysWith: { fst, _ in fst }) + } +} + extension OpenAPI.PathItem { /// Retrieve the operation for the given verb, if one is set for this path. public func `for`(_ verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { diff --git a/Sources/OpenAPIKit/Validator/Validator+Convenience.swift b/Sources/OpenAPIKit/Validator/Validator+Convenience.swift index 1c8ab0785..34f53d872 100644 --- a/Sources/OpenAPIKit/Validator/Validator+Convenience.swift +++ b/Sources/OpenAPIKit/Validator/Validator+Convenience.swift @@ -242,6 +242,75 @@ public func unwrap(_ path: KeyPath, into validations: Validation } } +/// Look up the value pointed to by the KeyPath. Fail +/// with a `ValidationError` if the value is not +/// found in the `Components` for the current document. +/// +/// - Parameters: +/// - path: The path to lookup. +/// - validations: One or more validations to perform on the value +/// the KeyPath points to. +/// +public func lookup(_ path: KeyPath, U>>, thenApply validations: Validation...) -> (ValidationContext) -> [ValidationError] { + return { context in + return validations.flatMap { validation -> [ValidationError] in + let subject = context.subject[keyPath: path] + do { + return validation.apply( + to: try context.document.components.lookup(subject), + at: context.codingPath, + in: context.document + ) + } catch { + return [ + ValidationError( + reason: "Could not find component being validated: \(String(describing: subject.reference?.absoluteString))", + at: context.codingPath + ) + ] + } + } + } +} + +/// Unwrap and look up the value pointed to by the KeyPath. +/// Fail with a `ValidationError` if the value is `nil` or +/// not found in the `Components` for the current document. +/// +/// - Parameters: +/// - path: The path to lookup. +/// - validations: One or more validations to perform on the value +/// the KeyPath points to. +/// +public func unwrapAndLookup(_ path: KeyPath, U>?>, thenApply validations: Validation...) -> (ValidationContext) -> [ValidationError] { + return { context in + return validations.flatMap { validation -> [ValidationError] in + guard let subject = context.subject[keyPath: path] else { + return [ + ValidationError( + reason: "Tried to unwrap an optional for path \(String(describing: path)) and found `nil`", + at: context.codingPath + ) + ] + } + do { + return validation.apply( + to: try context.document.components.lookup(subject), + at: context.codingPath, + in: context.document + ) + } catch { + return [ + ValidationError( + reason: "Could not find component being validated: \(String(describing: subject.reference?.absoluteString))", + at: context.codingPath + ) + ] + } + } + } +} + /// Apply all of the given validations to the current context. /// /// This is equivalent to calling `lift` with the keypath `\.self` From f9988225e67b650bee8c9500742d15bc7a54239d Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 12:44:35 -0800 Subject: [PATCH 2/6] line break formatting. --- .../Validator/Validator+Convenience.swift | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIKit/Validator/Validator+Convenience.swift b/Sources/OpenAPIKit/Validator/Validator+Convenience.swift index 34f53d872..461c5ae3c 100644 --- a/Sources/OpenAPIKit/Validator/Validator+Convenience.swift +++ b/Sources/OpenAPIKit/Validator/Validator+Convenience.swift @@ -155,7 +155,10 @@ public func take(_ path: KeyPath, U>, check: @escapin /// when: \.a == "hello" /// ) /// -public func lift(_ path: KeyPath, U>, into validations: Validation...) -> (ValidationContext) -> [ValidationError] { +public func lift( + _ path: KeyPath, U>, + into validations: Validation... +) -> (ValidationContext) -> [ValidationError] { return { context in return validations.flatMap { $0.apply(to: context[keyPath: path], at: context.codingPath, in: context.document) } } @@ -184,7 +187,10 @@ public func lift(_ path: KeyPath, U>, into validation /// when: \.a == "hello" /// ) /// -public func lift(_ path: KeyPath, into validations: Validation...) -> (ValidationContext) -> [ValidationError] { +public func lift( + _ path: KeyPath, + into validations: Validation... +) -> (ValidationContext) -> [ValidationError] { return { context in return validations.flatMap { $0.apply(to: context.subject[keyPath: path], at: context.codingPath, in: context.document) } } @@ -205,7 +211,11 @@ public func lift(_ path: KeyPath, into validations: Validation... /// on what this function does when the value pointed to /// is non-nil. /// -public func unwrap(_ path: KeyPath, U?>, into validations: Validation..., description: String? = nil) -> (ValidationContext) -> [ValidationError] { +public func unwrap( + _ path: KeyPath, U?>, + into validations: Validation..., + description: String? = nil +) -> (ValidationContext) -> [ValidationError] { return { context in guard let subject = context[keyPath: path] else { let error = description.map { "Tried to unwrap but found nil: \($0)" } @@ -231,7 +241,11 @@ public func unwrap(_ path: KeyPath, U?>, into validat /// on what this function does when the value pointed to /// is non-nil. /// -public func unwrap(_ path: KeyPath, into validations: Validation..., description: String? = nil) -> (ValidationContext) -> [ValidationError] { +public func unwrap( + _ path: KeyPath, + into validations: Validation..., + description: String? = nil +) -> (ValidationContext) -> [ValidationError] { return { context in guard let subject = context.subject[keyPath: path] else { let error = description.map { "Tried to unwrap but found nil: \($0)" } @@ -251,7 +265,10 @@ public func unwrap(_ path: KeyPath, into validations: Validation /// - validations: One or more validations to perform on the value /// the KeyPath points to. /// -public func lookup(_ path: KeyPath, U>>, thenApply validations: Validation...) -> (ValidationContext) -> [ValidationError] { +public func lookup( + _ path: KeyPath, U>>, + thenApply validations: Validation... +) -> (ValidationContext) -> [ValidationError] { return { context in return validations.flatMap { validation -> [ValidationError] in let subject = context.subject[keyPath: path] @@ -282,7 +299,10 @@ public func lookup(_ path: KeyPath, U>>, thenAp /// - validations: One or more validations to perform on the value /// the KeyPath points to. /// -public func unwrapAndLookup(_ path: KeyPath, U>?>, thenApply validations: Validation...) -> (ValidationContext) -> [ValidationError] { +public func unwrapAndLookup( + _ path: KeyPath, U>?>, + thenApply validations: Validation... +) -> (ValidationContext) -> [ValidationError] { return { context in return validations.flatMap { validation -> [ValidationError] in guard let subject = context.subject[keyPath: path] else { From 19eda4bffbc5bf92fa1143269b58c6f4b8b45af4 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 12:59:51 -0800 Subject: [PATCH 3/6] A bit more documentation and some more whitespace changes --- Sources/OpenAPIKit/Either/Either.swift | 10 ++++++ Sources/OpenAPIKit/Header/Header.swift | 32 ++++++++++++------- .../OrderedDictionary/OrderedDictionary.swift | 10 ++++-- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/Sources/OpenAPIKit/Either/Either.swift b/Sources/OpenAPIKit/Either/Either.swift index 71909aecd..423d9205c 100644 --- a/Sources/OpenAPIKit/Either/Either.swift +++ b/Sources/OpenAPIKit/Either/Either.swift @@ -18,6 +18,14 @@ public enum Either { case a(A) case b(B) + /// Get the first of the possible values of the `Either` (if it is + /// set). + /// + /// This is sometimes known as the `Left` or error case of some + /// `Either` types, but `OpenAPIKit` makes regular use of + /// this type in situations where neither of the possible values could + /// be considered an error. In fact, `OpenAPIKit` sticks to using + /// the Swift `Result` type where such semantics are needed. public var a: A? { guard case let .a(ret) = self else { return nil } return ret @@ -27,6 +35,8 @@ public enum Either { self = .a(a) } + /// Get the second of the possible values of the `Either` (if + /// it is set). public var b: B? { guard case let .b(ret) = self else { return nil } return ret diff --git a/Sources/OpenAPIKit/Header/Header.swift b/Sources/OpenAPIKit/Header/Header.swift index f6deda5e1..e4681762d 100644 --- a/Sources/OpenAPIKit/Header/Header.swift +++ b/Sources/OpenAPIKit/Header/Header.swift @@ -103,9 +103,11 @@ extension OpenAPI.Header { // MARK: - Header Convenience extension OpenAPI.Parameter.SchemaContext { - public static func header(_ schema: JSONSchema, - allowReserved: Bool = false, - example: AnyCodable? = nil) -> Self { + public static func header( + _ schema: JSONSchema, + allowReserved: Bool = false, + example: AnyCodable? = nil + ) -> Self { return .init( schema, style: .default(for: .header), @@ -114,9 +116,11 @@ extension OpenAPI.Parameter.SchemaContext { ) } - public static func header(schemaReference: JSONReference, - allowReserved: Bool = false, - example: AnyCodable? = nil) -> Self { + public static func header( + schemaReference: JSONReference, + allowReserved: Bool = false, + example: AnyCodable? = nil + ) -> Self { return .init( schemaReference: schemaReference, style: .default(for: .header), @@ -125,9 +129,11 @@ extension OpenAPI.Parameter.SchemaContext { ) } - public static func header(_ schema: JSONSchema, - allowReserved: Bool = false, - examples: OpenAPI.Example.Map?) -> Self { + public static func header( + _ schema: JSONSchema, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? + ) -> Self { return .init( schema, style: .default(for: .header), @@ -136,9 +142,11 @@ extension OpenAPI.Parameter.SchemaContext { ) } - public static func header(schemaReference: JSONReference, - allowReserved: Bool = false, - examples: OpenAPI.Example.Map?) -> Self { + public static func header( + schemaReference: JSONReference, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? + ) -> Self { return .init( schemaReference: schemaReference, style: .default(for: .header), diff --git a/Sources/OpenAPIKit/OrderedDictionary/OrderedDictionary.swift b/Sources/OpenAPIKit/OrderedDictionary/OrderedDictionary.swift index d0b75d312..0d600d35a 100644 --- a/Sources/OpenAPIKit/OrderedDictionary/OrderedDictionary.swift +++ b/Sources/OpenAPIKit/OrderedDictionary/OrderedDictionary.swift @@ -26,7 +26,10 @@ public struct OrderedDictionary where Key: Hashable { unorderedHash = [:] } - public init(grouping values: S, by keyForValue: (S.Element) throws -> Key) rethrows where Value == [S.Element], S : Sequence { + public init( + grouping values: S, + by keyForValue: (S.Element) throws -> Key + ) rethrows where Value == [S.Element], S : Sequence { var temporaryDictionary = Self() for value in values { @@ -35,7 +38,10 @@ public struct OrderedDictionary where Key: Hashable { self = temporaryDictionary } - public init(_ keysAndValues: S, uniquingKeysWith combine: (Value, Value) throws -> Value) rethrows where S : Sequence, S.Element == (Key, Value) { + public init( + _ keysAndValues: S, + uniquingKeysWith combine: (Value, Value) throws -> Value + ) rethrows where S : Sequence, S.Element == (Key, Value) { var temporaryDictionary = Self() for (key, value) in keysAndValues { From e7440877c965eaf2969fd26628c54556a55a5fba Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 18:21:41 -0800 Subject: [PATCH 4/6] Add lookup validation helper test --- .../Validation+ConvenienceTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index 0356c90b0..e6bb6962e 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -276,6 +276,45 @@ final class ValidationConvenienceTests: XCTestCase { ) } + func test_subject_lookup() { + let v = Validation( + description: "parameter is named test", + check: \.name == "test" + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/test": .init( + parameters: [ + .reference(.component(named: "test1")), // passes validation + .reference(.component(named: "test2")), // wrong name + .reference(.component(named: "test3")) // not found + ] + ) + ], + components: .init( + parameters: [ + "test1": .init(name: "test", context: .header, content: [:]), + "test2": .init(name: "test2", context: .query, content: [:]) + ] + ) + ) + + let context = ValidationContext(document: document, subject: document.paths["/test"]!, codingPath: []) + + XCTAssertTrue( + lookup(\OpenAPI.PathItem.parameters[0], thenApply: v)(context).isEmpty + ) + XCTAssertFalse( + lookup(\OpenAPI.PathItem.parameters[1], thenApply: v)(context).isEmpty + ) + XCTAssertFalse( + lookup(\OpenAPI.PathItem.parameters[2], thenApply: v)(context).isEmpty + ) + } + func test_allCombinator() { let v1 = Validation( description: "String is more than 5 characters", From d247cb89da667a46413e5efe4de4905404923554 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 18:32:12 -0800 Subject: [PATCH 5/6] unwrap and lookup validation helper test case. --- .../Validation+ConvenienceTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index e6bb6962e..89622eb82 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -315,6 +315,48 @@ final class ValidationConvenienceTests: XCTestCase { ) } + func test_subject_unwrapAndlookup() { + let v = Validation( + description: "parameter is named test", + check: \.name == "test" + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/test": .init( + parameters: [ + .reference(.component(named: "test1")), // passes validation + .reference(.component(named: "test2")), // wrong name + .reference(.component(named: "test3")) // not found + ] + ) + ], + components: .init( + parameters: [ + "test1": .init(name: "test", context: .header, content: [:]), + "test2": .init(name: "test2", context: .query, content: [:]) + ] + ) + ) + + let context = ValidationContext(document: document, subject: document, codingPath: []) + + XCTAssertTrue( + unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[0], thenApply: v)(context).isEmpty + ) + XCTAssertFalse( + unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[1], thenApply: v)(context).isEmpty + ) + XCTAssertFalse( + unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[2], thenApply: v)(context).isEmpty + ) + XCTAssertFalse( + unwrapAndLookup(\OpenAPI.Document.paths["/test2"]?.parameters.first, thenApply: v)(context).isEmpty + ) + } + func test_allCombinator() { let v1 = Validation( description: "String is more than 5 characters", From 3f72b68f505ed0c4058f1e29c25e322ea4a8bdce Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 9 Jan 2021 18:49:44 -0800 Subject: [PATCH 6/6] expose path filtering on the Document type and add test coverage. --- Sources/OpenAPIKit/Document/Document.swift | 20 +++++++++++++++++++ .../Document/DocumentTests.swift | 18 +++++++++++++++++ .../Validation+ConvenienceTests.swift | 7 +++++++ 3 files changed, 45 insertions(+) diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index cdb1a589f..7c0cf5998 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -156,6 +156,26 @@ extension OpenAPI { } } +extension OpenAPI.Document { + /// Create a new OpenAPI Document with + /// all paths not passign the given predicate + /// removed. + public func filteringPaths(with predicate: (OpenAPI.Path) -> Bool) -> OpenAPI.Document { + let filteredPaths = paths.filteringPaths(with: predicate) + return OpenAPI.Document( + openAPIVersion: openAPIVersion, + info: info, + servers: servers, + paths: filteredPaths, + components: components, + security: security, + tags: tags, + externalDocs: externalDocs, + vendorExtensions: vendorExtensions + ) + } +} + extension OpenAPI.Document { /// A `Route` is the combination of a path (where the route lives) /// and a path item (the definition of the route). diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index e8df93420..c9d9a584c 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -342,6 +342,24 @@ final class DocumentTests: XCTestCase { XCTAssertEqual(t.allServers, [s1, s2]) } + func test_pathFiltering() { + let t = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/test1": .init(), + "/test2": .init() + ], + components: .noComponents + ) + + let t2 = t.filteringPaths(with: { $0 == "/test1" }) + + XCTAssertEqual(t.paths.count, 2) + XCTAssertEqual(t2.paths.count, 1) + XCTAssertNotNil(t2.paths["/test1"]) + } + func test_existingSecuritySchemeSuccess() { let docData = """ diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index 89622eb82..d17a39172 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -304,12 +304,15 @@ final class ValidationConvenienceTests: XCTestCase { let context = ValidationContext(document: document, subject: document.paths["/test"]!, codingPath: []) + // passses XCTAssertTrue( lookup(\OpenAPI.PathItem.parameters[0], thenApply: v)(context).isEmpty ) + // wrong name XCTAssertFalse( lookup(\OpenAPI.PathItem.parameters[1], thenApply: v)(context).isEmpty ) + // not found reference XCTAssertFalse( lookup(\OpenAPI.PathItem.parameters[2], thenApply: v)(context).isEmpty ) @@ -343,15 +346,19 @@ final class ValidationConvenienceTests: XCTestCase { let context = ValidationContext(document: document, subject: document, codingPath: []) + // passes XCTAssertTrue( unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[0], thenApply: v)(context).isEmpty ) + // wrong name XCTAssertFalse( unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[1], thenApply: v)(context).isEmpty ) + // not found reference XCTAssertFalse( unwrapAndLookup(\OpenAPI.Document.paths["/test"]?.parameters[2], thenApply: v)(context).isEmpty ) + // nil keypath XCTAssertFalse( unwrapAndLookup(\OpenAPI.Document.paths["/test2"]?.parameters.first, thenApply: v)(context).isEmpty )