Skip to content

Commit 4198dc6

Browse files
MahdiBMczechboy0
andauthored
Command Plugin for generating the source code (#98)
### 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 <czechboy0@gmail.com> Co-authored-by: Honza Dvorsky <honza@apple.com>
1 parent b6d82cd commit 4198dc6

14 files changed

+512
-103
lines changed

Package.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ let package = Package(
3838
products: [
3939
.executable(name: "swift-openapi-generator", targets: ["swift-openapi-generator"]),
4040
.plugin(name: "OpenAPIGenerator", targets: ["OpenAPIGenerator"]),
41+
.plugin(name: "OpenAPIGeneratorCommand", targets: ["OpenAPIGeneratorCommand"]),
4142
.library(name: "_OpenAPIGeneratorCore", targets: ["_OpenAPIGeneratorCore"]),
4243
],
4344
dependencies: [
@@ -151,5 +152,24 @@ let package = Package(
151152
"swift-openapi-generator"
152153
]
153154
),
155+
156+
// Command Plugin
157+
.plugin(
158+
name: "OpenAPIGeneratorCommand",
159+
capability: .command(
160+
intent: .custom(
161+
verb: "generate-code-from-openapi",
162+
description: "Generate Swift code from an OpenAPI document."
163+
),
164+
permissions: [
165+
.writeToPackageDirectory(
166+
reason: "To write the generated Swift files back into the source directory of the package."
167+
)
168+
]
169+
),
170+
dependencies: [
171+
"swift-openapi-generator"
172+
]
173+
),
154174
]
155175
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Plugins/PluginsShared

Plugins/OpenAPIGenerator/plugin.swift

Lines changed: 15 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,81 +16,30 @@ import Foundation
1616

1717
@main
1818
struct SwiftOpenAPIGeneratorPlugin {
19-
enum Error: Swift.Error, CustomStringConvertible, LocalizedError {
20-
case incompatibleTarget(targetName: String)
21-
case noConfigFound(targetName: String)
22-
case noDocumentFound(targetName: String)
23-
case multiConfigFound(targetName: String, files: [Path])
24-
case multiDocumentFound(targetName: String, files: [Path])
25-
26-
var description: String {
27-
switch self {
28-
case .incompatibleTarget(let targetName):
29-
return
30-
"Incompatible target called '\(targetName)'. Only Swift source targets can be used with the Swift OpenAPI generator plugin."
31-
case .noConfigFound(let targetName):
32-
return
33-
"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."
34-
case .noDocumentFound(let targetName):
35-
return
36-
"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."
37-
case .multiConfigFound(let targetName, let files):
38-
return
39-
"Multiple config files found in the target named '\(targetName)', but exactly one is required. Found \(files.map(\.description).joined(separator: " "))."
40-
case .multiDocumentFound(let targetName, let files):
41-
return
42-
"Multiple OpenAPI documents found in the target named '\(targetName)', but exactly one is required. Found \(files.map(\.description).joined(separator: " "))."
43-
}
44-
}
45-
46-
var errorDescription: String? {
47-
description
48-
}
49-
}
50-
51-
private var supportedConfigFiles: Set<String> { Set(["yaml", "yml"].map { "openapi-generator-config." + $0 }) }
52-
private var supportedDocFiles: Set<String> { Set(["yaml", "yml", "json"].map { "openapi." + $0 }) }
53-
5419
func createBuildCommands(
55-
pluginWorkDirectory: PackagePlugin.Path,
56-
tool: (String) throws -> PackagePlugin.PluginContext.Tool,
20+
pluginWorkDirectory: Path,
21+
tool: (String) throws -> PluginContext.Tool,
5722
sourceFiles: FileList,
5823
targetName: String
5924
) throws -> [Command] {
60-
let inputFiles = sourceFiles
61-
let matchedConfigs = inputFiles.filter { supportedConfigFiles.contains($0.path.lastComponent) }.map(\.path)
62-
guard matchedConfigs.count > 0 else {
63-
throw Error.noConfigFound(targetName: targetName)
64-
}
65-
guard matchedConfigs.count == 1 else {
66-
throw Error.multiConfigFound(targetName: targetName, files: matchedConfigs)
67-
}
68-
let config = matchedConfigs[0]
25+
let inputs = try PluginUtils.validateInputs(
26+
workingDirectory: pluginWorkDirectory,
27+
tool: tool,
28+
sourceFiles: sourceFiles,
29+
targetName: targetName,
30+
pluginSource: .build
31+
)
6932

70-
let matchedDocs = inputFiles.filter { supportedDocFiles.contains($0.path.lastComponent) }.map(\.path)
71-
guard matchedDocs.count > 0 else {
72-
throw Error.noDocumentFound(targetName: targetName)
73-
}
74-
guard matchedDocs.count == 1 else {
75-
throw Error.multiDocumentFound(targetName: targetName, files: matchedDocs)
76-
}
77-
let doc = matchedDocs[0]
78-
let genSourcesDir = pluginWorkDirectory.appending("GeneratedSources")
79-
let outputFiles: [Path] = GeneratorMode.allCases.map { genSourcesDir.appending($0.outputFileName) }
33+
let outputFiles: [Path] = GeneratorMode.allCases.map { inputs.genSourcesDir.appending($0.outputFileName) }
8034
return [
8135
.buildCommand(
8236
displayName: "Running swift-openapi-generator",
83-
executable: try tool("swift-openapi-generator").path,
84-
arguments: [
85-
"generate", "\(doc)",
86-
"--config", "\(config)",
87-
"--output-directory", "\(genSourcesDir)",
88-
"--is-plugin-invocation",
89-
],
37+
executable: inputs.tool.path,
38+
arguments: inputs.arguments,
9039
environment: [:],
9140
inputFiles: [
92-
config,
93-
doc,
41+
inputs.config,
42+
inputs.doc,
9443
],
9544
outputFiles: outputFiles
9645
)
@@ -104,7 +53,7 @@ extension SwiftOpenAPIGeneratorPlugin: BuildToolPlugin {
10453
target: Target
10554
) async throws -> [Command] {
10655
guard let swiftTarget = target as? SwiftSourceModuleTarget else {
107-
throw Error.incompatibleTarget(targetName: target.name)
56+
throw PluginError.incompatibleTarget(name: target.name)
10857
}
10958
return try createBuildCommands(
11059
pluginWorkDirectory: context.pluginWorkDirectory,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Plugins/PluginsShared
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import PackagePlugin
15+
import Foundation
16+
17+
@main
18+
struct SwiftOpenAPIGeneratorPlugin {
19+
func runCommand(
20+
targetWorkingDirectory: Path,
21+
tool: (String) throws -> PluginContext.Tool,
22+
sourceFiles: FileList,
23+
targetName: String
24+
) throws {
25+
let inputs = try PluginUtils.validateInputs(
26+
workingDirectory: targetWorkingDirectory,
27+
tool: tool,
28+
sourceFiles: sourceFiles,
29+
targetName: targetName,
30+
pluginSource: .command
31+
)
32+
33+
let toolUrl = URL(fileURLWithPath: inputs.tool.path.string)
34+
let process = Process()
35+
process.executableURL = toolUrl
36+
process.arguments = inputs.arguments
37+
process.environment = [:]
38+
try process.run()
39+
process.waitUntilExit()
40+
guard process.terminationStatus == 0 else {
41+
throw PluginError.generatorFailure(targetName: targetName)
42+
}
43+
}
44+
}
45+
46+
extension SwiftOpenAPIGeneratorPlugin: CommandPlugin {
47+
func performCommand(
48+
context: PluginContext,
49+
arguments: [String]
50+
) async throws {
51+
let targetNameArguments = arguments.filter({ $0 != "--target" })
52+
let targets: [Target]
53+
if targetNameArguments.isEmpty {
54+
targets = context.package.targets
55+
} else {
56+
let matchingTargets = try context.package.targets(named: targetNameArguments)
57+
let packageTargets = Set(context.package.targets.map(\.id))
58+
let withLocalDependencies = matchingTargets.flatMap { [$0] + $0.recursiveTargetDependencies }
59+
.filter { packageTargets.contains($0.id) }
60+
let enumeratedKeyValues = withLocalDependencies.map(\.id).enumerated()
61+
.map { (key: $0.element, value: $0.offset) }
62+
let indexLookupTable = Dictionary(enumeratedKeyValues, uniquingKeysWith: { l, _ in l })
63+
let groupedByID = Dictionary(grouping: withLocalDependencies, by: \.id)
64+
let sortedUniqueTargets = groupedByID.map(\.value[0])
65+
.sorted { indexLookupTable[$0.id, default: 0] < indexLookupTable[$1.id, default: 0] }
66+
targets = sortedUniqueTargets
67+
}
68+
69+
guard !targets.isEmpty else {
70+
throw PluginError.noTargetsMatchingTargetNames(targetNameArguments)
71+
}
72+
73+
var hadASuccessfulRun = false
74+
75+
for target in targets {
76+
print("Considering target '\(target.name)':")
77+
guard let swiftTarget = target as? SwiftSourceModuleTarget else {
78+
print("- Not a swift source module. Can't generate OpenAPI code.")
79+
continue
80+
}
81+
do {
82+
print("- Trying OpenAPI code generation.")
83+
try runCommand(
84+
targetWorkingDirectory: target.directory,
85+
tool: context.tool,
86+
sourceFiles: swiftTarget.sourceFiles,
87+
targetName: target.name
88+
)
89+
print("- ✅ OpenAPI code generation for target '\(target.name)' successfully completed.")
90+
hadASuccessfulRun = true
91+
} catch let error as PluginError {
92+
if error.isMisconfigurationError {
93+
print("- OpenAPI code generation failed with error.")
94+
throw error
95+
} else {
96+
print("- Stopping because target isn't configured for OpenAPI code generation.")
97+
}
98+
}
99+
}
100+
101+
guard hadASuccessfulRun else {
102+
throw PluginError.noTargetsWithExpectedFiles(targetNames: targets.map(\.name))
103+
}
104+
}
105+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import PackagePlugin
15+
import Foundation
16+
17+
enum PluginError: Swift.Error, CustomStringConvertible, LocalizedError {
18+
case incompatibleTarget(name: String)
19+
case generatorFailure(targetName: String)
20+
case noTargetsWithExpectedFiles(targetNames: [String])
21+
case noTargetsMatchingTargetNames([String])
22+
case fileErrors([FileError])
23+
24+
var description: String {
25+
switch self {
26+
case .incompatibleTarget(let name):
27+
return
28+
"Incompatible target called '\(name)'. Only Swift source targets can be used with the Swift OpenAPI Generator plugin."
29+
case .generatorFailure(let targetName):
30+
return "The generator failed to generate OpenAPI files for target '\(targetName)'."
31+
case .noTargetsWithExpectedFiles(let targetNames):
32+
let fileNames = FileError.Kind.allCases.map(\.name)
33+
.joined(separator: ", ", lastSeparator: " or ")
34+
let targetNames = targetNames.joined(separator: ", ", lastSeparator: " and ")
35+
return
36+
"Targets with names \(targetNames) don't contain any \(fileNames) files with expected names. See documentation for details."
37+
case .noTargetsMatchingTargetNames(let targetNames):
38+
let targetNames = targetNames.joined(separator: ", ", lastSeparator: " and ")
39+
return "Found no targets with names \(targetNames)."
40+
case .fileErrors(let fileErrors):
41+
return "Issues with required files: \(fileErrors.map(\.description).joined(separator: ", and"))."
42+
}
43+
}
44+
45+
var errorDescription: String? {
46+
description
47+
}
48+
49+
/// The error is definitely due to misconfiguration of a target.
50+
var isMisconfigurationError: Bool {
51+
switch self {
52+
case .incompatibleTarget:
53+
return false
54+
case .generatorFailure:
55+
return false
56+
case .noTargetsWithExpectedFiles:
57+
return false
58+
case .noTargetsMatchingTargetNames:
59+
return false
60+
case .fileErrors(let errors):
61+
return errors.isMisconfigurationError
62+
}
63+
}
64+
}
65+
66+
struct FileError: Swift.Error, CustomStringConvertible, LocalizedError {
67+
68+
/// The kind of the file.
69+
enum Kind: CaseIterable {
70+
/// Config file.
71+
case config
72+
/// OpenAPI document file.
73+
case document
74+
75+
var name: String {
76+
switch self {
77+
case .config:
78+
return "config"
79+
case .document:
80+
return "OpenAPI document"
81+
}
82+
}
83+
}
84+
85+
/// Encountered issue.
86+
enum Issue {
87+
/// File wasn't found.
88+
case noFilesFound
89+
/// More than 1 file found.
90+
case multipleFilesFound(files: [Path])
91+
92+
/// The error is definitely due to misconfiguration of a target.
93+
var isMisconfigurationError: Bool {
94+
switch self {
95+
case .noFilesFound:
96+
return false
97+
case .multipleFilesFound:
98+
return true
99+
}
100+
}
101+
}
102+
103+
let targetName: String
104+
let fileKind: Kind
105+
let issue: Issue
106+
107+
var description: String {
108+
switch fileKind {
109+
case .config:
110+
switch issue {
111+
case .noFilesFound:
112+
return
113+
"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."
114+
case .multipleFilesFound(let files):
115+
return
116+
"Multiple config files found in the target named '\(targetName)', but exactly one is expected. Found \(files.map(\.description).joined(separator: " "))."
117+
}
118+
case .document:
119+
switch issue {
120+
case .noFilesFound:
121+
return
122+
"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."
123+
case .multipleFilesFound(let files):
124+
return
125+
"Multiple OpenAPI documents found in the target named '\(targetName)', but exactly one is expected. Found \(files.map(\.description).joined(separator: " "))."
126+
}
127+
}
128+
}
129+
130+
var errorDescription: String? {
131+
description
132+
}
133+
}
134+
135+
private extension Array where Element == FileError {
136+
/// The error is definitely due to misconfiguration of a target.
137+
var isMisconfigurationError: Bool {
138+
// If errors for both files exist and none is a "Misconfiguration Error" then the
139+
// error can be related to a target that isn't supposed to be generator compatible at all.
140+
if count == FileError.Kind.allCases.count, self.allSatisfy({ !$0.issue.isMisconfigurationError }) {
141+
return false
142+
}
143+
return true
144+
}
145+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Sources/swift-openapi-generator/PluginSource.swift

0 commit comments

Comments
 (0)