diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 55af772fb..8e22affe5 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -479,6 +479,30 @@ extension Validation { public static var serverVarialbeDefaultExistsInEnum : Validation { return serverVariableDefaultExistsInEnum } + + /// Validate the OpenAPI Document's `Links` with operationIds refer to + /// Operations that exist in the document. + /// + /// This validation ensures that Link Objects using operationIds have corresponding + /// Operations in the document that have those IDs. + /// + /// - Important: This is not an included validation by default. + public static var linkOperationsExist: Validation { + .init( + description: "Links with operationIds have corresponding Operations", + check: { context in + guard case let .b(operationId) = context.subject.operation else { + // don't make assertions about Links that don't have operationIds + return true + } + + // Collect all operation IDs from the document + let operationIds = context.document.allOperationIds + + return operationIds.contains(operationId) + } + ) + } } /// Used by both the Path Item parameter check and the diff --git a/Sources/OpenAPIKit30/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit30/Validator/Validation+Builtins.swift index 414d26d24..d880181df 100644 --- a/Sources/OpenAPIKit30/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit30/Validator/Validation+Builtins.swift @@ -396,6 +396,30 @@ extension Validation { } ) } + + /// Validate the OpenAPI Document's `Links` with operationIds refer to + /// Operations that exist in the document. + /// + /// This validation ensures that Link Objects using operationIds have corresponding + /// Operations in the document that have those IDs. + /// + /// - Important: This is not an included validation by default. + public static var linkOperationsExist: Validation { + .init( + description: "Links with operationIds have corresponding Operations", + check: { context in + guard case let .b(operationId) = context.subject.operation else { + // don't make assertions about Links that don't have operationIds + return true + } + + // Use the allOperationIds helper to get all operation IDs from the document + let operationIds = context.document.allOperationIds + + return operationIds.contains(operationId) + } + ) + } } /// Used by both the Path Item parameter check and the diff --git a/Tests/OpenAPIKit30Tests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKit30Tests/Validator/BuiltinValidationTests.swift index 2e1eba612..4a950c25e 100644 --- a/Tests/OpenAPIKit30Tests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKit30Tests/Validator/BuiltinValidationTests.swift @@ -749,4 +749,63 @@ final class BuiltinValidationTests: XCTestCase { // NOTE this is part of default validation try document.validate() } + + func test_linkOperationsExist_validates() throws { + // Create a link with an operationId that exists in the document + let link = OpenAPI.Link(operationId: "testOperation") + + // Create a document with an operation using that ID + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + responses: [:] + ) + ) + ], + components: .init( + links: [ + "testLink": link + ] + ) + ) + + let validator = Validator.blank.validating(.linkOperationsExist) + try document.validate(using: validator) + } + + func test_linkOperationsExist_fails() throws { + // Create a link with an operationId that doesn't exist in the document + let link = OpenAPI.Link(operationId: "nonExistentOperation") + + // Create a document with an operation using a different ID + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + responses: [:] + ) + ) + ], + components: .init( + links: [ + "testLink": link + ] + ) + ) + + let validator = Validator.blank.validating(.linkOperationsExist) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: Links with operationIds have corresponding Operations") + XCTAssertTrue((errorCollection?.values.first?.codingPath.map { $0.stringValue }.joined(separator: ".") ?? "").contains("testLink")) + } + } } diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index eb683ccee..f74c77a34 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -860,4 +860,84 @@ final class BuiltinValidationTests: XCTestCase { // NOTE this is part of default validation try document.validate() } + + func test_pathItemsTopLevelReferencesReferencingPathItemComponentsSuccess() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .reference(.component(named: "hello")), + "/world": .reference(.component(named: "world")) + ], + components: .init( + pathItems: [ + "hello": .init(), + "world": .init() + ] + ) + ) + + let validator = Validator.blank.validating(.pathItemReferencesAreValid) + + try document.validate(using: validator) + } + + func test_linkOperationsExist_validates() throws { + // Create a link with an operationId that exists in the document + let link = OpenAPI.Link(operationId: "testOperation") + + // Create a document with an operation using that ID + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + responses: [:] + ) + ) + ], + components: .init( + links: [ + "testLink": link + ] + ) + ) + + let validator = Validator.blank.validating(.linkOperationsExist) + try document.validate(using: validator) + } + + func test_linkOperationsExist_fails() throws { + // Create a link with an operationId that doesn't exist in the document + let link = OpenAPI.Link(operationId: "nonExistentOperation") + + // Create a document with an operation using a different ID + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + responses: [:] + ) + ) + ], + components: .init( + links: [ + "testLink": link + ] + ) + ) + + let validator = Validator.blank.validating(.linkOperationsExist) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: Links with operationIds have corresponding Operations") + XCTAssertTrue((errorCollection?.values.first?.codingPath.map { $0.stringValue }.joined(separator: ".") ?? "").contains("testLink")) + } + } }