diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 8bddb1ab75..3fb0438479 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/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 327f8991ac..87a746838a 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 4b8c5fadea..dcb5066ef3 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/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index ae9d6e3be4..fdd27f1b63 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) diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index f8ffa15b14..38b95a5f6b 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( + responses: [ + 200: .response(description: "hi") + ], + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "shared") + ] + ) + ) + ], + 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( + 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")!) + ] + ) + ) + ], + webhooks: [ + "/event": .init( + post: .init( + responses: [ + 200: .response(description: "ok") + ], + servers: [ + .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") + ] + ) + ) + ], + components: .noComponents + ) + + try document.validate() + } + func test_duplicateOperationParameterFails() { let document = OpenAPI.Document( info: .init(title: "test", version: "1.0"),