diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved index b0b05e7..2d8c601 100644 --- a/Examples/HelloWorldServer/Package.resolved +++ b/Examples/HelloWorldServer/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5d2dc35be5bbfb684ec3b54e067186d6944830d624f032286cfe8c92ae0ff381", + "originHash" : "31d303d801b25dca0806402753779e7987df455c18a085c237e5397220063124", "pins" : [ { "identity" : "graphql", @@ -36,6 +36,15 @@ "revision" : "4799286537280063c85a32f09884cfbca301b1a1", "version" : "602.0.0" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } } ], "version" : 3 diff --git a/Examples/StarWars/Package.resolved b/Examples/StarWars/Package.resolved index fe3a2ed..7557624 100644 --- a/Examples/StarWars/Package.resolved +++ b/Examples/StarWars/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a12ce6a75f11a221a80bdd52740bfca5fc042477fe853ae45c03b21c726fe4eb", + "originHash" : "499951276065e4591d2c23aeb63169b44c02709e9c5f1254bc45df92cc57ad8d", "pins" : [ { "identity" : "async-collections", @@ -234,6 +234,15 @@ "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", "version" : "1.6.3" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } } ], "version" : 3 diff --git a/Package.resolved b/Package.resolved index e2e0c7e..a43b3de 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "37898b2742ee9998877cf08f9aa8ece5501e7b816faac99f9ebd875b027dd94f", + "originHash" : "f9a807649c4b1db32f6ac6c375b3cdacb80613bd104b38d4f3141facd99212c1", "pins" : [ { "identity" : "graphql", @@ -36,6 +36,15 @@ "revision" : "4799286537280063c85a32f09884cfbca301b1a1", "version" : "602.0.0" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index d0f7fa6..7e4636a 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,7 @@ let package = Package( .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.1.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-syntax.git", "600.0.1"..<"603.0.0"), + .package(url: "https://github.com/jpsim/Yams.git", "4.0.0"..<"7.0.0"), ], targets: [ // Build plugin @@ -42,6 +43,7 @@ let package = Package( dependencies: [ "GraphQLGeneratorCore", .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Yams", package: "Yams"), ] ), .target( @@ -62,6 +64,12 @@ let package = Package( "GraphQLGeneratorCore" ] ), + .testTarget( + name: "GraphQLGeneratorTests", + dependencies: [ + "GraphQLGenerator" + ] + ), // Macro .macro( diff --git a/Plugins/GraphQLGeneratorPlugin.swift b/Plugins/GraphQLGeneratorPlugin.swift index 2efc561..fc80dcb 100644 --- a/Plugins/GraphQLGeneratorPlugin.swift +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -5,16 +5,13 @@ import PackagePlugin struct GraphQLGeneratorPlugin: BuildToolPlugin { /// Entry point for creating build commands for targets in Swift packages. func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { - // This plugin only runs for package targets that can have source files. - guard let sourceFiles = target.sourceModule?.sourceFiles else { return [] } - - // Find the GraphQL schema files - let schemaFiles = sourceFiles.filter { file in - file.url.pathExtension == "graphql" || file.url.pathExtension == "gql" + // This plugin only runs for Swift source targets. + guard let target = target as? SwiftSourceModuleTarget else { + return [] } - // If no schema files found, return early - guard !schemaFiles.isEmpty else { return [] } + // Find the config file, if present + let configFile = findConfigFile(in: target.sourceFiles) // Find the generator tool let generatorTool = try context.tool(named: "GraphQLGenerator") @@ -22,32 +19,51 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { // Create output directory for generated files let outputDirectory = context.pluginWorkDirectoryURL - // Generate a single set of files from all schema files - // (We could also generate per-file, but typically GraphQL schemas are combined) - let schemaInputs = schemaFiles.map(\.url) - let outputFiles = [ outputDirectory.appendingPathComponent("BuildGraphQLSchema.swift"), outputDirectory.appendingPathComponent("GraphQLRawSDL.swift"), outputDirectory.appendingPathComponent("GraphQLTypes.swift"), ] - let arguments = - schemaInputs.flatMap { ["\($0.path)"] } + [ - "--output-directory", outputDirectory.path, - ] + var arguments: [String] = [] + + // Pass the target's source directory for fallback schema discovery + arguments += ["--source-directory", target.directoryURL.path()] + + // Pass output directory + arguments += ["--output-directory", outputDirectory.path] + + // Pass config file if found + if let configURL = configFile { + arguments += ["--config", configURL.path] + } + + let inputFiles: [URL] = configFile.map { [$0] } ?? [] return [ .buildCommand( - displayName: - "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", + displayName: "Generating GraphQL Swift code", executable: generatorTool.url, arguments: arguments, - inputFiles: schemaInputs, + inputFiles: inputFiles, outputFiles: outputFiles ) ] } + + /// Supported config file names in the target's source directory. + private static let supportedConfigFiles: Set = [ + "graphql-generator-config.yaml", + "graphql-generator-config.yml", + ] + + /// Finds the generator config file in the target's source files, if present. + private func findConfigFile(in sourceFiles: FileList) -> URL? { + let configs = sourceFiles.map(\.url).filter { + Self.supportedConfigFiles.contains($0.lastPathComponent) + } + return configs.first + } } #if canImport(XcodeProjectPlugin) @@ -58,13 +74,13 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { - // Find GraphQL schema files - let schemaFiles = target.inputFiles.filter { file in - file.url.pathExtension == "graphql" || file.url.pathExtension == "gql" - } + // Find the config file + let configFile = findConfigFile(in: target.inputFiles) - // If no schema files found, return early - guard !schemaFiles.isEmpty else { return [] } + // Derive the source directory from the target's input files + let sourceDirectory = + target.inputFiles.first?.url.deletingLastPathComponent().path + ?? context.xcodeProject.directoryURL.path // Find the generator tool let generatorTool = try context.tool(named: "GraphQLGenerator") @@ -72,25 +88,32 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { // Create output directory for generated files let outputDirectory = context.pluginWorkDirectoryURL - let schemaInputs = schemaFiles.map(\.url) - let outputFiles = [ outputDirectory.appendingPathComponent("Types.swift"), outputDirectory.appendingPathComponent("Schema.swift"), ] - let arguments = - schemaInputs.flatMap { ["\($0.path)"] } + [ - "--output-directory", outputDirectory.path, - ] + var arguments: [String] = [] + + // Pass the source directory for fallback schema discovery + arguments += ["--source-directory", sourceDirectory] + + // Pass output directory + arguments += ["--output-directory", outputDirectory.path] + + // Pass config file if found + if let configURL = configFile { + arguments += ["--config", configURL.path] + } + + let inputFiles: [URL] = configFile.map { [$0] } ?? [] return [ .buildCommand( - displayName: - "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", + displayName: "Generating GraphQL Swift code", executable: generatorTool.url, arguments: arguments, - inputFiles: schemaInputs, + inputFiles: inputFiles, outputFiles: outputFiles ) ] diff --git a/README.md b/README.md index 7f4e650..e353393 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Take a look at the example projects to see real, fully featured implementations: Create a `.graphql` file in your target's `Sources` directory: -**Sources/ExamplePackage/schema.graphql**: +**Sources/MyTarget/schema.graphql**: ```graphql type User { name: String! @@ -35,14 +35,32 @@ type Query { } ``` -### 2. Build Your Project +### 2. Add the Plugin to your Target + +In your `package.swift`, add the plugin and dependencies to your GraphQL target: + +``` +.target( + name: "MyTarget", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorMacros", package: "graphql-generator"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + ], + plugins: [ + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + ] +), +``` + +### 3. Build Your Project When you build, the plugin will automatically generate Swift code. If you want, you can view it in the `.build/plugins/outputs` directory: - `BuildGraphQLSchema.swift` - Defines `buildGraphQLSchema` function that builds an executable schema. - `GraphQLRawSDL.swift` - The `graphQLRawSDL` global property, which is a Swift string literal of the input schema. This is used at runtime to parse the schema. - `GraphQLTypes.swift` - Swift protocols and types for your GraphQL types. These are all namespaced within `GraphQLGenerated`. -### 3. Create required types +### 4. Create required types Create a type named `GraphQLContext`: @@ -57,9 +75,9 @@ If your schema has any custom scalar types, you must create them manually in the Create a struct that conforms to `GraphQLGenerated.Resolvers` by defining the required typealiases: ```swift struct Resolvers: GraphQLGenerated.Resolvers { - typealias Query = ExamplePackage.Query - typealias Mutation = ExamplePackage.Mutation - typealias Subscription = ExamplePackage.Subscription + typealias Query = MyTarget.Query + typealias Mutation = MyTarget.Mutation + typealias Subscription = MyTarget.Subscription } ``` @@ -111,7 +129,7 @@ struct User: GraphQLGenerated.User { Note that you must include the `GraphQLGeneratedMacros` library to use the macros. -### 4. Execute GraphQL Queries +### 5. Execute GraphQL Queries You're done! You can now instantiate your GraphQL schema by calling `buildGraphQLSchema`, and run queries against it: @@ -126,6 +144,12 @@ let result = try await graphql(schema: schema, request: "{ users { name email } print(result) ``` +## Configuration + +You can configure this package by including a `graphql-generator-config.yaml` file in your target's source files. It supports the following fields: + +`schemas: [String]?`: Paths to GraphQL schema files or directories containing schema files. These paths are relative to the sources directory, and directories are explored recursively. If not provided, the whole sources directory is searched. + ## Design Philosophy This generator is designed with the following guiding principles: diff --git a/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift b/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift index 9d41718..9f3adad 100644 --- a/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift +++ b/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import GraphQLGeneratorCore +import Yams @main struct GraphQLGeneratorCommand: ParsableCommand { @@ -10,23 +11,49 @@ struct GraphQLGeneratorCommand: ParsableCommand { version: "0.1.0" ) - @Argument(help: "GraphQL schema files to process (.graphql or .gql)") - var schemaFiles: [String] + @Option( + name: .long, + help: "Target source directory" + ) + var sourceDirectory: String @Option(name: .shortAndLong, help: "Output directory for generated files") var outputDirectory: String + @Option( + name: .shortAndLong, + help: "Path to a YAML configuration file (graphql-generator-config.yaml)" + ) + var config: String? + @Flag(name: .long, help: "Enable verbose logging") var verbose: Bool = false + /// File extensions recognized as GraphQL schema files. + private static let schemaExtensions: Set = ["graphql", "gql"] + mutating func run() throws { if verbose { print("GraphQL Generator starting...") - print("Schema files: \(schemaFiles)") + print("Source directory: \(sourceDirectory)") + print("Config: \(config ?? "none")") print("Output directory: \(outputDirectory)") } - for filePath in schemaFiles { + // Resolve schema file paths + let resolvedSchemaFiles = try resolveSchemaFiles() + + if verbose { + print("Schema files: \(resolvedSchemaFiles)") + } + + guard !resolvedSchemaFiles.isEmpty else { + throw ValidationError( + "No schema files found. Either specify schemas in your config file or add .graphql/.gql files to your target's source directory." + ) + } + + for filePath in resolvedSchemaFiles { let fileURL = URL(fileURLWithPath: filePath) guard FileManager.default.fileExists(atPath: fileURL.path) else { throw ValidationError("Schema file not found: \(filePath)") @@ -40,7 +67,7 @@ struct GraphQLGeneratorCommand: ParsableCommand { print("Parsing schema files...") } var combinedSource = "" - for filePath in schemaFiles { + for filePath in resolvedSchemaFiles { let url = URL(fileURLWithPath: filePath) let content = try String(contentsOf: url, encoding: .utf8) combinedSource += content + "\n" @@ -61,4 +88,65 @@ struct GraphQLGeneratorCommand: ParsableCommand { print("Code generation complete!") } } + + /// Resolves the schema file paths. If the config file specifies `schemas`, each entry is resolved relative to + /// `sourceDirectory` and directories are expanded recursively. Otherwise, falls back to scanning + /// `sourceDirectory` recursively for `.graphql` and `.gql` files. + private mutating func resolveSchemaFiles() throws -> [String] { + // If a config file was provided with a `schemas` key, use it + var configSchemas: [String]? = nil + if let configPath = config { + let generatorConfig = try YAMLDecoder().decode( + GeneratorConfig.self, + from: Data(contentsOf: URL(fileURLWithPath: configPath)) + ) + configSchemas = generatorConfig.schemas + } + // Otherwise, recursively scan the source directory itself + let schemaPaths = configSchemas ?? ["./"] + + let schemaFileSet = try resolvePaths( + schemaPaths, + relativeTo: URL(fileURLWithPath: sourceDirectory) + ) + return Array(schemaFileSet).sorted() + } + + /// Resolves file or directory paths into concrete schema file paths. Files are added directly while directories are expanded recursively. + private func resolvePaths(_ paths: [String], relativeTo baseURL: URL) throws -> Set { + let fm = FileManager.default + var result: Set = [] + + for path in paths { + let resolvedURL = baseURL.appendingPathComponent(path) + let resolvedPath = resolvedURL.path + + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: resolvedPath, isDirectory: &isDirectory) else { + throw ValidationError( + "Schema path not found: \(path) (resolved to \(resolvedPath))" + ) + } + + if isDirectory.boolValue { + // Recursively finds all `.graphql` and `.gql` files under the directory + if let enumerator = fm.enumerator( + at: resolvedURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + for case let fileURL as URL in enumerator { + let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues?.isDirectory == true { continue } + if Self.schemaExtensions.contains(fileURL.pathExtension.lowercased()) { + result.insert(fileURL.path) + } + } + } + } else { + result.insert(resolvedPath) + } + } + return result + } } diff --git a/Sources/GraphQLGeneratorCore/Config/GeneratorConfig.swift b/Sources/GraphQLGeneratorCore/Config/GeneratorConfig.swift new file mode 100644 index 0000000..0ae3202 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Config/GeneratorConfig.swift @@ -0,0 +1,18 @@ +/// Configuration for the GraphQL Generator, loaded from a YAML file. +/// +/// Users can place a `graphql-generator-config.yaml` or `graphql-generator-config.yml` +/// file in their target's source directory to customize generator behavior. +package struct GeneratorConfig: Codable, Sendable { + + /// Paths to GraphQL schema files and/or directories containing schema files. + /// + /// Each entry is resolved relative to the target's source directory. + /// Directories are recursively expanded to include all contained `.graphql` and `.gql` files. + /// + /// If nil, the plugin falls back to scanning the target's source files for `.graphql` and `.gql` files. + package var schemas: [String]? + + package init(schemas: [String]? = nil) { + self.schemas = schemas + } +} diff --git a/Tests/GraphQLGeneratorTests/GraphQLGeneratorCommandTests.swift b/Tests/GraphQLGeneratorTests/GraphQLGeneratorCommandTests.swift new file mode 100644 index 0000000..0aed2d3 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/GraphQLGeneratorCommandTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import GraphQLGenerator + +@Suite +struct GraphQLGeneratorCommandTests { + @Test + func testFallbackScanNoConfig() throws { + let (outputDir, tmpRoot) = try runGenerator(files: [ + "schema.graphql": "type Query { hello: String! }" + ]) + defer { try? FileManager.default.removeItem(at: tmpRoot) } + + let types = try String(contentsOf: outputDir.appendingPathComponent("GraphQLTypes.swift")) + #expect(types.contains("protocol Query")) + } + + @Test + func testConfigWithExplicitSchemaPath() throws { + let (outputDir, tmpRoot) = try runGenerator( + files: [ + "schema.graphql": "type Query { hello: String! }", + "ignored.graphql": "type Query { bogus: Int }", + ], + config: "schemas: [schema.graphql]\n" + ) + defer { try? FileManager.default.removeItem(at: tmpRoot) } + + let types = try String(contentsOf: outputDir.appendingPathComponent("GraphQLTypes.swift")) + #expect(types.contains("protocol Query")) + #expect(!types.contains("bogus")) + } + + @Test + func testConfigWithDirectory() throws { + let (outputDir, tmpRoot) = try runGenerator( + files: [ + "api/users.graphql": "type User { name: String! }", + "api/posts.graphql": "type Post { title: String! }", + "legacy/old.graphql": "type Old { x: Int }", + ], + config: """ + schemas: + - api/ + """ + ) + defer { try? FileManager.default.removeItem(at: tmpRoot) } + + let types = try String(contentsOf: outputDir.appendingPathComponent("GraphQLTypes.swift")) + #expect(types.contains("protocol User")) + #expect(types.contains("protocol Post")) + #expect(!types.contains("protocol Old")) + } + + @Test + func testConfigWithoutSchemasKey() throws { + let (outputDir, tmpRoot) = try runGenerator( + files: [ + "schema.graphql": "type Query { hello: String! }" + ], + config: "some_other_key: 42\n" + ) + defer { try? FileManager.default.removeItem(at: tmpRoot) } + + let types = try String(contentsOf: outputDir.appendingPathComponent("GraphQLTypes.swift")) + #expect(types.contains("protocol Query")) + } + + @Test + func testEmptySourceDirectoryThrows() throws { + let tmpRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("graphql-gen-tests-\(UUID().uuidString)") + let sourceDir = tmpRoot.appendingPathComponent("Sources") + let outputDir = tmpRoot.appendingPathComponent("Generated") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpRoot) } + + #expect(throws: (any Error).self) { + var command = try GraphQLGeneratorCommand.parseAsRoot([ + "--source-directory", sourceDir.path, + "--output-directory", outputDir.path, + ]) + try command.run() + } + } + + /// Creates a temporary source and output directory, writes the given files keyed by relative path, then runs the generator. + /// Returns the output directory and the temp root (for cleanup via `defer`). + private func runGenerator( + files: [String: String] = [:], + config: String? = nil + ) throws -> (outputDir: URL, tmpRoot: URL) { + let tmpRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("graphql-gen-tests-\(UUID().uuidString)") + let sourceDir = tmpRoot.appendingPathComponent("Sources") + let outputDir = tmpRoot.appendingPathComponent("Generated") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + for (relativePath, content) in files { + let fileURL = sourceDir.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + } + + var args = [ + "--source-directory", sourceDir.path, + "--output-directory", outputDir.path, + ] + + if let configContent = config { + let configURL = sourceDir.appendingPathComponent("graphql-generator-config.yaml") + try configContent.write(to: configURL, atomically: true, encoding: .utf8) + args += ["--config", configURL.path] + } + + var command = try GraphQLGeneratorCommand.parseAsRoot(args) + try command.run() + + return (outputDir, tmpRoot) + } +}