From 9be464995e6d021bf46493863f8b03b34c4de736 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sat, 21 Mar 2026 23:38:56 +0530 Subject: [PATCH 1/3] Add default validation for unique server names --- .../Validator/Validation+Builtins.swift | 32 ++++++++ Sources/OpenAPIKit/Validator/Validator.swift | 3 + .../Validator/BuiltinValidationTests.swift | 78 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 327f8991a..87a746838 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -341,6 +341,23 @@ extension Validation { ) } + /// Validate that all named OpenAPI `Server`s have unique names across the + /// whole Document. + /// + /// The OpenAPI Specification requires server names + /// [are unique](https://spec.openapis.org/oas/v3.2.0.html#server-object). + /// + /// - Important: This is included in validation by default. + public static var documentServerNamesAreUnique: Validation { + .init( + description: "The names of Servers in the Document are unique", + check: { context in + let serverNames = allServerNames(in: context.subject) + return Set(serverNames).count == serverNames.count + } + ) + } + /// Validate that all OpenAPI Path Items have no duplicate parameters defined /// within them. /// @@ -581,3 +598,18 @@ fileprivate func parametersAreUnique(_ parameters: OpenAPI.Parameter.Array, comp return Set(identities).count == foundParameters.count } + +fileprivate func allServerNames(in document: OpenAPI.Document) -> [String] { + var serverNames = document.servers.compactMap(\.name) + let pathItems = (document.paths.values + document.webhooks.values).compactMap { document.components[$0] } + + for pathItem in pathItems { + serverNames.append(contentsOf: (pathItem.servers ?? []).compactMap(\.name)) + + for endpoint in pathItem.endpoints { + serverNames.append(contentsOf: (endpoint.operation.servers ?? []).compactMap(\.name)) + } + } + + return serverNames +} diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 4b8c5fade..dcb5066ef 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -75,6 +75,7 @@ extension OpenAPI.Document { /// The default validations are /// - Operations must contain at least one response. /// - Document-level tag names are unique. +/// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. /// - Operation Ids are unique across the whole Document. @@ -156,6 +157,7 @@ public final class Validator { internal var nonReferenceDefaultValidations: [AnyValidation] = [ .init(.documentTagNamesAreUnique), + .init(.documentServerNamesAreUnique), .init(.pathItemParametersAreUnique), .init(.operationParametersAreUnique), .init(.operationIdsAreUnique), @@ -200,6 +202,7 @@ public final class Validator { /// /// The default validations are /// - Document-level tag names are unique. + /// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. /// - Operation Ids are unique across the whole Document. diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index f8ffa15b1..182433c26 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -24,9 +24,10 @@ final class BuiltinValidationTests: XCTestCase { ]) let withoutReferenceValidations = Validator().skippingReferenceValidations() - XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 7) + XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 8) XCTAssertEqual(withoutReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", "All Operation Ids in Document are unique", @@ -36,9 +37,10 @@ final class BuiltinValidationTests: XCTestCase { ]) let defaultValidations = Validator() - XCTAssertEqual(defaultValidations.validationDescriptions.count, 17) + XCTAssertEqual(defaultValidations.validationDescriptions.count, 18) XCTAssertEqual(defaultValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", "All Operation Ids in Document are unique", @@ -58,9 +60,10 @@ final class BuiltinValidationTests: XCTestCase { ]) let stricterReferenceValidations = Validator().validatingAllReferencesFoundInComponents() - XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 17) + XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 18) XCTAssertEqual(stricterReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", "All Operation Ids in Document are unique", @@ -561,6 +564,75 @@ final class BuiltinValidationTests: XCTestCase { try document.validate() } + func test_duplicateServerNamesOnDocumentFails() { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [ + .init(url: URL(string: "https://root.example.com")!, name: "shared") + ], + paths: [ + "/hello": .init( + get: .init( + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "shared") + ], + responses: [ + 200: .response(description: "hi") + ] + ) + ) + ], + components: .noComponents + ) + + XCTAssertThrowsError(try document.validate()) { error in + let error = error as? ValidationErrorCollection + XCTAssertEqual(error?.values.first?.reason, "Failed to satisfy: The names of Servers in the Document are unique") + XCTAssertEqual(error?.values.first?.codingPath.map { $0.stringValue }, []) + } + } + + func test_uniqueServerNamesOnDocumentSucceeds() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [ + .init(url: URL(string: "https://root.example.com")!, name: "root") + ], + paths: [ + "/hello": .init( + servers: [ + .init(url: URL(string: "https://path.example.com")!, name: "path"), + .init(url: URL(string: "https://unnamed-path.example.com")!) + ], + get: .init( + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "operation"), + .init(url: URL(string: "https://unnamed-operation.example.com")!) + ], + responses: [ + 200: .response(description: "hi") + ] + ) + ) + ], + webhooks: [ + "/event": .init( + post: .init( + servers: [ + .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") + ], + responses: [ + 200: .response(description: "ok") + ] + ) + ) + ], + components: .noComponents + ) + + try document.validate() + } + func test_duplicateOperationParameterFails() { let document = OpenAPI.Document( info: .init(title: "test", version: "1.0"), From 87d7e193145fe2b7b282b551ff2b3066dca9c39c Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sat, 21 Mar 2026 23:45:17 +0530 Subject: [PATCH 2/3] Fix server validation test argument order --- .../Validator/BuiltinValidationTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 182433c26..38b95a5f6 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -573,11 +573,11 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello": .init( get: .init( - servers: [ - .init(url: URL(string: "https://operation.example.com")!, name: "shared") - ], responses: [ 200: .response(description: "hi") + ], + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "shared") ] ) ) @@ -605,12 +605,12 @@ final class BuiltinValidationTests: XCTestCase { .init(url: URL(string: "https://unnamed-path.example.com")!) ], get: .init( + responses: [ + 200: .response(description: "hi") + ], servers: [ .init(url: URL(string: "https://operation.example.com")!, name: "operation"), .init(url: URL(string: "https://unnamed-operation.example.com")!) - ], - responses: [ - 200: .response(description: "hi") ] ) ) @@ -618,11 +618,11 @@ final class BuiltinValidationTests: XCTestCase { webhooks: [ "/event": .init( post: .init( - servers: [ - .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") - ], responses: [ 200: .response(description: "ok") + ], + servers: [ + .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") ] ) ) From e4f5713570fd139b3e921c720899fdbb4070c5af Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sat, 21 Mar 2026 23:58:22 +0530 Subject: [PATCH 3/3] Avoid async let crashes in path item dereferencing --- .../Path Item/DereferencedPathItem.swift | 121 +++++++++--------- .../Path Item/DereferencedPathItem.swift | 102 ++++++++------- 2 files changed, 118 insertions(+), 105 deletions(-) diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 8bddb1ab7..3fb043847 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -167,66 +167,73 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { let oldAdditionalOperations = additionalOperations - async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) -// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) - async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) - async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) - async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) - async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) - async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) - async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) - async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) - async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) - - async let (newAdditionalOperations, c12, m12) = oldAdditionalOperations.externallyDereferenced(with: loader) - var pathItem = self - var newComponents = try await c1 - var newMessages = try await m1 - - // ideally we would async let all of the props above and then set them here, - // but for now since there seems to be some sort of compiler bug we will do - // newServers in an if let below - pathItem.parameters = try await newParameters - pathItem.get = try await newGet - pathItem.put = try await newPut - pathItem.post = try await newPost - pathItem.delete = try await newDelete - pathItem.options = try await newOptions - pathItem.head = try await newHead - pathItem.patch = try await newPatch - pathItem.trace = try await newTrace - pathItem.query = try await newQuery - pathItem.additionalOperations = try await newAdditionalOperations - - try await newComponents.merge(c3) - try await newComponents.merge(c4) - try await newComponents.merge(c5) - try await newComponents.merge(c6) - try await newComponents.merge(c7) - try await newComponents.merge(c8) - try await newComponents.merge(c9) - try await newComponents.merge(c10) - try await newComponents.merge(c11) - try await newComponents.merge(c12) - - try await newMessages += m3 - try await newMessages += m4 - try await newMessages += m5 - try await newMessages += m6 - try await newMessages += m7 - try await newMessages += m8 - try await newMessages += m9 - try await newMessages += m10 - try await newMessages += m11 - try await newMessages += m12 + + // Older Swift 6.0/6.1 Linux runtimes have been observed crashing when this + // dereference path fans out into a large batch of `async let` child tasks. + // The sequential form keeps behavior the same while staying compatible + // across the full test matrix. + let (newParameters, c1, m1) = try await oldParameters.externallyDereferenced(with: loader) + pathItem.parameters = newParameters + + var newComponents = c1 + var newMessages = m1 + + let (newGet, c3, m3) = try await oldGet.externallyDereferenced(with: loader) + pathItem.get = newGet + try newComponents.merge(c3) + newMessages += m3 + + let (newPut, c4, m4) = try await oldPut.externallyDereferenced(with: loader) + pathItem.put = newPut + try newComponents.merge(c4) + newMessages += m4 + + let (newPost, c5, m5) = try await oldPost.externallyDereferenced(with: loader) + pathItem.post = newPost + try newComponents.merge(c5) + newMessages += m5 + + let (newDelete, c6, m6) = try await oldDelete.externallyDereferenced(with: loader) + pathItem.delete = newDelete + try newComponents.merge(c6) + newMessages += m6 + + let (newOptions, c7, m7) = try await oldOptions.externallyDereferenced(with: loader) + pathItem.options = newOptions + try newComponents.merge(c7) + newMessages += m7 + + let (newHead, c8, m8) = try await oldHead.externallyDereferenced(with: loader) + pathItem.head = newHead + try newComponents.merge(c8) + newMessages += m8 + + let (newPatch, c9, m9) = try await oldPatch.externallyDereferenced(with: loader) + pathItem.patch = newPatch + try newComponents.merge(c9) + newMessages += m9 + + let (newTrace, c10, m10) = try await oldTrace.externallyDereferenced(with: loader) + pathItem.trace = newTrace + try newComponents.merge(c10) + newMessages += m10 + + let (newQuery, c11, m11) = try await oldQuery.externallyDereferenced(with: loader) + pathItem.query = newQuery + try newComponents.merge(c11) + newMessages += m11 + + let (newAdditionalOperations, c12, m12) = try await oldAdditionalOperations.externallyDereferenced(with: loader) + pathItem.additionalOperations = newAdditionalOperations + try newComponents.merge(c12) + newMessages += m12 if let oldServers { - async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - pathItem.servers = try await newServers - try await newComponents.merge(c2) - try await newMessages += m2 + let (newServers, c2, m2) = try await oldServers.externallyDereferenced(with: loader) + pathItem.servers = newServers + try newComponents.merge(c2) + newMessages += m2 } return (pathItem, newComponents, newMessages) diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index ae9d6e3be..fdd27f1b6 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -153,57 +153,63 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { let oldPatch = patch let oldTrace = trace - async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) -// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) - async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) - async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) - async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) - async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) - async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) - async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) - async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) - var pathItem = self - var newComponents = try await c1 - var newMessages = try await m1 - - // ideally we would async let all of the props above and then set them here, - // but for now since there seems to be some sort of compiler bug we will do - // newServers in an if let below - pathItem.parameters = try await newParameters - pathItem.get = try await newGet - pathItem.put = try await newPut - pathItem.post = try await newPost - pathItem.delete = try await newDelete - pathItem.options = try await newOptions - pathItem.head = try await newHead - pathItem.patch = try await newPatch - pathItem.trace = try await newTrace - - try await newComponents.merge(c3) - try await newComponents.merge(c4) - try await newComponents.merge(c5) - try await newComponents.merge(c6) - try await newComponents.merge(c7) - try await newComponents.merge(c8) - try await newComponents.merge(c9) - try await newComponents.merge(c10) - - try await newMessages += m3 - try await newMessages += m4 - try await newMessages += m5 - try await newMessages += m6 - try await newMessages += m7 - try await newMessages += m8 - try await newMessages += m9 - try await newMessages += m10 + + // Older Swift 6.0/6.1 Linux runtimes have been observed crashing when this + // dereference path fans out into a large batch of `async let` child tasks. + // The sequential form keeps behavior the same while staying compatible + // across the full test matrix. + let (newParameters, c1, m1) = try await oldParameters.externallyDereferenced(with: loader) + pathItem.parameters = newParameters + + var newComponents = c1 + var newMessages = m1 + + let (newGet, c3, m3) = try await oldGet.externallyDereferenced(with: loader) + pathItem.get = newGet + try newComponents.merge(c3) + newMessages += m3 + + let (newPut, c4, m4) = try await oldPut.externallyDereferenced(with: loader) + pathItem.put = newPut + try newComponents.merge(c4) + newMessages += m4 + + let (newPost, c5, m5) = try await oldPost.externallyDereferenced(with: loader) + pathItem.post = newPost + try newComponents.merge(c5) + newMessages += m5 + + let (newDelete, c6, m6) = try await oldDelete.externallyDereferenced(with: loader) + pathItem.delete = newDelete + try newComponents.merge(c6) + newMessages += m6 + + let (newOptions, c7, m7) = try await oldOptions.externallyDereferenced(with: loader) + pathItem.options = newOptions + try newComponents.merge(c7) + newMessages += m7 + + let (newHead, c8, m8) = try await oldHead.externallyDereferenced(with: loader) + pathItem.head = newHead + try newComponents.merge(c8) + newMessages += m8 + + let (newPatch, c9, m9) = try await oldPatch.externallyDereferenced(with: loader) + pathItem.patch = newPatch + try newComponents.merge(c9) + newMessages += m9 + + let (newTrace, c10, m10) = try await oldTrace.externallyDereferenced(with: loader) + pathItem.trace = newTrace + try newComponents.merge(c10) + newMessages += m10 if let oldServers { - async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - pathItem.servers = try await newServers - try await newComponents.merge(c2) - try await newMessages += m2 + let (newServers, c2, m2) = try await oldServers.externallyDereferenced(with: loader) + pathItem.servers = newServers + try newComponents.merge(c2) + newMessages += m2 } return (pathItem, newComponents, newMessages)