diff --git a/Sources/ApolloCodegenLib/ApolloCodegen.swift b/Sources/ApolloCodegenLib/ApolloCodegen.swift index 93e0f25427..ea496d6589 100644 --- a/Sources/ApolloCodegenLib/ApolloCodegen.swift +++ b/Sources/ApolloCodegenLib/ApolloCodegen.swift @@ -124,7 +124,7 @@ public class ApolloCodegen { ) } } - + public static func generateOperationManifest( with configuration: ApolloCodegenConfiguration, withRootURL rootURL: URL? = nil, diff --git a/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift b/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift index 63b5348a8d..822570e615 100644 --- a/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift +++ b/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift @@ -167,7 +167,7 @@ public struct ApolloCodegenConfiguration: Codable, Equatable { /// Configures the generation of an operation manifest JSON file for use with persisted queries /// or [Automatic Persisted Queries (APQs)](https://www.apollographql.com/docs/apollo-server/performance/apq). /// Defaults to `nil`. - public let operationManifest: OperationManifestFileOutput? + public var operationManifest: OperationManifestFileOutput? /// Default property values public struct Default { @@ -480,11 +480,11 @@ public struct ApolloCodegenConfiguration: Codable, Equatable { /// Defaults to `nil`. public struct OperationManifestFileOutput: Codable, Equatable { /// Local path where the generated operation manifest file should be written. - let path: String + public let path: String /// The version format to use when generating the operation manifest. Defaults to `.persistedQueries`. - let version: Version + public let version: Version - public enum Version: String, Codable, Equatable { + public enum Version: String, Codable, Equatable, CaseIterable { /// Generates an operation manifest for use with persisted queries. case persistedQueries /// Generates an operation manifest for pre-registering operations with the legacy @@ -944,7 +944,7 @@ public struct ApolloCodegenConfiguration: Codable, Equatable { /// The input files required for code generation. public let input: FileInput /// The paths and files output by code generation. - public let output: FileOutput + public var output: FileOutput /// Rules and options to customize the generated code. public let options: OutputOptions /// Allows users to enable experimental features. diff --git a/Sources/CodegenCLI/Commands/FetchSchema.swift b/Sources/CodegenCLI/Commands/FetchSchema.swift index df6bf2c108..15a97abbcc 100644 --- a/Sources/CodegenCLI/Commands/FetchSchema.swift +++ b/Sources/CodegenCLI/Commands/FetchSchema.swift @@ -28,22 +28,16 @@ public struct FetchSchema: ParsableCommand { ) throws { logger.SetLoggingLevel(verbose: inputs.verbose) - switch (inputs.string, inputs.path) { - case let (.some(string), _): - try fetchSchema(data: try string.asData(), schemaDownloadProvider: schemaDownloadProvider) - - case let (nil, path): - let data = try fileManager.unwrappedContents(atPath: path) - try fetchSchema(data: data, schemaDownloadProvider: schemaDownloadProvider) - } + try fetchSchema( + configuration: inputs.getCodegenConfiguration(fileManager: fileManager), + schemaDownloadProvider: schemaDownloadProvider + ) } private func fetchSchema( - data: Data, + configuration codegenConfiguration: ApolloCodegenConfiguration, schemaDownloadProvider: SchemaDownloadProvider.Type ) throws { - let codegenConfiguration = try JSONDecoder().decode(ApolloCodegenConfiguration.self, from: data) - guard let schemaDownloadConfiguration = codegenConfiguration.schemaDownloadConfiguration else { throw Error(errorDescription: """ Missing schema download configuration. Hint: check the `schemaDownloadConfiguration` \ diff --git a/Sources/CodegenCLI/Commands/Generate.swift b/Sources/CodegenCLI/Commands/Generate.swift index d6006c19f2..ceaa3c674e 100644 --- a/Sources/CodegenCLI/Commands/Generate.swift +++ b/Sources/CodegenCLI/Commands/Generate.swift @@ -38,31 +38,18 @@ public struct Generate: ParsableCommand { with: inputs ) - switch (inputs.string, inputs.path) { - case let (.some(string), _): - try generate( - data: try string.asData(), - codegenProvider: codegenProvider, - schemaDownloadProvider: schemaDownloadProvider - ) - - case let (nil, path): - let data = try fileManager.unwrappedContents(atPath: path) - try generate( - data: data, - codegenProvider: codegenProvider, - schemaDownloadProvider: schemaDownloadProvider - ) - } + try generate( + configuration: inputs.getCodegenConfiguration(fileManager: fileManager), + codegenProvider: codegenProvider, + schemaDownloadProvider: schemaDownloadProvider + ) } private func generate( - data: Data, + configuration: ApolloCodegenConfiguration, codegenProvider: CodegenProvider.Type, schemaDownloadProvider: SchemaDownloadProvider.Type ) throws { - let configuration = try JSONDecoder().decode(ApolloCodegenConfiguration.self, from: data) - if fetchSchema { guard let schemaDownloadConfiguration = configuration.schemaDownloadConfiguration diff --git a/Sources/CodegenCLI/Commands/GenerateOperationManifest.swift b/Sources/CodegenCLI/Commands/GenerateOperationManifest.swift index faef37325f..b7b0dc277e 100644 --- a/Sources/CodegenCLI/Commands/GenerateOperationManifest.swift +++ b/Sources/CodegenCLI/Commands/GenerateOperationManifest.swift @@ -9,9 +9,34 @@ public struct GenerateOperationManifest: ParsableCommand { public static var configuration = CommandConfiguration( abstract: "Generate Persisted Queries operation manifest based on a code generation configuration." ) - + + struct OutputOptions: ParsableArguments { + @Option( + name: .shortAndLong, + help: """ + Output the operation manifest to the given path. This overrides the value of the \ + `output.operationManifest.path` in your configuration. + + **If the `output.operationManifest` is not included in your configuration, this is required.** + """ + ) + var outputPath: String? + + @Option( + name: .long, + help: """ + The version for the operation manifest format to generate. This overrides the value of the \ + `output.operationManifest.path` in your configuration. + + **If the `output.operationManifest` is not included in your configuration, this is required.** + """ + ) + var manifestVersion: ApolloCodegenConfiguration.OperationManifestFileOutput.Version? + } + @OptionGroup var inputs: InputOptions - + @OptionGroup var outputOptions: OutputOptions + // MARK: - Implementation public init() { } @@ -26,36 +51,61 @@ public struct GenerateOperationManifest: ParsableCommand { logger: LogLevelSetter.Type = CodegenLogger.self ) throws { logger.SetLoggingLevel(verbose: inputs.verbose) - - try checkForCLIVersionMismatch( - with: inputs - ) - - switch (inputs.string, inputs.path) { - case let (.some(string), _): - try generateManifest( - data: try string.asData(), - codegenProvider: codegenProvider - ) - case let (nil, path): - try generateManifest( - data: try fileManager.unwrappedContents(atPath: path), - codegenProvider: codegenProvider + + var configuration = try inputs.getCodegenConfiguration(fileManager: fileManager) + + try validate(configuration: configuration) + + if let outputPath = outputOptions.outputPath, + let manifestVersion = outputOptions.manifestVersion { + configuration.output.operationManifest = .init( + path: outputPath, + version: manifestVersion ) } + + try generateManifest( + configuration: configuration, + codegenProvider: codegenProvider + ) } private func generateManifest( - data: Data, + configuration: ApolloCodegenConfiguration, codegenProvider: CodegenProvider.Type ) throws { - let configuration = try JSONDecoder().decode(ApolloCodegenConfiguration.self, from: data) - try codegenProvider.generateOperationManifest( with: configuration, withRootURL: rootOutputURL(for: inputs), fileManager: .default ) } + + // MARK: - Validation + + func validate(configuration: ApolloCodegenConfiguration) throws { + try checkForCLIVersionMismatch(with: inputs) + + if configuration.output.operationManifest == nil { + guard outputOptions.outputPath != nil else { + throw ValidationError(""" + `manifest-version` argument missing. When `output-path` is used, `manifest-version` \ + must also be present. + """) + } + } + + if outputOptions.outputPath != nil { + guard outputOptions.manifestVersion != nil else { + throw ValidationError(""" + No output path for operation manifest found. You must either provide the `output-path` \ + argument or your codegen configuration must have a value present for the \ + `output.operationManifest` option. + """) + } + } + } } + +extension ApolloCodegenConfiguration.OperationManifestFileOutput.Version: ExpressibleByArgument {} diff --git a/Sources/CodegenCLI/OptionGroups/InputOptions.swift b/Sources/CodegenCLI/OptionGroups/InputOptions.swift index d076b466b3..107d54d99b 100644 --- a/Sources/CodegenCLI/OptionGroups/InputOptions.swift +++ b/Sources/CodegenCLI/OptionGroups/InputOptions.swift @@ -1,4 +1,6 @@ +import Foundation import ArgumentParser +import ApolloCodegenLib /// Shared group of common arguments used in commands for input parameters. struct InputOptions: ParsableArguments { @@ -28,4 +30,16 @@ struct InputOptions: ParsableArguments { help: "Ignore Apollo version mismatch errors. Warning: This may lead to incompatible generated objects." ) var ignoreVersionMismatch: Bool = false + + func getCodegenConfiguration(fileManager: FileManager) throws -> ApolloCodegenConfiguration { + var data: Data + switch (string, path) { + case let (.some(string), _): + data = try string.asData() + + case let (nil, path): + data = try fileManager.unwrappedContents(atPath: path) + } + return try JSONDecoder().decode(ApolloCodegenConfiguration.self, from: data) + } } diff --git a/Tests/CodegenCLITests/Commands/GenerateOperationManifestTests.swift b/Tests/CodegenCLITests/Commands/GenerateOperationManifestTests.swift index a035cd5933..f8833888af 100644 --- a/Tests/CodegenCLITests/Commands/GenerateOperationManifestTests.swift +++ b/Tests/CodegenCLITests/Commands/GenerateOperationManifestTests.swift @@ -3,7 +3,7 @@ import Nimble import ApolloInternalTestHelpers @testable import CodegenCLI import ApolloCodegenLib -import ArgumentParser +@testable import ArgumentParser class GenerateOperationManifestTests: XCTestCase { @@ -48,7 +48,7 @@ class GenerateOperationManifestTests: XCTestCase { })) var didCallBuild = false - MockApolloCodegen.buildHandler = { configuration in + MockApolloCodegen.generateOperationManifestHandler = { configuration in expect(configuration).to(equal(mockConfiguration)) didCallBuild = true @@ -77,7 +77,7 @@ class GenerateOperationManifestTests: XCTestCase { ] var didCallBuild = false - MockApolloCodegen.buildHandler = { configuration in + MockApolloCodegen.generateOperationManifestHandler = { configuration in expect(configuration).to(equal(mockConfiguration)) didCallBuild = true @@ -107,7 +107,7 @@ class GenerateOperationManifestTests: XCTestCase { ] var didCallBuild = false - MockApolloCodegen.buildHandler = { configuration in + MockApolloCodegen.generateOperationManifestHandler = { configuration in expect(configuration).to(equal(mockConfiguration)) didCallBuild = true @@ -135,7 +135,7 @@ class GenerateOperationManifestTests: XCTestCase { "--string=\(jsonString)" ] - MockApolloCodegen.buildHandler = { configuration in } + MockApolloCodegen.generateOperationManifestHandler = { configuration in } MockApolloSchemaDownloader.fetchHandler = { configuration in } var level: CodegenLogger.LogLevel? @@ -169,7 +169,7 @@ class GenerateOperationManifestTests: XCTestCase { "--verbose" ] - MockApolloCodegen.buildHandler = { configuration in } + MockApolloCodegen.generateOperationManifestHandler = { configuration in } MockApolloSchemaDownloader.fetchHandler = { configuration in } var level: CodegenLogger.LogLevel? @@ -188,6 +188,145 @@ class GenerateOperationManifestTests: XCTestCase { // then expect(level).toEventually(equal(.debug)) } + + func test__generate__givenParameters_outputPathAndManifestVersion_configHasOperationManifestOption__overridesOperationManifestInConfiguration() throws { + // given + let inputPath = "./config.json" + let outputPath = "./operationManifest.json" + + let options = [ + "--path=\(inputPath)", + "--output-path=\(outputPath)", + "--manifest-version=persistedQueries" + ] + + var didCallGenerate = false + MockApolloCodegen.generateOperationManifestHandler = { configuration in + let actual = configuration.output.operationManifest + expect(actual?.path).to(equal(outputPath)) + expect(actual?.version).to(equal(.persistedQueries)) + + didCallGenerate = true + } + + let mockFileManager = MockApolloFileManager(strict: true) + + mockFileManager.mock(closure: .contents({ path in + return try! JSONEncoder().encode(ApolloCodegenConfiguration.mock()) + })) + + // when + let command = try parse(options) + try command._run(fileManager: mockFileManager.base, codegenProvider: MockApolloCodegen.self) + + // then + expect(didCallGenerate).to(beTrue()) + } + + func test__generate__givenParameters_manifestVersion_legacyAPQs__overridesOperationManifestInConfiguration() throws { + // given + let inputPath = "./config.json" + let outputPath = "./operationManifest.json" + + let options = [ + "--path=\(inputPath)", + "--output-path=\(outputPath)", + "--manifest-version=legacyAPQ" + ] + + var didCallGenerate = false + MockApolloCodegen.generateOperationManifestHandler = { configuration in + let actual = configuration.output.operationManifest + expect(actual?.path).to(equal(outputPath)) + expect(actual?.version).to(equal(.legacyAPQ)) + + didCallGenerate = true + } + + let mockFileManager = MockApolloFileManager(strict: true) + + mockFileManager.mock(closure: .contents({ path in + return try! JSONEncoder().encode(ApolloCodegenConfiguration.mock()) + })) + + // when + let command = try parse(options) + try command._run(fileManager: mockFileManager.base, codegenProvider: MockApolloCodegen.self) + + // then + expect(didCallGenerate).to(beTrue()) + } + + // MARK: Argument Validation Tests + + func test__generate__givenParameters_outputPath_noManifestVersion_throwsValidationError() throws { + // given + let mockConfiguration = ApolloCodegenConfiguration.mock() + + let jsonString = String( + data: try! JSONEncoder().encode(mockConfiguration), + encoding: .utf8 + )! + + + let options = [ + "--output-path=./operationManifest.json", + "--string=\(jsonString)" + ] + + // when + let command = try parse(options) + + // then + expect( + try command._run(codegenProvider: MockApolloCodegen.self) + ).to(throwError()) + } + + func test__generate__givenConfigHasNoOperationManifestOption_outputPathMissing__throwsValidationError() throws { + // given + let inputPath = "./config.json" + + let options = [ + "--path=\(inputPath)" + ] + + let mockConfiguration = ApolloCodegenConfiguration( + schemaNamespace: "MockSchema", + input: .init( + schemaPath: "./schema.graphqls" + ), + output: .init( + schemaTypes: .init(path: ".", moduleType: .swiftPackageManager) + ), + options: .init( + operationDocumentFormat: [.definition, .operationId] + ), + schemaDownloadConfiguration: .init( + using: .introspection(endpointURL: URL(string: "http://some.server")!), + outputPath: "./schema.graphqls" + ) + ) + + let mockFileManager = MockApolloFileManager(strict: true) + + mockFileManager.mock(closure: .contents({ path in + let actualPath = URL(fileURLWithPath: path).standardizedFileURL.path + let expectedPath = URL(fileURLWithPath: inputPath).standardizedFileURL.path + + expect(actualPath).to(equal(expectedPath)) + + return try! JSONEncoder().encode(mockConfiguration) + })) + + // when + let command = try parse(options) + + // then + expect( + try command._run(fileManager: mockFileManager.base, codegenProvider: MockApolloCodegen.self) + ).to(throwError()) + } // MARK: Version Checking Tests @@ -205,7 +344,7 @@ class GenerateOperationManifestTests: XCTestCase { "--verbose" ] - MockApolloCodegen.buildHandler = { configuration in } + MockApolloCodegen.generateOperationManifestHandler = { configuration in } MockApolloSchemaDownloader.fetchHandler = { configuration in } try self.testIsolatedFileManager().createFile( @@ -254,7 +393,7 @@ class GenerateOperationManifestTests: XCTestCase { "--ignore-version-mismatch" ] - MockApolloCodegen.buildHandler = { configuration in } + MockApolloCodegen.generateOperationManifestHandler = { configuration in } MockApolloSchemaDownloader.fetchHandler = { configuration in } try self.testIsolatedFileManager().createFile( @@ -302,7 +441,7 @@ class GenerateOperationManifestTests: XCTestCase { "--verbose" ] - MockApolloCodegen.buildHandler = { configuration in } + MockApolloCodegen.generateOperationManifestHandler = { configuration in } MockApolloSchemaDownloader.fetchHandler = { configuration in } // when diff --git a/Tests/CodegenCLITests/Support/MockApolloCodegen.swift b/Tests/CodegenCLITests/Support/MockApolloCodegen.swift index d1ca6ac2f3..5fdbb23a9e 100644 --- a/Tests/CodegenCLITests/Support/MockApolloCodegen.swift +++ b/Tests/CodegenCLITests/Support/MockApolloCodegen.swift @@ -4,6 +4,7 @@ import ApolloCodegenLib class MockApolloCodegen: CodegenProvider { static var buildHandler: ((ApolloCodegenConfiguration) throws -> Void)? = nil + static var generateOperationManifestHandler: ((ApolloCodegenConfiguration) throws -> Void)? = nil static func build( with configuration: ApolloCodegenConfiguration, @@ -25,12 +26,12 @@ class MockApolloCodegen: CodegenProvider { withRootURL rootURL: URL?, fileManager: ApolloCodegenLib.ApolloFileManager ) throws { - guard let handler = buildHandler else { - fatalError("You must set buildHandler before calling \(#function)!") + guard let handler = generateOperationManifestHandler else { + fatalError("You must set generateOperationManifestHandler before calling \(#function)!") } defer { - buildHandler = nil + generateOperationManifestHandler = nil } try handler(configuration)