Skip to content

Commit

Permalink
Command Plugin for generating the source code (#98)
Browse files Browse the repository at this point in the history
### Motivation

This PR adds the option to use the package as a Command plugin instead
of a BuildTool plugin.

This benefits those who use heavy OpenAPI documents, and prefer not to
have to wait for an extra round of OpenAPI code generation which can be
accidentally triggered at times, for example if you clean your build
folder.

The whole idea of creating this Command plugin came after @czechboy0 's
comment here:
#96 (comment)

### Modifications

Generally, add a Command plugin target to the package, plus modifying
the functions etc... to match/allow this addition.

### Result

There is a new Command plugin, and users can choose between the Command
plugin and the BuildTool plugin at will.

### Test Plan

As visible in the PR discussions below, we've done enough manual-testing
of the Command plugin.

---------

Co-authored-by: Honza Dvorsky <[email protected]>
Co-authored-by: Honza Dvorsky <[email protected]>
  • Loading branch information
3 people authored Jul 21, 2023
1 parent b6d82cd commit 4198dc6
Show file tree
Hide file tree
Showing 14 changed files with 512 additions and 103 deletions.
20 changes: 20 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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"
]
),
]
)
1 change: 1 addition & 0 deletions Plugins/OpenAPIGenerator/PluginsShared
81 changes: 15 additions & 66 deletions Plugins/OpenAPIGenerator/plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> { Set(["yaml", "yml"].map { "openapi-generator-config." + $0 }) }
private var supportedDocFiles: Set<String> { 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
)
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Plugins/OpenAPIGeneratorCommand/PluginsShared
105 changes: 105 additions & 0 deletions Plugins/OpenAPIGeneratorCommand/plugin.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
File renamed without changes.
145 changes: 145 additions & 0 deletions Plugins/PluginsShared/PluginError.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions Plugins/PluginsShared/PluginSource.swift
Loading

0 comments on commit 4198dc6

Please sign in to comment.