diff --git a/Package.swift b/Package.swift index 9f7cb209..8256a45b 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( products: [ .executable(name: "swift-openapi-generator", targets: ["swift-openapi-generator"]), .plugin(name: "OpenAPIGenerator", targets: ["OpenAPIGenerator"]), + .plugin(name: "OpenAPIGeneratorCommand", targets: ["OpenAPIGeneratorCommand"]), .library(name: "_OpenAPIGeneratorCore", targets: ["_OpenAPIGeneratorCore"]), ], dependencies: [ @@ -151,5 +152,24 @@ let package = Package( "swift-openapi-generator" ] ), + + // Command Plugin + .plugin( + name: "OpenAPIGeneratorCommand", + capability: .command( + intent: .custom( + verb: "generate-code-from-openapi", + description: "Generate Swift code from an OpenAPI document." + ), + permissions: [ + .writeToPackageDirectory( + reason: "To write the generated Swift files back into the source directory of the package." + ) + ] + ), + dependencies: [ + "swift-openapi-generator" + ] + ), ] ) diff --git a/Plugins/OpenAPIGenerator/PluginsShared b/Plugins/OpenAPIGenerator/PluginsShared new file mode 120000 index 00000000..a8c88dc3 --- /dev/null +++ b/Plugins/OpenAPIGenerator/PluginsShared @@ -0,0 +1 @@ +../../Plugins/PluginsShared \ No newline at end of file diff --git a/Plugins/OpenAPIGenerator/plugin.swift b/Plugins/OpenAPIGenerator/plugin.swift index 33a52d41..d904d7c7 100644 --- a/Plugins/OpenAPIGenerator/plugin.swift +++ b/Plugins/OpenAPIGenerator/plugin.swift @@ -16,81 +16,30 @@ import Foundation @main struct SwiftOpenAPIGeneratorPlugin { - enum Error: Swift.Error, CustomStringConvertible, LocalizedError { - case incompatibleTarget(targetName: String) - case noConfigFound(targetName: String) - case noDocumentFound(targetName: String) - case multiConfigFound(targetName: String, files: [Path]) - case multiDocumentFound(targetName: String, files: [Path]) - - var description: String { - switch self { - case .incompatibleTarget(let targetName): - return - "Incompatible target called '\(targetName)'. Only Swift source targets can be used with the Swift OpenAPI generator plugin." - case .noConfigFound(let targetName): - return - "No config file found in the target named '\(targetName)'. Add a file called 'openapi-generator-config.yaml' or 'openapi-generator-config.yml' to the target's source directory. See documentation for details." - case .noDocumentFound(let targetName): - return - "No OpenAPI document found in the target named '\(targetName)'. Add a file called 'openapi.yaml', 'openapi.yml' or 'openapi.json' (can also be a symlink) to the target's source directory. See documentation for details." - case .multiConfigFound(let targetName, let files): - return - "Multiple config files found in the target named '\(targetName)', but exactly one is required. Found \(files.map(\.description).joined(separator: " "))." - case .multiDocumentFound(let targetName, let files): - return - "Multiple OpenAPI documents found in the target named '\(targetName)', but exactly one is required. Found \(files.map(\.description).joined(separator: " "))." - } - } - - var errorDescription: String? { - description - } - } - - private var supportedConfigFiles: Set { Set(["yaml", "yml"].map { "openapi-generator-config." + $0 }) } - private var supportedDocFiles: Set { Set(["yaml", "yml", "json"].map { "openapi." + $0 }) } - func createBuildCommands( - pluginWorkDirectory: PackagePlugin.Path, - tool: (String) throws -> PackagePlugin.PluginContext.Tool, + pluginWorkDirectory: Path, + tool: (String) throws -> PluginContext.Tool, sourceFiles: FileList, targetName: String ) throws -> [Command] { - let inputFiles = sourceFiles - let matchedConfigs = inputFiles.filter { supportedConfigFiles.contains($0.path.lastComponent) }.map(\.path) - guard matchedConfigs.count > 0 else { - throw Error.noConfigFound(targetName: targetName) - } - guard matchedConfigs.count == 1 else { - throw Error.multiConfigFound(targetName: targetName, files: matchedConfigs) - } - let config = matchedConfigs[0] + let inputs = try PluginUtils.validateInputs( + workingDirectory: pluginWorkDirectory, + tool: tool, + sourceFiles: sourceFiles, + targetName: targetName, + pluginSource: .build + ) - let matchedDocs = inputFiles.filter { supportedDocFiles.contains($0.path.lastComponent) }.map(\.path) - guard matchedDocs.count > 0 else { - throw Error.noDocumentFound(targetName: targetName) - } - guard matchedDocs.count == 1 else { - throw Error.multiDocumentFound(targetName: targetName, files: matchedDocs) - } - let doc = matchedDocs[0] - let genSourcesDir = pluginWorkDirectory.appending("GeneratedSources") - let outputFiles: [Path] = GeneratorMode.allCases.map { genSourcesDir.appending($0.outputFileName) } + let outputFiles: [Path] = GeneratorMode.allCases.map { inputs.genSourcesDir.appending($0.outputFileName) } return [ .buildCommand( displayName: "Running swift-openapi-generator", - executable: try tool("swift-openapi-generator").path, - arguments: [ - "generate", "\(doc)", - "--config", "\(config)", - "--output-directory", "\(genSourcesDir)", - "--is-plugin-invocation", - ], + executable: inputs.tool.path, + arguments: inputs.arguments, environment: [:], inputFiles: [ - config, - doc, + inputs.config, + inputs.doc, ], outputFiles: outputFiles ) @@ -104,7 +53,7 @@ extension SwiftOpenAPIGeneratorPlugin: BuildToolPlugin { target: Target ) async throws -> [Command] { guard let swiftTarget = target as? SwiftSourceModuleTarget else { - throw Error.incompatibleTarget(targetName: target.name) + throw PluginError.incompatibleTarget(name: target.name) } return try createBuildCommands( pluginWorkDirectory: context.pluginWorkDirectory, diff --git a/Plugins/OpenAPIGeneratorCommand/PluginsShared b/Plugins/OpenAPIGeneratorCommand/PluginsShared new file mode 120000 index 00000000..a8c88dc3 --- /dev/null +++ b/Plugins/OpenAPIGeneratorCommand/PluginsShared @@ -0,0 +1 @@ +../../Plugins/PluginsShared \ No newline at end of file diff --git a/Plugins/OpenAPIGeneratorCommand/plugin.swift b/Plugins/OpenAPIGeneratorCommand/plugin.swift new file mode 100644 index 00000000..dfd63452 --- /dev/null +++ b/Plugins/OpenAPIGeneratorCommand/plugin.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import PackagePlugin +import Foundation + +@main +struct SwiftOpenAPIGeneratorPlugin { + func runCommand( + targetWorkingDirectory: Path, + tool: (String) throws -> PluginContext.Tool, + sourceFiles: FileList, + targetName: String + ) throws { + let inputs = try PluginUtils.validateInputs( + workingDirectory: targetWorkingDirectory, + tool: tool, + sourceFiles: sourceFiles, + targetName: targetName, + pluginSource: .command + ) + + let toolUrl = URL(fileURLWithPath: inputs.tool.path.string) + let process = Process() + process.executableURL = toolUrl + process.arguments = inputs.arguments + process.environment = [:] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw PluginError.generatorFailure(targetName: targetName) + } + } +} + +extension SwiftOpenAPIGeneratorPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let targetNameArguments = arguments.filter({ $0 != "--target" }) + let targets: [Target] + if targetNameArguments.isEmpty { + targets = context.package.targets + } else { + let matchingTargets = try context.package.targets(named: targetNameArguments) + let packageTargets = Set(context.package.targets.map(\.id)) + let withLocalDependencies = matchingTargets.flatMap { [$0] + $0.recursiveTargetDependencies } + .filter { packageTargets.contains($0.id) } + let enumeratedKeyValues = withLocalDependencies.map(\.id).enumerated() + .map { (key: $0.element, value: $0.offset) } + let indexLookupTable = Dictionary(enumeratedKeyValues, uniquingKeysWith: { l, _ in l }) + let groupedByID = Dictionary(grouping: withLocalDependencies, by: \.id) + let sortedUniqueTargets = groupedByID.map(\.value[0]) + .sorted { indexLookupTable[$0.id, default: 0] < indexLookupTable[$1.id, default: 0] } + targets = sortedUniqueTargets + } + + guard !targets.isEmpty else { + throw PluginError.noTargetsMatchingTargetNames(targetNameArguments) + } + + var hadASuccessfulRun = false + + for target in targets { + print("Considering target '\(target.name)':") + guard let swiftTarget = target as? SwiftSourceModuleTarget else { + print("- Not a swift source module. Can't generate OpenAPI code.") + continue + } + do { + print("- Trying OpenAPI code generation.") + try runCommand( + targetWorkingDirectory: target.directory, + tool: context.tool, + sourceFiles: swiftTarget.sourceFiles, + targetName: target.name + ) + print("- ✅ OpenAPI code generation for target '\(target.name)' successfully completed.") + hadASuccessfulRun = true + } catch let error as PluginError { + if error.isMisconfigurationError { + print("- OpenAPI code generation failed with error.") + throw error + } else { + print("- Stopping because target isn't configured for OpenAPI code generation.") + } + } + } + + guard hadASuccessfulRun else { + throw PluginError.noTargetsWithExpectedFiles(targetNames: targets.map(\.name)) + } + } +} diff --git a/Plugins/OpenAPIGenerator/GeneratorMode.swift b/Plugins/PluginsShared/GeneratorMode.swift similarity index 100% rename from Plugins/OpenAPIGenerator/GeneratorMode.swift rename to Plugins/PluginsShared/GeneratorMode.swift diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift new file mode 100644 index 00000000..604e94d1 --- /dev/null +++ b/Plugins/PluginsShared/PluginError.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import PackagePlugin +import Foundation + +enum PluginError: Swift.Error, CustomStringConvertible, LocalizedError { + case incompatibleTarget(name: String) + case generatorFailure(targetName: String) + case noTargetsWithExpectedFiles(targetNames: [String]) + case noTargetsMatchingTargetNames([String]) + case fileErrors([FileError]) + + var description: String { + switch self { + case .incompatibleTarget(let name): + return + "Incompatible target called '\(name)'. Only Swift source targets can be used with the Swift OpenAPI Generator plugin." + case .generatorFailure(let targetName): + return "The generator failed to generate OpenAPI files for target '\(targetName)'." + case .noTargetsWithExpectedFiles(let targetNames): + let fileNames = FileError.Kind.allCases.map(\.name) + .joined(separator: ", ", lastSeparator: " or ") + let targetNames = targetNames.joined(separator: ", ", lastSeparator: " and ") + return + "Targets with names \(targetNames) don't contain any \(fileNames) files with expected names. See documentation for details." + case .noTargetsMatchingTargetNames(let targetNames): + let targetNames = targetNames.joined(separator: ", ", lastSeparator: " and ") + return "Found no targets with names \(targetNames)." + case .fileErrors(let fileErrors): + return "Issues with required files: \(fileErrors.map(\.description).joined(separator: ", and"))." + } + } + + var errorDescription: String? { + description + } + + /// The error is definitely due to misconfiguration of a target. + var isMisconfigurationError: Bool { + switch self { + case .incompatibleTarget: + return false + case .generatorFailure: + return false + case .noTargetsWithExpectedFiles: + return false + case .noTargetsMatchingTargetNames: + return false + case .fileErrors(let errors): + return errors.isMisconfigurationError + } + } +} + +struct FileError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The kind of the file. + enum Kind: CaseIterable { + /// Config file. + case config + /// OpenAPI document file. + case document + + var name: String { + switch self { + case .config: + return "config" + case .document: + return "OpenAPI document" + } + } + } + + /// Encountered issue. + enum Issue { + /// File wasn't found. + case noFilesFound + /// More than 1 file found. + case multipleFilesFound(files: [Path]) + + /// The error is definitely due to misconfiguration of a target. + var isMisconfigurationError: Bool { + switch self { + case .noFilesFound: + return false + case .multipleFilesFound: + return true + } + } + } + + let targetName: String + let fileKind: Kind + let issue: Issue + + var description: String { + switch fileKind { + case .config: + switch issue { + case .noFilesFound: + return + "No config file found in the target named '\(targetName)'. Add a file called 'openapi-generator-config.yaml' or 'openapi-generator-config.yml' to the target's source directory. See documentation for details." + case .multipleFilesFound(let files): + return + "Multiple config files found in the target named '\(targetName)', but exactly one is expected. Found \(files.map(\.description).joined(separator: " "))." + } + case .document: + switch issue { + case .noFilesFound: + return + "No OpenAPI document found in the target named '\(targetName)'. Add a file called 'openapi.yaml', 'openapi.yml' or 'openapi.json' (can also be a symlink) to the target's source directory. See documentation for details." + case .multipleFilesFound(let files): + return + "Multiple OpenAPI documents found in the target named '\(targetName)', but exactly one is expected. Found \(files.map(\.description).joined(separator: " "))." + } + } + } + + var errorDescription: String? { + description + } +} + +private extension Array where Element == FileError { + /// The error is definitely due to misconfiguration of a target. + var isMisconfigurationError: Bool { + // If errors for both files exist and none is a "Misconfiguration Error" then the + // error can be related to a target that isn't supposed to be generator compatible at all. + if count == FileError.Kind.allCases.count, self.allSatisfy({ !$0.issue.isMisconfigurationError }) { + return false + } + return true + } +} diff --git a/Plugins/PluginsShared/PluginSource.swift b/Plugins/PluginsShared/PluginSource.swift new file mode 120000 index 00000000..fef39cd8 --- /dev/null +++ b/Plugins/PluginsShared/PluginSource.swift @@ -0,0 +1 @@ +../../Sources/swift-openapi-generator/PluginSource.swift \ No newline at end of file diff --git a/Plugins/PluginsShared/PluginUtils.swift b/Plugins/PluginsShared/PluginUtils.swift new file mode 100644 index 00000000..a654c1db --- /dev/null +++ b/Plugins/PluginsShared/PluginUtils.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import PackagePlugin + +enum PluginUtils { + private static var supportedConfigFiles: Set { + Set(["yaml", "yml"].map { "openapi-generator-config." + $0 }) + } + private static var supportedDocFiles: Set { Set(["yaml", "yml", "json"].map { "openapi." + $0 }) } + + /// Validated values to run a plugin with. + struct ValidatedInputs { + let doc: Path + let config: Path + let genSourcesDir: Path + let arguments: [String] + let tool: PluginContext.Tool + } + + /// Validates the inputs and returns the necessary values to run a plugin. + static func validateInputs( + workingDirectory: Path, + tool: (String) throws -> PluginContext.Tool, + sourceFiles: FileList, + targetName: String, + pluginSource: PluginSource + ) throws -> ValidatedInputs { + let (config, doc) = try findFiles(inputFiles: sourceFiles, targetName: targetName) + let genSourcesDir = workingDirectory.appending("GeneratedSources") + + let arguments = [ + "generate", "\(doc)", + "--config", "\(config)", + "--output-directory", "\(genSourcesDir)", + "--plugin-source", "\(pluginSource.rawValue)", + ] + + let tool = try tool("swift-openapi-generator") + + return ValidatedInputs( + doc: doc, + config: config, + genSourcesDir: genSourcesDir, + arguments: arguments, + tool: tool + ) + } + + /// Finds the OpenAPI config and document files or throws an error including both possible + /// previous errors from the process of finding the config and document files. + private static func findFiles( + inputFiles: FileList, + targetName: String + ) throws -> (config: Path, doc: Path) { + let config = findConfig(inputFiles: inputFiles, targetName: targetName) + let doc = findDocument(inputFiles: inputFiles, targetName: targetName) + switch (config, doc) { + case (.failure(let error1), .failure(let error2)): + throw PluginError.fileErrors([error1, error2]) + case (_, .failure(let error)): + throw PluginError.fileErrors([error]) + case (.failure(let error), _): + throw PluginError.fileErrors([error]) + case (.success(let config), .success(let doc)): + return (config, doc) + } + } + + /// Find the config file. + private static func findConfig( + inputFiles: FileList, + targetName: String + ) -> Result { + let matchedConfigs = inputFiles.filter { supportedConfigFiles.contains($0.path.lastComponent) }.map(\.path) + guard matchedConfigs.count > 0 else { + return .failure( + FileError( + targetName: targetName, + fileKind: .config, + issue: .noFilesFound + ) + ) + } + guard matchedConfigs.count == 1 else { + return .failure( + FileError( + targetName: targetName, + fileKind: .config, + issue: .multipleFilesFound(files: matchedConfigs) + ) + ) + } + return .success(matchedConfigs[0]) + } + + /// Find the document file. + private static func findDocument( + inputFiles: FileList, + targetName: String + ) -> Result { + let matchedDocs = inputFiles.filter { supportedDocFiles.contains($0.path.lastComponent) }.map(\.path) + guard matchedDocs.count > 0 else { + return .failure( + FileError( + targetName: targetName, + fileKind: .document, + issue: .noFilesFound + ) + ) + } + guard matchedDocs.count == 1 else { + return .failure( + FileError( + targetName: targetName, + fileKind: .document, + issue: .multipleFilesFound(files: matchedDocs) + ) + ) + } + return .success(matchedDocs[0]) + } +} + +extension Array where Element == String { + func joined(separator: String, lastSeparator: String) -> String { + guard count > 1 else { + return self.joined(separator: separator) + } + return "\(self.dropLast().joined(separator: separator))\(lastSeparator)\(self.last!)" + } +} diff --git a/Sources/swift-openapi-generator/GenerateCommand.swift b/Sources/swift-openapi-generator/GenerateCommand.swift index fc1ac08f..06e90fab 100644 --- a/Sources/swift-openapi-generator/GenerateCommand.swift +++ b/Sources/swift-openapi-generator/GenerateCommand.swift @@ -42,16 +42,19 @@ struct _GenerateCommand: AsyncParsableCommand { ) var outputDirectory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - @Flag( + @Option( help: - "Whether this invocation is from the SwiftPM plugin. We always need to produce all files when invoked from the plugin. Non-requested modes produce empty files." + "Source of invocation if by a plugin. The generator needs to produce all files when invoked as a build plugin, so non-requested modes produce empty files." ) - var isPluginInvocation: Bool = false + var pluginSource: PluginSource? func run() async throws { try generate.runGenerator( outputDirectory: outputDirectory, - isPluginInvocation: isPluginInvocation + pluginSource: pluginSource ) } } + +// MARK: - InvocationSource + ExpressibleByArgument +extension PluginSource: ExpressibleByArgument {} diff --git a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift index 01314929..5c272af0 100644 --- a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift +++ b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift @@ -21,15 +21,10 @@ extension _GenerateOptions { /// - Parameters: /// - outputDirectory: The directory path to which the generator writes /// the generated Swift files. - /// - isPluginInvocation: A Boolean value that indicates whether this - /// generator invocation is coming from a SwiftPM plugin, as that forces - /// the generator to emit all 3 files (Types.swift, Client.Swift, and - /// Server.swift) regardless of which generator mode was requested, with - /// the caveat that the not requested files are empty. This is due to - /// a limitation of the build system used by SwiftPM under the hood. + /// - pluginSource: The source of the generator invocation if from a plugin. func runGenerator( outputDirectory: URL, - isPluginInvocation: Bool + pluginSource: PluginSource? ) throws { let config = try loadedConfig() let sortedModes = try resolvedModes(config) @@ -62,7 +57,7 @@ extension _GenerateOptions { - Output directory: \(outputDirectory.path) - Diagnostics output path: \(diagnosticsOutputPath?.path ?? "") - Current directory: \(FileManager.default.currentDirectoryPath) - - Is plugin invocation: \(isPluginInvocation) + - Plugin source: \(pluginSource?.rawValue ?? "") - Additional imports: \(resolvedAdditionalImports.isEmpty ? "" : resolvedAdditionalImports.joined(separator: ", ")) """ ) @@ -70,7 +65,7 @@ extension _GenerateOptions { try _Tool.runGenerator( doc: doc, configs: configs, - isPluginInvocation: isPluginInvocation, + pluginSource: pluginSource, outputDirectory: outputDirectory, diagnostics: diagnostics ) diff --git a/Sources/swift-openapi-generator/PluginSource.swift b/Sources/swift-openapi-generator/PluginSource.swift new file mode 100644 index 00000000..cf57d914 --- /dev/null +++ b/Sources/swift-openapi-generator/PluginSource.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// The source of a plugin generator invocation. +enum PluginSource: String, Codable { + /// BuildTool plugin + case build + /// Command plugin. + case command +} diff --git a/Sources/swift-openapi-generator/runGenerator.swift b/Sources/swift-openapi-generator/runGenerator.swift index 7fc2f56a..53c58c89 100644 --- a/Sources/swift-openapi-generator/runGenerator.swift +++ b/Sources/swift-openapi-generator/runGenerator.swift @@ -21,15 +21,14 @@ extension _Tool { /// - Parameters: /// - doc: A path to the OpenAPI document. /// - configs: A list of generator configurations. - /// - isPluginInvocation: A Boolean value that indicates whether this - /// generator invocation is coming from a SwiftPM plugin. + /// - pluginSource: The source of the generator invocation. /// - outputDirectory: The directory to which the generator writes /// the generated Swift files. /// - diagnostics: A collector for diagnostics emitted by the generator. static func runGenerator( doc: URL, configs: [Config], - isPluginInvocation: Bool, + pluginSource: PluginSource?, outputDirectory: URL, diagnostics: any DiagnosticCollector ) throws { @@ -39,23 +38,29 @@ extension _Tool { } catch { throw ValidationError("Failed to load the OpenAPI document at path \(doc.path), error: \(error)") } - let filePathForMode: (GeneratorMode) -> URL = { mode in - outputDirectory.appendingPathComponent(mode.outputFileName) - } for config in configs { try runGenerator( doc: doc, docData: docData, config: config, - outputFilePath: filePathForMode(config.mode), + outputDirectory: outputDirectory, + outputFileName: config.mode.outputFileName, diagnostics: diagnostics ) } - if isPluginInvocation { + + // If from a BuildTool plugin, the generator will have to emit all 3 files + // (Types.swift, Client.Swift, and Server.swift) regardless of which generator + // mode was requested, with the caveat that the not-requested files are empty. + // This is due to a limitation of the build system used by SwiftPM under the hood. + if pluginSource == .build { let nonGeneratedModes = Set(GeneratorMode.allCases).subtracting(configs.map(\.mode)) for mode in nonGeneratedModes.sorted() { - let path = filePathForMode(mode) - try replaceFileContents(at: path, with: { Data() }) + try replaceFileContents( + inDirectory: outputDirectory, + fileName: mode.outputFileName, + with: { Data() } + ) } } } @@ -65,17 +70,23 @@ extension _Tool { /// - doc: A path to the OpenAPI document. /// - docData: The raw contents of the OpenAPI document. /// - config: A set of configuration values for the generator. - /// - outputFilePath: The directory to which the generator writes - /// the generated Swift files. + /// - outputDirectory: The directory to which the generator writes + /// the generated Swift file. + /// - outputFileName: The file name to which the generator writes + /// the generated Swift file. /// - diagnostics: A collector for diagnostics emitted by the generator. static func runGenerator( doc: URL, docData: Data, config: Config, - outputFilePath: URL, + outputDirectory: URL, + outputFileName: String, diagnostics: any DiagnosticCollector ) throws { - let didChange = try replaceFileContents(at: outputFilePath) { + let didChange = try replaceFileContents( + inDirectory: outputDirectory, + fileName: outputFileName + ) { let output = try _OpenAPIGeneratorCore.runGenerator( input: .init(absolutePath: doc, contents: docData), config: config, @@ -83,7 +94,7 @@ extension _Tool { ) return output.contents } - print("File \(outputFilePath.lastPathComponent): \(didChange ? "changed" : "unchanged")") + print("File \(outputFileName): \(didChange ? "changed" : "unchanged")") } /// Evaluates a closure to generate file data and writes the data to disk @@ -94,18 +105,31 @@ extension _Tool { /// - Throws: When writing to disk fails. /// - Returns: `true` if the generated contents changed, otherwise `false`. @discardableResult - static func replaceFileContents(at path: URL, with contents: () throws -> Data) throws -> Bool { + static func replaceFileContents( + inDirectory outputDirectory: URL, + fileName: String, + with contents: () throws -> Data + ) throws -> Bool { + let fileManager = FileManager.default + + // Create directory if it doesn't exist. + if !fileManager.fileExists(atPath: outputDirectory.path) { + try fileManager.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + } + + let path = outputDirectory.appendingPathComponent(fileName) let data = try contents() - let didChange: Bool - if FileManager.default.fileExists(atPath: path.path) { - let existingData = try? Data(contentsOf: path) - didChange = existingData != data - } else { - didChange = true + guard fileManager.fileExists(atPath: path.path) else { + return fileManager.createFile(atPath: path.path, contents: data) } - if didChange { + let existingData = try? Data(contentsOf: path) + guard existingData == data else { try data.write(to: path) + return true } - return didChange + return false } } diff --git a/scripts/check-license-headers.sh b/scripts/check-license-headers.sh index 06932197..d0e0c398 100644 --- a/scripts/check-license-headers.sh +++ b/scripts/check-license-headers.sh @@ -66,6 +66,8 @@ read -ra PATHS_TO_CHECK_FOR_LICENSE <<< "$( \ ":(exclude)**/petstore.yaml" \ ":(exclude)**/openapi-generator-config.yaml" \ ":(exclude)**/openapi-generator-config.yml" \ + ":(exclude)Plugins/OpenAPIGenerator/PluginsShared" \ + ":(exclude)Plugins/OpenAPIGeneratorCommand/PluginsShared" \ | xargs -0 \ )"