diff --git a/Sources/_OpenAPIGeneratorCore/Diagnostics.swift b/Sources/_OpenAPIGeneratorCore/Diagnostics.swift index 8c312af5..e1c629c8 100644 --- a/Sources/_OpenAPIGeneratorCore/Diagnostics.swift +++ b/Sources/_OpenAPIGeneratorCore/Diagnostics.swift @@ -37,39 +37,55 @@ public struct Diagnostic: Error, Codable { /// A user-friendly description of the diagnostic. public var message: String + /// Describes the source file that triggered a diagnostic. + public struct Location: Codable { + /// The absolute path to a specific source file that triggered the diagnostic. + public var filePath: String + + /// The line number (if known) of the line within the source file that triggered the diagnostic. + public var lineNumber: Int? + } + + /// The source file that triggered the diagnostic. + public var location: Location? + /// Additional information about where the issue occurred. public var context: [String: String] = [:] /// Creates an informative message, which doesn't represent an issue. - public static func note(message: String, context: [String: String] = [:]) -> Diagnostic { - .init(severity: .note, message: message, context: context) + public static func note(message: String, location: Location? = nil, context: [String: String] = [:]) -> Diagnostic { + .init(severity: .note, message: message, location: location, context: context) } /// Creates a recoverable issue, which doesn't prevent the generator /// from continuing. /// - Parameters: /// - message: The message that describes the warning. + /// - location: Describe the source file that triggered the diagnostic (if known). /// - context: A set of key-value pairs that help the user understand /// where the warning occurred. /// - Returns: A warning diagnostic. public static func warning( message: String, + location: Location? = nil, context: [String: String] = [:] ) -> Diagnostic { - .init(severity: .warning, message: message, context: context) + .init(severity: .warning, message: message, location: location, context: context) } /// Creates a non-recoverable issue, which leads the generator to stop. /// - Parameters: /// - message: The message that describes the error. + /// - location: Describe the source file that triggered the diagnostic (if known). /// - context: A set of key-value pairs that help the user understand /// where the warning occurred. /// - Returns: An error diagnostic. public static func error( message: String, + location: Location? = nil, context: [String: String] = [:] ) -> Diagnostic { - .init(severity: .error, message: message, context: context) + .init(severity: .error, message: message, location: location, context: context) } /// Creates a diagnostic for an unsupported feature. @@ -79,17 +95,23 @@ public struct Diagnostic: Error, Codable { /// - feature: A human-readable name of the feature. /// - foundIn: A description of the location in which the unsupported /// feature was detected. + /// - location: Describe the source file that triggered the diagnostic (if known). /// - context: A set of key-value pairs that help the user understand /// where the warning occurred. /// - Returns: A warning diagnostic. public static func unsupported( _ feature: String, foundIn: String, + location: Location? = nil, context: [String: String] = [:] ) -> Diagnostic { var context = context context["foundIn"] = foundIn - return warning(message: "Feature \"\(feature)\" is not supported, skipping", context: context) + return warning( + message: "Feature \"\(feature)\" is not supported, skipping", + location: location, + context: context + ) } } @@ -101,8 +123,16 @@ extension Diagnostic.Severity: CustomStringConvertible { extension Diagnostic: CustomStringConvertible { public var description: String { + var prefix = "" + if let location = location { + prefix = "\(location.filePath):" + if let line = location.lineNumber { + prefix += "\(line):" + } + prefix += " " + } let contextString = context.map { "\($0)=\($1)" }.sorted().joined(separator: ", ") - return "\(severity): \(message) [\(contextString.isEmpty ? "" : "context: \(contextString)")]" + return "\(prefix)\(severity): \(message)\(contextString.isEmpty ? "" : " [context: \(contextString)]")" } } diff --git a/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift b/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift index c4c3cb44..b7ccc238 100644 --- a/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift +++ b/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift @@ -11,9 +11,9 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// -import Yams -import OpenAPIKit30 import Foundation +import OpenAPIKit30 +import Yams /// A parser that uses the Yams library to parse the provided /// raw file into an OpenAPI document. @@ -30,37 +30,93 @@ struct YamsParser: ParserProtocol { var openapi: String? } - struct OpenAPIVersionError: Error, CustomStringConvertible, LocalizedError { - var versionString: String - var description: String { - "Unsupported document version: \(versionString). Please provide a document with OpenAPI versions in the 3.0.x set." - } + let versionedDocument: OpenAPIVersionedDocument + do { + versionedDocument = try decoder.decode( + OpenAPIVersionedDocument.self, + from: openapiData + ) + } catch DecodingError.dataCorrupted(let errorContext) { + try checkParsingError(context: errorContext, input: input) + throw DecodingError.dataCorrupted(errorContext) } - struct OpenAPIMissingVersionError: Error, CustomStringConvertible, LocalizedError { - var description: String { - "No openapi key found, please provide a valid OpenAPI document with OpenAPI versions in the 3.0.x set." - } - } - - let versionedDocument = try decoder.decode( - OpenAPIVersionedDocument.self, - from: openapiData - ) - guard let openAPIVersion = versionedDocument.openapi else { - throw OpenAPIMissingVersionError() + throw Diagnostic.openAPIMissingVersionError(location: .init(filePath: input.absolutePath.path)) } switch openAPIVersion { case "3.0.0", "3.0.1", "3.0.2", "3.0.3": break default: - throw OpenAPIVersionError(versionString: "openapi: \(openAPIVersion)") + throw Diagnostic.openAPIVersionError( + versionString: "openapi: \(openAPIVersion)", + location: .init(filePath: input.absolutePath.path) + ) } - return try decoder.decode( - OpenAPI.Document.self, - from: input.contents + do { + return try decoder.decode( + OpenAPI.Document.self, + from: input.contents + ) + } catch DecodingError.dataCorrupted(let errorContext) { + try checkParsingError(context: errorContext, input: input) + throw DecodingError.dataCorrupted(errorContext) + } + } + + /// Detect specific YAML parsing errors to throw nicely formatted diagnostics for IDEs + /// - Parameters: + /// - context: The error context that triggered the `DecodingError`. + /// - input: The input file that was being worked on when the error was triggered. + /// - Throws: Will throw a `Diagnostic` if the decoding error is a common parsing error. + private func checkParsingError( + context: DecodingError.Context, + input: InMemoryInputFile + ) throws { + if let yamlError = context.underlyingError as? YamlError { + if case .parser(let yamlContext, let yamlProblem, let yamlMark, _) = yamlError { + throw Diagnostic.error( + message: "\(yamlProblem) \(yamlContext?.description ?? "")", + location: .init(filePath: input.absolutePath.path, lineNumber: yamlMark.line - 1) + ) + } else if case .scanner(let yamlContext, let yamlProblem, let yamlMark, _) = yamlError { + throw Diagnostic.error( + message: "\(yamlProblem) \(yamlContext?.description ?? "")", + location: .init(filePath: input.absolutePath.path, lineNumber: yamlMark.line - 1) + ) + } + } else if let openAPIError = context.underlyingError as? OpenAPIError { + throw Diagnostic.error( + message: openAPIError.localizedDescription, + location: .init(filePath: input.absolutePath.path) + ) + } + } +} + +extension Diagnostic { + /// Use when the document is an unsupported version. + /// - Parameters: + /// - versionString: The OpenAPI version number that was parsed from the document. + /// - location: Describes the input file being worked on when the error occurred. + /// - Returns: An error diagnostic. + static func openAPIVersionError(versionString: String, location: Location) -> Diagnostic { + return error( + message: + "Unsupported document version: \(versionString). Please provide a document with OpenAPI versions in the 3.0.x set.", + location: location + ) + } + + /// Use when the YAML document is completely missing the `openapi` version key. + /// - Parameter location: Describes the input file being worked on when the error occurred + /// - Returns: An error diagnostic. + static func openAPIMissingVersionError(location: Location) -> Diagnostic { + return error( + message: + "No openapi key found, please provide a valid OpenAPI document with OpenAPI versions in the 3.0.x set.", + location: location ) } } diff --git a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift index 5a59a32e..02627486 100644 --- a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift +++ b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// import _OpenAPIGeneratorCore +import ArgumentParser import Foundation extension _GenerateOptions { @@ -65,13 +66,19 @@ extension _GenerateOptions { - Additional imports: \(resolvedAdditionalImports.isEmpty ? "" : resolvedAdditionalImports.joined(separator: ", ")) """ ) - try _Tool.runGenerator( - doc: doc, - configs: configs, - isPluginInvocation: isPluginInvocation, - outputDirectory: outputDirectory, - diagnostics: diagnostics - ) + do { + try _Tool.runGenerator( + doc: doc, + configs: configs, + isPluginInvocation: isPluginInvocation, + outputDirectory: outputDirectory, + diagnostics: diagnostics + ) + } catch let error as Diagnostic { + // Emit our nice Diagnostics message instead of relying on ArgumentParser output. + diagnostics.emit(error) + throw ExitCode.failure + } try finalizeDiagnostics() } } diff --git a/Tests/OpenAPIGeneratorCoreTests/Parser/Test_YamsParser.swift b/Tests/OpenAPIGeneratorCoreTests/Parser/Test_YamsParser.swift index 045f1748..18d9f52b 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Parser/Test_YamsParser.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Parser/Test_YamsParser.swift @@ -17,32 +17,137 @@ import XCTest final class Test_YamsParser: Test_Core { func testVersionValidation() throws { - XCTAssertNoThrow(try _test("3.0.0")) - XCTAssertNoThrow(try _test("3.0.1")) - XCTAssertNoThrow(try _test("3.0.2")) - XCTAssertNoThrow(try _test("3.0.3")) - XCTAssertThrowsError(try _test("3.1.0")) - XCTAssertThrowsError(try _test("2.0")) + XCTAssertNoThrow(try _test(openAPIVersionString: "3.0.0")) + XCTAssertNoThrow(try _test(openAPIVersionString: "3.0.1")) + XCTAssertNoThrow(try _test(openAPIVersionString: "3.0.2")) + XCTAssertNoThrow(try _test(openAPIVersionString: "3.0.3")) + + let expected1 = + "/foo.yaml: error: Unsupported document version: openapi: 3.1.0. Please provide a document with OpenAPI versions in the 3.0.x set." + assertThrownError(try _test(openAPIVersionString: "3.1.0"), expectedDiagnostic: expected1) + + let expected2 = + "/foo.yaml: error: Unsupported document version: openapi: 2.0. Please provide a document with OpenAPI versions in the 3.0.x set." + assertThrownError(try _test(openAPIVersionString: "2.0"), expectedDiagnostic: expected2) + } + + private func _test(openAPIVersionString: String) throws -> ParsedOpenAPIRepresentation { + try _test( + """ + openapi: "\(openAPIVersionString)" + info: + title: "Test" + version: "1.0.0" + paths: {} + """ + ) + } + + func testMissingOpenAPIVersionError() throws { + // No `openapi` key in the YAML + let yaml = """ + info: + title: "Test" + version: "1.0.0" + paths: {} + """ + + let expected = + "/foo.yaml: error: No openapi key found, please provide a valid OpenAPI document with OpenAPI versions in the 3.0.x set." + assertThrownError(try _test(yaml), expectedDiagnostic: expected) + } + + func testEmitsYamsParsingError() throws { + // The `title: "Test"` line is indented the wrong amount to make the YAML invalid for the parser + let yaml = """ + openapi: "3.0.0" + info: + title: "Test" + version: 1.0.0 + paths: {} + """ + + let expected = + "/foo.yaml:3: error: did not find expected key while parsing a block mapping in line 3, column 2\n" + assertThrownError(try _test(yaml), expectedDiagnostic: expected) + } + + func testEmitsYamsScanningError() throws { + // The `version:"1.0.0"` line is missing a space after the colon to make it invalid YAML for the scanner + let yaml = """ + openapi: "3.0.0" + info: + title: "Test" + version:"1.0.0" + paths: {} + """ + + let expected = + "/foo.yaml:4: error: could not find expected ':' while scanning a simple key in line 4, column 3\n" + assertThrownError(try _test(yaml), expectedDiagnostic: expected) + } + + func testEmitsMissingInfoKeyOpenAPIParsingError() throws { + // The `smurf` line should be `info` in a real OpenAPI document. + let yaml = """ + openapi: "3.0.0" + smurf: + title: "Test" + version: "1.0.0" + paths: {} + """ + + let expected = + "/foo.yaml: error: Expected to find `info` key in the root Document object but it is missing." + assertThrownError(try _test(yaml), expectedDiagnostic: expected) } - func _test(_ openAPIVersionString: String) throws -> ParsedOpenAPIRepresentation { + func testEmitsComplexOpenAPIParsingError() throws { + // The `resonance` line should be `response` in a real OpenAPI document. + let yaml = """ + openapi: "3.0.0" + info: + title: "Test" + version: "1.0.0" + paths: + /system: + get: + description: This is a unit test. + resonance: + '200': + description: Success + """ + + let expected = + "/foo.yaml: error: Expected to find `responses` key for the **GET** endpoint under `/system` but it is missing." + assertThrownError(try _test(yaml), expectedDiagnostic: expected) + } + + private func _test(_ yaml: String) throws -> ParsedOpenAPIRepresentation { try YamsParser() .parseOpenAPI( .init( absolutePath: URL(fileURLWithPath: "/foo.yaml"), - contents: Data( - """ - openapi: "\(openAPIVersionString)" - info: - title: "Test" - version: "1.0.0" - paths: {} - """ - .utf8 - ) + contents: Data(yaml.utf8) ), config: .init(mode: .types), diagnostics: PrintingDiagnosticCollector() ) } + + private func assertThrownError( + _ closure: @autoclosure () throws -> ParsedOpenAPIRepresentation, + expectedDiagnostic: String, + file: StaticString = #file, + line: UInt = #line + ) { + XCTAssertThrowsError(try closure(), file: file, line: line) { error in + if let exitError = error as? Diagnostic { + XCTAssertEqual(exitError.localizedDescription, expectedDiagnostic, file: file, line: line) + } else { + XCTFail("Thrown error is \(type(of: error)) but should be Diagnostic", file: file, line: line) + } + } + } + }