diff --git a/Sources/_OpenAPIGeneratorCore/Config.swift b/Sources/_OpenAPIGeneratorCore/Config.swift index 74bb6f13e..cfd6a7346 100644 --- a/Sources/_OpenAPIGeneratorCore/Config.swift +++ b/Sources/_OpenAPIGeneratorCore/Config.swift @@ -68,6 +68,9 @@ public struct Config: Sendable { /// Additional pre-release features to enable. public var featureFlags: FeatureFlags + /// Options that affect generated output files. + public var output: OutputOptions + /// Creates a configuration with the specified generator mode and imports. /// - Parameters: /// - mode: The mode to use for generation. @@ -81,6 +84,7 @@ public struct Config: Sendable { /// of the naming strategy. /// - typeOverrides: A map of OpenAPI schema names to desired custom type names. /// - featureFlags: Additional pre-release features to enable. + /// - output: Options that affect generated output files. public init( mode: GeneratorMode, access: AccessModifier, @@ -90,7 +94,8 @@ public struct Config: Sendable { namingStrategy: NamingStrategy, nameOverrides: [String: String] = [:], typeOverrides: TypeOverrides = .init(), - featureFlags: FeatureFlags = [] + featureFlags: FeatureFlags = [], + output: OutputOptions = .init() ) { self.mode = mode self.access = access @@ -101,5 +106,6 @@ public struct Config: Sendable { self.nameOverrides = nameOverrides self.typeOverrides = typeOverrides self.featureFlags = featureFlags + self.output = output } } diff --git a/Sources/_OpenAPIGeneratorCore/GeneratorMode.swift b/Sources/_OpenAPIGeneratorCore/GeneratorMode.swift index a0b8ebdaa..e10631312 100644 --- a/Sources/_OpenAPIGeneratorCore/GeneratorMode.swift +++ b/Sources/_OpenAPIGeneratorCore/GeneratorMode.swift @@ -39,15 +39,23 @@ extension GeneratorMode { /// The Swift file name including its file extension. public var outputFileName: String { switch self { - case .types: return "Types.swift" - case .client: return "Client.swift" - case .server: return "Server.swift" + case .types: return Self.outputFileName("Types") + case .client: return Self.outputFileName("Client") + case .server: return Self.outputFileName("Server") } } /// The Swift file names for all supported generator mode values. public static var allOutputFileNames: [String] { GeneratorMode.allCases.map(\.outputFileName) } + /// Returns a Swift output file name composed from the base name and type-name suffixes. + static func outputFileName(_ baseName: String, _ suffixes: String...) -> String { + let names = ([baseName] + suffixes).map { name in + name.hasSuffix(".swift") ? String(name.dropLast(".swift".count)) : name + } + return names.joined(separator: "+") + ".swift" + } + /// Defines an order in which generators should be run. var order: Int { switch self { diff --git a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift index 8f4a8cf13..bd573cb4b 100644 --- a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift +++ b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift @@ -18,8 +18,8 @@ import Yams /// A sequence of steps that combined represents the full end-to-end /// functionality of the generator. /// -/// The input is an in-memory OpenAPI document, and the output is an -/// in-memory generated Swift file. Which file is generated (types, client, +/// The input is an in-memory OpenAPI document, and the output is one or more +/// in-memory generated Swift files. Which files are generated (types, client, /// or server) is controlled by the generator configuration, in the translation /// stage. /// @@ -30,8 +30,8 @@ import Yams /// document into a structure Swift representation of the requested generated /// file, for one of: types, client, or server. This stage contains most of the /// OpenAPI to Swift generation code. -/// 3. Rendering: TranslatedOutput -> RenderedOutput, which converts a -/// structured Swift representation into a raw Swift file. +/// 3. Rendering: TranslatedOutput -> RenderedOutput, which converts each +/// structured Swift file into a raw Swift file. struct GeneratorPipeline { // Note: Until we have variadic generics we need to have a fixed number @@ -80,10 +80,14 @@ struct GeneratorPipeline { /// - diagnostics: A collector to which the generator emits diagnostics. /// - Throws: When encountering a non-recoverable error. For recoverable /// issues, emits issues into the diagnostics collector. -/// - Returns: The raw contents of the generated Swift file. -public func runGenerator(input: InMemoryInputFile, config: Config, diagnostics: any DiagnosticCollector) throws - -> InMemoryOutputFile -{ try makeGeneratorPipeline(config: config, diagnostics: diagnostics).run(input) } +/// - Returns: The raw contents of all generated Swift files. +public func runGenerator( + input: InMemoryInputFile, + config: Config, + diagnostics: any DiagnosticCollector +) throws -> [InMemoryOutputFile] { + try makeGeneratorPipeline(config: config, diagnostics: diagnostics).run(input) +} /// Creates a new pipeline instance. /// - Parameters: @@ -99,7 +103,7 @@ func makeGeneratorPipeline( parser: any ParserProtocol = YamsParser(), validator: @escaping (ParsedOpenAPIRepresentation, Config) throws -> [Diagnostic] = validateDoc, translator: any TranslatorProtocol = MultiplexTranslator(), - renderer: any RendererProtocol = TextBasedRenderer.default, + renderer: @escaping () -> any RendererProtocol = { TextBasedRenderer.default }, config: Config, diagnostics: any DiagnosticCollector ) -> GeneratorPipeline { @@ -128,7 +132,11 @@ func makeGeneratorPipeline( ), renderSwiftFilesStage: .init( preTransitionHooks: [], - transition: { input in try renderer.render(structured: input, config: config, diagnostics: diagnostics) }, + transition: { input in + try input.files.map { namedFile in + try renderer().render(namedFile: namedFile, config: config, diagnostics: diagnostics) + } + }, postTransitionHooks: [] ) ) diff --git a/Sources/_OpenAPIGeneratorCore/Layers/RenderedSwiftRepresentation.swift b/Sources/_OpenAPIGeneratorCore/Layers/RenderedSwiftRepresentation.swift index 872c044d7..8b1556d67 100644 --- a/Sources/_OpenAPIGeneratorCore/Layers/RenderedSwiftRepresentation.swift +++ b/Sources/_OpenAPIGeneratorCore/Layers/RenderedSwiftRepresentation.swift @@ -19,8 +19,8 @@ public import struct Foundation.URL public import struct Foundation.Data #endif -/// An in-memory file that contains the generated Swift code. -typealias RenderedSwiftRepresentation = InMemoryOutputFile +/// In-memory output files emitted by rendering a generator pipeline run. +typealias RenderedSwiftRepresentation = [InMemoryOutputFile] /// An in-memory input file that contains the raw data of an OpenAPI document. /// diff --git a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift index b14212761..020d1991c 100644 --- a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift +++ b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift @@ -1087,8 +1087,8 @@ struct NamedFileDescription: Equatable, Codable { /// A file with contents made up of structured Swift code. struct StructuredSwiftRepresentation: Equatable, Codable { - /// The contents of the file. - var file: NamedFileDescription + /// The contents of the files. + var files: [NamedFileDescription] } // MARK: - Conveniences diff --git a/Sources/_OpenAPIGeneratorCore/OutputOptions.swift b/Sources/_OpenAPIGeneratorCore/OutputOptions.swift new file mode 100644 index 000000000..a1f020f00 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/OutputOptions.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Configuration for generated output files. +public struct OutputOptions: Sendable, Codable, Equatable { + + /// Options that only affect `Types.swift` generation. + public var types: TypesOutputOptions? + + /// Creates output options. + /// - Parameter types: Options that only affect `Types.swift` generation. + public init(types: TypesOutputOptions? = nil) { self.types = types } +} + +/// Configuration for generated types output files. +public struct TypesOutputOptions: Sendable, Codable, Equatable { + + /// Optional configuration for splitting generated types across files. + public var fileSplitting: TypesFileSplittingConfig? + + /// Creates types output options. + /// - Parameter fileSplitting: Optional configuration for splitting generated types across files. + public init(fileSplitting: TypesFileSplittingConfig? = nil) { self.fileSplitting = fileSplitting } +} + +/// Configuration for splitting generated types across files. +public struct TypesFileSplittingConfig: Sendable, Codable, Equatable { + + /// The strategy to use when splitting generated types across files. + public var strategy: TypesFileSplittingStrategy + + /// Creates a file splitting configuration. + /// - Parameter strategy: The strategy to use when splitting generated types across files. + public init(strategy: TypesFileSplittingStrategy) { + self.strategy = strategy + } +} + +/// A strategy for splitting generated types across files. +public enum TypesFileSplittingStrategy: String, Sendable, Codable, Equatable, CaseIterable { + + /// Splits generated types into a small fixed set of files by top-level namespace. + case namespace +} + +extension TypesFileSplittingConfig { + + /// Returns the Swift output file names emitted by the configuration. + /// - Parameter primaryTypesFileName: The file name for the primary generated types file. + /// - Returns: The emitted Swift output file names. + public func outputFileNames(primaryTypesFileName: String) -> [String] { + switch strategy { + case .namespace: + return [ + primaryTypesFileName, + GeneratorMode.outputFileName(primaryTypesFileName, "Components"), + GeneratorMode.outputFileName(primaryTypesFileName, "Operations"), + ] + } + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Renderer/RendererProtocol.swift b/Sources/_OpenAPIGeneratorCore/Renderer/RendererProtocol.swift index c40ef2824..8ace1a36c 100644 --- a/Sources/_OpenAPIGeneratorCore/Renderer/RendererProtocol.swift +++ b/Sources/_OpenAPIGeneratorCore/Renderer/RendererProtocol.swift @@ -18,13 +18,13 @@ /// Rendering is the last phase of the generator pipeline. protocol RendererProtocol { - /// Renders the specified structured code into a raw Swift file. + /// Renders the specified structured Swift file into a raw Swift file. /// - Parameters: - /// - code: A structured representation of the Swift code. + /// - namedFile: A structured representation of the Swift file. /// - config: The configuration of the generator. /// - diagnostics: The collector to which to emit diagnostics. /// - Returns: A raw file with Swift contents. /// - Throws: An error if an issue occurs during rendering. - func render(structured code: StructuredSwiftRepresentation, config: Config, diagnostics: any DiagnosticCollector) + func render(namedFile: NamedFileDescription, config: Config, diagnostics: any DiagnosticCollector) throws -> InMemoryOutputFile } diff --git a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift index 1fdb035fd..518c51d28 100644 --- a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift +++ b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift @@ -89,10 +89,9 @@ final class StringCodeWriter { /// to convert the provided structure code into raw string form. struct TextBasedRenderer: RendererProtocol { - func render(structured: StructuredSwiftRepresentation, config: Config, diagnostics: any DiagnosticCollector) throws - -> InMemoryOutputFile + func render(namedFile: NamedFileDescription, config: Config, diagnostics: any DiagnosticCollector) + throws -> InMemoryOutputFile { - let namedFile = structured.file renderFile(namedFile.contents) let string = writer.rendered() return InMemoryOutputFile(baseName: namedFile.name, contents: Data(string.utf8)) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/ClientTranslator.swift b/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/ClientTranslator.swift index 58798e2ee..91964de90 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/ClientTranslator.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/ClientTranslator.swift @@ -117,10 +117,12 @@ struct ClientFileTranslator: FileTranslator { ) return StructuredSwiftRepresentation( - file: .init( - name: GeneratorMode.client.outputFileName, - contents: .init(topComment: topComment, imports: imports, codeBlocks: [.declaration(clientStructDecl)]) - ) + files: [ + .init( + name: GeneratorMode.client.outputFileName, + contents: .init(topComment: topComment, imports: imports, codeBlocks: [.declaration(clientStructDecl)]) + ) + ] ) } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/ServerTranslator.swift b/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/ServerTranslator.swift index 427b25bbe..5890363bf 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/ServerTranslator.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/ServerTranslator.swift @@ -54,14 +54,16 @@ struct ServerFileTranslator: FileTranslator { ) return StructuredSwiftRepresentation( - file: .init( - name: GeneratorMode.server.outputFileName, - contents: .init( - topComment: topComment, - imports: imports, - codeBlocks: [.declaration(protocolExtensionDecl), .declaration(serverExtensionDecl)] + files: [ + .init( + name: GeneratorMode.server.outputFileName, + contents: .init( + topComment: topComment, + imports: imports, + codeBlocks: [.declaration(protocolExtensionDecl), .declaration(serverExtensionDecl)] + ) ) - ) + ] ) } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift index cdf447f7b..e4fec726e 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift @@ -57,6 +57,32 @@ struct TypesFileTranslator: FileTranslator { ] ) - return StructuredSwiftRepresentation(file: .init(name: GeneratorMode.types.outputFileName, contents: typesFile)) + if let fileSplitting = config.output.types?.fileSplitting, fileSplitting.strategy == .namespace { + let fileNames = fileSplitting.outputFileNames(primaryTypesFileName: GeneratorMode.types.outputFileName) + return StructuredSwiftRepresentation( + files: [ + .init( + name: fileNames[0], + contents: .init( + topComment: topComment, + imports: imports, + codeBlocks: [ + .declaration(apiProtocol), .declaration(apiProtocolExtension), .declaration(serversDecl), + ] + ) + ), + .init( + name: fileNames[1], + contents: .init(topComment: topComment, imports: imports, codeBlocks: [components]) + ), + .init( + name: fileNames[2], + contents: .init(topComment: topComment, imports: imports, codeBlocks: [operations]) + ), + ] + ) + } + + return StructuredSwiftRepresentation(files: [.init(name: GeneratorMode.types.outputFileName, contents: typesFile)]) } } diff --git a/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md b/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md index 1bce2ffa9..e97153af6 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md @@ -48,6 +48,12 @@ The configuration file has the following keys: - `typeOverrides` (optional): Allows replacing a generated type with a custom type. - `schemas` (optional): a string to string dictionary. The key is the name of the schema, the last component of `#/components/schemas/Foo` (here, `Foo`). The value is the custom type name, such as `CustomFoo`. Check out details in [SOAR-0014](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0014). - `featureFlags` (optional): array of strings. Each string must be a valid feature flag to enable. For a list of currently supported feature flags, check out [FeatureFlags.swift](https://github.com/apple/swift-openapi-generator/blob/main/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift). +- `output` (optional): Options that affect generated output files. + - `types` (optional): Options that only affect `types` generation. + - `fileSplitting` (optional): Splits generated types across multiple files. + - `strategy` (required): The file splitting strategy. Known values: + - `namespace`: Split generated types into a small fixed set of files by top-level namespace. + - `namespace` (optional): Options for the `namespace` strategy. ### Example config files @@ -110,6 +116,22 @@ additionalFileComments: - "swiftlint:disable all" ``` +To split generated types across multiple Swift files: + +```yaml +generate: + - types +namingStrategy: idiomatic +output: + types: + fileSplitting: + strategy: namespace +``` + +The same strategy can be enabled from the command line with `--types-file-splitting namespace`. + +> Note: Types file splitting is currently supported when running the generator directly or with the command plugin. The build tool plugin does not yet support types file splitting. + ### Document filtering The generator supports filtering the OpenAPI document prior to generation, which can be useful when diff --git a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift index 632dc1e6d..835fb54c4 100644 --- a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift +++ b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift @@ -37,6 +37,12 @@ extension _GenerateOptions { let resolvedNameOverrides = resolvedNameOverrides(config) let resolvedTypeOverrides = resolvedTypeOverrides(config) let resolvedFeatureFlags = resolvedFeatureFlags(config) + let resolvedOutputOptions = resolvedOutputOptions(config) + guard pluginSource != .build || resolvedOutputOptions.types?.fileSplitting == nil else { + throw ValidationError( + "Types file splitting is not supported by the build tool plugin yet. Run swift-openapi-generator directly or use the command plugin to generate split files." + ) + } let configs: [Config] = sortedModes.map { .init( mode: $0, @@ -47,7 +53,8 @@ extension _GenerateOptions { namingStrategy: resolvedNamingStragy, nameOverrides: resolvedNameOverrides, typeOverrides: resolvedTypeOverrides, - featureFlags: resolvedFeatureFlags + featureFlags: resolvedFeatureFlags, + output: resolvedOutputOptions ) } let (diagnostics, finalizeDiagnostics) = preparedDiagnosticsCollector(outputPath: diagnosticsOutputPath) @@ -67,6 +74,7 @@ extension _GenerateOptions { .sorted(by: { $0.key < $1.key }) .map { "\"\($0.key)\"->\"\($0.value)\"" }.joined(separator: ", ")) - Feature flags: \(resolvedFeatureFlags.isEmpty ? "" : resolvedFeatureFlags.map(\.rawValue).joined(separator: ", ")) + - Types file splitting: \(resolvedOutputOptions.types?.fileSplitting?.strategy.rawValue ?? "") - Output file names: \(sortedModes.map(\.outputFileName).joined(separator: ", ")) - Output directory: \(outputDirectory.path) - Diagnostics output path: \(diagnosticsOutputPath?.path ?? "") diff --git a/Sources/swift-openapi-generator/GenerateOptions.swift b/Sources/swift-openapi-generator/GenerateOptions.swift index 6539fae9c..421bef1e7 100644 --- a/Sources/swift-openapi-generator/GenerateOptions.swift +++ b/Sources/swift-openapi-generator/GenerateOptions.swift @@ -48,10 +48,16 @@ struct _GenerateOptions: ParsableArguments { @Option( help: "When specified, writes out the diagnostics into a YAML file instead of emitting them to standard error." ) var diagnosticsOutputPath: URL? + + @Option( + help: + "Split generated types across files using the specified strategy. Options: \(TypesFileSplittingStrategy.prettyListing)." + ) var typesFileSplitting: TypesFileSplittingStrategy? } extension AccessModifier: ExpressibleByArgument {} extension NamingStrategy: ExpressibleByArgument {} +extension TypesFileSplittingStrategy: ExpressibleByArgument {} /// Executes a throwing operation and transforms file-not-found errors into user-friendly messages. /// @@ -151,6 +157,29 @@ extension _GenerateOptions { return config?.featureFlags ?? [] } + /// Returns the output options requested by the user. + /// - Parameter config: The configuration specified by the user. + /// - Returns: Output options requested by the user. + func resolvedOutputOptions(_ config: _UserConfig?) -> OutputOptions { + var output = config?.output ?? .init() + if let fileSplitting = resolvedTypesFileSplittingConfig() { + var typesOutput = output.types ?? .init() + typesOutput.fileSplitting = fileSplitting + output.types = typesOutput + } + return output + } + + /// Returns the types file splitting configuration requested from command-line options. + /// - Returns: Types file splitting configuration requested from command-line options. + func resolvedTypesFileSplittingConfig() -> TypesFileSplittingConfig? { + guard let typesFileSplitting else { return nil } + switch typesFileSplitting { + case .namespace: + return .init(strategy: .namespace) + } + } + /// Validates a collection of keys against a predefined set of allowed keys. /// /// - Parameter keys: A collection of keys to be validated. diff --git a/Sources/swift-openapi-generator/UserConfig.swift b/Sources/swift-openapi-generator/UserConfig.swift index f83dd71fd..540a9f0cf 100644 --- a/Sources/swift-openapi-generator/UserConfig.swift +++ b/Sources/swift-openapi-generator/UserConfig.swift @@ -51,6 +51,9 @@ struct _UserConfig: Codable { /// A set of features to explicitly enable. var featureFlags: FeatureFlags? + /// Options that affect generated output files. + var output: OutputOptions? + /// A set of raw values corresponding to the coding keys of this struct. static let codingKeysRawValues = Set(CodingKeys.allCases.map({ $0.rawValue })) @@ -64,6 +67,7 @@ struct _UserConfig: Codable { case nameOverrides case typeOverrides case featureFlags + case output } /// A container of type overrides. diff --git a/Sources/swift-openapi-generator/runGenerator.swift b/Sources/swift-openapi-generator/runGenerator.swift index 08b62d469..e6b292bab 100644 --- a/Sources/swift-openapi-generator/runGenerator.swift +++ b/Sources/swift-openapi-generator/runGenerator.swift @@ -56,7 +56,6 @@ extension _Tool { docData: docData, config: config, outputDirectory: outputDirectory, - outputFileName: config.mode.outputFileName, isDryRun: isDryRun, diagnostics: diagnostics ) @@ -88,9 +87,7 @@ extension _Tool { /// - docData: The raw contents of the OpenAPI document. /// - config: A set of configuration values for the generator. /// - 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. + /// the generated Swift files. /// - isDryRun: A Boolean value that indicates whether this invocation should /// be a dry run. /// - diagnostics: A collector for diagnostics emitted by the generator. @@ -101,23 +98,22 @@ extension _Tool { docData: Data, config: Config, outputDirectory: URL, - outputFileName: String, isDryRun: Bool, diagnostics: any DiagnosticCollector ) throws { - try replaceFileContents( - inDirectory: outputDirectory, - fileName: outputFileName, - with: { - let output = try _OpenAPIGeneratorCore.runGenerator( - input: .init(absolutePath: doc, contents: docData), - config: config, - diagnostics: diagnostics - ) - return output.contents - }, - isDryRun: isDryRun + let outputs = try _OpenAPIGeneratorCore.runGenerator( + input: .init(absolutePath: doc, contents: docData), + config: config, + diagnostics: diagnostics ) + for output in outputs { + try replaceFileContents( + inDirectory: outputDirectory, + fileName: output.baseName, + with: { output.contents }, + isDryRun: isDryRun + ) + } } /// Evaluates a closure to generate file data and writes the data to disk diff --git a/Tests/OpenAPIGeneratorCoreTests/Hooks/Test_TypesFileTranslatorFileSplitting.swift b/Tests/OpenAPIGeneratorCoreTests/Hooks/Test_TypesFileTranslatorFileSplitting.swift new file mode 100644 index 000000000..b20b8b685 --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Hooks/Test_TypesFileTranslatorFileSplitting.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import XCTest +@testable import _OpenAPIGeneratorCore + +final class Test_TypesFileTranslatorFileSplitting: Test_Core { + + func testNamespaceSplittingProducesRootComponentsAndOperationsFiles() throws { + let input = InMemoryInputFile( + absolutePath: URL(string: "openapi.yaml")!, + contents: Data(Self.source.utf8) + ) + let diagnostics = AccumulatingDiagnosticCollector() + let outputs = try runGenerator( + input: input, + config: Self.splitConfig(strategy: .namespace), + diagnostics: diagnostics + ) + + XCTAssertEqual(diagnostics.diagnostics.count, 0) + XCTAssertEqual(outputs.map(\.baseName), ["Types.swift", "Types+Components.swift", "Types+Operations.swift"]) + + let outputByName = Dictionary(uniqueKeysWithValues: outputs.map { output in + (output.baseName, String(decoding: output.contents, as: UTF8.self)) + }) + let rootSource = try XCTUnwrap(outputByName["Types.swift"]) + let componentsSource = try XCTUnwrap(outputByName["Types+Components.swift"]) + let operationsSource = try XCTUnwrap(outputByName["Types+Operations.swift"]) + + XCTAssertTrue(rootSource.contains("import OpenAPIRuntime")) + XCTAssertTrue(componentsSource.contains("import OpenAPIRuntime")) + XCTAssertTrue(operationsSource.contains("import OpenAPIRuntime")) + XCTAssertTrue(componentsSource.contains("import struct Foundation.Date")) + XCTAssertTrue(operationsSource.contains("import struct Foundation.Date")) + + XCTAssertTrue(rootSource.contains("protocol APIProtocol")) + XCTAssertFalse(rootSource.contains("enum Components")) + XCTAssertFalse(rootSource.contains("enum Operations")) + + XCTAssertTrue(componentsSource.contains("enum Components")) + XCTAssertTrue(componentsSource.contains("struct User")) + XCTAssertFalse(componentsSource.contains("protocol APIProtocol")) + XCTAssertFalse(componentsSource.contains("enum Operations")) + + XCTAssertTrue(operationsSource.contains("enum Operations")) + XCTAssertFalse(operationsSource.contains("enum Components")) + XCTAssertFalse(operationsSource.contains("protocol APIProtocol")) + } + + func testNamespaceSplittingIsDisabledByDefault() throws { + let input = InMemoryInputFile( + absolutePath: URL(string: "openapi.yaml")!, + contents: Data(Self.source.utf8) + ) + let diagnostics = AccumulatingDiagnosticCollector() + let outputs = try runGenerator( + input: input, + config: Config(mode: .types, access: .public, namingStrategy: .defensive), + diagnostics: diagnostics + ) + + XCTAssertEqual(diagnostics.diagnostics.count, 0) + XCTAssertEqual(outputs.map(\.baseName), ["Types.swift"]) + } + + private static func splitConfig(strategy: TypesFileSplittingStrategy) -> Config { + .init( + mode: .types, + access: .public, + namingStrategy: .defensive, + output: .init( + types: .init( + fileSplitting: .init(strategy: strategy) + ) + ) + ) + } + + private static let source = """ + openapi: "3.1.0" + info: + title: GreetingService + version: "1.0.0" + paths: + /users/{id}: + get: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: A user. + headers: + X-Expires-After: + schema: + type: string + format: date-time + content: + application/json: + schema: + $ref: "#/components/schemas/User" + components: + schemas: + User: + type: object + properties: + id: + type: string + createdAt: + type: string + format: date-time + required: + - id + """ +} diff --git a/Tests/OpenAPIGeneratorCoreTests/Test_Config.swift b/Tests/OpenAPIGeneratorCoreTests/Test_Config.swift index 19c38bd18..2cbf684c8 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Test_Config.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Test_Config.swift @@ -30,4 +30,34 @@ final class Test_Config: Test_Core { let config = Config(mode: .types, access: .public, namingStrategy: .defensive) XCTAssertEqual(config.additionalFileComments, []) } + + func testOutputOptionsDefaultToEmpty() { + let config = Config(mode: .types, access: .public, namingStrategy: .defensive) + XCTAssertNil(config.output.types) + } + + func testTypesFileSplittingConfig() { + let config = Config( + mode: .types, + access: .public, + namingStrategy: .defensive, + output: .init(types: .init(fileSplitting: .init(strategy: .namespace))) + ) + XCTAssertEqual(config.output.types?.fileSplitting?.strategy, .namespace) + } + + func testGeneratorModeOutputFileNameHelper() { + XCTAssertEqual(GeneratorMode.outputFileName("Types"), "Types.swift") + XCTAssertEqual(GeneratorMode.outputFileName("Types.swift", "Components"), "Types+Components.swift") + XCTAssertEqual(GeneratorMode.outputFileName("Types.swift", "Operations.swift"), "Types+Operations.swift") + } + + func testNamespaceFileSplittingOutputFileNames() { + let config = TypesFileSplittingConfig(strategy: .namespace) + + XCTAssertEqual( + config.outputFileNames(primaryTypesFileName: "Types.swift"), + ["Types.swift", "Types+Components.swift", "Types+Operations.swift"] + ) + } } diff --git a/Tests/OpenAPIGeneratorCoreTests/Test_GeneratorPipeline.swift b/Tests/OpenAPIGeneratorCoreTests/Test_GeneratorPipeline.swift new file mode 100644 index 000000000..180107ba4 --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Test_GeneratorPipeline.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import XCTest +@testable import _OpenAPIGeneratorCore + +final class Test_GeneratorPipeline: Test_Core { + + func testRunGeneratorReturnsSingleFileByDefault() throws { + let source = """ + openapi: "3.1.0" + info: + title: GreetingService + version: "1.0.0" + paths: {} + """ + let input = InMemoryInputFile( + absolutePath: URL(string: "openapi.yaml")!, + contents: Data(source.utf8) + ) + let diagnostics = AccumulatingDiagnosticCollector() + let outputs = try runGenerator( + input: input, + config: Config(mode: .types, access: .public, namingStrategy: .defensive), + diagnostics: diagnostics + ) + + XCTAssertEqual(diagnostics.diagnostics.count, 0) + XCTAssertEqual(outputs.map(\.baseName), ["Types.swift"]) + } + + func testPipelineRendersMultipleStructuredFiles() throws { + let structured = StructuredSwiftRepresentation( + files: [ + .init( + name: "First.swift", + contents: .init( + topComment: nil, + imports: nil, + codeBlocks: [.declaration(.enum(name: "First"))] + ) + ), + .init( + name: "Second.swift", + contents: .init( + topComment: nil, + imports: nil, + codeBlocks: [.declaration(.enum(name: "Second"))] + ) + ), + ] + ) + let pipeline = makeGeneratorPipeline( + parser: YamsParser(), + translator: MultiplexTranslator(), + renderer: { TextBasedRenderer.default }, + config: Config(mode: .types, access: .public, namingStrategy: .defensive), + diagnostics: AccumulatingDiagnosticCollector() + ) + let outputs = try pipeline.renderSwiftFilesStage.run(structured) + + XCTAssertEqual(outputs.map(\.baseName), ["First.swift", "Second.swift"]) + XCTAssertTrue(String(decoding: outputs[0].contents, as: UTF8.self).contains("enum First")) + XCTAssertFalse(String(decoding: outputs[0].contents, as: UTF8.self).contains("enum Second")) + XCTAssertTrue(String(decoding: outputs[1].contents, as: UTF8.self).contains("enum Second")) + XCTAssertFalse(String(decoding: outputs[1].contents, as: UTF8.self).contains("enum First")) + } +} diff --git a/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift b/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift index 1e244c233..7d52ab44b 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift @@ -248,7 +248,7 @@ fileprivate extension CompatibilityTest { // Run the generator. log("Generating Swift code (document size: \(documentSize))") let input = InMemoryInputFile(absolutePath: URL(string: "openapi.yaml")!, contents: documentData) - let outputs: [GeneratorPipeline.RenderedOutput] + let outputs: [InMemoryOutputFile] if compatibilityTestParallelCodegen { outputs = try await withThrowingTaskGroup(of: GeneratorPipeline.RenderedOutput.self) { group in for mode in GeneratorMode.allCases { @@ -260,10 +260,10 @@ fileprivate extension CompatibilityTest { return try assertNoThrowWithValue(generator.run(input)) } } - return try await group.reduce(into: []) { $0.append($1) } + return try await group.reduce(into: []) { $0.append(contentsOf: $1) } } } else { - outputs = try GeneratorMode.allCases.map { mode in + outputs = try GeneratorMode.allCases.flatMap { mode in let generator = makeGeneratorPipeline( config: Config(mode: mode, access: .public, namingStrategy: .defensive), diagnostics: diagnosticsCollector diff --git a/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift index 5596602bd..0aa3de36d 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift @@ -86,7 +86,9 @@ final class FileBasedReferenceTests: XCTestCase { config: referenceTest.asConfig, ignoredDiagnosticMessages: ignoredDiagnosticMessages ) - let generatedOutputSource = try generatorPipeline.run(input) + let generatedOutputSources = try generatorPipeline.run(input) + let generatedOutputSource = try XCTUnwrap(generatedOutputSources.first) + XCTAssertEqual(generatedOutputSources.count, 1) // Write generated sources to temporary directory let generatedOutputDir = try self.temporaryDirectory() @@ -151,12 +153,10 @@ extension FileBasedReferenceTests { { let parser = YamsParser() let translator = MultiplexTranslator() - let renderer = TextBasedRenderer.default - return _OpenAPIGeneratorCore.makeGeneratorPipeline( parser: parser, translator: translator, - renderer: renderer, + renderer: { TextBasedRenderer.default }, config: config, diagnostics: XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages) ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index 6ea8d60b6..0ad4ec3d9 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -6462,7 +6462,7 @@ final class SnippetBasedReferenceTests: XCTestCase { let document = try YAMLDecoder().decode(OpenAPI.Document.self, from: documentYAML) let translation = try translator.translateFile(parsedOpenAPI: document) try XCTAssertSwiftEquivalent( - XCTUnwrap(translation.file.contents.topComment), + XCTUnwrap(translation.files.first?.contents.topComment), """ // Generated by swift-openapi-generator, do not modify. // hello world diff --git a/Tests/OpenAPIGeneratorTests/Test_GenerateOptions.swift b/Tests/OpenAPIGeneratorTests/Test_GenerateOptions.swift index 9638fda0a..01c8a44a0 100644 --- a/Tests/OpenAPIGeneratorTests/Test_GenerateOptions.swift +++ b/Tests/OpenAPIGeneratorTests/Test_GenerateOptions.swift @@ -141,5 +141,68 @@ final class Test_GenerateOptions: XCTestCase { ) } catch { XCTFail("Expected ArgumentParser.ValidationError, but got: \(type(of: error)) - \(error)") } } + + func testLoadedConfigDecodesOutputOptions() throws { + let configURL = try makeTemporaryConfig( + """ + generate: + - types + output: + types: + fileSplitting: + strategy: namespace + """ + ) + let options = try _GenerateOptions.parse(["openapi.yaml", "--config", configURL.path]) + let config = try XCTUnwrap(options.loadedConfig()) + + XCTAssertEqual(config.output?.types?.fileSplitting?.strategy, .namespace) + } + + func testTypesFileSplittingOptionResolvesOutputOptions() throws { + let options = try _GenerateOptions.parse([ + "openapi.yaml", "--mode", "types", "--types-file-splitting", "namespace", + ]) + + XCTAssertEqual(options.resolvedOutputOptions(nil).types?.fileSplitting?.strategy, .namespace) + } + + func testTypesFileSplittingIsRejectedForBuildToolPlugin() async throws { + let configURL = try makeTemporaryConfig( + """ + generate: + - types + output: + types: + fileSplitting: + strategy: namespace + """ + ) + let options = try _GenerateOptions.parse(["openapi.yaml", "--config", configURL.path]) + + do { + try await options.runGenerator( + outputDirectory: URL(fileURLWithPath: "/tmp/generated"), + pluginSource: .build, + isDryRun: true + ) + XCTFail("Expected build tool plugin invocation to reject types file splitting") + } catch let error as ArgumentParser.ValidationError { + XCTAssertTrue( + String(describing: error).contains("Types file splitting is not supported by the build tool plugin yet"), + "Unexpected error: \(error)" + ) + } catch { + XCTFail("Expected ArgumentParser.ValidationError, but got: \(type(of: error)) - \(error)") + } + } #endif + + private func makeTemporaryConfig(_ contents: String) throws -> URL { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let configURL = tempDir.appendingPathComponent("openapi-generator-config.yaml") + try contents.write(to: configURL, atomically: true, encoding: .utf8) + return configURL + } }