diff --git a/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift b/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift index 48db6463..c21e6933 100644 --- a/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift +++ b/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift @@ -14,249 +14,12 @@ import OpenAPIKit -/// Validates all content types from an OpenAPI document represented by a ParsedOpenAPIRepresentation. -/// -/// This function iterates through the paths, endpoints, and components of the OpenAPI document, -/// checking and reporting any invalid content types using the provided validation closure. -/// -/// - Parameters: -/// - doc: The OpenAPI document representation. -/// - validate: A closure to validate each content type. -/// - Throws: An error with diagnostic information if any invalid content types are found. -func validateContentTypes(in doc: ParsedOpenAPIRepresentation, validate: (String) -> Bool) throws { - for (path, pathValue) in doc.paths { - guard let pathItem = pathValue.pathItemValue else { continue } - for endpoint in pathItem.endpoints { - - if let eitherRequest = endpoint.operation.requestBody { - if let actualRequest = eitherRequest.requestValue { - for contentType in actualRequest.content.keys { - if !validate(contentType.rawValue) { - throw Diagnostic.error( - message: "Invalid content type string.", - context: [ - "contentType": contentType.rawValue, - "location": "\(path.rawValue)/\(endpoint.method.rawValue)/requestBody", - "recoverySuggestion": - "Must have 2 components separated by a slash '/'.", - ] - ) - } - } - } - } - - for eitherResponse in endpoint.operation.responses.values { - if let actualResponse = eitherResponse.responseValue { - for contentType in actualResponse.content.keys { - if !validate(contentType.rawValue) { - throw Diagnostic.error( - message: "Invalid content type string.", - context: [ - "contentType": contentType.rawValue, - "location": "\(path.rawValue)/\(endpoint.method.rawValue)/responses", - "recoverySuggestion": - "Must have 2 components separated by a slash '/'.", - ] - ) - } - } - } - } - } - } - - for (key, component) in doc.components.requestBodies { - let component = try doc.components.assumeLookupOnce(component) - for contentType in component.content.keys { - if !validate(contentType.rawValue) { - throw Diagnostic.error( - message: "Invalid content type string.", - context: [ - "contentType": contentType.rawValue, "location": "#/components/requestBodies/\(key.rawValue)", - "recoverySuggestion": "Must have 2 components separated by a slash '/'.", - ] - ) - } - } - } - - for (key, component) in doc.components.responses { - let component = try doc.components.assumeLookupOnce(component) - for contentType in component.content.keys { - if !validate(contentType.rawValue) { - throw Diagnostic.error( - message: "Invalid content type string.", - context: [ - "contentType": contentType.rawValue, "location": "#/components/responses/\(key.rawValue)", - "recoverySuggestion": "Must have 2 components separated by a slash '/'.", - ] - ) - } - } - } -} - -/// Validates all references from an OpenAPI document represented by a ParsedOpenAPIRepresentation against its components. -/// -/// This method traverses the OpenAPI document to ensure that all references -/// within the document are valid and point to existing components. -/// -/// - Parameter doc: The OpenAPI document to validate. -/// - Throws: `Diagnostic.error` if an external reference is found or a reference is not found in components. -func validateReferences(in doc: ParsedOpenAPIRepresentation) throws { - func validateReference( - _ reference: OpenAPI.Reference, - in components: OpenAPI.Components, - location: String - ) throws { - if reference.isExternal { - throw Diagnostic.error( - message: "External references are not suppported.", - context: ["reference": reference.absoluteString, "location": location] - ) - } - if components[reference] == nil { - throw Diagnostic.error( - message: "Reference not found in components.", - context: ["reference": reference.absoluteString, "location": location] - ) - } - } - - func validateReferencesInContentTypes(_ content: OpenAPI.Content.Map, location: String) throws { - for (contentKey, contentType) in content { - switch contentType { - case .a(let ref): - try validateReference(ref, in: doc.components, location: location + "/content/\(contentKey.rawValue)") - case .b(let contentType): - if let reference: JSONReference = contentType.schema?.reference { - try validateReference( - .init(reference), - in: doc.components, - location: location + "/content/\(contentKey.rawValue)/schema" - ) - } - if let eitherExamples = contentType.examples?.values { - for example in eitherExamples { - if let reference = example.reference { - try validateReference( - reference, - in: doc.components, - location: location + "/content/\(contentKey.rawValue)/examples" - ) - } - } - } - } - } - } - - for (key, value) in doc.webhooks { - if let reference = value.reference { try validateReference(reference, in: doc.components, location: key) } - } - - for (path, pathValue) in doc.paths { - if let reference = pathValue.reference { - try validateReference(reference, in: doc.components, location: path.rawValue) - } else if let pathItem = pathValue.pathItemValue { - - for endpoint in pathItem.endpoints { - for (endpointKey, endpointValue) in endpoint.operation.callbacks { - if let reference = endpointValue.reference { - try validateReference( - reference, - in: doc.components, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/callbacks/\(endpointKey)" - ) - } - } - - for eitherParameter in endpoint.operation.parameters { - if let reference = eitherParameter.reference { - try validateReference( - reference, - in: doc.components, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/parameters" - ) - } else if let parameter = eitherParameter.parameterValue { - if let reference = parameter.schemaOrContent.schemaReference { - try validateReference( - reference, - in: doc.components, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/parameters/\(parameter.name)" - ) - } else if let content = parameter.schemaOrContent.contentValue { - try validateReferencesInContentTypes( - content, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/parameters/\(parameter.name)" - ) - } - } - } - if let reference = endpoint.operation.requestBody?.reference { - try validateReference( - reference, - in: doc.components, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/requestBody" - ) - } else if let requestBodyValue = endpoint.operation.requestBody?.requestValue { - try validateReferencesInContentTypes( - requestBodyValue.content, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/requestBody" - ) - } - - for (statusCode, eitherResponse) in endpoint.operation.responses { - if let reference = eitherResponse.reference { - try validateReference( - reference, - in: doc.components, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/responses/\(statusCode.rawValue)" - ) - } else if let responseValue = eitherResponse.responseValue { - try validateReferencesInContentTypes( - responseValue.content, - location: "\(path.rawValue)/\(endpoint.method.rawValue)/responses/\(statusCode.rawValue)" - ) - } - if let headers = eitherResponse.responseValue?.headers { - for (headerKey, eitherHeader) in headers { - if let reference = eitherHeader.reference { - try validateReference( - reference, - in: doc.components, - location: - "\(path.rawValue)/\(endpoint.method.rawValue)/responses/\(statusCode.rawValue)/headers/\(headerKey)" - ) - } else if let headerValue = eitherHeader.headerValue { - if let schemaReference = headerValue.schemaOrContent.schemaReference { - try validateReference( - schemaReference, - in: doc.components, - location: - "\(path.rawValue)/\(endpoint.method.rawValue)/responses/\(statusCode.rawValue)/headers/\(headerKey)" - ) - } else if let contentValue = headerValue.schemaOrContent.contentValue { - try validateReferencesInContentTypes( - contentValue, - location: - "\(path.rawValue)/\(endpoint.method.rawValue)/responses/\(statusCode.rawValue)/headers/\(headerKey)" - ) - } - } - } - } - } - } - - for eitherParameter in pathItem.parameters { - if let reference = eitherParameter.reference { - try validateReference(reference, in: doc.components, location: "\(path.rawValue)/parameters") - } - } - } - } +/// Validates all content types from an OpenAPI document can be parsed as a ContentType. +var contentTypesValidation: Validation { + .init( + description: "Content type is of form '/'.", + check: { context in (try? _OpenAPIGeneratorCore.ContentType(string: context.subject.rawValue)) != nil } + ) } /// Validates all type overrides from a Config are present in the components of a ParsedOpenAPIRepresentation. @@ -288,13 +51,13 @@ func validateTypeOverrides(_ doc: ParsedOpenAPIRepresentation, config: Config) - /// - Returns: An array of diagnostic messages representing validation warnings. /// - Throws: An error if a fatal issue is found. func validateDoc(_ doc: ParsedOpenAPIRepresentation, config: Config) throws -> [Diagnostic] { - try validateReferences(in: doc) - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } let typeOverrideDiagnostics = validateTypeOverrides(doc, config: config) - // Run OpenAPIKit's built-in validation. + // Run OpenAPIKit's default built-in validations and additionally check + // that all references point to entries in the Components Object, all + // operations contain responses, and all content types parse by this + // library's code. + // // Pass `false` to `strict`, however, because we don't // want to turn schema loading warnings into errors. // We already propagate the warnings to the generator's @@ -318,7 +81,8 @@ func validateDoc(_ doc: ParsedOpenAPIRepresentation, config: Config) throws -> [ extension OpenAPIKit.Validator { static var swiftOpenAPICustomValidator: Validator { - Validator().validating(\.operationsContainResponses) + Validator().validatingAllReferencesFoundInComponents().validating(\.operationsContainResponses) + .validating(contentTypesValidation) // Skip this one to be backwards compatible with previous versions of Swift OpenAPI Generator. // Even when run with strict=false, this one will cause OpenAPIKit to throw an error. Previous verions were more // lenient and Swift OpenAPI Generator would later emit a warning that it's unsupported. diff --git a/Tests/OpenAPIGeneratorCoreTests/Parser/Test_validateDoc.swift b/Tests/OpenAPIGeneratorCoreTests/Parser/Test_validateDoc.swift index c8d89f3f..ce15992a 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Parser/Test_validateDoc.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Parser/Test_validateDoc.swift @@ -30,16 +30,17 @@ final class Test_validateDoc: Test_Core { "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", - "JSONSchema reference can be found in components/schemas", - "JSONSchema reference can be found in components/schemas", - "Response reference can be found in components/responses", - "Parameter reference can be found in components/parameters", - "Example reference can be found in components/examples", - "Request reference can be found in components/requestBodies", - "Header reference can be found in components/headers", - "Link reference can be found in components/links", - "Callbacks reference can be found in components/callbacks", - "PathItem reference can be found in components/pathItems", "Operations contain at least one response", + "JSONSchema reference points to this document and can be found in components/schemas", + "JSONSchema reference points to this document and can be found in components/schemas", + "Response reference points to this document and can be found in components/responses", + "Parameter reference points to this document and can be found in components/parameters", + "Example reference points to this document and can be found in components/examples", + "Request reference points to this document and can be found in components/requestBodies", + "Header reference points to this document and can be found in components/headers", + "Link reference points to this document and can be found in components/links", + "Callbacks reference points to this document and can be found in components/callbacks", + "PathItem reference points to this document and can be found in components/pathItems", + "Operations contain at least one response", "Content type is of form \'/\'.", ] ) } @@ -143,11 +144,8 @@ final class Test_validateDoc: Test_Core { ], components: .noComponents ) - XCTAssertNoThrow( - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } - ) + let validator = Validator.blank.validating(contentTypesValidation) + XCTAssertNoThrow(try doc.validate(using: validator, strict: false)) } func testValidateContentTypes_invalidContentTypesInRequestBody() throws { @@ -194,15 +192,12 @@ final class Test_validateDoc: Test_Core { ], components: .noComponents ) - XCTAssertThrowsError( - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } - ) { error in - XCTAssertTrue(error is Diagnostic) + let validator = Validator.blank.validating(contentTypesValidation) + XCTAssertThrowsError(try doc.validate(using: validator, strict: false)) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: Invalid content type string. [context: contentType=application/, location=/path1/GET/requestBody, recoverySuggestion=Must have 2 components separated by a slash '/'.]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: Content type is of form '/' at path: .paths['/path1'].get.requestBody.content['application/']" ) } } @@ -251,15 +246,12 @@ final class Test_validateDoc: Test_Core { ], components: .noComponents ) - XCTAssertThrowsError( - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } - ) { error in - XCTAssertTrue(error is Diagnostic) + let validator = Validator.blank.validating(contentTypesValidation) + XCTAssertThrowsError(try doc.validate(using: validator, strict: true)) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: Invalid content type string. [context: contentType=/plain, location=/path2/GET/responses, recoverySuggestion=Must have 2 components separated by a slash '/'.]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: Content type is of form '/' at path: .paths['/path2'].get.responses.200.content['/plain']" ) } } @@ -296,15 +288,12 @@ final class Test_validateDoc: Test_Core { "exampleRequestBody2": .init(content: [.init(rawValue: "image/")!: .content(.init(schema: .string))]), ]) ) - XCTAssertThrowsError( - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } - ) { error in - XCTAssertTrue(error is Diagnostic) + let validator = Validator.blank.validating(contentTypesValidation) + XCTAssertThrowsError(try doc.validate(using: validator, strict: false)) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: Invalid content type string. [context: contentType=image/, location=#/components/requestBodies/exampleRequestBody2, recoverySuggestion=Must have 2 components separated by a slash '/'.]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: Content type is of form '/' at path: .components.requestBodies.exampleRequestBody2.content['image/']" ) } } @@ -345,15 +334,12 @@ final class Test_validateDoc: Test_Core { ), ]) ) - XCTAssertThrowsError( - try validateContentTypes(in: doc) { contentType in - (try? _OpenAPIGeneratorCore.ContentType(string: contentType)) != nil - } - ) { error in - XCTAssertTrue(error is Diagnostic) + let validator = Validator.blank.validating(contentTypesValidation) + XCTAssertThrowsError(try doc.validate(using: validator, strict: false)) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: Invalid content type string. [context: contentType=, location=#/components/responses/exampleRequestBody2, recoverySuggestion=Must have 2 components separated by a slash '/'.]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: Content type is of form '/' at path: .components.responses.exampleRequestBody2.content." ) } } @@ -421,7 +407,7 @@ final class Test_validateDoc: Test_Core { ) ]) ), - responses: [:], + responses: [200: .response()], callbacks: [.init("Callback"): .a(.component(named: "CallbackReference"))] ) ) @@ -447,7 +433,16 @@ final class Test_validateDoc: Test_Core { pathItems: ["Path2Reference": .init()] ) ) - XCTAssertNoThrow(try validateReferences(in: doc)) + XCTAssertNoThrow( + try validateDoc( + doc, + config: .init( + mode: .types, + access: Config.defaultAccessModifier, + namingStrategy: Config.defaultNamingStrategy + ) + ) + ) } func testValidateReferences_referenceNotFoundInComponents() throws { @@ -467,18 +462,27 @@ final class Test_validateDoc: Test_Core { ) ]) ), - responses: [:] + responses: [200: .response()] ) ) ) ], components: .init(schemas: ["RequestBodyContentSchema": .init(schema: .integer(.init(), .init()))]) ) - XCTAssertThrowsError(try validateReferences(in: doc)) { error in - XCTAssertTrue(error is Diagnostic) + XCTAssertThrowsError( + try validateDoc( + doc, + config: .init( + mode: .types, + access: Config.defaultAccessModifier, + namingStrategy: Config.defaultNamingStrategy + ) + ) + ) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: Reference not found in components. [context: location=/path/GET/requestBody/content/text/html/schema, reference=#/components/schemas/RequestBodyContentSchemaReference]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: JSONSchema reference points to this document and can be found in components/schemas at path: .paths['/path'].get.requestBody.content['text/html'].schema" ) } } @@ -499,11 +503,20 @@ final class Test_validateDoc: Test_Core { ], components: .noComponents ) - XCTAssertThrowsError(try validateReferences(in: doc)) { error in - XCTAssertTrue(error is Diagnostic) + XCTAssertThrowsError( + try validateDoc( + doc, + config: .init( + mode: .types, + access: Config.defaultAccessModifier, + namingStrategy: Config.defaultNamingStrategy + ) + ) + ) { error in + XCTAssertTrue(error is ValidationErrorCollection) XCTAssertEqual( - error.localizedDescription, - "error: External references are not suppported. [context: location=/path/GET/responses/200, reference=ExternalURL]" + OpenAPI.Error(from: error).localizedDescription, + "Failed to satisfy: Response reference points to this document and can be found in components/responses at path: .paths['/path'].get.responses.200" ) } }