From 714a5b6032e3012add2c1a170976509c40d55636 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Tue, 23 Dec 2025 20:56:45 -0600 Subject: [PATCH 01/38] feat: Adds basic generator. Claude-code generated --- .gitignore | 1 + Examples/HelloWorldServer/.gitignore | 8 + Examples/HelloWorldServer/Package.resolved | 33 ++ Examples/HelloWorldServer/Package.swift | 26 ++ .../Sources/HelloWorldServer/main.swift | 5 + .../Sources/HelloWorldServer/schema.graphql | 83 ++++ Package.resolved | 33 ++ Package.swift | 60 ++- Plugins/GraphQLGeneratorPlugin.swift | 97 +++++ Plugins/graphql-generator.swift | 57 --- README.md | 226 +++++++++++ Sources/GraphQLGenerator/main.swift | 72 ++++ .../Generator/CodeGenerator.swift | 31 ++ .../Generator/ResolverGenerator.swift | 179 +++++++++ .../Generator/SchemaGenerator.swift | 32 ++ .../Generator/TypeGenerator.swift | 165 ++++++++ .../Parser/SchemaParser.swift | 27 ++ .../Utilities/ContentType.swift | 16 + .../Utilities/SafeNameGenerator.swift | 367 +++++++++++++++++ .../ResolverContext.swift | 16 + .../PlaceholderTests.swift | 1 + plan.md | 380 ++++++++++++++++++ 22 files changed, 1851 insertions(+), 64 deletions(-) create mode 100644 Examples/HelloWorldServer/.gitignore create mode 100644 Examples/HelloWorldServer/Package.resolved create mode 100644 Examples/HelloWorldServer/Package.swift create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql create mode 100644 Package.resolved create mode 100644 Plugins/GraphQLGeneratorPlugin.swift delete mode 100644 Plugins/graphql-generator.swift create mode 100644 README.md create mode 100644 Sources/GraphQLGenerator/main.swift create mode 100644 Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift create mode 100644 Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift create mode 100644 Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift create mode 100644 Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift create mode 100644 Sources/GraphQLGeneratorCore/Parser/SchemaParser.swift create mode 100644 Sources/GraphQLGeneratorCore/Utilities/ContentType.swift create mode 100644 Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift create mode 100644 Sources/GraphQLGeneratorRuntime/ResolverContext.swift create mode 100644 Tests/GraphQLGeneratorTests/PlaceholderTests.swift create mode 100644 plan.md diff --git a/.gitignore b/.gitignore index 0023a53..8c849fb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +/.vscode diff --git a/Examples/HelloWorldServer/.gitignore b/Examples/HelloWorldServer/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Examples/HelloWorldServer/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved new file mode 100644 index 0000000..b84558d --- /dev/null +++ b/Examples/HelloWorldServer/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "06ff74ba9d083090d6365334b9fb7efae3aac50834ec4b30e75b67c5eb078b11", + "pins" : [ + { + "identity" : "graphql", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/GraphQL.git", + "state" : { + "revision" : "6e483aec1a8f86f7fee95b1f72e678bdf0e537a4", + "version" : "4.0.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift new file mode 100644 index 0000000..50890e6 --- /dev/null +++ b/Examples/HelloWorldServer/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "HelloWorldServer", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(name: "graphql-generator", path: "../.."), + .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), + ], + targets: [ + .executableTarget( + name: "HelloWorldServer", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + ], + plugins: [ + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + ] + ), + ] +) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift new file mode 100644 index 0000000..74388f4 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -0,0 +1,5 @@ +import Foundation + +// This file will use the generated code once we build the project +print("Hello, GraphQL Server!") +print("Generated code will be available after building") diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql new file mode 100644 index 0000000..ebec41e --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -0,0 +1,83 @@ +""" +A simple user type +""" +type User { + """ + The unique identifier for the user + """ + id: ID! + + """ + The user's display name + """ + name: String! + + """ + The user's email address + """ + email: String! + + """ + The user's age + """ + age: Int +} + +""" +A blog post +""" +type Post { + """ + The unique identifier for the post + """ + id: ID! + + """ + The post title + """ + title: String! + + """ + The post content + """ + content: String! + + """ + The author of the post + """ + author: User! +} + +""" +User role enumeration +""" +enum Role { + ADMIN + USER + GUEST +} + +""" +Root query type +""" +type Query { + """ + Get a user by ID + """ + user(id: ID!): User + + """ + Get all users + """ + users: [User!]! + + """ + Get a post by ID + """ + post(id: ID!): Post + + """ + Get recent posts + """ + posts(limit: Int = 10): [Post!]! +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..32454f7 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "eb57461e78e402360fb2a1deaab20d1947d9aa4ae36eaad2f9b806ef82750c4a", + "pins" : [ + { + "identity" : "graphql", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/GraphQL.git", + "state" : { + "revision" : "6e483aec1a8f86f7fee95b1f72e678bdf0e537a4", + "version" : "4.0.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 5673a2c..9c98ac8 100644 --- a/Package.swift +++ b/Package.swift @@ -5,19 +5,65 @@ import PackageDescription let package = Package( name: "graphql-generator", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9) + ], products: [ - // Products can be used to vend plugins, making them visible to other packages. .plugin( - name: "graphql-generator", - targets: ["graphql-generator"] + name: "GraphQLGeneratorPlugin", + targets: ["GraphQLGeneratorPlugin"] + ), + .library( + name: "GraphQLGeneratorRuntime", + targets: ["GraphQLGeneratorRuntime"] ), ], + dependencies: [ + .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. + // Build plugin that discovers .graphql files and invokes the generator .plugin( - name: "graphql-generator", - capability: .buildTool() + name: "GraphQLGeneratorPlugin", + capability: .buildTool(), + dependencies: ["GraphQLGenerator"] + ), + + // CLI executable that performs code generation + .executableTarget( + name: "GraphQLGenerator", + dependencies: [ + "GraphQLGeneratorCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + + // Core library with parsing and generation logic + .target( + name: "GraphQLGeneratorCore", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + ] + ), + + // Runtime library for generated code + .target( + name: "GraphQLGeneratorRuntime", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + ] + ), + + // Tests + .testTarget( + name: "GraphQLGeneratorTests", + dependencies: [ + "GraphQLGeneratorCore", + ] ), ] ) diff --git a/Plugins/GraphQLGeneratorPlugin.swift b/Plugins/GraphQLGeneratorPlugin.swift new file mode 100644 index 0000000..ed76e28 --- /dev/null +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -0,0 +1,97 @@ +import PackagePlugin +import Foundation + +@main +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" + } + + // If no schema files found, return early + guard !schemaFiles.isEmpty else { return [] } + + // Find the generator tool + let generatorTool = try context.tool(named: "GraphQLGenerator") + + // 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) + + // Output files: Types.swift, Resolvers.swift, Schema.swift + let outputFiles = [ + outputDirectory.appendingPathComponent("Types.swift"), + outputDirectory.appendingPathComponent("Resolvers.swift"), + outputDirectory.appendingPathComponent("Schema.swift") + ] + + let arguments = schemaInputs.flatMap { ["\($0.path)"] } + [ + "--output-directory", outputDirectory.path + ] + + return [ + .buildCommand( + displayName: "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", + executable: generatorTool.url, + arguments: arguments, + inputFiles: schemaInputs, + outputFiles: outputFiles + ) + ] + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension GraphQLGeneratorPlugin: XcodeBuildToolPlugin { + /// Entry point for creating build commands for targets in Xcode projects. + 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" + } + + // If no schema files found, return early + guard !schemaFiles.isEmpty else { return [] } + + // Find the generator tool + let generatorTool = try context.tool(named: "GraphQLGenerator") + + // Create output directory for generated files + let outputDirectory = context.pluginWorkDirectoryURL + + let schemaInputs = schemaFiles.map(\.url) + + // Output files: Types.swift, Resolvers.swift, Schema.swift + let outputFiles = [ + outputDirectory.appendingPathComponent("Types.swift"), + outputDirectory.appendingPathComponent("Resolvers.swift"), + outputDirectory.appendingPathComponent("Schema.swift") + ] + + let arguments = schemaInputs.flatMap { ["\($0.path)"] } + [ + "--output-directory", outputDirectory.path + ] + + return [ + .buildCommand( + displayName: "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", + executable: generatorTool.url, + arguments: arguments, + inputFiles: schemaInputs, + outputFiles: outputFiles + ) + ] + } +} + +#endif diff --git a/Plugins/graphql-generator.swift b/Plugins/graphql-generator.swift deleted file mode 100644 index a3bcc1f..0000000 --- a/Plugins/graphql-generator.swift +++ /dev/null @@ -1,57 +0,0 @@ -import PackagePlugin -import struct Foundation.URL - -@main -struct graphql_generator: 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 code generator tool to run (replace this with the actual one). - let generatorTool = try context.tool(named: "my-code-generator") - - // Construct a build command for each source file with a particular suffix. - return sourceFiles.map(\.url).compactMap { - createBuildCommand(for: $0, in: context.pluginWorkDirectoryURL, with: generatorTool.url) - } - } -} - -#if canImport(XcodeProjectPlugin) -import XcodeProjectPlugin - -extension graphql_generator: XcodeBuildToolPlugin { - // Entry point for creating build commands for targets in Xcode projects. - func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { - // Find the code generator tool to run (replace this with the actual one). - let generatorTool = try context.tool(named: "my-code-generator") - - // Construct a build command for each source file with a particular suffix. - return target.inputFiles.map(\.url).compactMap { - createBuildCommand(for: $0, in: context.pluginWorkDirectoryURL, with: generatorTool.url) - } - } -} - -#endif - -extension graphql_generator { - /// Shared function that returns a configured build command if the input files is one that should be processed. - func createBuildCommand(for inputPath: URL, in outputDirectoryPath: URL, with generatorToolPath: URL) -> Command? { - // Skip any file that doesn't have the extension we're looking for (replace this with the actual one). - guard inputPath.pathExtension == "my-input-suffix" else { return .none } - - // Return a command that will run during the build to generate the output file. - let inputName = inputPath.lastPathComponent - let outputName = inputPath.deletingPathExtension().lastPathComponent + ".swift" - let outputPath = outputDirectoryPath.appendingPathComponent(outputName) - return .buildCommand( - displayName: "Generating \(outputName) from \(inputName)", - executable: generatorToolPath, - arguments: ["\(inputPath)", "-o", "\(outputPath)"], - inputFiles: [inputPath], - outputFiles: [outputPath] - ) - } -} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a71eb8 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# GraphQL Generator for Swift + +A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [swift-openapi-generator](https://github.com/apple/swift-openapi-generator). + +This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code from your GraphQL schemas, eliminating boilerplate while maintaining full control over your business logic. + +## Status + +🚧 **Phase 1 Complete** - Foundation is in place with basic code generation + +Currently implemented: +- βœ… Build plugin for SPM integration +- βœ… GraphQL schema parsing using GraphQL Swift's `buildSchema` +- βœ… Type generation (Swift structs from GraphQL types) +- βœ… Resolver protocol generation +- βœ… Basic runtime library with ResolverContext +- βœ… CLI tool for code generation + +Still in development (see [plan.md](plan.md)): +- ⏳ Complete schema builder generation (Phase 5) +- ⏳ Mutations and subscriptions support +- ⏳ Custom scalar mappings +- ⏳ Configuration file support +- ⏳ Complete test coverage +- ⏳ Working end-to-end examples + +## Features + +- **Build-time code generation**: Code is generated at build time and never needs to be committed +- **Type-safe**: Leverages Swift's type system for compile-time safety +- **Framework-agnostic**: Generated code works with any Swift server framework (Vapor, Hummingbird, etc.) +- **Modern Swift**: Uses async/await for all resolver functions +- **Minimal boilerplate**: Generates only ceremony code - you write the business logic + +## Requirements + +- Swift 6.2+ +- macOS 13+, iOS 16+, tvOS 16+, or watchOS 9+ +- GraphQL Swift 4.0+ + +## Installation + +Add the package to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), + .package(url: "https://github.com/YourOrg/graphql-generator", from: "1.0.0") +], +targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + ], + plugins: [ + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + ] + ) +] +``` + +## Quick Start + +### 1. Create a GraphQL Schema + +Create a `.graphql` file in your target's `Sources` directory: + +**Sources/YourTarget/schema.graphql**: +```graphql +type User { + id: ID! + name: String! + email: String! +} + +type Query { + user(id: ID!): User + users: [User!]! +} +``` + +### 2. Build Your Project + +When you build, the plugin will automatically generate Swift code: +- `Types.swift` - Swift structs for your GraphQL types +- `Resolvers.swift` - Protocol defining resolver methods +- `Schema.swift` - Schema builder (coming in Phase 5) + +### 3. Implement the Resolver Protocol + +```swift +import GraphQL +import GraphQLGeneratorRuntime + +struct MyResolvers: GraphQLResolvers { + func user(id: String, context: ResolverContext) async throws -> User? { + // Your business logic here + return User(id: id, name: "John Doe", email: "john@example.com") + } + + func users(context: ResolverContext) async throws -> [User] { + // Your business logic here + return [ + User(id: "1", name: "Alice", email: "alice@example.com"), + User(id: "2", name: "Bob", email: "bob@example.com"), + ] + } +} +``` + +### 4. Execute GraphQL Queries + +```swift +import GraphQL + +// Create resolvers +let resolvers = MyResolvers() + +// Build schema (Phase 5 - not yet implemented) +// let schema = try buildGraphQLSchema(resolvers: resolvers) + +// Execute a query +// let result = try await graphql(schema: schema, request: "{ users { name email } }") +// print(result) +``` + +## Project Structure + +``` +graphql-generator/ +β”œβ”€β”€ Package.swift +β”œβ”€β”€ README.md +β”œβ”€β”€ plan.md # Detailed implementation plan +β”œβ”€β”€ Plugins/ +β”‚ └── GraphQLGeneratorPlugin.swift # SPM build plugin +β”œβ”€β”€ Sources/ +β”‚ β”œβ”€β”€ GraphQLGenerator/ # CLI executable +β”‚ β”œβ”€β”€ GraphQLGeneratorCore/ # Parsing and generation logic +β”‚ β”‚ β”œβ”€β”€ Parser/ +β”‚ β”‚ β”‚ └── SchemaParser.swift +β”‚ β”‚ └── Generator/ +β”‚ β”‚ β”œβ”€β”€ CodeGenerator.swift +β”‚ β”‚ β”œβ”€β”€ TypeGenerator.swift +β”‚ β”‚ β”œβ”€β”€ ResolverGenerator.swift +β”‚ β”‚ └── SchemaGenerator.swift +β”‚ └── GraphQLGeneratorRuntime/ # Runtime support library +β”‚ └── ResolverContext.swift +β”œβ”€β”€ Tests/ +β”‚ └── GraphQLGeneratorTests/ +└── Examples/ + └── HelloWorldServer/ +``` + +## Generated Code Examples + +### From this GraphQL schema: + +```graphql +type User { + id: ID! + name: String! + email: String! +} +``` + +### Generates this Swift code: + +```swift +// Types.swift +public struct User: Codable { + public let id: String + public let name: String + public let email: String + + public init( + id: String, + name: String, + email: String + ) { + self.id = id + self.name = name + self.email = email + } +} + +// Resolvers.swift +public protocol GraphQLResolvers { + func user(id: String, context: ResolverContext) async throws -> User? + func users(context: ResolverContext) async throws -> [User] +} +``` + +## Development Roadmap + +See [plan.md](plan.md) for the complete implementation plan across 8 phases: + +- **Phase 1: Foundation** βœ… - Basic infrastructure and plugin setup +- **Phase 2: Schema Parsing** - Complete SDL parsing (in progress) +- **Phase 3: Type Generation** - Full type generation with all GraphQL constructs +- **Phase 4: Resolver Generation** - Complete resolver protocol generation +- **Phase 5: Schema Builder** - Generate executable GraphQL schema +- **Phase 6: Advanced Features** - Mutations, subscriptions, custom scalars +- **Phase 7: Runtime & Ergonomics** - Helper utilities and patterns +- **Phase 8: Examples & Documentation** - Complete examples and guides + +## Contributing + +This project is in active development. Contributions are welcome! + +## License + +TBD + +## Inspiration + +This project is inspired by: +- [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) - Build plugin architecture +- [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) - Runtime GraphQL implementation + +## Related Projects + +- [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) - The Swift GraphQL implementation +- [Vapor](https://github.com/vapor/vapor) - Server-side Swift framework +- [Hummingbird](https://github.com/hummingbird-project/hummingbird) - Lightweight server framework diff --git a/Sources/GraphQLGenerator/main.swift b/Sources/GraphQLGenerator/main.swift new file mode 100644 index 0000000..468c192 --- /dev/null +++ b/Sources/GraphQLGenerator/main.swift @@ -0,0 +1,72 @@ +import Foundation +import ArgumentParser +import GraphQLGeneratorCore + +@main +struct GraphQLGeneratorCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "graphql-generator", + abstract: "Generate Swift code from GraphQL schema files", + version: "0.1.0" + ) + + @Argument(help: "GraphQL schema files to process (.graphql or .gql)") + var schemaFiles: [String] + + @Option(name: .shortAndLong, help: "Output directory for generated files") + var outputDirectory: String + + @Flag(name: .long, help: "Enable verbose logging") + var verbose: Bool = false + + mutating func run() throws { + if verbose { + print("GraphQL Generator starting...") + print("Schema files: \(schemaFiles)") + print("Output directory: \(outputDirectory)") + } + + // Validate input files exist + for filePath in schemaFiles { + let fileURL = URL(fileURLWithPath: filePath) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw ValidationError("Schema file not found: \(filePath)") + } + } + + // Create output directory if it doesn't exist + let outputURL = URL(fileURLWithPath: outputDirectory) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + + if verbose { + print("Parsing schema files...") + } + + // Parse schema files + let parser = SchemaParser() + let schema = try parser.parseSchemaFiles(schemaFiles) + + if verbose { + print("Schema parsed successfully") + print("Generating Swift code...") + } + + // Generate code + let generator = CodeGenerator(schema: schema) + let generatedFiles = try generator.generate() + + // Write generated files + for (filename, content) in generatedFiles { + let fileURL = outputURL.appendingPathComponent(filename) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + + if verbose { + print("Generated: \(fileURL.path)") + } + } + + if verbose { + print("Code generation complete!") + } + } +} diff --git a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift new file mode 100644 index 0000000..559da10 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -0,0 +1,31 @@ +import Foundation +import GraphQL + +/// Main code generator that orchestrates generation of all Swift files +public struct CodeGenerator { + let schema: GraphQLSchema + + public init(schema: GraphQLSchema) { + self.schema = schema + } + + /// Generate all Swift files from the schema + /// Returns a dictionary of filename -> file content + public func generate() throws -> [String: String] { + var files: [String: String] = [:] + + // Generate Types.swift + let typeGenerator = TypeGenerator(schema: schema) + files["Types.swift"] = try typeGenerator.generate() + + // Generate Resolvers.swift + let resolverGenerator = ResolverGenerator(schema: schema) + files["Resolvers.swift"] = try resolverGenerator.generate() + + // Generate Schema.swift + let schemaGenerator = SchemaGenerator(schema: schema) + files["Schema.swift"] = try schemaGenerator.generate() + + return files + } +} diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift new file mode 100644 index 0000000..e87e7bf --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -0,0 +1,179 @@ +import Foundation +import GraphQL + +/// Generates resolver protocol from GraphQL schema +public struct ResolverGenerator { + let schema: GraphQLSchema + + public init(schema: GraphQLSchema) { + self.schema = schema + } + + public func generate() throws -> String { + var output = """ + // Generated by GraphQL Generator + // DO NOT EDIT - This file is automatically generated + + import Foundation + import GraphQL + import GraphQLGeneratorRuntime + + /// Protocol defining all resolver methods for your GraphQL schema + public protocol GraphQLResolvers { + + """ + + // Generate resolver methods for Query type + if let queryType = schema.queryType { + output += try generateResolverMethods(for: queryType, rootType: "Query") + } + + // Generate resolver methods for Mutation type + if let mutationType = schema.mutationType { + output += try generateResolverMethods(for: mutationType, rootType: "Mutation") + } + + // Generate resolver methods for nested fields + let typeMap = schema.typeMap + let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } + + for objectType in objectTypes { + // Skip introspection types and root operation types + if objectType.name.hasPrefix("__") || + objectType.name == "Query" || + objectType.name == "Mutation" || + objectType.name == "Subscription" { + continue + } + + output += try generateNestedResolverMethods(for: objectType) + } + + output += "}\n" + + return output + } + + private func generateResolverMethods(for type: GraphQLObjectType, rootType: String) throws -> String { + var output = "" + + output += " // MARK: - \(rootType) Resolvers\n\n" + + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += " /// \(description)\n" + } + + let returnType = try swiftTypeName(for: field.type) + + // Generate parameter list + var params: [String] = [] + + // Add arguments + for (argName, arg) in field.args { + let argType = try swiftTypeName(for: arg.type) + params.append("\(argName): \(argType)") + } + + // Add context parameter + params.append("context: ResolverContext") + + let paramString = params.joined(separator: ", ") + + output += " func \(fieldName)(\(paramString)) async throws -> \(returnType)\n\n" + } + + return output + } + + private func generateNestedResolverMethods(for type: GraphQLObjectType) throws -> String { + var output = "" + let fields = try type.fields() + + // Only generate nested resolvers for fields that reference other object types + let nestedFields = fields.filter { (_, field) in + let unwrappedType = unwrapType(field.type) + return unwrappedType is GraphQLObjectType && !(unwrappedType is GraphQLScalarType) + } + + if nestedFields.isEmpty { + return "" + } + + output += " // MARK: - \(type.name) Field Resolvers\n\n" + + for (fieldName, field) in nestedFields { + if let description = field.description { + output += " /// \(description)\n" + } + + let returnType = try swiftTypeName(for: field.type) + + // Parent parameter is the type itself + var params: [String] = ["parent: \(type.name)"] + + // Add arguments if any + for (argName, arg) in field.args { + let argType = try swiftTypeName(for: arg.type) + params.append("\(argName): \(argType)") + } + + // Add context parameter + params.append("context: ResolverContext") + + let paramString = params.joined(separator: ", ") + + output += " func \(type.name.lowercased())\(fieldName.capitalized)(\(paramString)) async throws -> \(returnType)\n\n" + } + + return output + } + + /// Convert GraphQL type to Swift type name + private func swiftTypeName(for type: GraphQLType) throws -> String { + if let nonNull = type as? GraphQLNonNull { + return try swiftTypeName(for: nonNull.ofType) + } + + if let list = type as? GraphQLList { + let innerType = try swiftTypeName(for: list.ofType) + if innerType.hasSuffix("?") { + let baseType = String(innerType.dropLast()) + return "[\(baseType)]?" + } + return "[\(innerType)]?" + } + + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + let swiftType = mapScalarType(typeName) + return "\(swiftType)?" + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") + } + + /// Unwrap GraphQL type to get the base type + private func unwrapType(_ type: GraphQLType) -> GraphQLType { + if let nonNull = type as? GraphQLNonNull { + return unwrapType(nonNull.ofType) + } + if let list = type as? GraphQLList { + return unwrapType(list.ofType) + } + return type + } + + /// Map GraphQL scalar types to Swift types + private func mapScalarType(_ graphQLType: String) -> String { + switch graphQLType { + case "ID": return "String" + case "String": return "String" + case "Int": return "Int" + case "Float": return "Double" + case "Boolean": return "Bool" + default: return graphQLType + } + } +} diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift new file mode 100644 index 0000000..d3a974b --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -0,0 +1,32 @@ +import Foundation +import GraphQL + +/// Generates the GraphQL schema builder function +public struct SchemaGenerator { + let schema: GraphQLSchema + + public init(schema: GraphQLSchema) { + self.schema = schema + } + + public func generate() throws -> String { + let output = """ + // Generated by GraphQL Generator + // DO NOT EDIT - This file is automatically generated + + import Foundation + import GraphQL + import GraphQLGeneratorRuntime + + /// Build a GraphQL schema with the provided resolvers + public func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { + // TODO: Generate complete schema builder + // This will be implemented in Phase 5 + fatalError("Schema builder not yet implemented") + } + + """ + + return output + } +} diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift new file mode 100644 index 0000000..6a06bf4 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -0,0 +1,165 @@ +import Foundation +import GraphQL + +/// Generates Swift type definitions from GraphQL types +public struct TypeGenerator { + let schema: GraphQLSchema + let nameGenerator: SafeNameGenerator + + public init(schema: GraphQLSchema, nameGenerator: SafeNameGenerator = .idiomatic) { + self.schema = schema + self.nameGenerator = nameGenerator + } + + public func generate() throws -> String { + var output = """ + // Generated by GraphQL Generator + // DO NOT EDIT - This file is automatically generated + + import Foundation + + """ + + // Generate struct for each object type (excluding Query, Mutation, Subscription) + let typeMap = schema.typeMap + let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } + + for objectType in objectTypes { + // Skip introspection types (prefixed with __) and root operation types + if objectType.name.hasPrefix("__") || + objectType.name == "Query" || + objectType.name == "Mutation" || + objectType.name == "Subscription" { + continue + } + + output += "\n" + output += try generateStruct(for: objectType) + } + + // Generate enums + let enumTypes = typeMap.values.compactMap { $0 as? GraphQLEnumType } + for enumType in enumTypes { + // Skip GraphQL internal enums (prefixed with __) + if enumType.name.hasPrefix("__") { + continue + } + + output += "\n" + output += try generateEnum(for: enumType) + } + + return output + } + + private func generateStruct(for type: GraphQLObjectType) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += "/// \(description)\n" + } + + // Use safe name generator for type name + let safeTypeName = nameGenerator.swiftTypeName(for: type.name) + output += "public struct \(safeTypeName): Codable {\n" + + // Generate properties + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += " /// \(description)\n" + } + + let swiftType = try swiftTypeName(for: field.type) + let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) + output += " public let \(safeFieldName): \(swiftType)\n" + } + + // Swift auto-generates memberwise initializers for structs, so we don't need to generate one + output += "}\n" + + return output + } + + private func generateEnum(for type: GraphQLEnumType) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += "/// \(description)\n" + } + + // Use safe name generator for enum name + let safeEnumName = nameGenerator.swiftTypeName(for: type.name) + output += "public enum \(safeEnumName): String, Codable {\n" + + // Generate cases + for value in type.values { + if let description = value.description { + output += " /// \(description)\n" + } + // Use safe name generator for case names + let safeCaseName = nameGenerator.swiftMemberName(for: value.name) + output += " case \(safeCaseName) = \"\(value.name)\"\n" + } + + output += "}\n" + + return output + } + + /// Convert GraphQL type to Swift type name + private func swiftTypeName(for type: GraphQLType) throws -> String { + // GraphQLNonNull means the field is required (non-optional) + if let nonNull = type as? GraphQLNonNull { + let innerType = try swiftTypeName(for: nonNull.ofType) + // Remove the optional marker if present + if innerType.hasSuffix("?") { + return String(innerType.dropLast()) + } + return innerType + } + + // GraphQLList means an array + if let list = type as? GraphQLList { + let innerType = try swiftTypeName(for: list.ofType) + return "[\(innerType)]?" + } + + // Named types (scalars, enums, objects) + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + let swiftType = mapScalarType(typeName) + // By default, GraphQL fields are nullable, so add optional marker + return "\(swiftType)?" + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") + } + + /// Map GraphQL scalar types to Swift types + private func mapScalarType(_ graphQLType: String) -> String { + switch graphQLType { + case "ID": return "String" + case "String": return "String" + case "Int": return "Int" + case "Float": return "Double" + case "Boolean": return "Bool" + default: + // For custom types (enums, objects), use safe name generator + return nameGenerator.swiftTypeName(for: graphQLType) + } + } +} + +public enum GeneratorError: Error, CustomStringConvertible { + case unsupportedType(String) + + public var description: String { + switch self { + case .unsupportedType(let message): + return "Unsupported type: \(message)" + } + } +} diff --git a/Sources/GraphQLGeneratorCore/Parser/SchemaParser.swift b/Sources/GraphQLGeneratorCore/Parser/SchemaParser.swift new file mode 100644 index 0000000..6739334 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Parser/SchemaParser.swift @@ -0,0 +1,27 @@ +import Foundation +import GraphQL + +/// Parses GraphQL schema files and builds a GraphQLSchema +public struct SchemaParser { + public init() {} + + /// Parse GraphQL schema files and combine them into a single schema + public func parseSchemaFiles(_ filePaths: [String]) throws -> GraphQLSchema { + var combinedSource = "" + + // Read and combine all schema files + for filePath in filePaths { + let url = URL(fileURLWithPath: filePath) + let content = try String(contentsOf: url, encoding: .utf8) + combinedSource += content + "\n" + } + + // Use GraphQL Swift's built-in buildSchema function + return try GraphQL.buildSchema(source: combinedSource) + } + + /// Parse a single GraphQL schema string + public func parseSchema(_ source: String) throws -> GraphQLSchema { + return try GraphQL.buildSchema(source: source) + } +} diff --git a/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift b/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift new file mode 100644 index 0000000..c06c543 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A simple stub for ContentType - we don't need the full implementation for GraphQL +public struct ContentType { + public let lowercasedTypeSubtypeAndParameters: String + public let originallyCasedType: String + public let originallyCasedSubtype: String + public let lowercasedParameterPairs: [String] +} + +extension String { + var uppercasingFirstLetter: String { + guard let first = first else { return self } + return first.uppercased() + dropFirst() + } +} diff --git a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift new file mode 100644 index 0000000..917d960 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -0,0 +1,367 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// Computes a string sanitized to be usable as a Swift identifier in various contexts. +public protocol SafeNameGenerator { + + /// Returns a string sanitized to be usable as a Swift type name in a general context. + /// - Parameter documentedName: The input unsanitized string from the OpenAPI document. + /// - Returns: The sanitized string. + func swiftTypeName(for documentedName: String) -> String + + /// Returns a string sanitized to be usable as a Swift member name in a general context. + /// - Parameter documentedName: The input unsanitized string from the OpenAPI document. + /// - Returns: The sanitized string. + func swiftMemberName(for documentedName: String) -> String + + /// Returns a string sanitized to be usable as a Swift identifier for the provided content type. + /// - Parameter contentType: The content type for which to compute a Swift identifier. + /// - Returns: A Swift identifier for the provided content type. + func swiftContentTypeName(for contentType: ContentType) -> String +} + +extension SafeNameGenerator { + + /// Returns a Swift identifier override for the provided content type. + /// - Parameter contentType: A content type. + /// - Returns: A Swift identifer for the content type, or nil if the provided content type doesn't + /// have an override. + func swiftNameOverride(for contentType: ContentType) -> String? { + let rawContentType = contentType.lowercasedTypeSubtypeAndParameters + switch rawContentType { + case "application/json": return "json" + case "application/x-www-form-urlencoded": return "urlEncodedForm" + case "multipart/form-data": return "multipartForm" + case "text/plain": return "plainText" + case "*/*": return "any" + case "application/xml": return "xml" + case "application/octet-stream": return "binary" + case "text/html": return "html" + case "application/yaml": return "yaml" + case "text/csv": return "csv" + case "image/png": return "png" + case "application/pdf": return "pdf" + case "image/jpeg": return "jpeg" + default: return nil + } + } +} + +/// Returns a string sanitized to be usable as a Swift identifier. +/// +/// See the proposal SOAR-0001 for details. +/// +/// For example, the string `$nake…` would be returned as `_dollar_nake_x2026_`, because +/// both the dollar and ellipsis sign are not valid characters in a Swift identifier. +/// So, it replaces such characters with their html entity equivalents or unicode hex representation, +/// in case it's not present in the `specialCharsMap`. It marks this replacement with `_` as a delimiter. +/// +/// In addition to replacing illegal characters, it also +/// ensures that the identifier starts with a letter and not a number. +public struct DefensiveSafeNameGenerator: SafeNameGenerator { + + public func swiftTypeName(for documentedName: String) -> String { swiftName(for: documentedName) } + public func swiftMemberName(for documentedName: String) -> String { swiftName(for: documentedName) } + private func swiftName(for documentedName: String) -> String { + guard !documentedName.isEmpty else { return "_empty" } + + let firstCharSet: CharacterSet = .letters.union(.init(charactersIn: "_")) + let numbers: CharacterSet = .decimalDigits + let otherCharSet: CharacterSet = .alphanumerics.union(.init(charactersIn: "_")) + + var sanitizedScalars: [Unicode.Scalar] = [] + for (index, scalar) in documentedName.unicodeScalars.enumerated() { + let allowedSet = index == 0 ? firstCharSet : otherCharSet + let outScalar: Unicode.Scalar + if allowedSet.contains(scalar) { + outScalar = scalar + } else if index == 0 && numbers.contains(scalar) { + sanitizedScalars.append("_") + outScalar = scalar + } else { + sanitizedScalars.append("_") + if let entityName = Self.specialCharsMap[scalar] { + for char in entityName.unicodeScalars { sanitizedScalars.append(char) } + } else { + sanitizedScalars.append("x") + let hexString = String(scalar.value, radix: 16, uppercase: true) + for char in hexString.unicodeScalars { sanitizedScalars.append(char) } + } + sanitizedScalars.append("_") + continue + } + sanitizedScalars.append(outScalar) + } + + let validString = String(String.UnicodeScalarView(sanitizedScalars)) + + // Special case for a single underscore. + // We can't add it to the map as its a valid swift identifier in other cases. + if validString == "_" { return "_underscore_" } + + guard Self.keywords.contains(validString) else { return validString } + return "_\(validString)" + } + + public func swiftContentTypeName(for contentType: ContentType) -> String { + if let common = swiftNameOverride(for: contentType) { return common } + let safedType = swiftName(for: contentType.originallyCasedType) + let safedSubtype = swiftName(for: contentType.originallyCasedSubtype) + let componentSeparator = "_" + let prefix = "\(safedType)\(componentSeparator)\(safedSubtype)" + let params = contentType.lowercasedParameterPairs + guard !params.isEmpty else { return prefix } + let safedParams = + params.map { pair in + pair.split(separator: "=").map { component in swiftName(for: String(component)) } + .joined(separator: componentSeparator) + } + .joined(separator: componentSeparator) + return prefix + componentSeparator + safedParams + } + + /// A list of Swift keywords. + /// + /// Copied from SwiftSyntax/TokenKind.swift + private static let keywords: Set = [ + "associatedtype", "class", "deinit", "enum", "extension", "func", "import", "init", "inout", "let", "operator", + "precedencegroup", "protocol", "struct", "subscript", "typealias", "var", "fileprivate", "internal", "private", + "public", "static", "defer", "if", "guard", "do", "repeat", "else", "for", "in", "while", "return", "break", + "continue", "fallthrough", "switch", "case", "default", "where", "catch", "throw", "as", "Any", "false", "is", + "nil", "rethrows", "super", "self", "Self", "true", "try", "throws", "yield", "String", "Error", "Int", "Bool", + "Array", "Type", "type", "Protocol", "await", + ] + + /// A map of ASCII printable characters to their HTML entity names. Used to reduce collisions in generated names. + private static let specialCharsMap: [Unicode.Scalar: String] = [ + " ": "space", "!": "excl", "\"": "quot", "#": "num", "$": "dollar", "%": "percnt", "&": "amp", "'": "apos", + "(": "lpar", ")": "rpar", "*": "ast", "+": "plus", ",": "comma", "-": "hyphen", ".": "period", "/": "sol", + ":": "colon", ";": "semi", "<": "lt", "=": "equals", ">": "gt", "?": "quest", "@": "commat", "[": "lbrack", + "\\": "bsol", "]": "rbrack", "^": "hat", "`": "grave", "{": "lcub", "|": "verbar", "}": "rcub", "~": "tilde", + ] +} + +public extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { + static var defensive: DefensiveSafeNameGenerator { DefensiveSafeNameGenerator() } +} + +/// Returns a string sanitized to be usable as a Swift identifier, and tries to produce UpperCamelCase +/// or lowerCamelCase string, the casing is controlled using the provided options. +/// +/// If the string contains any illegal characters, falls back to the behavior +/// matching `safeForSwiftCode_defensive`. +/// +/// Check out [SOAR-0013](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0013) for details. +public struct IdiomaticSafeNameGenerator: SafeNameGenerator { + + /// The defensive strategy to use as fallback. + public var defensive: DefensiveSafeNameGenerator + + public func swiftTypeName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: true) } + public func swiftMemberName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: false) } + private func swiftName(for documentedName: String, capitalize: Bool) -> String { + if documentedName.isEmpty { return capitalize ? "_Empty_" : "_empty_" } + + // Detect cases like HELLO_WORLD, sometimes used for constants. + let isAllUppercase = documentedName.allSatisfy { + // Must check that no characters are lowercased, as non-letter characters + // don't return `true` to `isUppercase`. + !$0.isLowercase + } + + // 1. Leave leading underscores as-are + // 2. In the middle: word separators: ["_", "-", "/", "+", ] -> remove and capitalize + // next word + // 3. In the middle: period: ["."] -> replace with "_" + // 4. In the middle: drop ["{", "}"] -> replace with "" + + var buffer: [Character] = [] + buffer.reserveCapacity(documentedName.count) + enum State: Equatable { + case modifying + case preFirstWord + struct AccumulatingFirstWordContext: Equatable { var isAccumulatingInitialUppercase: Bool } + case accumulatingFirstWord(AccumulatingFirstWordContext) + case accumulatingWord + case waitingForWordStarter + } + var state: State = .preFirstWord + for index in documentedName[...].indices { + let char = documentedName[index] + let _state = state + state = .modifying + switch _state { + case .preFirstWord: + if char == "_" { + // Leading underscores are kept. + buffer.append(char) + state = .preFirstWord + } else if char.isNumber { + // The underscore will be added by the defensive strategy. + buffer.append(char) + state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) + } else if char.isLetter { + // First character in the identifier. + buffer.append(contentsOf: capitalize ? char.uppercased() : char.lowercased()) + state = .accumulatingFirstWord( + .init(isAccumulatingInitialUppercase: !capitalize && char.isUppercase) + ) + } else { + // Illegal character, keep and let the defensive strategy deal with it. + state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) + buffer.append(char) + } + case .accumulatingFirstWord(var context): + if char.isLetter || char.isNumber { + if isAllUppercase { + buffer.append(contentsOf: char.lowercased()) + } else if context.isAccumulatingInitialUppercase { + // Example: "HTTPProxy"/"HTTP_Proxy"/"HTTP_proxy"" should all + // become "httpProxy" when capitalize == false. + // This means treating the first word differently. + // Here we are on the second or later character of the first word (the first + // character is handled in `.preFirstWord`. + // If the first character was uppercase, and we're in lowercasing mode, + // we need to lowercase every consequtive uppercase character while there's + // another uppercase character after it. + if char.isLowercase { + // No accumulating anymore, just append it and turn off accumulation. + buffer.append(char) + context.isAccumulatingInitialUppercase = false + } else { + let suffix = documentedName.suffix(from: documentedName.index(after: index)) + if suffix.count >= 2 { + let next = suffix.first! + let secondNext = suffix.dropFirst().first! + if next.isUppercase && secondNext.isLowercase { + // Finished lowercasing. + context.isAccumulatingInitialUppercase = false + buffer.append(contentsOf: char.lowercased()) + } else if Self.wordSeparators.contains(next) { + // Finished lowercasing. + context.isAccumulatingInitialUppercase = false + buffer.append(contentsOf: char.lowercased()) + } else if next.isUppercase { + // Keep lowercasing. + buffer.append(contentsOf: char.lowercased()) + } else { + // Append as-is, stop accumulating. + context.isAccumulatingInitialUppercase = false + buffer.append(char) + } + } else { + // This is the last or second to last character, + // since we were accumulating capitals, lowercase it. + buffer.append(contentsOf: char.lowercased()) + context.isAccumulatingInitialUppercase = false + } + } + } else { + buffer.append(char) + } + state = .accumulatingFirstWord(context) + } else if ["_", "-", " ", "/", "+"].contains(char) { + // In the middle of an identifier, these are considered + // word separators, so we remove the character and end the current word. + state = .waitingForWordStarter + } else if ["."].contains(char) { + // In the middle of an identifier, these get replaced with + // an underscore, but continue the current word. + buffer.append("_") + state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) + } else if ["{", "}"].contains(char) { + // In the middle of an identifier, curly braces are dropped. + state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) + } else { + // Illegal character, keep and let the defensive strategy deal with it. + state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) + buffer.append(char) + } + case .accumulatingWord: + if char.isLetter || char.isNumber { + if isAllUppercase { buffer.append(contentsOf: char.lowercased()) } else { buffer.append(char) } + state = .accumulatingWord + } else if Self.wordSeparators.contains(char) { + // In the middle of an identifier, these are considered + // word separators, so we remove the character and end the current word. + state = .waitingForWordStarter + } else if ["."].contains(char) { + // In the middle of an identifier, these get replaced with + // an underscore, but continue the current word. + buffer.append("_") + state = .accumulatingWord + } else if ["{", "}"].contains(char) { + // In the middle of an identifier, these are dropped. + state = .accumulatingWord + } else { + // Illegal character, keep and let the defensive strategy deal with it. + state = .accumulatingWord + buffer.append(char) + } + case .waitingForWordStarter: + if ["_", "-", ".", "/", "+", "{", "}"].contains(char) { + // Between words, just drop allowed special characters, since + // we're already between words anyway. + state = .waitingForWordStarter + } else if char.isLetter || char.isNumber { + // Starting a new word in the middle of the identifier. + buffer.append(contentsOf: char.uppercased()) + state = .accumulatingWord + } else { + // Illegal character, keep and let the defensive strategy deal with it. + state = .waitingForWordStarter + buffer.append(char) + } + case .modifying: preconditionFailure("Logic error in \(#function), string: '\(self)'") + } + precondition(state != .modifying, "Logic error in \(#function), string: '\(self)'") + } + let defensiveFallback: (String) -> String + if capitalize { + defensiveFallback = defensive.swiftTypeName + } else { + defensiveFallback = defensive.swiftMemberName + } + return defensiveFallback(String(buffer)) + } + + public func swiftContentTypeName(for contentType: ContentType) -> String { + if let common = swiftNameOverride(for: contentType) { return common } + let safedType = swiftMemberName(for: contentType.originallyCasedType) + let safedSubtype = swiftMemberName(for: contentType.originallyCasedSubtype) + let prettifiedSubtype = safedSubtype.uppercasingFirstLetter + let prefix = "\(safedType)\(prettifiedSubtype)" + let params = contentType.lowercasedParameterPairs + guard !params.isEmpty else { return prefix } + let safedParams = + params.map { pair in + pair.split(separator: "=") + .map { component in + let safedComponent = swiftMemberName(for: String(component)) + return safedComponent.uppercasingFirstLetter + } + .joined() + } + .joined() + return prefix + safedParams + } + + /// A list of word separator characters for the idiomatic naming strategy. + private static let wordSeparators: Set = ["_", "-", " ", "/", "+"] +} + +public extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { + static var idiomatic: IdiomaticSafeNameGenerator { IdiomaticSafeNameGenerator(defensive: .defensive) } +} diff --git a/Sources/GraphQLGeneratorRuntime/ResolverContext.swift b/Sources/GraphQLGeneratorRuntime/ResolverContext.swift new file mode 100644 index 0000000..c211156 --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/ResolverContext.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Protocol for resolver context that can be passed to resolver functions +/// Implement this protocol to provide dependencies and services to resolvers +public protocol ResolverContext { + // Add common context properties here + // For example: + // var currentUser: User? { get } + // var database: Database { get } + // var cache: Cache { get } +} + +/// A basic empty context implementation +public struct EmptyResolverContext: ResolverContext { + public init() {} +} diff --git a/Tests/GraphQLGeneratorTests/PlaceholderTests.swift b/Tests/GraphQLGeneratorTests/PlaceholderTests.swift new file mode 100644 index 0000000..0199977 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/PlaceholderTests.swift @@ -0,0 +1 @@ +// Test placeholder diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..8bcb343 --- /dev/null +++ b/plan.md @@ -0,0 +1,380 @@ +# GraphQL Generator for Swift - Implementation Plan + +## Project Overview + +Create a Swift package plugin that generates server-side GraphQL API code from GraphQL schema files (.graphql), similar to how swift-openapi-generator works with OpenAPI specs. The generator will produce Swift code using the GraphQL Swift package for implementing GraphQL servers. + +## Architecture + +### Core Components + +1. **Build Plugin** - Swift Package Manager build tool plugin that discovers `.graphql` files and invokes the generator +2. **Generator Executable** - CLI tool that parses GraphQL schemas and generates Swift code +3. **Generator Core** - Shared logic for parsing and code generation +4. **Runtime Library** (optional) - Helper types and utilities for generated code + +### Package Structure + +``` +graphql-generator/ +β”œβ”€β”€ Package.swift +β”œβ”€β”€ Plugins/ +β”‚ └── GraphQLGeneratorPlugin/ # Build plugin +β”‚ └── plugin.swift +β”œβ”€β”€ Sources/ +β”‚ β”œβ”€β”€ GraphQLGenerator/ # CLI executable +β”‚ β”‚ └── main.swift +β”‚ β”œβ”€β”€ GraphQLGeneratorCore/ # Shared logic +β”‚ β”‚ β”œβ”€β”€ Parser/ +β”‚ β”‚ β”‚ β”œβ”€β”€ GraphQLSchemaParser.swift +β”‚ β”‚ β”‚ └── SchemaModels.swift +β”‚ β”‚ β”œβ”€β”€ Generator/ +β”‚ β”‚ β”‚ β”œβ”€β”€ SwiftCodeEmitter.swift +β”‚ β”‚ β”‚ β”œβ”€β”€ TypeGenerator.swift +β”‚ β”‚ β”‚ β”œβ”€β”€ ResolverGenerator.swift +β”‚ β”‚ β”‚ └── SchemaGenerator.swift +β”‚ β”‚ └── Config/ +β”‚ β”‚ └── GeneratorConfig.swift +β”‚ └── GraphQLGeneratorRuntime/ # Runtime support (optional) +β”‚ β”œβ”€β”€ ResolverContext.swift +β”‚ └── Helpers.swift +β”œβ”€β”€ Tests/ +β”‚ └── GraphQLGeneratorTests/ +β”‚ β”œβ”€β”€ ParserTests/ +β”‚ β”œβ”€β”€ GeneratorTests/ +β”‚ └── IntegrationTests/ +└── Examples/ + β”œβ”€β”€ HelloWorldServer/ + └── AdvancedSchema/ +``` + +### Code Generation Strategy + +#### Input: GraphQL Schema +```graphql +type Query { + user(id: ID!): User + posts(limit: Int = 10): [Post!]! +} + +type User { + id: ID! + name: String! + email: String! +} + +type Post { + id: ID! + title: String! + author: User! +} +``` + +#### Generated Output + +**Types.swift** - Swift structs matching GraphQL types +```swift +struct User: Codable { + let id: String + let name: String + let email: String +} + +struct Post: Codable { + let id: String + let title: String + let authorId: String +} +``` + +**Resolvers.swift** - Protocol for implementing business logic +```swift +protocol GraphQLResolvers { + func user(id: String, context: ResolverContext) async throws -> User? + func posts(limit: Int, context: ResolverContext) async throws -> [Post] + func postAuthor(post: Post, context: ResolverContext) async throws -> User +} +``` + +**Schema.swift** - GraphQL schema builder using GraphQL Swift +```swift +func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { + // Generated GraphQLObjectType definitions with resolver callbacks +} +``` + +## Implementation Phases + +### Phase 1: Foundation βœ“ (Current Phase) + +**Goal**: Set up the basic package structure and build plugin + +- [x] Set up Package.swift with proper dependencies +- [x] Create build plugin structure +- [x] Implement .graphql file discovery +- [x] Create basic schema parser foundation +- [x] Set up test infrastructure + +**Deliverables**: +- Working build plugin that discovers .graphql files +- Basic GraphQL SDL tokenizer and parser +- Project structure ready for code generation + +### Phase 2: Schema Parsing (Weeks 1-2) + +**Goal**: Complete GraphQL schema parsing with full SDL support + +Tasks: +1. Implement complete SDL parser + - Object types, fields, arguments + - Scalar types (built-in and custom) + - Enum types + - Interface types + - Union types + - Input object types + - Directives +2. Create AST/IR models for schema representation +3. Add validation and error reporting +4. Write comprehensive parser tests + +**Deliverables**: +- Full-featured GraphQL SDL parser +- Schema representation models +- Parser test suite + +### Phase 3: Type Generation (Weeks 3-4) + +**Goal**: Generate Swift types from GraphQL schema + +Tasks: +1. Build Swift code emitter infrastructure +2. Generate Swift structs from GraphQL object types + - Handle field types and nullability + - Handle lists/arrays + - Add Codable conformance +3. Generate Swift enums from GraphQL enums +4. Handle custom scalar mappings (ID -> String, etc.) +5. Generate input types for mutations +6. Add code formatting and documentation comments + +**Deliverables**: +- Types.swift generation +- Type mapping configuration +- Type generation tests + +### Phase 4: Resolver Protocol Generation (Weeks 5-6) + +**Goal**: Generate resolver protocols with proper signatures + +Tasks: +1. Generate protocol with resolver methods + - Query field resolvers + - Nested field resolvers (e.g., Post.author) + - Mutation resolvers +2. Handle async/await patterns +3. Include proper argument types +4. Add default argument values +5. Generate resolver context protocol + +**Deliverables**: +- Resolvers.swift generation +- ResolverContext protocol +- Resolver generation tests + +### Phase 5: Schema Builder Generation (Weeks 7-8) + +**Goal**: Generate GraphQL Swift schema construction code + +Tasks: +1. Generate GraphQLObjectType definitions +2. Wire up resolver callbacks to protocol methods +3. Handle field arguments and return types +4. Support interfaces and unions +5. Add directive handling +6. Generate complete schema builder function + +**Deliverables**: +- Schema.swift generation +- Working end-to-end generation +- Integration tests + +### Phase 6: Advanced Features (Weeks 9-10) + +**Goal**: Add mutations, subscriptions, and advanced patterns + +Tasks: +1. Mutation support + - Input types + - Mutation resolvers +2. Subscription support (if needed) + - Async sequences + - Subscription resolvers +3. Custom scalar configuration +4. Directive support for code generation +5. Configuration file support (graphql-generator-config.yaml) + +**Deliverables**: +- Mutation/subscription support +- Config file parsing +- Advanced feature tests + +### Phase 7: Runtime Library & Ergonomics (Week 11) + +**Goal**: Create runtime helpers for better developer experience + +Tasks: +1. ResolverContext protocol and implementations +2. Error handling utilities +3. Common patterns (pagination, connections) +4. Authentication/authorization helpers +5. Testing utilities for resolvers + +**Deliverables**: +- GraphQLGeneratorRuntime module +- Helper utilities +- Documentation + +### Phase 8: Examples & Documentation (Week 12) + +**Goal**: Provide examples and comprehensive documentation + +Tasks: +1. Hello world server example +2. CRUD API example +3. Integration with Vapor example +4. Integration with Hummingbird example +5. Write README with quickstart +6. API documentation +7. Migration guide from manual GraphQL Swift usage + +**Deliverables**: +- 4+ working examples +- Complete documentation +- Tutorial content + +## Configuration + +**graphql-generator-config.yaml** (optional) +```yaml +# What to generate +generate: + - types # Swift type definitions + - resolvers # Resolver protocol + - schema # Schema builder + +# Custom scalar mappings +scalarMappings: + DateTime: Foundation.Date + UUID: Foundation.UUID + URL: Foundation.URL + +# Additional options +options: + accessControl: public + includeDocumentation: true +``` + +## Developer Usage + +### Setup + +**Package.swift**: +```swift +let package = Package( + name: "MyGraphQLAPI", + dependencies: [ + .package(url: "https://github.com/GraphQLSwift/GraphQL", from: "2.0.0"), + .package(url: "https://github.com/YourOrg/graphql-generator", from: "1.0.0") + ], + targets: [ + .target( + name: "MyGraphQLAPI", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator") + ], + plugins: [ + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + ] + ) + ] +) +``` + +### Workflow + +1. Add schema file: `Sources/MyGraphQLAPI/schema.graphql` +2. (Optional) Add config: `Sources/MyGraphQLAPI/graphql-generator-config.yaml` +3. Build project β†’ code auto-generates into build directory +4. Implement resolver protocol with business logic +5. Create schema and integrate with server framework + +### Example Implementation + +```swift +import GraphQL +import GraphQLGeneratorRuntime + +// Implement generated protocol +struct MyResolvers: GraphQLResolvers { + func user(id: String, context: ResolverContext) async throws -> User? { + // Business logic here + return await database.findUser(id: id) + } + + func posts(limit: Int, context: ResolverContext) async throws -> [Post] { + return await database.fetchPosts(limit: limit) + } + + func postAuthor(post: Post, context: ResolverContext) async throws -> User { + return await database.findUser(id: post.authorId) + } +} + +// Build schema from generated function +let resolvers = MyResolvers() +let schema = try buildGraphQLSchema(resolvers: resolvers) + +// Execute queries +let result = try await graphql(schema: schema, request: "{ user(id: \"1\") { name } }") +``` + +## Key Design Decisions + +1. **Follow swift-openapi-generator patterns**: Build plugin architecture, build-time generation +2. **Type-safe by default**: Leverage Swift's type system for compile-time safety +3. **Framework-agnostic**: Generated code works with any Swift server framework (Vapor, Hummingbird, etc.) +4. **Async/await native**: All resolver functions use modern Swift concurrency +5. **Minimal generated code**: Generate only ceremony code, developers write business logic +6. **Extensible**: Allow custom scalar mappings and directive handling +7. **Zero runtime overhead**: Generated code is straightforward, no reflection or dynamic dispatch + +## Dependencies + +### Required +- **GraphQL Swift** (`https://github.com/GraphQLSwift/GraphQL`): Runtime dependency for schema execution +- **Swift Argument Parser**: For CLI tool argument parsing + +### Optional +- **Swift Syntax**: For more robust Swift code generation (consider for future) + +## Success Criteria + +1. Plugin successfully discovers and processes .graphql files +2. Parses all standard GraphQL SDL constructs +3. Generates valid, compilable Swift code +4. Generated code integrates cleanly with GraphQL Swift +5. Developer experience matches swift-openapi-generator quality +6. Comprehensive test coverage (>80%) +7. Working examples for common use cases +8. Complete documentation + +## Future Enhancements + +- Xcode previews for generated code +- Watch mode for development +- GraphQL client generation (queries/mutations) +- Federation support +- Performance optimizations (caching, incremental generation) +- IDE integration (syntax highlighting, autocomplete) +- Migration tools from other GraphQL Swift patterns From d7ecea6fc7c1a0ffaf18a3a4404b964df99d5e83 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 13:39:36 -0600 Subject: [PATCH 02/38] feat: Implements schema builder Claude-code generated --- .../Generator/ResolverGenerator.swift | 2 +- .../Generator/SchemaGenerator.swift | 531 +++++++++++++++++- 2 files changed, 527 insertions(+), 6 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift index e87e7bf..a46b10c 100644 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -19,7 +19,7 @@ public struct ResolverGenerator { import GraphQLGeneratorRuntime /// Protocol defining all resolver methods for your GraphQL schema - public protocol GraphQLResolvers { + public protocol GraphQLResolvers: Sendable { """ diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index d3a974b..7dae62f 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -4,13 +4,15 @@ import GraphQL /// Generates the GraphQL schema builder function public struct SchemaGenerator { let schema: GraphQLSchema + let nameGenerator: SafeNameGenerator - public init(schema: GraphQLSchema) { + public init(schema: GraphQLSchema, nameGenerator: SafeNameGenerator = .idiomatic) { self.schema = schema + self.nameGenerator = nameGenerator } public func generate() throws -> String { - let output = """ + var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated @@ -20,13 +22,532 @@ public struct SchemaGenerator { /// Build a GraphQL schema with the provided resolvers public func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { - // TODO: Generate complete schema builder - // This will be implemented in Phase 5 - fatalError("Schema builder not yet implemented") + + """ + + // Generate type definitions for all object types + let typeMap = schema.typeMap + let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } + + // Generate GraphQLObjectType definitions for non-root types + for objectType in objectTypes { + // Skip introspection types and root operation types + if objectType.name.hasPrefix("__") || + objectType.name == "Query" || + objectType.name == "Mutation" || + objectType.name == "Subscription" { + continue + } + + output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers") + output += "\n" + } + + // Generate enum type definitions + let enumTypes = typeMap.values.compactMap { $0 as? GraphQLEnumType } + for enumType in enumTypes { + // Skip GraphQL internal enums + if enumType.name.hasPrefix("__") { + continue + } + + output += try generateEnumTypeDefinition(for: enumType) + output += "\n" + } + + // Generate Query type + if let queryType = schema.queryType { + output += try generateQueryTypeDefinition(for: queryType, resolvers: "resolvers") + output += "\n" + } + + // Generate Mutation type if it exists + if let mutationType = schema.mutationType { + output += try generateMutationTypeDefinition(for: mutationType, resolvers: "resolvers") + output += "\n" + } + + // Build and return the schema + output += """ + return try GraphQLSchema( + query: queryType + """ + + if schema.mutationType != nil { + output += ",\n mutation: mutationType" + } + + output += """ + + ) + } + + """ + + return output + } + + private func generateObjectTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" + + var output = """ + let \(varName) = try GraphQLObjectType( + name: "\(type.name)", + + """ + + if let description = type.description { + output += """ + description: \"\"\" + \(description) + \"\"\", + + """ + } + + output += """ + fields: [ + + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + // For non-root types, only generate resolver callbacks for fields that return object types + let needsResolver = isObjectType(field.type) + + output += try generateFieldDefinition( + fieldName: fieldName, + field: field, + parentTypeName: type.name, + resolvers: resolvers, + isRootType: false, + needsResolver: needsResolver + ) + } + + output += """ + ] + ) + + """ + + return output + } + + private func generateEnumTypeDefinition(for type: GraphQLEnumType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" + + var output = """ + let \(varName) = try GraphQLEnumType( + name: "\(type.name)", + + """ + + if let description = type.description { + output += """ + description: \"\"\" + \(description) + \"\"\", + + """ + } + + output += """ + values: [ + + """ + + for value in type.values { + let safeCaseName = nameGenerator.swiftMemberName(for: value.name) + output += """ + "\(value.name)": GraphQLEnumValue( + + """ + + if let description = value.description { + output += """ + value: \(safeCaseName), + description: \"\"\"\(description)\"\"\" + + """ + } else { + output += """ + value: Map.string("\(value.name)") + + """ + } + + output += """ + ), + + """ + } + + output += """ + ] + ) + + """ + + return output + } + + private func generateQueryTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + var output = """ + let queryType = try GraphQLObjectType( + name: "Query", + + """ + + if let description = type.description { + output += """ + description: \"\"\" + \(description) + \"\"\", + + """ + } + + output += """ + fields: [ + + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + output += try generateFieldDefinition( + fieldName: fieldName, + field: field, + parentTypeName: "Query", + resolvers: resolvers, + isRootType: true + ) + } + + output += """ + ] + ) + + """ + + return output + } + + private func generateMutationTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + var output = """ + let mutationType = try GraphQLObjectType( + name: "Mutation", + + """ + + if let description = type.description { + output += """ + description: \"\"\" + \(description) + \"\"\", + + """ + } + + output += """ + fields: [ + + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + output += try generateFieldDefinition( + fieldName: fieldName, + field: field, + parentTypeName: "Mutation", + resolvers: resolvers, + isRootType: true + ) } + output += """ + ] + ) + """ return output } + + private func generateFieldDefinition( + fieldName: String, + field: GraphQLField, + parentTypeName: String, + resolvers: String, + isRootType: Bool, + needsResolver: Bool = true + ) throws -> String { + var output = """ + "\(fieldName)": GraphQLField( + type: \(try graphQLTypeReference(for: field.type)), + + """ + + if let description = field.description { + output += """ + description: \"\"\" + \(description) + \"\"\", + + """ + } + + // Add arguments if any + if !field.args.isEmpty { + output += """ + args: [ + + """ + + for (argName, arg) in field.args { + output += """ + "\(argName)": GraphQLArgument( + type: \(try graphQLTypeReference(for: arg.type)) + + """ + + if let description = arg.description { + output += """ + , description: \"\"\" + \(description) + \"\"\" + + """ + } + + output += """ + ), + + """ + } + + output += """ + ], + + """ + } + + // Generate resolver function only if needed + if needsResolver { + output += try generateResolverCallback( + fieldName: fieldName, + field: field, + parentTypeName: parentTypeName, + resolvers: resolvers, + isRootType: isRootType + ) + } + + output += """ + ), + + """ + + return output + } + + private func generateResolverCallback( + fieldName: String, + field: GraphQLField, + parentTypeName: String, + resolvers: String, + isRootType: Bool + ) throws -> String { + let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) + + var output = """ + resolve: { source, args, context, _ in + + """ + + // Build argument list + var argsList: [String] = [] + + if !isRootType { + // For nested resolvers, first argument is the parent object + let safeParentTypeName = nameGenerator.swiftTypeName(for: parentTypeName) + output += """ + guard let parent = source as? \(safeParentTypeName) else { + throw GraphQLError(message: "Invalid source type for \(parentTypeName).\(fieldName)") + } + + """ + argsList.append("parent: parent") + } + + // Add field arguments + for (argName, arg) in field.args { + let safeArgName = nameGenerator.swiftMemberName(for: argName) + let swiftType = try swiftTypeName(for: arg.type) + let conversionCode = try mapConversionCode(for: arg.type, valueName: "value", swiftType: swiftType) + let isOptional = !(arg.type is GraphQLNonNull) + + // Extract value from Map based on type + if isOptional { + output += """ + let \(safeArgName): \(swiftType) = args["\(argName)"].map { try! \(conversionCode) } + + """ + } else { + output += """ + let \(safeArgName): \(swiftType) + if let value = args["\(argName)"] { + \(safeArgName) = try \(conversionCode) + } else { + throw GraphQLError(message: "Required argument '\(argName)' is missing") + } + + """ + } + + argsList.append("\(safeArgName): \(safeArgName)") + } + + // Add context + argsList.append("context: context as! ResolverContext") + + // Call the resolver + let resolverMethodName: String + if isRootType { + resolverMethodName = safeFieldName + } else { + let parentName = nameGenerator.swiftMemberName(for: parentTypeName) + let fieldNameCapitalized = safeFieldName.prefix(1).uppercased() + safeFieldName.dropFirst() + resolverMethodName = "\(parentName)\(fieldNameCapitalized)" + } + + output += """ + let result = try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) + return result as (any Sendable)? + } + + """ + + return output + } + + /// Generate GraphQL type reference string (e.g., "GraphQLString", "GraphQLNonNull(GraphQLString)") + private func graphQLTypeReference(for type: GraphQLType) throws -> String { + if let nonNull = type as? GraphQLNonNull { + return "GraphQLNonNull(\(try graphQLTypeReference(for: nonNull.ofType)))" + } + + if let list = type as? GraphQLList { + return "GraphQLList(\(try graphQLTypeReference(for: list.ofType)))" + } + + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + + // Map to built-in GraphQL types + switch typeName { + case "ID": return "GraphQLID" + case "String": return "GraphQLString" + case "Int": return "GraphQLInt" + case "Float": return "GraphQLFloat" + case "Boolean": return "GraphQLBoolean" + default: + // Reference to a custom type variable + let varName = nameGenerator.swiftMemberName(for: typeName) + "Type" + return varName + } + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") + } + + /// Convert GraphQL type to Swift type name for argument parsing + private func swiftTypeName(for type: GraphQLType) throws -> String { + if let nonNull = type as? GraphQLNonNull { + return try swiftTypeName(for: nonNull.ofType) + } + + if let list = type as? GraphQLList { + let innerType = try swiftTypeName(for: list.ofType) + return "[\(innerType)]" + } + + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + + switch typeName { + case "ID": return "String" + case "String": return "String" + case "Int": return "Int" + case "Float": return "Double" + case "Boolean": return "Bool" + default: + return nameGenerator.swiftTypeName(for: typeName) + } + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") + } + + /// Generate code to convert a Map value to a Swift type + private func mapConversionCode(for type: GraphQLType, valueName: String, swiftType: String) throws -> String { + // For non-null types, unwrap and convert + if let nonNull = type as? GraphQLNonNull { + let innerCode = try mapConversionCode(for: nonNull.ofType, valueName: valueName, swiftType: String(swiftType.dropLast())) + return innerCode + } + + // For list types, map over the array + if let list = type as? GraphQLList { + return "\(valueName).arrayValue?.map { try! \(try swiftTypeName(for: list.ofType))($0) }" + } + + // For named types, convert based on scalar type + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + + switch typeName { + case "ID", "String": + return "\(valueName).string!" + case "Int": + return "\(valueName).int!" + case "Float": + return "\(valueName).double!" + case "Boolean": + return "\(valueName).bool!" + default: + // For custom types (enums, etc.), try to decode + return "try \(swiftType)(\(valueName))" + } + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") + } + + /// Check if a GraphQL type is an object type (not a scalar, enum, etc.) + private func isObjectType(_ type: GraphQLType) -> Bool { + // Unwrap NonNull and List + if let nonNull = type as? GraphQLNonNull { + return isObjectType(nonNull.ofType) + } + if let list = type as? GraphQLList { + return isObjectType(list.ofType) + } + + // Check if it's a named object type (not a scalar or enum) + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + // Built-in scalars are not object types + if ["ID", "String", "Int", "Float", "Boolean"].contains(typeName) { + return false + } + // Check if it's an object type (not an enum or scalar) + return namedType is GraphQLObjectType + } + + return false + } } From 703de4d2bea3dcef9eea982fb88cdd38b9669791 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 13:52:02 -0600 Subject: [PATCH 03/38] feat: Removes ContentType support --- .../Utilities/ContentType.swift | 16 ----- .../Utilities/SafeNameGenerator.swift | 70 ------------------- 2 files changed, 86 deletions(-) delete mode 100644 Sources/GraphQLGeneratorCore/Utilities/ContentType.swift diff --git a/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift b/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift deleted file mode 100644 index c06c543..0000000 --- a/Sources/GraphQLGeneratorCore/Utilities/ContentType.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// A simple stub for ContentType - we don't need the full implementation for GraphQL -public struct ContentType { - public let lowercasedTypeSubtypeAndParameters: String - public let originallyCasedType: String - public let originallyCasedSubtype: String - public let lowercasedParameterPairs: [String] -} - -extension String { - var uppercasingFirstLetter: String { - guard let first = first else { return self } - return first.uppercased() + dropFirst() - } -} diff --git a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift index 917d960..8469cd6 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -25,38 +25,6 @@ public protocol SafeNameGenerator { /// - Parameter documentedName: The input unsanitized string from the OpenAPI document. /// - Returns: The sanitized string. func swiftMemberName(for documentedName: String) -> String - - /// Returns a string sanitized to be usable as a Swift identifier for the provided content type. - /// - Parameter contentType: The content type for which to compute a Swift identifier. - /// - Returns: A Swift identifier for the provided content type. - func swiftContentTypeName(for contentType: ContentType) -> String -} - -extension SafeNameGenerator { - - /// Returns a Swift identifier override for the provided content type. - /// - Parameter contentType: A content type. - /// - Returns: A Swift identifer for the content type, or nil if the provided content type doesn't - /// have an override. - func swiftNameOverride(for contentType: ContentType) -> String? { - let rawContentType = contentType.lowercasedTypeSubtypeAndParameters - switch rawContentType { - case "application/json": return "json" - case "application/x-www-form-urlencoded": return "urlEncodedForm" - case "multipart/form-data": return "multipartForm" - case "text/plain": return "plainText" - case "*/*": return "any" - case "application/xml": return "xml" - case "application/octet-stream": return "binary" - case "text/html": return "html" - case "application/yaml": return "yaml" - case "text/csv": return "csv" - case "image/png": return "png" - case "application/pdf": return "pdf" - case "image/jpeg": return "jpeg" - default: return nil - } - } } /// Returns a string sanitized to be usable as a Swift identifier. @@ -115,23 +83,6 @@ public struct DefensiveSafeNameGenerator: SafeNameGenerator { return "_\(validString)" } - public func swiftContentTypeName(for contentType: ContentType) -> String { - if let common = swiftNameOverride(for: contentType) { return common } - let safedType = swiftName(for: contentType.originallyCasedType) - let safedSubtype = swiftName(for: contentType.originallyCasedSubtype) - let componentSeparator = "_" - let prefix = "\(safedType)\(componentSeparator)\(safedSubtype)" - let params = contentType.lowercasedParameterPairs - guard !params.isEmpty else { return prefix } - let safedParams = - params.map { pair in - pair.split(separator: "=").map { component in swiftName(for: String(component)) } - .joined(separator: componentSeparator) - } - .joined(separator: componentSeparator) - return prefix + componentSeparator + safedParams - } - /// A list of Swift keywords. /// /// Copied from SwiftSyntax/TokenKind.swift @@ -337,27 +288,6 @@ public struct IdiomaticSafeNameGenerator: SafeNameGenerator { return defensiveFallback(String(buffer)) } - public func swiftContentTypeName(for contentType: ContentType) -> String { - if let common = swiftNameOverride(for: contentType) { return common } - let safedType = swiftMemberName(for: contentType.originallyCasedType) - let safedSubtype = swiftMemberName(for: contentType.originallyCasedSubtype) - let prettifiedSubtype = safedSubtype.uppercasingFirstLetter - let prefix = "\(safedType)\(prettifiedSubtype)" - let params = contentType.lowercasedParameterPairs - guard !params.isEmpty else { return prefix } - let safedParams = - params.map { pair in - pair.split(separator: "=") - .map { component in - let safedComponent = swiftMemberName(for: String(component)) - return safedComponent.uppercasingFirstLetter - } - .joined() - } - .joined() - return prefix + safedParams - } - /// A list of word separator characters for the idiomatic naming strategy. private static let wordSeparators: Set = ["_", "-", " ", "/", "+"] } From a42af7698a8adb1716af7f55eb53ec45ea110cd4 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 13:52:27 -0600 Subject: [PATCH 04/38] feat: Reduces public scope --- .../Generator/CodeGenerator.swift | 6 ++--- .../Generator/ResolverGenerator.swift | 6 ++--- .../Generator/SchemaGenerator.swift | 9 +++---- .../Generator/TypeGenerator.swift | 13 +++++----- .../Utilities/SafeNameGenerator.swift | 25 +++++++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift index 559da10..5faa009 100644 --- a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -2,16 +2,16 @@ import Foundation import GraphQL /// Main code generator that orchestrates generation of all Swift files -public struct CodeGenerator { +package struct CodeGenerator { let schema: GraphQLSchema - public init(schema: GraphQLSchema) { + package init(schema: GraphQLSchema) { self.schema = schema } /// Generate all Swift files from the schema /// Returns a dictionary of filename -> file content - public func generate() throws -> [String: String] { + package func generate() throws -> [String: String] { var files: [String: String] = [:] // Generate Types.swift diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift index a46b10c..3d20af2 100644 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -2,14 +2,14 @@ import Foundation import GraphQL /// Generates resolver protocol from GraphQL schema -public struct ResolverGenerator { +package struct ResolverGenerator { let schema: GraphQLSchema - public init(schema: GraphQLSchema) { + package init(schema: GraphQLSchema) { self.schema = schema } - public func generate() throws -> String { + package func generate() throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 7dae62f..1b5107c 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -2,16 +2,15 @@ import Foundation import GraphQL /// Generates the GraphQL schema builder function -public struct SchemaGenerator { +package struct SchemaGenerator { let schema: GraphQLSchema - let nameGenerator: SafeNameGenerator + let nameGenerator: SafeNameGenerator = .idiomatic - public init(schema: GraphQLSchema, nameGenerator: SafeNameGenerator = .idiomatic) { + package init(schema: GraphQLSchema) { self.schema = schema - self.nameGenerator = nameGenerator } - public func generate() throws -> String { + package func generate() throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 6a06bf4..ecd7c45 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -2,16 +2,15 @@ import Foundation import GraphQL /// Generates Swift type definitions from GraphQL types -public struct TypeGenerator { +package struct TypeGenerator { let schema: GraphQLSchema - let nameGenerator: SafeNameGenerator + let nameGenerator: SafeNameGenerator = .idiomatic - public init(schema: GraphQLSchema, nameGenerator: SafeNameGenerator = .idiomatic) { + package init(schema: GraphQLSchema) { self.schema = schema - self.nameGenerator = nameGenerator } - public func generate() throws -> String { + package func generate() throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated @@ -153,10 +152,10 @@ public struct TypeGenerator { } } -public enum GeneratorError: Error, CustomStringConvertible { +package enum GeneratorError: Error, CustomStringConvertible { case unsupportedType(String) - public var description: String { + package var description: String { switch self { case .unsupportedType(let message): return "Unsupported type: \(message)" diff --git a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift index 8469cd6..3a7f383 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -14,7 +14,7 @@ import Foundation /// Computes a string sanitized to be usable as a Swift identifier in various contexts. -public protocol SafeNameGenerator { +protocol SafeNameGenerator { /// Returns a string sanitized to be usable as a Swift type name in a general context. /// - Parameter documentedName: The input unsanitized string from the OpenAPI document. @@ -38,10 +38,15 @@ public protocol SafeNameGenerator { /// /// In addition to replacing illegal characters, it also /// ensures that the identifier starts with a letter and not a number. -public struct DefensiveSafeNameGenerator: SafeNameGenerator { +struct DefensiveSafeNameGenerator: SafeNameGenerator { + func swiftTypeName(for documentedName: String) -> String { + swiftName(for: documentedName) + } + + func swiftMemberName(for documentedName: String) -> String { + swiftName(for: documentedName) + } - public func swiftTypeName(for documentedName: String) -> String { swiftName(for: documentedName) } - public func swiftMemberName(for documentedName: String) -> String { swiftName(for: documentedName) } private func swiftName(for documentedName: String) -> String { guard !documentedName.isEmpty else { return "_empty" } @@ -104,7 +109,7 @@ public struct DefensiveSafeNameGenerator: SafeNameGenerator { ] } -public extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { +extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { static var defensive: DefensiveSafeNameGenerator { DefensiveSafeNameGenerator() } } @@ -115,13 +120,13 @@ public extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { /// matching `safeForSwiftCode_defensive`. /// /// Check out [SOAR-0013](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0013) for details. -public struct IdiomaticSafeNameGenerator: SafeNameGenerator { +struct IdiomaticSafeNameGenerator: SafeNameGenerator { /// The defensive strategy to use as fallback. - public var defensive: DefensiveSafeNameGenerator + var defensive: DefensiveSafeNameGenerator - public func swiftTypeName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: true) } - public func swiftMemberName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: false) } + func swiftTypeName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: true) } + func swiftMemberName(for documentedName: String) -> String { swiftName(for: documentedName, capitalize: false) } private func swiftName(for documentedName: String, capitalize: Bool) -> String { if documentedName.isEmpty { return capitalize ? "_Empty_" : "_empty_" } @@ -292,6 +297,6 @@ public struct IdiomaticSafeNameGenerator: SafeNameGenerator { private static let wordSeparators: Set = ["_", "-", " ", "/", "+"] } -public extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { +extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { static var idiomatic: IdiomaticSafeNameGenerator { IdiomaticSafeNameGenerator(defensive: .defensive) } } From 1ef93bcc75a8a247f3a9a7a706ccb9154851adce Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 13:56:20 -0600 Subject: [PATCH 05/38] feat: Conforms all types to sendable --- Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift | 3 +-- Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 1b5107c..aa850c0 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -424,8 +424,7 @@ package struct SchemaGenerator { } output += """ - let result = try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) - return result as (any Sendable)? + return try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) } """ diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index ecd7c45..c3e8802 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -61,7 +61,7 @@ package struct TypeGenerator { // Use safe name generator for type name let safeTypeName = nameGenerator.swiftTypeName(for: type.name) - output += "public struct \(safeTypeName): Codable {\n" + output += "public struct \(safeTypeName): Codable, Sendable {\n" // Generate properties let fields = try type.fields() @@ -91,7 +91,7 @@ package struct TypeGenerator { // Use safe name generator for enum name let safeEnumName = nameGenerator.swiftTypeName(for: type.name) - output += "public enum \(safeEnumName): String, Codable {\n" + output += "public enum \(safeEnumName): String, Codable, Sendable {\n" // Generate cases for value in type.values { From d2f6b01ce85439a8e3c46c71dd2828f61fe0ff85 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 15:38:59 -0600 Subject: [PATCH 06/38] feat: Improves code reuse --- .../Generator/ResolverGenerator.swift | 45 +++---------------- .../Generator/SchemaGenerator.swift | 32 +------------ .../Generator/TypeGenerator.swift | 45 +------------------ .../Utilities/SafeNameGenerator.swift | 2 + .../Utilities/swiftTypeName.swift | 45 +++++++++++++++++++ 5 files changed, 55 insertions(+), 114 deletions(-) create mode 100644 Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift index 3d20af2..836fe38 100644 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -4,6 +4,7 @@ import GraphQL /// Generates resolver protocol from GraphQL schema package struct ResolverGenerator { let schema: GraphQLSchema + let nameGenerator: SafeNameGenerator = .idiomatic package init(schema: GraphQLSchema) { self.schema = schema @@ -65,14 +66,14 @@ package struct ResolverGenerator { output += " /// \(description)\n" } - let returnType = try swiftTypeName(for: field.type) + let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) // Generate parameter list var params: [String] = [] // Add arguments for (argName, arg) in field.args { - let argType = try swiftTypeName(for: arg.type) + let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) params.append("\(argName): \(argType)") } @@ -108,14 +109,14 @@ package struct ResolverGenerator { output += " /// \(description)\n" } - let returnType = try swiftTypeName(for: field.type) + let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) // Parent parameter is the type itself var params: [String] = ["parent: \(type.name)"] // Add arguments if any for (argName, arg) in field.args { - let argType = try swiftTypeName(for: arg.type) + let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) params.append("\(argName): \(argType)") } @@ -130,30 +131,6 @@ package struct ResolverGenerator { return output } - /// Convert GraphQL type to Swift type name - private func swiftTypeName(for type: GraphQLType) throws -> String { - if let nonNull = type as? GraphQLNonNull { - return try swiftTypeName(for: nonNull.ofType) - } - - if let list = type as? GraphQLList { - let innerType = try swiftTypeName(for: list.ofType) - if innerType.hasSuffix("?") { - let baseType = String(innerType.dropLast()) - return "[\(baseType)]?" - } - return "[\(innerType)]?" - } - - if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - let swiftType = mapScalarType(typeName) - return "\(swiftType)?" - } - - throw GeneratorError.unsupportedType("Unknown type: \(type)") - } - /// Unwrap GraphQL type to get the base type private func unwrapType(_ type: GraphQLType) -> GraphQLType { if let nonNull = type as? GraphQLNonNull { @@ -164,16 +141,4 @@ package struct ResolverGenerator { } return type } - - /// Map GraphQL scalar types to Swift types - private func mapScalarType(_ graphQLType: String) -> String { - switch graphQLType { - case "ID": return "String" - case "String": return "String" - case "Int": return "Int" - case "Float": return "Double" - case "Boolean": return "Bool" - default: return graphQLType - } - } } diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index aa850c0..8409b32 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -385,7 +385,7 @@ package struct SchemaGenerator { // Add field arguments for (argName, arg) in field.args { let safeArgName = nameGenerator.swiftMemberName(for: argName) - let swiftType = try swiftTypeName(for: arg.type) + let swiftType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) let conversionCode = try mapConversionCode(for: arg.type, valueName: "value", swiftType: swiftType) let isOptional = !(arg.type is GraphQLNonNull) @@ -462,34 +462,6 @@ package struct SchemaGenerator { throw GeneratorError.unsupportedType("Unknown type: \(type)") } - /// Convert GraphQL type to Swift type name for argument parsing - private func swiftTypeName(for type: GraphQLType) throws -> String { - if let nonNull = type as? GraphQLNonNull { - return try swiftTypeName(for: nonNull.ofType) - } - - if let list = type as? GraphQLList { - let innerType = try swiftTypeName(for: list.ofType) - return "[\(innerType)]" - } - - if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - - switch typeName { - case "ID": return "String" - case "String": return "String" - case "Int": return "Int" - case "Float": return "Double" - case "Boolean": return "Bool" - default: - return nameGenerator.swiftTypeName(for: typeName) - } - } - - throw GeneratorError.unsupportedType("Unknown type: \(type)") - } - /// Generate code to convert a Map value to a Swift type private func mapConversionCode(for type: GraphQLType, valueName: String, swiftType: String) throws -> String { // For non-null types, unwrap and convert @@ -500,7 +472,7 @@ package struct SchemaGenerator { // For list types, map over the array if let list = type as? GraphQLList { - return "\(valueName).arrayValue?.map { try! \(try swiftTypeName(for: list.ofType))($0) }" + return "try \(valueName).arrayValue?.map { try \(try swiftTypeName(for: list.ofType, nameGenerator: nameGenerator))($0) }" } // For named types, convert based on scalar type diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index c3e8802..2737258 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -70,7 +70,7 @@ package struct TypeGenerator { output += " /// \(description)\n" } - let swiftType = try swiftTypeName(for: field.type) + let swiftType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) output += " public let \(safeFieldName): \(swiftType)\n" } @@ -107,49 +107,6 @@ package struct TypeGenerator { return output } - - /// Convert GraphQL type to Swift type name - private func swiftTypeName(for type: GraphQLType) throws -> String { - // GraphQLNonNull means the field is required (non-optional) - if let nonNull = type as? GraphQLNonNull { - let innerType = try swiftTypeName(for: nonNull.ofType) - // Remove the optional marker if present - if innerType.hasSuffix("?") { - return String(innerType.dropLast()) - } - return innerType - } - - // GraphQLList means an array - if let list = type as? GraphQLList { - let innerType = try swiftTypeName(for: list.ofType) - return "[\(innerType)]?" - } - - // Named types (scalars, enums, objects) - if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - let swiftType = mapScalarType(typeName) - // By default, GraphQL fields are nullable, so add optional marker - return "\(swiftType)?" - } - - throw GeneratorError.unsupportedType("Unknown type: \(type)") - } - - /// Map GraphQL scalar types to Swift types - private func mapScalarType(_ graphQLType: String) -> String { - switch graphQLType { - case "ID": return "String" - case "String": return "String" - case "Int": return "Int" - case "Float": return "Double" - case "Boolean": return "Bool" - default: - // For custom types (enums, objects), use safe name generator - return nameGenerator.swiftTypeName(for: graphQLType) - } - } } package enum GeneratorError: Error, CustomStringConvertible { diff --git a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift index 3a7f383..3750a5c 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -1,3 +1,5 @@ +// Copied from https://github.com/apple/swift-openapi-generator/blob/a6928974a6132e5de7376de2f2db4e867802add7/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/SafeNameGenerator.swift + //===----------------------------------------------------------------------===// // // This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift new file mode 100644 index 0000000..0a02323 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -0,0 +1,45 @@ +import GraphQL + +/// Convert GraphQL type to Swift type name +func swiftTypeName(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { + if let nonNull = type as? GraphQLNonNull { + let innerType = try swiftTypeName(for: nonNull.ofType, nameGenerator: nameGenerator) + // Remove the optional marker if present + if innerType.hasSuffix("?") { + return String(innerType.dropLast()) + } + return innerType + } + + if let list = type as? GraphQLList { + let innerType = try swiftTypeName(for: list.ofType, nameGenerator: nameGenerator) + if innerType.hasSuffix("?") { + let baseType = String(innerType.dropLast()) + return "[\(baseType)]?" + } + return "[\(innerType)]?" + } + + if let namedType = type as? GraphQLNamedType { + let typeName = namedType.name + let swiftType = mapScalarType(typeName, nameGenerator: nameGenerator) + // By default, GraphQL fields are nullable, so add optional marker + return "\(swiftType)?" + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") +} + +/// Map GraphQL scalar types to Swift types +func mapScalarType(_ graphQLType: String, nameGenerator: SafeNameGenerator) -> String { + switch graphQLType { + case "ID": return "String" + case "String": return "String" + case "Int": return "Int" + case "Float": return "Double" + case "Boolean": return "Bool" + default: + // For custom types (enums, objects), use safe name generator + return nameGenerator.swiftTypeName(for: graphQLType) + } +} From 4e5c012b6696e784c95b82661f8768b9423b56ee Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 16:52:06 -0600 Subject: [PATCH 07/38] fix: Improve schema indentation --- .../Generator/SchemaGenerator.swift | 152 +++++++++--------- .../Utilities/indent.swift | 11 ++ Tests/GraphQLGeneratorTests/IndentTests.swift | 36 +++++ 3 files changed, 125 insertions(+), 74 deletions(-) create mode 100644 Sources/GraphQLGeneratorCore/Utilities/indent.swift create mode 100644 Tests/GraphQLGeneratorTests/IndentTests.swift diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 8409b32..1eeb4b6 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -38,7 +38,7 @@ package struct SchemaGenerator { continue } - output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers") + output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers").indent(1) output += "\n" } @@ -50,19 +50,19 @@ package struct SchemaGenerator { continue } - output += try generateEnumTypeDefinition(for: enumType) + output += try generateEnumTypeDefinition(for: enumType).indent(1) output += "\n" } // Generate Query type if let queryType = schema.queryType { - output += try generateQueryTypeDefinition(for: queryType, resolvers: "resolvers") + output += try generateQueryTypeDefinition(for: queryType, resolvers: "resolvers").indent(1) output += "\n" } // Generate Mutation type if it exists if let mutationType = schema.mutationType { - output += try generateMutationTypeDefinition(for: mutationType, resolvers: "resolvers") + output += try generateMutationTypeDefinition(for: mutationType, resolvers: "resolvers").indent(1) output += "\n" } @@ -73,7 +73,10 @@ package struct SchemaGenerator { """ if schema.mutationType != nil { - output += ",\n mutation: mutationType" + output += """ + , + mutation: mutationType + """ } output += """ @@ -90,23 +93,22 @@ package struct SchemaGenerator { let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" var output = """ - let \(varName) = try GraphQLObjectType( - name: "\(type.name)", - + let \(varName) = try GraphQLObjectType( + name: "\(type.name)", """ if let description = type.description { output += """ - description: \"\"\" - \(description) - \"\"\", + description: \"\"\" + \(description) + \"\"\", """ } output += """ - fields: [ + fields: [ """ // Generate fields @@ -122,13 +124,13 @@ package struct SchemaGenerator { resolvers: resolvers, isRootType: false, needsResolver: needsResolver - ) + ).indent(2) } output += """ - ] - ) + ] + ) """ return output @@ -138,55 +140,57 @@ package struct SchemaGenerator { let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" var output = """ - let \(varName) = try GraphQLEnumType( - name: "\(type.name)", + let \(varName) = try GraphQLEnumType( + name: "\(type.name)", """ if let description = type.description { output += """ - description: \"\"\" - \(description) - \"\"\", + description: \"\"\" + \(description.indent(1, includeFirst: false)) + \"\"\", """ } output += """ - values: [ + values: [ """ for value in type.values { let safeCaseName = nameGenerator.swiftMemberName(for: value.name) output += """ - "\(value.name)": GraphQLEnumValue( + "\(value.name)": GraphQLEnumValue( """ if let description = value.description { output += """ - value: \(safeCaseName), - description: \"\"\"\(description)\"\"\" + value: \(safeCaseName), + description: \"\"\" + \(description.indent(3, includeFirst: false)) + \"\"\", """ } else { output += """ - value: Map.string("\(value.name)") + value: Map.string("\(value.name)") """ } output += """ - ), + ), """ } output += """ - ] - ) + ] + ) """ return output @@ -194,23 +198,23 @@ package struct SchemaGenerator { private func generateQueryTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { var output = """ - let queryType = try GraphQLObjectType( - name: "Query", + let queryType = try GraphQLObjectType( + name: "Query", """ if let description = type.description { output += """ - description: \"\"\" - \(description) - \"\"\", + description: \"\"\" + \(description.indent(1, includeFirst: false)) + \"\"\", """ } output += """ - fields: [ + fields: [ """ // Generate fields @@ -222,13 +226,13 @@ package struct SchemaGenerator { parentTypeName: "Query", resolvers: resolvers, isRootType: true - ) + ).indent(2) } output += """ - ] - ) + ] + ) """ return output @@ -236,23 +240,23 @@ package struct SchemaGenerator { private func generateMutationTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { var output = """ - let mutationType = try GraphQLObjectType( - name: "Mutation", + let mutationType = try GraphQLObjectType( + name: "Mutation", """ if let description = type.description { output += """ - description: \"\"\" - \(description) - \"\"\", + description: \"\"\" + \(description.indent(1, includeFirst: false)) + \"\"\", """ } output += """ - fields: [ + fields: [ """ // Generate fields @@ -264,13 +268,13 @@ package struct SchemaGenerator { parentTypeName: "Mutation", resolvers: resolvers, isRootType: true - ) + ).indent(2) } output += """ - ] - ) + ] + ) """ return output @@ -285,52 +289,52 @@ package struct SchemaGenerator { needsResolver: Bool = true ) throws -> String { var output = """ - "\(fieldName)": GraphQLField( - type: \(try graphQLTypeReference(for: field.type)), + "\(fieldName)": GraphQLField( + type: \(try graphQLTypeReference(for: field.type)), """ if let description = field.description { output += """ - description: \"\"\" + + description: \"\"\" \(description) \"\"\", - """ } // Add arguments if any if !field.args.isEmpty { output += """ - args: [ + args: [ """ for (argName, arg) in field.args { output += """ - "\(argName)": GraphQLArgument( - type: \(try graphQLTypeReference(for: arg.type)) + "\(argName)": GraphQLArgument( + type: \(try graphQLTypeReference(for: arg.type)) """ if let description = arg.description { output += """ - , description: \"\"\" - \(description) - \"\"\" - + , + description: \"\"\" + \(description) + \"\"\" """ } output += """ - ), + ), """ } output += """ - ], + ], """ } @@ -342,12 +346,12 @@ package struct SchemaGenerator { parentTypeName: parentTypeName, resolvers: resolvers, isRootType: isRootType - ) + ).indent(1) } output += """ - ), + ), """ return output @@ -363,8 +367,8 @@ package struct SchemaGenerator { let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) var output = """ - resolve: { source, args, context, _ in + resolve: { source, args, context, _ in """ // Build argument list @@ -374,10 +378,10 @@ package struct SchemaGenerator { // For nested resolvers, first argument is the parent object let safeParentTypeName = nameGenerator.swiftTypeName(for: parentTypeName) output += """ - guard let parent = source as? \(safeParentTypeName) else { - throw GraphQLError(message: "Invalid source type for \(parentTypeName).\(fieldName)") - } + guard let parent = source as? \(safeParentTypeName) else { + throw GraphQLError(message: "Invalid source type for \(parentTypeName).\(fieldName)") + } """ argsList.append("parent: parent") } @@ -392,18 +396,18 @@ package struct SchemaGenerator { // Extract value from Map based on type if isOptional { output += """ - let \(safeArgName): \(swiftType) = args["\(argName)"].map { try! \(conversionCode) } + let \(safeArgName): \(swiftType) = args["\(argName)"].map { try! \(conversionCode) } """ } else { output += """ - let \(safeArgName): \(swiftType) - if let value = args["\(argName)"] { - \(safeArgName) = try \(conversionCode) - } else { - throw GraphQLError(message: "Required argument '\(argName)' is missing") - } + let \(safeArgName): \(swiftType) + if let value = args["\(argName)"] { + \(safeArgName) = try \(conversionCode) + } else { + throw GraphQLError(message: "Required argument '\(argName)' is missing") + } """ } @@ -424,9 +428,9 @@ package struct SchemaGenerator { } output += """ - return try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) - } + return try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) + } """ return output diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift new file mode 100644 index 0000000..cc58b34 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -0,0 +1,11 @@ +extension String { + func indent(_ num: Int, includeFirst: Bool = true) -> String { + let indent = String.init(repeating: " ", count: num) + let body = self.replacingOccurrences(of: "\n", with: "\n" + indent) + if includeFirst { + return indent + body + } else { + return body + } + } +} diff --git a/Tests/GraphQLGeneratorTests/IndentTests.swift b/Tests/GraphQLGeneratorTests/IndentTests.swift new file mode 100644 index 0000000..16f2eac --- /dev/null +++ b/Tests/GraphQLGeneratorTests/IndentTests.swift @@ -0,0 +1,36 @@ +@testable import GraphQLGeneratorCore +import Testing + +@Suite +struct IndentTests { + @Test func singleLine() async throws { + #expect("abc".indent(1, includeFirst: false) == "abc") + #expect("abc".indent(1, includeFirst: true) == " abc") + #expect("abc".indent(2, includeFirst: true) == " abc") + } + + @Test func multiLine() async throws { + #expect( + """ + abc + def + """.indent(1, includeFirst: false) + == + """ + abc + def + """ + ) + #expect( + """ + abc + def + """.indent(1, includeFirst: true) + == + """ + abc + def + """ + ) + } +} From 09300c6f14649cb3abeb05e10041ad071de2fa3b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 20:55:34 -0600 Subject: [PATCH 08/38] fix: Fixes argument decoding --- .../Generator/SchemaGenerator.swift | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 1eeb4b6..ea7f171 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -380,7 +380,9 @@ package struct SchemaGenerator { output += """ guard let parent = source as? \(safeParentTypeName) else { - throw GraphQLError(message: "Invalid source type for \(parentTypeName).\(fieldName)") + throw GraphQLError( + message: "Expected source type \(safeParentTypeName) but got \\(type(of: source))" + ) } """ argsList.append("parent: parent") @@ -390,32 +392,24 @@ package struct SchemaGenerator { for (argName, arg) in field.args { let safeArgName = nameGenerator.swiftMemberName(for: argName) let swiftType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) - let conversionCode = try mapConversionCode(for: arg.type, valueName: "value", swiftType: swiftType) - let isOptional = !(arg.type is GraphQLNonNull) - // Extract value from Map based on type - if isOptional { - output += """ - - let \(safeArgName): \(swiftType) = args["\(argName)"].map { try! \(conversionCode) } - """ - } else { - output += """ - - let \(safeArgName): \(swiftType) - if let value = args["\(argName)"] { - \(safeArgName) = try \(conversionCode) - } else { - throw GraphQLError(message: "Required argument '\(argName)' is missing") - } - """ - } + output += """ + let \(safeArgName) = try MapDecoder().decode(\(swiftType).self, from: args["\(argName)"]) + """ argsList.append("\(safeArgName): \(safeArgName)") } // Add context - argsList.append("context: context as! ResolverContext") + output += """ + + guard let context = context as? ResolverContext else { + throw GraphQLError( + message: "Expected context type ResolverContext but got \\(type(of: context))" + ) + } + """ + argsList.append("context: context") // Call the resolver let resolverMethodName: String From a79fcee043ca85408d4032a10406b0498f246d54 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 20:56:23 -0600 Subject: [PATCH 09/38] fix: Fixes referencing enums/recursive types It does this by post-populating the object type fields --- .../Sources/HelloWorldServer/schema.graphql | 5 + .../Generator/SchemaGenerator.swift | 146 +++++++++++------- 2 files changed, 91 insertions(+), 60 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index ebec41e..ed23b80 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -21,6 +21,11 @@ type User { The user's age """ age: Int + + """ + The user's age + """ + role: Role } """ diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index ea7f171..6b0fb93 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -24,23 +24,9 @@ package struct SchemaGenerator { """ - // Generate type definitions for all object types let typeMap = schema.typeMap - let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } - - // Generate GraphQLObjectType definitions for non-root types - for objectType in objectTypes { - // Skip introspection types and root operation types - if objectType.name.hasPrefix("__") || - objectType.name == "Query" || - objectType.name == "Mutation" || - objectType.name == "Subscription" { - continue - } - output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers").indent(1) - output += "\n" - } + // TODO: Scalars // Generate enum type definitions let enumTypes = typeMap.values.compactMap { $0 as? GraphQLEnumType } @@ -54,6 +40,31 @@ package struct SchemaGenerator { output += "\n" } + // TODO: Input Objects + + // Generate type definitions for all object types + let objectTypes = typeMap.values.compactMap { + $0 as? GraphQLObjectType + }.filter { objectType in + // Skip introspection types and root operation types + !objectType.name.hasPrefix("__") && + objectType.name != "Query" && + objectType.name != "Mutation" && + objectType.name != "Subscription" + } + + // Generate GraphQLObjectType definitions for non-root types + for objectType in objectTypes { + output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers").indent(1) + output += "\n" + } + + // Generate GraphQLObjectType field definitions for non-root types + for objectType in objectTypes { + output += try generateObjectTypeFieldDefinition(for: objectType, resolvers: "resolvers").indent(1) + output += "\n" + } + // Generate Query type if let queryType = schema.queryType { output += try generateQueryTypeDefinition(for: queryType, resolvers: "resolvers").indent(1) @@ -66,6 +77,8 @@ package struct SchemaGenerator { output += "\n" } + // TODO: Subscription + // Build and return the schema output += """ return try GraphQLSchema( @@ -89,11 +102,12 @@ package struct SchemaGenerator { return output } - private func generateObjectTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + private func generateEnumTypeDefinition(for type: GraphQLEnumType) throws -> String { let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" var output = """ - let \(varName) = try GraphQLObjectType( + + let \(varName) = try GraphQLEnumType( name: "\(type.name)", """ @@ -101,30 +115,42 @@ package struct SchemaGenerator { output += """ description: \"\"\" - \(description) + \(description.indent(1, includeFirst: false)) \"\"\", """ } output += """ - fields: [ + values: [ """ - // Generate fields - let fields = try type.fields() - for (fieldName, field) in fields { - // For non-root types, only generate resolver callbacks for fields that return object types - let needsResolver = isObjectType(field.type) + for value in type.values { + let safeCaseName = nameGenerator.swiftMemberName(for: value.name) + output += """ - output += try generateFieldDefinition( - fieldName: fieldName, - field: field, - parentTypeName: type.name, - resolvers: resolvers, - isRootType: false, - needsResolver: needsResolver - ).indent(2) + "\(value.name)": GraphQLEnumValue( + """ + + if let description = value.description { + output += """ + + value: \(safeCaseName), + description: \"\"\" + \(description.indent(3, includeFirst: false)) + \"\"\", + """ + } else { + output += """ + + value: .string("\(value.name)") + """ + } + + output += """ + + ), + """ } output += """ @@ -136,12 +162,11 @@ package struct SchemaGenerator { return output } - private func generateEnumTypeDefinition(for type: GraphQLEnumType) throws -> String { + private func generateObjectTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" var output = """ - - let \(varName) = try GraphQLEnumType( + let \(varName) = try GraphQLObjectType( name: "\(type.name)", """ @@ -149,48 +174,49 @@ package struct SchemaGenerator { output += """ description: \"\"\" - \(description.indent(1, includeFirst: false)) + \(description) \"\"\", """ } + // Delay field generation to support recursive type systems + output += """ - values: [ + ) """ - for value in type.values { - let safeCaseName = nameGenerator.swiftMemberName(for: value.name) - output += """ - - "\(value.name)": GraphQLEnumValue( - """ - - if let description = value.description { - output += """ + return output + } - value: \(safeCaseName), - description: \"\"\" - \(description.indent(3, includeFirst: false)) - \"\"\", - """ - } else { - output += """ + private func generateObjectTypeFieldDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" - value: Map.string("\(value.name)") - """ - } + var output = """ + \(varName).fields = { + [ + """ - output += """ + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + // For non-root types, only generate resolver callbacks for fields that return object types + let needsResolver = isObjectType(field.type) - ), - """ + output += try generateFieldDefinition( + fieldName: fieldName, + field: field, + parentTypeName: type.name, + resolvers: resolvers, + isRootType: false, + needsResolver: needsResolver + ).indent(2) } output += """ ] - ) + } """ return output From b01ade57c6cb563624984b660f88488b3d65499b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 21:02:15 -0600 Subject: [PATCH 10/38] feat: Adds GraphQLResolveInfo support --- .../GraphQLGeneratorCore/Generator/ResolverGenerator.swift | 6 ++++++ .../GraphQLGeneratorCore/Generator/SchemaGenerator.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift index 836fe38..873a969 100644 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -80,6 +80,9 @@ package struct ResolverGenerator { // Add context parameter params.append("context: ResolverContext") + // Add resolve info parameter + params.append("info: GraphQLResolveInfo") + let paramString = params.joined(separator: ", ") output += " func \(fieldName)(\(paramString)) async throws -> \(returnType)\n\n" @@ -123,6 +126,9 @@ package struct ResolverGenerator { // Add context parameter params.append("context: ResolverContext") + // Add resolve info parameter + params.append("info: GraphQLResolveInfo") + let paramString = params.joined(separator: ", ") output += " func \(type.name.lowercased())\(fieldName.capitalized)(\(paramString)) async throws -> \(returnType)\n\n" diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 6b0fb93..024c761 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -394,7 +394,7 @@ package struct SchemaGenerator { var output = """ - resolve: { source, args, context, _ in + resolve: { source, args, context, info in """ // Build argument list @@ -437,6 +437,9 @@ package struct SchemaGenerator { """ argsList.append("context: context") + // Add resolver info + argsList.append("info: info") + // Call the resolver let resolverMethodName: String if isRootType { From f0257b9e6d117fa9191aeca65cdca494a84c66ed Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 24 Dec 2025 21:56:47 -0600 Subject: [PATCH 11/38] feat: Adds interface generation --- .../Generator/SchemaGenerator.swift | 94 +++++++++++++++++-- 1 file changed, 86 insertions(+), 8 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 024c761..bca1505 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -40,17 +40,30 @@ package struct SchemaGenerator { output += "\n" } + // Generate type definitions for all object types + let interfaceTypes = typeMap.values.compactMap { + $0 as? GraphQLInterfaceType + }.filter { + // Skip introspection types and root operation types + !$0.name.hasPrefix("__") + } + + for interfaceType in interfaceTypes { + output += try generateInterfaceTypeDefinition(for: interfaceType, resolvers: "resolvers").indent(1) + output += "\n" + } + // TODO: Input Objects // Generate type definitions for all object types let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType - }.filter { objectType in + }.filter { // Skip introspection types and root operation types - !objectType.name.hasPrefix("__") && - objectType.name != "Query" && - objectType.name != "Mutation" && - objectType.name != "Subscription" + !$0.name.hasPrefix("__") && + $0.name != "Query" && + $0.name != "Mutation" && + $0.name != "Subscription" } // Generate GraphQLObjectType definitions for non-root types @@ -60,6 +73,10 @@ package struct SchemaGenerator { } // Generate GraphQLObjectType field definitions for non-root types + for interfaceType in interfaceTypes { + output += try generateInterfaceTypeFieldDefinition(for: interfaceType, resolvers: "resolvers").indent(1) + output += "\n" + } for objectType in objectTypes { output += try generateObjectTypeFieldDefinition(for: objectType, resolvers: "resolvers").indent(1) output += "\n" @@ -162,6 +179,67 @@ package struct SchemaGenerator { return output } + private func generateInterfaceTypeDefinition(for type: GraphQLInterfaceType, resolvers: String) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + "Interface" + + var output = """ + let \(varName) = try GraphQLInterfaceType( + name: "\(type.name)", + """ + + if let description = type.description { + output += """ + + description: \"\"\" + \(description) + \"\"\", + """ + } + + // Delay field & interface generation to support recursive type systems + + output += """ + + ) + """ + + return output + } + + private func generateInterfaceTypeFieldDefinition(for type: GraphQLInterfaceType, resolvers: String) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + "Interface" + + var output = """ + \(varName).fields = { + [ + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + // For non-root types, only generate resolver callbacks for fields that return object types + let needsResolver = isObjectType(field.type) + + output += try generateFieldDefinition( + fieldName: fieldName, + field: field, + parentTypeName: type.name, + resolvers: resolvers, + isRootType: false, + needsResolver: needsResolver + ).indent(2) + } + + output += """ + + ] + } + """ + + return output + } + + private func generateObjectTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" @@ -524,7 +602,7 @@ package struct SchemaGenerator { throw GeneratorError.unsupportedType("Unknown type: \(type)") } - /// Check if a GraphQL type is an object type (not a scalar, enum, etc.) + /// Check if a GraphQL type is a composite object type (not a scalar, enum, etc.) private func isObjectType(_ type: GraphQLType) -> Bool { // Unwrap NonNull and List if let nonNull = type as? GraphQLNonNull { @@ -541,8 +619,8 @@ package struct SchemaGenerator { if ["ID", "String", "Int", "Float", "Boolean"].contains(typeName) { return false } - // Check if it's an object type (not an enum or scalar) - return namedType is GraphQLObjectType + // Check if it's a composite type (not an enum or scalar) + return namedType is GraphQLCompositeType } return false From 0f927120c68ac7f67d1ed864b718615f784fd18d Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 14:16:29 -0600 Subject: [PATCH 12/38] feat: Builds result example in HelloWorldServer --- Examples/HelloWorldServer/.gitignore | 1 + Examples/HelloWorldServer/Package.resolved | 11 +- Examples/HelloWorldServer/Package.swift | 6 +- .../HelloWorldServer/Gen/Resolvers.swift | 24 +++ .../Sources/HelloWorldServer/Gen/Schema.swift | 176 ++++++++++++++++++ .../Sources/HelloWorldServer/Gen/Types.swift | 54 ++++++ .../Helper/ResolverHelpers.swift | 12 ++ .../Sources/HelloWorldServer/main.swift | 110 ++++++++++- Sources/GraphQLGenerator/main.swift | 4 +- .../Generator/CodeGenerator.swift | 20 +- .../Generator/ResolverGenerator.swift | 7 +- .../Generator/SchemaGenerator.swift | 31 ++- .../Generator/TypeGenerator.swift | 56 +++--- .../Utilities/indent.swift | 20 +- .../ResolverGeneratorTests.swift | 61 ++++++ .../SchemaGeneratorTests.swift | 109 +++++++++++ .../TypeGeneratorTests.swift | 82 ++++++++ 17 files changed, 713 insertions(+), 71 deletions(-) create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift create mode 100644 Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift create mode 100644 Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift create mode 100644 Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift diff --git a/Examples/HelloWorldServer/.gitignore b/Examples/HelloWorldServer/.gitignore index 0023a53..f8c5fd7 100644 --- a/Examples/HelloWorldServer/.gitignore +++ b/Examples/HelloWorldServer/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.vscode diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved index b84558d..169194a 100644 --- a/Examples/HelloWorldServer/Package.resolved +++ b/Examples/HelloWorldServer/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "06ff74ba9d083090d6365334b9fb7efae3aac50834ec4b30e75b67c5eb078b11", + "originHash" : "e541794e4c1d6067e84640d47eee9266b8a8b887699f439ac836c3bed05af6d1", "pins" : [ { "identity" : "graphql", @@ -10,15 +10,6 @@ "version" : "4.0.3" } }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift index 50890e6..9f1f86b 100644 --- a/Examples/HelloWorldServer/Package.swift +++ b/Examples/HelloWorldServer/Package.swift @@ -8,7 +8,7 @@ let package = Package( .macOS(.v13) ], dependencies: [ - .package(name: "graphql-generator", path: "../.."), + // .package(name: "graphql-generator", path: "../.."), .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), ], targets: [ @@ -16,10 +16,10 @@ let package = Package( name: "HelloWorldServer", dependencies: [ .product(name: "GraphQL", package: "GraphQL"), - .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + // .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), ], plugins: [ - .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + // .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") ] ), ] diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift new file mode 100644 index 0000000..9d90ab6 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -0,0 +1,24 @@ + +import Foundation +import GraphQL + +/// Protocol defining all resolver methods for your GraphQL schema +public protocol GraphQLResolvers: Sendable { + associatedtype Context: Sendable + associatedtype Post: PostProtocol where Post.Context == Context + associatedtype User: UserProtocol where User.Context == Context + + // MARK: - Query Resolvers + + /// Get a user by ID + func user(id: String, context: Context, info: GraphQLResolveInfo) async throws -> User? + + /// Get all users + func users(context: Context, info: GraphQLResolveInfo) async throws -> [User] + + /// Get a post by ID + func post(id: String, context: Context, info: GraphQLResolveInfo) async throws -> Post? + + /// Get recent posts + func posts(limit: Int?, context: Context, info: GraphQLResolveInfo) async throws -> [Post] +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift new file mode 100644 index 0000000..13d515d --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -0,0 +1,176 @@ +// Generated by GraphQL Generator +// DO NOT EDIT - This file is automatically generated + +import Foundation +import GraphQL + +/// Build a GraphQL schema with the provided resolvers +public func buildGraphQLSchema(resolvers: T) throws -> GraphQLSchema { + + let roleType = try GraphQLEnumType( + name: "Role", + description: """ + User role enumeration + """, + values: [ + "ADMIN": GraphQLEnumValue( + value: .string("ADMIN") + ), + "USER": GraphQLEnumValue( + value: .string("USER") + ), + "GUEST": GraphQLEnumValue( + value: .string("GUEST") + ), + ] + ) + let userType = try GraphQLObjectType( + name: "User", + description: """ + A simple user type + """, + ) + let postType = try GraphQLObjectType( + name: "Post", + description: """ + A blog post + """, + ) + userType.fields = { + [ + "id": GraphQLField( + type: GraphQLNonNull(GraphQLID), + description: """ + The unique identifier for the user + """, + ), + "name": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The user's display name + """, + ), + "email": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The user's email address + """, + ), + "age": GraphQLField( + type: GraphQLInt, + description: """ + The user's age + """, + ), + "role": GraphQLField( + type: roleType, + description: """ + The user's age + """, + ), + ] + } + postType.fields = { + [ + "id": GraphQLField( + type: GraphQLNonNull(GraphQLID), + description: """ + The unique identifier for the post + """, + ), + "title": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The post title + """, + ), + "content": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The post content + """, + ), + "author": GraphQLField( + type: GraphQLNonNull(userType), + description: """ + The author of the post + """, + resolve: { source, args, context, info in + let parent = try resolvers.cast(source, to: T.Post.self) + let context = try resolvers.cast(context, to: T.Context.self) + return try await parent.author(context: context, info: info) + } + ), + ] + } + + let queryType = try GraphQLObjectType( + name: "Query", + description: """ + Root query type + """, + fields: [ + "user": GraphQLField( + type: userType, + description: """ + Get a user by ID + """, + args: [ + "id": GraphQLArgument( + type: GraphQLNonNull(GraphQLID) + ), + ], + resolve: { source, args, context, info in + let id = try MapDecoder().decode(String.self, from: args["id"]) + let context = try resolvers.cast(context, to: T.Context.self) + return try await resolvers.user(id: id, context: context, info: info) + } + ), + "users": GraphQLField( + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(userType))), + description: """ + Get all users + """, + resolve: { source, args, context, info in + let context = try resolvers.cast(context, to: T.Context.self) + return try await resolvers.users(context: context, info: info) + } + ), + "post": GraphQLField( + type: postType, + description: """ + Get a post by ID + """, + args: [ + "id": GraphQLArgument( + type: GraphQLNonNull(GraphQLID) + ), + ], + resolve: { source, args, context, info in + let id = try MapDecoder().decode(String.self, from: args["id"]) + let context = try resolvers.cast(context, to: T.Context.self) + return try await resolvers.post(id: id, context: context, info: info) + } + ), + "posts": GraphQLField( + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(postType))), + description: """ + Get recent posts + """, + args: [ + "limit": GraphQLArgument( + type: GraphQLInt + ), + ], + resolve: { source, args, context, info in + let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil + let context = try resolvers.cast(context, to: T.Context.self) + return try await resolvers.posts(limit: limit, context: context, info: info) + } + ), + ] + ) + return try GraphQLSchema( + query: queryType + ) +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift new file mode 100644 index 0000000..e601ee6 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -0,0 +1,54 @@ +// Generated by GraphQL Generator +// DO NOT EDIT - This file is automatically generated + +import Foundation +import GraphQL + +// Enums + +/// User role enumeration +public enum Role: String, Codable, Sendable { + case admin = "ADMIN" + case user = "USER" + case guest = "GUEST" +} + +// TODO: Directives + +// TODO: InputObjects + +// TODO: Interfaces + +// TODO: Union + +// Object Types + +/// A simple user type +public protocol UserProtocol: Sendable { + associatedtype Context + + /// The unique identifier for the user + func id(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The user's display name + func name(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The user's email address + func email(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The user's age + func age(context: Context, info: GraphQLResolveInfo) async throws -> Int? + /// The user's age + func role(context: Context, info: GraphQLResolveInfo) async throws -> Role? +} + +/// A blog post +public protocol PostProtocol: Sendable { + associatedtype Context + + /// The unique identifier for the post + func id(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The post title + func title(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The post content + func content(context: Context, info: GraphQLResolveInfo) async throws -> String + /// The author of the post + func author(context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift new file mode 100644 index 0000000..ed8008e --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift @@ -0,0 +1,12 @@ +import GraphQL + +public extension GraphQLResolvers { + func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { + guard let result = anySendable as? T else { + throw GraphQLError( + message: "Expected source type \(T.self) but got \(type(of: anySendable))" + ) + } + return result + } +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 74388f4..c541a3b 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -1,5 +1,109 @@ import Foundation +import GraphQL -// This file will use the generated code once we build the project -print("Hello, GraphQL Server!") -print("Generated code will be available after building") +struct Context { + // User can choose structure + var users: [String: User] + var posts: [String: Post] +} +struct User: UserProtocol { + // User can choose structure + let id: String + let name: String + let email: String + let age: Int? + let role: Role? + + // Required implementations + typealias Context = HelloWorldServer.Context + func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return id + } + func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return name + } + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return email + } + func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { + return age + } + func role(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { + return role + } +} +struct Post: PostProtocol { + // User can choose structure + let id: String + let title: String + let content: String + let authorId: String + + // Required implementations + typealias Context = HelloWorldServer.Context + func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return id + } + func title(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return title + } + func content(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return content + } + // TODO: When referencing other types, we are implicitly casting to the Resolver typealias... + func author(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> any UserProtocol { + return context.users[authorId]! + } +} + +struct HelloWorldResolvers: GraphQLResolvers { + // Required implementations + + // TypeMap + typealias Context = HelloWorldServer.Context + typealias Post = HelloWorldServer.Post + typealias User = HelloWorldServer.User + + // Query + func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { + return context.users[id] + } + func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [User] { + return .init(context.users.values) + } + func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Post? { + return context.posts[id] + } + func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { + return .init(context.posts.values) + } +} + +let resolvers = HelloWorldResolvers() +let schema = try buildGraphQLSchema(resolvers: resolvers) + +let queryResponse = try await graphql( + schema: schema, + request: """ + { + posts { + id + title + content + author { + id + name + email + age + role + } + } + } + """, + context: HelloWorldResolvers.Context( + users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] + ) +) + +print(queryResponse) diff --git a/Sources/GraphQLGenerator/main.swift b/Sources/GraphQLGenerator/main.swift index 468c192..0febe8e 100644 --- a/Sources/GraphQLGenerator/main.swift +++ b/Sources/GraphQLGenerator/main.swift @@ -52,8 +52,8 @@ struct GraphQLGeneratorCommand: ParsableCommand { } // Generate code - let generator = CodeGenerator(schema: schema) - let generatedFiles = try generator.generate() + let generator = CodeGenerator() + let generatedFiles = try generator.generate(schema: schema) // Write generated files for (filename, content) in generatedFiles { diff --git a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift index 5faa009..c2d41fa 100644 --- a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -3,28 +3,24 @@ import GraphQL /// Main code generator that orchestrates generation of all Swift files package struct CodeGenerator { - let schema: GraphQLSchema - - package init(schema: GraphQLSchema) { - self.schema = schema - } + package init() { } /// Generate all Swift files from the schema /// Returns a dictionary of filename -> file content - package func generate() throws -> [String: String] { + package func generate(schema: GraphQLSchema) throws -> [String: String] { var files: [String: String] = [:] // Generate Types.swift - let typeGenerator = TypeGenerator(schema: schema) - files["Types.swift"] = try typeGenerator.generate() + let typeGenerator = TypeGenerator() + files["Types.swift"] = try typeGenerator.generate(schema: schema) // Generate Resolvers.swift - let resolverGenerator = ResolverGenerator(schema: schema) - files["Resolvers.swift"] = try resolverGenerator.generate() + let resolverGenerator = ResolverGenerator() + files["Resolvers.swift"] = try resolverGenerator.generate(schema: schema) // Generate Schema.swift - let schemaGenerator = SchemaGenerator(schema: schema) - files["Schema.swift"] = try schemaGenerator.generate() + let schemaGenerator = SchemaGenerator() + files["Schema.swift"] = try schemaGenerator.generate(schema: schema) return files } diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift index 873a969..4b45537 100644 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift @@ -3,14 +3,9 @@ import GraphQL /// Generates resolver protocol from GraphQL schema package struct ResolverGenerator { - let schema: GraphQLSchema let nameGenerator: SafeNameGenerator = .idiomatic - package init(schema: GraphQLSchema) { - self.schema = schema - } - - package func generate() throws -> String { + package func generate(schema: GraphQLSchema) throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index bca1505..11f38c3 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -3,14 +3,9 @@ import GraphQL /// Generates the GraphQL schema builder function package struct SchemaGenerator { - let schema: GraphQLSchema let nameGenerator: SafeNameGenerator = .idiomatic - package init(schema: GraphQLSchema) { - self.schema = schema - } - - package func generate() throws -> String { + package func generate(schema: GraphQLSchema) throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated @@ -236,6 +231,30 @@ package struct SchemaGenerator { } """ + let interfaces = try type.interfaces() + if !interfaces.isEmpty { + output += """ + + \(varName).interfaces = { + [ + """ + + // Generate fields + for interface in interfaces { + output += """ + + \(nameGenerator.swiftMemberName(for: interface.name) + "Interface") + """ + } + + output += """ + + ] + } + """ + } + + return output } diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 2737258..b97980a 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -3,14 +3,9 @@ import GraphQL /// Generates Swift type definitions from GraphQL types package struct TypeGenerator { - let schema: GraphQLSchema let nameGenerator: SafeNameGenerator = .idiomatic - package init(schema: GraphQLSchema) { - self.schema = schema - } - - package func generate() throws -> String { + package func generate(schema: GraphQLSchema) throws -> String { var output = """ // Generated by GraphQL Generator // DO NOT EDIT - This file is automatically generated @@ -21,19 +16,18 @@ package struct TypeGenerator { // Generate struct for each object type (excluding Query, Mutation, Subscription) let typeMap = schema.typeMap - let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } - + let objectTypes = typeMap.values.compactMap { + $0 as? GraphQLObjectType + }.filter { + // Skip introspection types and root operation types + !$0.name.hasPrefix("__") && + $0.name != "Query" && + $0.name != "Mutation" && + $0.name != "Subscription" + } for objectType in objectTypes { - // Skip introspection types (prefixed with __) and root operation types - if objectType.name.hasPrefix("__") || - objectType.name == "Query" || - objectType.name == "Mutation" || - objectType.name == "Subscription" { - continue - } - output += "\n" - output += try generateStruct(for: objectType) + output += try generateTypeProtocol(for: objectType) } // Generate enums @@ -51,7 +45,7 @@ package struct TypeGenerator { return output } - private func generateStruct(for type: GraphQLObjectType) throws -> String { + func generateTypeProtocol(for type: GraphQLObjectType) throws -> String { var output = "" // Add description if available @@ -61,7 +55,7 @@ package struct TypeGenerator { // Use safe name generator for type name let safeTypeName = nameGenerator.swiftTypeName(for: type.name) - output += "public struct \(safeTypeName): Codable, Sendable {\n" + output += "public protocol \(safeTypeName)Protocol: Sendable {\n" // Generate properties let fields = try type.fields() @@ -70,9 +64,25 @@ package struct TypeGenerator { output += " /// \(description)\n" } - let swiftType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) - let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) - output += " public let \(safeFieldName): \(swiftType)\n" + let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) + + var params: [String] = [] + + // Add arguments if any + for (argName, arg) in field.args { + let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) + params.append("\(argName): \(argType)") + } + + // Add context parameter + params.append("context: ResolverContext") + + // Add resolve info parameter + params.append("info: GraphQLResolveInfo") + + let paramString = params.joined(separator: ", ") + + output += " public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType)\n\n" } // Swift auto-generates memberwise initializers for structs, so we don't need to generate one @@ -81,7 +91,7 @@ package struct TypeGenerator { return output } - private func generateEnum(for type: GraphQLEnumType) throws -> String { + func generateEnum(for type: GraphQLEnumType) throws -> String { var output = "" // Add description if available diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift index cc58b34..be9592b 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/indent.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -1,11 +1,19 @@ extension String { func indent(_ num: Int, includeFirst: Bool = true) -> String { let indent = String.init(repeating: " ", count: num) - let body = self.replacingOccurrences(of: "\n", with: "\n" + indent) - if includeFirst { - return indent + body - } else { - return body - } + var firstLine = true + return self.split(separator: "\n").map { line in + var result = line + if firstLine { + firstLine == false + } + if !line.isEmpty { + if !firstLine || includeFirst { + result = indent + line + } + } + firstLine = false + return result + }.joined(separator: "\n") } } diff --git a/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift b/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift new file mode 100644 index 0000000..48e8e05 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift @@ -0,0 +1,61 @@ +import GraphQL +@testable import GraphQLGeneratorCore +import Testing + +@Suite +struct ResolverGeneratorTests { + @Test func generate() async throws { + let bar = try GraphQLObjectType( + name: "Bar", + description: "bar", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + ] + ) + let schema = try GraphQLSchema( + query: .init( + name: "Query", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + "bar": .init( + type: bar, + description: "bar", + ) + ] + ), + types: [ + bar + ] + ) + let actual = try ResolverGenerator().generate(schema: schema) + #expect( + actual == """ + // Generated by GraphQL Generator + // DO NOT EDIT - This file is automatically generated + + import Foundation + import GraphQL + import GraphQLGeneratorRuntime + + /// Protocol defining all resolver methods for your GraphQL schema + public protocol GraphQLResolvers: Sendable { + // MARK: - Query Resolvers + + /// foo + func foo(context: ResolverContext, info: GraphQLResolveInfo) async throws -> String? + + /// bar + func bar(context: ResolverContext, info: GraphQLResolveInfo) async throws -> Bar? + + } + + """ + ) + } +} diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift new file mode 100644 index 0000000..4545cc0 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -0,0 +1,109 @@ +import GraphQL +@testable import GraphQLGeneratorCore +import Testing + +@Suite +struct SchemaGeneratorTests { + @Test func generate() async throws { + let bar = try GraphQLObjectType( + name: "Bar", + description: "bar", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + ] + ) + let schema = try GraphQLSchema( + query: .init( + name: "Query", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + "bar": .init( + type: bar, + description: "bar", + ) + ] + ), + types: [ + bar + ] + ) + let actual = try SchemaGenerator().generate(schema: schema) + let expected = #""" + // Generated by GraphQL Generator + // DO NOT EDIT - This file is automatically generated + + import Foundation + import GraphQL + import GraphQLGeneratorRuntime + + /// Build a GraphQL schema with the provided resolvers + public func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { + + let barType = try GraphQLObjectType( + name: "Bar", + description: """ + bar + """, + ) + barType.fields = { + [ + "foo": GraphQLField( + type: GraphQLString, + description: """ + foo + """, + ), + ] + } + + let queryType = try GraphQLObjectType( + name: "Query", + fields: [ + "foo": GraphQLField( + type: GraphQLString, + description: """ + foo + """, + resolve: { source, args, context, info in + guard let context = context as? ResolverContext else { + throw GraphQLError( + message: "Expected context type ResolverContext but got \(type(of: context))" + ) + } + return try await resolvers.foo(context: context, info: info) + } + ), + "bar": GraphQLField( + type: barType, + description: """ + bar + """, + resolve: { source, args, context, info in + guard let context = context as? ResolverContext else { + throw GraphQLError( + message: "Expected context type ResolverContext but got \(type(of: context))" + ) + } + return try await resolvers.bar(context: context, info: info) + } + ), + ] + ) + return try GraphQLSchema( + query: queryType + ) + } + + """# + print(actual.difference(from: expected)) + #expect( + actual == expected + ) + } +} diff --git a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift new file mode 100644 index 0000000..c1fd7e6 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -0,0 +1,82 @@ +import GraphQL +@testable import GraphQLGeneratorCore +import Testing + +@Suite +struct TypeGeneratorTests { + @Test func enumType() async throws { + let actual = try TypeGenerator().generateEnum( + for: .init( + name: "Foo", + description: "foo", + values: [ + "foo": .init( + value: .string("foo"), + description: "foo", + ), + "bar": .init( + value: .string("bar"), + description: "bar", + ) + ] + ) + ) + #expect( + actual == """ + /// foo + public enum Foo: String, Codable, Sendable { + /// foo + case foo = "foo" + /// bar + case bar = "bar" + } + + """ + ) + } + + @Test func objectType() async throws { + let actual = try TypeGenerator().generateTypeProtocol( + for: .init( + name: "Foo", + description: "Foo", + fields: [ + "foo": .init( + type: GraphQLNonNull(GraphQLString), + description: "foo" + ), + "bar": .init( + type: GraphQLString, + description: "bar", + args: [ + "foo": .init( + type: GraphQLNonNull(GraphQLString), + description: "foo", + ), + "bar": .init( + type: GraphQLString, + description: "bar", + defaultValue: .string("bar"), + ), + ] + ) + ] + ) + ) + #expect( + actual == """ + /// Foo + public protocol FooProtocol: Sendable { + /// foo + public func foo(context: ResolverContext, info: GraphQLResolveInfo) async throws -> String + + /// bar + public func bar(foo: String, bar: String?, context: ResolverContext, info: GraphQLResolveInfo) async throws -> String? + + } + + """ + ) + } + +} From 10cd5362115ae3964da992540e928a4c2af70d40 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 14:33:45 -0600 Subject: [PATCH 13/38] refactor: Use TypeMap protocol to collect typealiases This allows object-level resolvers to strongly reference result types, as defined by our typemap --- .../HelloWorldServer/Gen/Resolvers.swift | 18 ++++++----- .../Sources/HelloWorldServer/Gen/Schema.swift | 12 +++---- .../Sources/HelloWorldServer/Gen/Types.swift | 22 ++++++------- .../Sources/HelloWorldServer/main.swift | 31 ++++++++++--------- 4 files changed, 45 insertions(+), 38 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 9d90ab6..3152483 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -2,23 +2,27 @@ import Foundation import GraphQL +public protocol TypeMapProtocol { + associatedtype Context: Sendable + associatedtype Post: PostProtocol where Post.TypeMap == Self + associatedtype User: UserProtocol where User.TypeMap == Self +} + /// Protocol defining all resolver methods for your GraphQL schema public protocol GraphQLResolvers: Sendable { - associatedtype Context: Sendable - associatedtype Post: PostProtocol where Post.Context == Context - associatedtype User: UserProtocol where User.Context == Context + associatedtype TypeMap: TypeMapProtocol // MARK: - Query Resolvers /// Get a user by ID - func user(id: String, context: Context, info: GraphQLResolveInfo) async throws -> User? + func user(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User? /// Get all users - func users(context: Context, info: GraphQLResolveInfo) async throws -> [User] + func users(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.User] /// Get a post by ID - func post(id: String, context: Context, info: GraphQLResolveInfo) async throws -> Post? + func post(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.Post? /// Get recent posts - func posts(limit: Int?, context: Context, info: GraphQLResolveInfo) async throws -> [Post] + func posts(limit: Int?, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.Post] } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 13d515d..0149f1f 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -96,8 +96,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap The author of the post """, resolve: { source, args, context, info in - let parent = try resolvers.cast(source, to: T.Post.self) - let context = try resolvers.cast(context, to: T.Context.self) + let parent = try resolvers.cast(source, to: T.TypeMap.Post.self) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) return try await parent.author(context: context, info: info) } ), @@ -122,7 +122,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try resolvers.cast(context, to: T.Context.self) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) return try await resolvers.user(id: id, context: context, info: info) } ), @@ -132,7 +132,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap Get all users """, resolve: { source, args, context, info in - let context = try resolvers.cast(context, to: T.Context.self) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) return try await resolvers.users(context: context, info: info) } ), @@ -148,7 +148,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try resolvers.cast(context, to: T.Context.self) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) return try await resolvers.post(id: id, context: context, info: info) } ), @@ -164,7 +164,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil - let context = try resolvers.cast(context, to: T.Context.self) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) return try await resolvers.posts(limit: limit, context: context, info: info) } ), diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index e601ee6..157efaf 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -25,30 +25,30 @@ public enum Role: String, Codable, Sendable { /// A simple user type public protocol UserProtocol: Sendable { - associatedtype Context + associatedtype TypeMap: TypeMapProtocol /// The unique identifier for the user - func id(context: Context, info: GraphQLResolveInfo) async throws -> String + func id(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The user's display name - func name(context: Context, info: GraphQLResolveInfo) async throws -> String + func name(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The user's email address - func email(context: Context, info: GraphQLResolveInfo) async throws -> String + func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The user's age - func age(context: Context, info: GraphQLResolveInfo) async throws -> Int? + func age(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> Int? /// The user's age - func role(context: Context, info: GraphQLResolveInfo) async throws -> Role? + func role(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> Role? } /// A blog post public protocol PostProtocol: Sendable { - associatedtype Context + associatedtype TypeMap: TypeMapProtocol /// The unique identifier for the post - func id(context: Context, info: GraphQLResolveInfo) async throws -> String + func id(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The post title - func title(context: Context, info: GraphQLResolveInfo) async throws -> String + func title(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The post content - func content(context: Context, info: GraphQLResolveInfo) async throws -> String + func content(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String /// The author of the post - func author(context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol + func author(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index c541a3b..efa3550 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -6,6 +6,11 @@ struct Context { var users: [String: User] var posts: [String: Post] } +struct TypeMap: TypeMapProtocol { + typealias Context = HelloWorldServer.Context + typealias User = HelloWorldServer.User + typealias Post = HelloWorldServer.Post +} struct User: UserProtocol { // User can choose structure let id: String @@ -15,20 +20,20 @@ struct User: UserProtocol { let role: Role? // Required implementations - typealias Context = HelloWorldServer.Context - func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + typealias TypeMap = HelloWorldServer.TypeMap + func id(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func name(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return name } - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func email(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return email } - func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { + func age(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { return age } - func role(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { + func role(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { return role } } @@ -40,18 +45,17 @@ struct Post: PostProtocol { let authorId: String // Required implementations - typealias Context = HelloWorldServer.Context - func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + typealias TypeMap = HelloWorldServer.TypeMap + func id(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func title(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func title(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return title } - func content(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func content(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return content } - // TODO: When referencing other types, we are implicitly casting to the Resolver typealias... - func author(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> any UserProtocol { + func author(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> TypeMap.User { return context.users[authorId]! } } @@ -61,8 +65,7 @@ struct HelloWorldResolvers: GraphQLResolvers { // TypeMap typealias Context = HelloWorldServer.Context - typealias Post = HelloWorldServer.Post - typealias User = HelloWorldServer.User + typealias TypeMap = HelloWorldServer.TypeMap // Query func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { From 581b6c8edff05b52dba447c1125e49993c13c34b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 15:00:29 -0600 Subject: [PATCH 14/38] feat: Adds interface type support --- .../HelloWorldServer/Gen/Resolvers.swift | 3 +- .../Sources/HelloWorldServer/Gen/Schema.swift | 32 +++++++++++++++++++ .../Sources/HelloWorldServer/Gen/Types.swift | 19 ++++++++--- .../Sources/HelloWorldServer/main.swift | 11 +++++++ .../Sources/HelloWorldServer/schema.graphql | 13 +++++++- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 3152483..aca663e 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -4,8 +4,9 @@ import GraphQL public protocol TypeMapProtocol { associatedtype Context: Sendable - associatedtype Post: PostProtocol where Post.TypeMap == Self associatedtype User: UserProtocol where User.TypeMap == Self + associatedtype Contact: ContactProtocol where Contact.TypeMap == Self + associatedtype Post: PostProtocol where Post.TypeMap == Self } /// Protocol defining all resolver methods for your GraphQL schema diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 0149f1f..bb18d65 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -24,18 +24,34 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ), ] ) + let hasEmailInterface = try GraphQLInterfaceType( + name: "HasEmail" + ) let userType = try GraphQLObjectType( name: "User", description: """ A simple user type """, ) + let contactType = try GraphQLObjectType( + name: "Contact", + ) let postType = try GraphQLObjectType( name: "Post", description: """ A blog post """, ) + hasEmailInterface.fields = { + [ + "email": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The user's email address + """, + ), + ] + } userType.fields = { [ "id": GraphQLField( @@ -70,6 +86,22 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ), ] } + userType.interfaces = { + [hasEmailInterface] + } + contactType.fields = { + [ + "email": GraphQLField( + type: GraphQLNonNull(GraphQLString), + description: """ + The user's email address + """, + ), + ] + } + contactType.interfaces = { + [hasEmailInterface] + } postType.fields = { [ "id": GraphQLField( diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index 157efaf..1f7f58e 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -17,15 +17,20 @@ public enum Role: String, Codable, Sendable { // TODO: InputObjects -// TODO: Interfaces - // TODO: Union +public protocol HasEmailInterface: Sendable { + associatedtype TypeMap: TypeMapProtocol + + /// An email address + func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String +} + // Object Types /// A simple user type -public protocol UserProtocol: Sendable { - associatedtype TypeMap: TypeMapProtocol +public protocol UserProtocol: HasEmailInterface, Sendable { + // No type map associatedType needed because of HasEmailInterface inheritance /// The unique identifier for the user func id(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String @@ -39,6 +44,12 @@ public protocol UserProtocol: Sendable { func role(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> Role? } +public protocol ContactProtocol: HasEmailInterface, Sendable { + // No type map associatedType needed because of HasEmailInterface inheritance + + func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String +} + /// A blog post public protocol PostProtocol: Sendable { associatedtype TypeMap: TypeMapProtocol diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index efa3550..316d65b 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -9,6 +9,7 @@ struct Context { struct TypeMap: TypeMapProtocol { typealias Context = HelloWorldServer.Context typealias User = HelloWorldServer.User + typealias Contact = HelloWorldServer.Contact typealias Post = HelloWorldServer.Post } struct User: UserProtocol { @@ -37,6 +38,16 @@ struct User: UserProtocol { return role } } +struct Contact: ContactProtocol { + // User can choose structure + let email: String + + // Required implementations + typealias TypeMap = HelloWorldServer.TypeMap + func email(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + return email + } +} struct Post: PostProtocol { // User can choose structure let id: String diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index ed23b80..78d81f8 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -1,7 +1,14 @@ +interface HasEmail { + """ + An email address + """ + email: String! +} + """ A simple user type """ -type User { +type User implements HasEmail { """ The unique identifier for the user """ @@ -28,6 +35,10 @@ type User { role: Role } +type Contact implements HasEmail { + email: String! +} + """ A blog post """ From f8ecb5dea17f3dc96d027510d513d8482e376490 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 15:14:31 -0600 Subject: [PATCH 15/38] feat: Adds union type support --- .../HelloWorldServer/Gen/Resolvers.swift | 3 +++ .../Sources/HelloWorldServer/Gen/Schema.swift | 17 ++++++++++++++++- .../Sources/HelloWorldServer/Gen/Types.swift | 6 +++--- .../Sources/HelloWorldServer/main.swift | 3 +++ .../Sources/HelloWorldServer/schema.graphql | 7 +++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index aca663e..5d91288 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -26,4 +26,7 @@ public protocol GraphQLResolvers: Sendable { /// Get recent posts func posts(limit: Int?, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.Post] + + /// Get a user or post by ID + func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index bb18d65..7e8128a 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -42,6 +42,13 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap A blog post """, ) + let userOrPostUnion = try GraphQLUnionType( + name: "UserOrPost", + description: "Get a user or post by ID", + types: { + [userType, postType] + } + ) hasEmailInterface.fields = { [ "email": GraphQLField( @@ -203,6 +210,14 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ] ) return try GraphQLSchema( - query: queryType + query: queryType, + types: [ + roleType, + hasEmailInterface, + userType, + contactType, + postType, + userOrPostUnion, + ] ) } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index 1f7f58e..51d3541 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -17,7 +17,7 @@ public enum Role: String, Codable, Sendable { // TODO: InputObjects -// TODO: Union +public protocol UserOrPostUnion {} public protocol HasEmailInterface: Sendable { associatedtype TypeMap: TypeMapProtocol @@ -29,7 +29,7 @@ public protocol HasEmailInterface: Sendable { // Object Types /// A simple user type -public protocol UserProtocol: HasEmailInterface, Sendable { +public protocol UserProtocol: HasEmailInterface, UserOrPostUnion, Sendable { // No type map associatedType needed because of HasEmailInterface inheritance /// The unique identifier for the user @@ -51,7 +51,7 @@ public protocol ContactProtocol: HasEmailInterface, Sendable { } /// A blog post -public protocol PostProtocol: Sendable { +public protocol PostProtocol: UserOrPostUnion, Sendable { associatedtype TypeMap: TypeMapProtocol /// The unique identifier for the post diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 316d65b..1ffb172 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -91,6 +91,9 @@ struct HelloWorldResolvers: GraphQLResolvers { func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { return .init(context.posts.values) } + func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { + return context.users[id] ?? context.posts[id] + } } let resolvers = HelloWorldResolvers() diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 78d81f8..7bfed8f 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -5,6 +5,8 @@ interface HasEmail { email: String! } +union UserOrPost = User | Post + """ A simple user type """ @@ -96,4 +98,9 @@ type Query { Get recent posts """ posts(limit: Int = 10): [Post!]! + + """ + Get a user or post by ID + """ + userOrPost(id: ID!): UserOrPost } From beaf0baf15e65dd631bb4f039f0ffb8209e8d5dd Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 15:40:32 -0600 Subject: [PATCH 16/38] feat: Adds input objects and mutations --- .../HelloWorldServer/Gen/Resolvers.swift | 4 ++ .../Sources/HelloWorldServer/Gen/Schema.swift | 42 +++++++++++ .../Sources/HelloWorldServer/Gen/Types.swift | 10 ++- .../Sources/HelloWorldServer/main.swift | 72 ++++++++++++++----- .../Sources/HelloWorldServer/schema.graphql | 12 ++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 5d91288..4537cfa 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -29,4 +29,8 @@ public protocol GraphQLResolvers: Sendable { /// Get a user or post by ID func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? + + // MARK: - Mutation Resolvers + + func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 7e8128a..747fddf 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -27,6 +27,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap let hasEmailInterface = try GraphQLInterfaceType( name: "HasEmail" ) + let userInfoInput = try GraphQLInputObjectType(name: "UserInfo") let userType = try GraphQLObjectType( name: "User", description: """ @@ -59,6 +60,25 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ), ] } + userInfoInput.fields = { + [ + "id": InputObjectField( + type: GraphQLNonNull(GraphQLID), + ), + "name": InputObjectField( + type: GraphQLNonNull(GraphQLString), + ), + "email": InputObjectField( + type: GraphQLNonNull(GraphQLString), + ), + "age": InputObjectField( + type: GraphQLInt, + ), + "role": InputObjectField( + type: roleType, + ), + ] + } userType.fields = { [ "id": GraphQLField( @@ -209,8 +229,30 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ), ] ) + let mutationType = try GraphQLObjectType( + name: "Mutation", + description: """ + Root mutation type + """, + fields: [ + "upsertUser": GraphQLField( + type: userType, + args: [ + "userInfo": GraphQLArgument( + type: GraphQLNonNull(userInfoInput) + ), + ], + resolve: { source, args, context, info in + let userInfoInput = try MapDecoder().decode(UserInfoInput.self, from: args["userInfo"]) + let context = try resolvers.cast(context, to: T.TypeMap.Context.self) + return try await resolvers.upsertUser(userInfo: userInfoInput, context: context, info: info) + } + ), + ] + ) return try GraphQLSchema( query: queryType, + mutation: mutationType, types: [ roleType, hasEmailInterface, diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index 51d3541..84bf86e 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -15,8 +15,16 @@ public enum Role: String, Codable, Sendable { // TODO: Directives -// TODO: InputObjects +// Input objects are just codable structs +public struct UserInfoInput: Codable, Sendable { + let id: String + let name: String + let email: String + let age: Int? + let role: Role? +} +// Unions are represented by a marker protocol, with associated types conforming public protocol UserOrPostUnion {} public protocol HasEmailInterface: Sendable { diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 1ffb172..eebefa9 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -1,10 +1,18 @@ import Foundation import GraphQL -struct Context { +class Context: @unchecked Sendable { // User can choose structure var users: [String: User] var posts: [String: Post] + + init( + users: [String: User], + posts: [String: Post] + ) { + self.users = users + self.posts = posts + } } struct TypeMap: TypeMapProtocol { typealias Context = HelloWorldServer.Context @@ -94,20 +102,55 @@ struct HelloWorldResolvers: GraphQLResolvers { func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { return context.users[id] ?? context.posts[id] } + func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) -> User { + let user = User( + id: userInfo.id, + name: userInfo.name, + email: userInfo.email, + age: userInfo.age, + role: userInfo.role + ) + context.users[userInfo.id] = user + return user + } } let resolvers = HelloWorldResolvers() let schema = try buildGraphQLSchema(resolvers: resolvers) -let queryResponse = try await graphql( - schema: schema, - request: """ - { - posts { - id - title - content - author { +let context = HelloWorldResolvers.Context( + users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] +) +print( + try await graphql( + schema: schema, + request: """ + { + posts { + id + title + content + author { + id + name + email + age + role + } + } + } + """, + context: context + ) +) + +print( + try await graphql( + schema: schema, + request: """ + mutation { + upsertUser(userInfo: {id: "2", name: "Jane", email: "jane@example.com"}) { id name email @@ -115,12 +158,7 @@ let queryResponse = try await graphql( role } } - } - """, - context: HelloWorldResolvers.Context( - users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], - posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] + """, + context: context ) ) - -print(queryResponse) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 7bfed8f..0c2863a 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -7,6 +7,14 @@ interface HasEmail { union UserOrPost = User | Post +input UserInfo { + id: ID! + name: name! + email: String! + age: Int + role: Role +} + """ A simple user type """ @@ -104,3 +112,7 @@ type Query { """ userOrPost(id: ID!): UserOrPost } + +type Mutation { + upsertUser(userInfo: UserInfo!): User! +} From 50e176de5c6b15f1c8ac67a4fe94bc8fbee3b4c4 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 16:13:58 -0600 Subject: [PATCH 17/38] feat: Adds scalar support --- .../HelloWorldServer/Gen/Resolvers.swift | 1 + .../Sources/HelloWorldServer/Gen/Schema.swift | 14 ++++ .../HelloWorldServer/Helper/Scalar.swift | 77 +++++++++++++++++++ .../Sources/HelloWorldServer/main.swift | 2 + 4 files changed, 94 insertions(+) create mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 4537cfa..b661d7c 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -4,6 +4,7 @@ import GraphQL public protocol TypeMapProtocol { associatedtype Context: Sendable + associatedtype DateTime: Scalar associatedtype User: UserProtocol where User.TypeMap == Self associatedtype Contact: ContactProtocol where Contact.TypeMap == Self associatedtype Post: PostProtocol where Post.TypeMap == Self diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 747fddf..3fc95fa 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -24,6 +24,19 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ), ] ) + + let datetimeScalar = try GraphQLScalarType( + name: "DateTime", + serialize: { any in + try T.TypeMap.DateTime.serialize(any: any) + }, + parseValue: { map in + try T.TypeMap.DateTime.parseValue(map: map) + }, + parseLiteral: { value in + try T.TypeMap.DateTime.parseLiteral(value: value) + } + ) let hasEmailInterface = try GraphQLInterfaceType( name: "HasEmail" ) @@ -255,6 +268,7 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap mutation: mutationType, types: [ roleType, + datetimeScalar, hasEmailInterface, userType, contactType, diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift new file mode 100644 index 0000000..7d75a95 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift @@ -0,0 +1,77 @@ +import GraphQL +import OrderedCollections + +public protocol Scalar: Sendable { + static func serialize(any: Any) throws -> Map + static func parseValue(map: Map) throws -> Map + static func parseLiteral(value: any Value) throws -> Map +} + +extension Scalar { + static func serialize(any: Any) throws -> Map { + return try Map(any: any) + } + static func parseValue(map: Map) throws -> Map { + return map + } + static func parseLiteral(value: any Value) throws -> Map { + return value.map + } +} + + +extension GraphQL.Value { + var map: Map { + if + let value = self as? BooleanValue + { + return .bool(value.value) + } + + if + let value = self as? IntValue, + let int = Int(value.value) + { + return .int(int) + } + + if + let value = self as? FloatValue, + let double = Double(value.value) + { + return .double(double) + } + + if + let value = self as? StringValue + { + return .string(value.value) + } + + if + let value = self as? EnumValue + { + return .string(value.value) + } + + if + let value = self as? ListValue + { + let array = value.values.map { $0.map } + return .array(array) + } + + if + let value = self as? ObjectValue + { + let dictionary: OrderedDictionary = value.fields + .reduce(into: [:]) { result, field in + result[field.name.value] = field.value.map + } + + return .dictionary(dictionary) + } + + return .null + } +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index eebefa9..6030e2c 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -16,10 +16,12 @@ class Context: @unchecked Sendable { } struct TypeMap: TypeMapProtocol { typealias Context = HelloWorldServer.Context + typealias DateTime = HelloWorldServer.DateTime typealias User = HelloWorldServer.User typealias Contact = HelloWorldServer.Contact typealias Post = HelloWorldServer.Post } +struct DateTime: Scalar { } struct User: UserProtocol { // User can choose structure let id: String From 09de8397a7d66f5a5cdc8b10e14570db8587bea6 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 21:26:29 -0600 Subject: [PATCH 18/38] feat: Splits out Query & Mutation as types --- .../HelloWorldServer/Gen/Resolvers.swift | 28 ++-------------- .../Sources/HelloWorldServer/Gen/Schema.swift | 32 +++++++++---------- .../Sources/HelloWorldServer/Gen/Types.swift | 25 +++++++++++++++ .../Helper/ResolverHelpers.swift | 14 ++++---- .../Sources/HelloWorldServer/main.swift | 31 +++++++++--------- 5 files changed, 65 insertions(+), 65 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index b661d7c..66919e5 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -8,30 +8,6 @@ public protocol TypeMapProtocol { associatedtype User: UserProtocol where User.TypeMap == Self associatedtype Contact: ContactProtocol where Contact.TypeMap == Self associatedtype Post: PostProtocol where Post.TypeMap == Self -} - -/// Protocol defining all resolver methods for your GraphQL schema -public protocol GraphQLResolvers: Sendable { - associatedtype TypeMap: TypeMapProtocol - - // MARK: - Query Resolvers - - /// Get a user by ID - func user(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User? - - /// Get all users - func users(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.User] - - /// Get a post by ID - func post(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.Post? - - /// Get recent posts - func posts(limit: Int?, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.Post] - - /// Get a user or post by ID - func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? - - // MARK: - Mutation Resolvers - - func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User + associatedtype Query: QueryProtocol where Query.TypeMap == Self + associatedtype Mutation: MutationProtocol where Mutation.TypeMap == Self } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 3fc95fa..a773b41 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -5,7 +5,7 @@ import Foundation import GraphQL /// Build a GraphQL schema with the provided resolvers -public func buildGraphQLSchema(resolvers: T) throws -> GraphQLSchema { +public func buildGraphQLSchema(typeMap: T.Type) throws -> GraphQLSchema { let roleType = try GraphQLEnumType( name: "Role", @@ -28,13 +28,13 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap let datetimeScalar = try GraphQLScalarType( name: "DateTime", serialize: { any in - try T.TypeMap.DateTime.serialize(any: any) + try T.DateTime.serialize(any: any) }, parseValue: { map in - try T.TypeMap.DateTime.parseValue(map: map) + try T.DateTime.parseValue(map: map) }, parseLiteral: { value in - try T.TypeMap.DateTime.parseLiteral(value: value) + try T.DateTime.parseLiteral(value: value) } ) let hasEmailInterface = try GraphQLInterfaceType( @@ -168,8 +168,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap The author of the post """, resolve: { source, args, context, info in - let parent = try resolvers.cast(source, to: T.TypeMap.Post.self) - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) + let parent = try cast(source, to: T.Post.self) + let context = try cast(context, to: T.Context.self) return try await parent.author(context: context, info: info) } ), @@ -194,8 +194,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) - return try await resolvers.user(id: id, context: context, info: info) + let context = try cast(context, to: T.Context.self) + return try await T.Query.user(id: id, context: context, info: info) } ), "users": GraphQLField( @@ -204,8 +204,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap Get all users """, resolve: { source, args, context, info in - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) - return try await resolvers.users(context: context, info: info) + let context = try cast(context, to: T.Context.self) + return try await T.Query.users(context: context, info: info) } ), "post": GraphQLField( @@ -220,8 +220,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) - return try await resolvers.post(id: id, context: context, info: info) + let context = try cast(context, to: T.Context.self) + return try await T.Query.post(id: id, context: context, info: info) } ), "posts": GraphQLField( @@ -236,8 +236,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) - return try await resolvers.posts(limit: limit, context: context, info: info) + let context = try cast(context, to: T.Context.self) + return try await T.Query.posts(limit: limit, context: context, info: info) } ), ] @@ -257,8 +257,8 @@ public func buildGraphQLSchema(resolvers: T) throws -> Grap ], resolve: { source, args, context, info in let userInfoInput = try MapDecoder().decode(UserInfoInput.self, from: args["userInfo"]) - let context = try resolvers.cast(context, to: T.TypeMap.Context.self) - return try await resolvers.upsertUser(userInfo: userInfoInput, context: context, info: info) + let context = try cast(context, to: T.Context.self) + return try await T.Mutation.upsertUser(userInfo: userInfoInput, context: context, info: info) } ), ] diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index 84bf86e..a963e3b 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -71,3 +71,28 @@ public protocol PostProtocol: UserOrPostUnion, Sendable { /// The author of the post func author(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User } + +public protocol QueryProtocol: Sendable { + associatedtype TypeMap: TypeMapProtocol + + /// Get a user by ID + static func user(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User? + + /// Get all users + static func users(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.User] + + /// Get a post by ID + static func post(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.Post? + + /// Get recent posts + static func posts(limit: Int?, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.Post] + + /// Get a user or post by ID + static func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? +} + +public protocol MutationProtocol: Sendable { + associatedtype TypeMap: TypeMapProtocol + + static func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift index ed8008e..07418d5 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift @@ -1,12 +1,10 @@ import GraphQL -public extension GraphQLResolvers { - func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { - guard let result = anySendable as? T else { - throw GraphQLError( - message: "Expected source type \(T.self) but got \(type(of: anySendable))" - ) - } - return result +func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { + guard let result = anySendable as? T else { + throw GraphQLError( + message: "Expected source type \(T.self) but got \(type(of: anySendable))" + ) } + return result } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 6030e2c..892ae01 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -20,6 +20,8 @@ struct TypeMap: TypeMapProtocol { typealias User = HelloWorldServer.User typealias Contact = HelloWorldServer.Contact typealias Post = HelloWorldServer.Post + typealias Query = HelloWorldServer.Query + typealias Mutation = HelloWorldServer.Mutation } struct DateTime: Scalar { } struct User: UserProtocol { @@ -81,30 +83,30 @@ struct Post: PostProtocol { } } -struct HelloWorldResolvers: GraphQLResolvers { +struct Query: QueryProtocol { // Required implementations - - // TypeMap - typealias Context = HelloWorldServer.Context typealias TypeMap = HelloWorldServer.TypeMap - - // Query - func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { + static func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { return context.users[id] } - func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [User] { + static func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [User] { return .init(context.users.values) } - func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Post? { + static func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Post? { return context.posts[id] } - func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { + static func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { return .init(context.posts.values) } - func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { + static func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { return context.users[id] ?? context.posts[id] } - func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) -> User { +} + +struct Mutation: MutationProtocol { + // Required implementations + typealias TypeMap = HelloWorldServer.TypeMap + static func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) -> User { let user = User( id: userInfo.id, name: userInfo.name, @@ -117,10 +119,9 @@ struct HelloWorldResolvers: GraphQLResolvers { } } -let resolvers = HelloWorldResolvers() -let schema = try buildGraphQLSchema(resolvers: resolvers) +let schema = try buildGraphQLSchema(typeMap: TypeMap.self) -let context = HelloWorldResolvers.Context( +let context = Context( users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] ) From eb588ef6adce8ff1effda3fb9846f081fb90ab5f Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 21:29:42 -0600 Subject: [PATCH 19/38] feat: Changes TypeMap to Resolvers --- .../HelloWorldServer/Gen/Resolvers.swift | 12 +++--- .../Sources/HelloWorldServer/Gen/Schema.swift | 2 +- .../Sources/HelloWorldServer/Gen/Types.swift | 42 +++++++++---------- .../Sources/HelloWorldServer/main.swift | 38 ++++++++--------- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 66919e5..52bfe9e 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -2,12 +2,12 @@ import Foundation import GraphQL -public protocol TypeMapProtocol { +public protocol ResolversProtocol { associatedtype Context: Sendable associatedtype DateTime: Scalar - associatedtype User: UserProtocol where User.TypeMap == Self - associatedtype Contact: ContactProtocol where Contact.TypeMap == Self - associatedtype Post: PostProtocol where Post.TypeMap == Self - associatedtype Query: QueryProtocol where Query.TypeMap == Self - associatedtype Mutation: MutationProtocol where Mutation.TypeMap == Self + associatedtype User: UserProtocol where User.Resolvers == Self + associatedtype Contact: ContactProtocol where Contact.Resolvers == Self + associatedtype Post: PostProtocol where Post.Resolvers == Self + associatedtype Query: QueryProtocol where Query.Resolvers == Self + associatedtype Mutation: MutationProtocol where Mutation.Resolvers == Self } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index a773b41..5f46d59 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -5,7 +5,7 @@ import Foundation import GraphQL /// Build a GraphQL schema with the provided resolvers -public func buildGraphQLSchema(typeMap: T.Type) throws -> GraphQLSchema { +public func buildGraphQLSchema(Resolvers: T.Type) throws -> GraphQLSchema { let roleType = try GraphQLEnumType( name: "Role", diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index a963e3b..dac241e 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -28,10 +28,10 @@ public struct UserInfoInput: Codable, Sendable { public protocol UserOrPostUnion {} public protocol HasEmailInterface: Sendable { - associatedtype TypeMap: TypeMapProtocol + associatedtype Resolvers: ResolversProtocol /// An email address - func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String } // Object Types @@ -41,58 +41,58 @@ public protocol UserProtocol: HasEmailInterface, UserOrPostUnion, Sendable { // No type map associatedType needed because of HasEmailInterface inheritance /// The unique identifier for the user - func id(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func id(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The user's display name - func name(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func name(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The user's email address - func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The user's age - func age(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> Int? + func age(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Int? /// The user's age - func role(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> Role? + func role(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Role? } public protocol ContactProtocol: HasEmailInterface, Sendable { // No type map associatedType needed because of HasEmailInterface inheritance - func email(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String } /// A blog post public protocol PostProtocol: UserOrPostUnion, Sendable { - associatedtype TypeMap: TypeMapProtocol + associatedtype Resolvers: ResolversProtocol /// The unique identifier for the post - func id(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func id(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The post title - func title(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func title(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The post content - func content(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> String + func content(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String /// The author of the post - func author(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User + func author(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User } public protocol QueryProtocol: Sendable { - associatedtype TypeMap: TypeMapProtocol + associatedtype Resolvers: ResolversProtocol /// Get a user by ID - static func user(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User? + static func user(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User? /// Get all users - static func users(context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.User] + static func users(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> [Resolvers.User] /// Get a post by ID - static func post(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.Post? + static func post(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.Post? /// Get recent posts - static func posts(limit: Int?, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> [TypeMap.Post] + static func posts(limit: Int?, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> [Resolvers.Post] /// Get a user or post by ID - static func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? + static func userOrPost(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? } public protocol MutationProtocol: Sendable { - associatedtype TypeMap: TypeMapProtocol + associatedtype Resolvers: ResolversProtocol - static func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> TypeMap.User + static func upsertUser(userInfo: UserInfoInput, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 892ae01..a2db3cd 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -14,7 +14,7 @@ class Context: @unchecked Sendable { self.posts = posts } } -struct TypeMap: TypeMapProtocol { +struct Resolvers: ResolversProtocol { typealias Context = HelloWorldServer.Context typealias DateTime = HelloWorldServer.DateTime typealias User = HelloWorldServer.User @@ -33,20 +33,20 @@ struct User: UserProtocol { let role: Role? // Required implementations - typealias TypeMap = HelloWorldServer.TypeMap - func id(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + typealias Resolvers = HelloWorldServer.Resolvers + func id(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func name(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func name(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return name } - func email(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func email(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return email } - func age(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { + func age(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { return age } - func role(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { + func role(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { return role } } @@ -55,8 +55,8 @@ struct Contact: ContactProtocol { let email: String // Required implementations - typealias TypeMap = HelloWorldServer.TypeMap - func email(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + typealias Resolvers = HelloWorldServer.Resolvers + func email(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return email } } @@ -68,24 +68,24 @@ struct Post: PostProtocol { let authorId: String // Required implementations - typealias TypeMap = HelloWorldServer.TypeMap - func id(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + typealias Resolvers = HelloWorldServer.Resolvers + func id(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func title(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func title(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return title } - func content(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func content(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return content } - func author(context: TypeMap.Context, info: GraphQL.GraphQLResolveInfo) async throws -> TypeMap.User { + func author(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Resolvers.User { return context.users[authorId]! } } struct Query: QueryProtocol { // Required implementations - typealias TypeMap = HelloWorldServer.TypeMap + typealias Resolvers = HelloWorldServer.Resolvers static func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { return context.users[id] } @@ -98,15 +98,15 @@ struct Query: QueryProtocol { static func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { return .init(context.posts.values) } - static func userOrPost(id: String, context: TypeMap.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { + static func userOrPost(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { return context.users[id] ?? context.posts[id] } } struct Mutation: MutationProtocol { // Required implementations - typealias TypeMap = HelloWorldServer.TypeMap - static func upsertUser(userInfo: UserInfoInput, context: TypeMap.Context, info: GraphQLResolveInfo) -> User { + typealias Resolvers = HelloWorldServer.Resolvers + static func upsertUser(userInfo: UserInfoInput, context: Resolvers.Context, info: GraphQLResolveInfo) -> User { let user = User( id: userInfo.id, name: userInfo.name, @@ -119,7 +119,7 @@ struct Mutation: MutationProtocol { } } -let schema = try buildGraphQLSchema(typeMap: TypeMap.self) +let schema = try buildGraphQLSchema(Resolvers: Resolvers.self) let context = Context( users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], From 94f160e0f58806de3229db8ef464bf47bd188005 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 21:54:47 -0600 Subject: [PATCH 20/38] feat: Loosens type restrictions In particular, it removes strong type adherence to stated schema types. This allows returning any conforming type, which can help with mocking, etc. It also removes the Context typealias, in preference of an expected "Context" type & Scalar types, and removes the output/Scalar types from the TypeMap, as they are no longer needed. --- .../HelloWorldServer/Gen/Resolvers.swift | 9 +--- .../Sources/HelloWorldServer/Gen/Schema.swift | 20 +++---- .../Sources/HelloWorldServer/Gen/Types.swift | 46 ++++++---------- .../Sources/HelloWorldServer/main.swift | 53 ++++++++----------- 4 files changed, 52 insertions(+), 76 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift index 52bfe9e..388a518 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift @@ -3,11 +3,6 @@ import Foundation import GraphQL public protocol ResolversProtocol { - associatedtype Context: Sendable - associatedtype DateTime: Scalar - associatedtype User: UserProtocol where User.Resolvers == Self - associatedtype Contact: ContactProtocol where Contact.Resolvers == Self - associatedtype Post: PostProtocol where Post.Resolvers == Self - associatedtype Query: QueryProtocol where Query.Resolvers == Self - associatedtype Mutation: MutationProtocol where Mutation.Resolvers == Self + associatedtype Query: QueryProtocol + associatedtype Mutation: MutationProtocol } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index 5f46d59..f577737 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -28,13 +28,13 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - let datetimeScalar = try GraphQLScalarType( name: "DateTime", serialize: { any in - try T.DateTime.serialize(any: any) + try DateTime.serialize(any: any) }, parseValue: { map in - try T.DateTime.parseValue(map: map) + try DateTime.parseValue(map: map) }, parseLiteral: { value in - try T.DateTime.parseLiteral(value: value) + try DateTime.parseLiteral(value: value) } ) let hasEmailInterface = try GraphQLInterfaceType( @@ -168,8 +168,8 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - The author of the post """, resolve: { source, args, context, info in - let parent = try cast(source, to: T.Post.self) - let context = try cast(context, to: T.Context.self) + let parent = try cast(source, to: (any PostProtocol).self) + let context = try cast(context, to: Context.self) return try await parent.author(context: context, info: info) } ), @@ -194,7 +194,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try cast(context, to: T.Context.self) + let context = try cast(context, to: Context.self) return try await T.Query.user(id: id, context: context, info: info) } ), @@ -204,7 +204,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - Get all users """, resolve: { source, args, context, info in - let context = try cast(context, to: T.Context.self) + let context = try cast(context, to: Context.self) return try await T.Query.users(context: context, info: info) } ), @@ -220,7 +220,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - ], resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try cast(context, to: T.Context.self) + let context = try cast(context, to: Context.self) return try await T.Query.post(id: id, context: context, info: info) } ), @@ -236,7 +236,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - ], resolve: { source, args, context, info in let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil - let context = try cast(context, to: T.Context.self) + let context = try cast(context, to: Context.self) return try await T.Query.posts(limit: limit, context: context, info: info) } ), @@ -257,7 +257,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - ], resolve: { source, args, context, info in let userInfoInput = try MapDecoder().decode(UserInfoInput.self, from: args["userInfo"]) - let context = try cast(context, to: T.Context.self) + let context = try cast(context, to: Context.self) return try await T.Mutation.upsertUser(userInfo: userInfoInput, context: context, info: info) } ), diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index dac241e..b5971f0 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -28,71 +28,59 @@ public struct UserInfoInput: Codable, Sendable { public protocol UserOrPostUnion {} public protocol HasEmailInterface: Sendable { - associatedtype Resolvers: ResolversProtocol - /// An email address - func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Context, info: GraphQLResolveInfo) async throws -> String } // Object Types /// A simple user type public protocol UserProtocol: HasEmailInterface, UserOrPostUnion, Sendable { - // No type map associatedType needed because of HasEmailInterface inheritance - /// The unique identifier for the user - func id(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func id(context: Context, info: GraphQLResolveInfo) async throws -> String /// The user's display name - func name(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func name(context: Context, info: GraphQLResolveInfo) async throws -> String /// The user's email address - func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Context, info: GraphQLResolveInfo) async throws -> String /// The user's age - func age(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Int? + func age(context: Context, info: GraphQLResolveInfo) async throws -> Int? /// The user's age - func role(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Role? + func role(context: Context, info: GraphQLResolveInfo) async throws -> Role? } public protocol ContactProtocol: HasEmailInterface, Sendable { - // No type map associatedType needed because of HasEmailInterface inheritance - - func email(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func email(context: Context, info: GraphQLResolveInfo) async throws -> String } /// A blog post public protocol PostProtocol: UserOrPostUnion, Sendable { - associatedtype Resolvers: ResolversProtocol - /// The unique identifier for the post - func id(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func id(context: Context, info: GraphQLResolveInfo) async throws -> String /// The post title - func title(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func title(context: Context, info: GraphQLResolveInfo) async throws -> String /// The post content - func content(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> String + func content(context: Context, info: GraphQLResolveInfo) async throws -> String /// The author of the post - func author(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User + func author(context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol } public protocol QueryProtocol: Sendable { - associatedtype Resolvers: ResolversProtocol - /// Get a user by ID - static func user(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User? + static func user(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserProtocol)? /// Get all users - static func users(context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> [Resolvers.User] + static func users(context: Context, info: GraphQLResolveInfo) async throws -> [any UserProtocol] /// Get a post by ID - static func post(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.Post? + static func post(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any PostProtocol)? /// Get recent posts - static func posts(limit: Int?, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> [Resolvers.Post] + static func posts(limit: Int?, context: Context, info: GraphQLResolveInfo) async throws -> [any PostProtocol] /// Get a user or post by ID - static func userOrPost(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? + static func userOrPost(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? } public protocol MutationProtocol: Sendable { - associatedtype Resolvers: ResolversProtocol - - static func upsertUser(userInfo: UserInfoInput, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> Resolvers.User + static func upsertUser(userInfo: UserInfoInput, context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol } diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index a2db3cd..a61ee83 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -1,7 +1,8 @@ import Foundation import GraphQL -class Context: @unchecked Sendable { +// Must be created by user and named `Context`. +public class Context: @unchecked Sendable { // User can choose structure var users: [String: User] var posts: [String: Post] @@ -14,16 +15,13 @@ class Context: @unchecked Sendable { self.posts = posts } } +// Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol +struct DateTime: Scalar { } + struct Resolvers: ResolversProtocol { - typealias Context = HelloWorldServer.Context - typealias DateTime = HelloWorldServer.DateTime - typealias User = HelloWorldServer.User - typealias Contact = HelloWorldServer.Contact - typealias Post = HelloWorldServer.Post typealias Query = HelloWorldServer.Query typealias Mutation = HelloWorldServer.Mutation } -struct DateTime: Scalar { } struct User: UserProtocol { // User can choose structure let id: String @@ -33,20 +31,19 @@ struct User: UserProtocol { let role: Role? // Required implementations - typealias Resolvers = HelloWorldServer.Resolvers - func id(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func name(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return name } - func email(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return email } - func age(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { + func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { return age } - func role(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { + func role(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { return role } } @@ -55,8 +52,7 @@ struct Contact: ContactProtocol { let email: String // Required implementations - typealias Resolvers = HelloWorldServer.Resolvers - func email(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return email } } @@ -68,45 +64,42 @@ struct Post: PostProtocol { let authorId: String // Required implementations - typealias Resolvers = HelloWorldServer.Resolvers - func id(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func title(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func title(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return title } - func content(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func content(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return content } - func author(context: Resolvers.Context, info: GraphQL.GraphQLResolveInfo) async throws -> Resolvers.User { + func author(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> any UserProtocol { return context.users[authorId]! } } struct Query: QueryProtocol { // Required implementations - typealias Resolvers = HelloWorldServer.Resolvers - static func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> User? { + static func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> (any UserProtocol)? { return context.users[id] } - static func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [User] { - return .init(context.users.values) + static func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [any UserProtocol] { + return context.users.values.map { $0 as any UserProtocol } } - static func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Post? { + static func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> (any PostProtocol)? { return context.posts[id] } - static func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [Post] { - return .init(context.posts.values) + static func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [any PostProtocol] { + return context.posts.values.map { $0 as any PostProtocol } } - static func userOrPost(id: String, context: Resolvers.Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { + static func userOrPost(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { return context.users[id] ?? context.posts[id] } } struct Mutation: MutationProtocol { // Required implementations - typealias Resolvers = HelloWorldServer.Resolvers - static func upsertUser(userInfo: UserInfoInput, context: Resolvers.Context, info: GraphQLResolveInfo) -> User { + static func upsertUser(userInfo: UserInfoInput, context: Context, info: GraphQLResolveInfo) -> any UserProtocol { let user = User( id: userInfo.id, name: userInfo.name, From c228bfb18f5241c66cea047288844a1995577653 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 26 Dec 2025 22:04:45 -0600 Subject: [PATCH 21/38] refactor: Schema generic naming improvements --- .../Sources/HelloWorldServer/Gen/Schema.swift | 12 ++++++------ .../Sources/HelloWorldServer/main.swift | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift index f577737..541b1b9 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift @@ -5,7 +5,7 @@ import Foundation import GraphQL /// Build a GraphQL schema with the provided resolvers -public func buildGraphQLSchema(Resolvers: T.Type) throws -> GraphQLSchema { +public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { let roleType = try GraphQLEnumType( name: "Role", @@ -195,7 +195,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) let context = try cast(context, to: Context.self) - return try await T.Query.user(id: id, context: context, info: info) + return try await Resolvers.Query.user(id: id, context: context, info: info) } ), "users": GraphQLField( @@ -205,7 +205,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - """, resolve: { source, args, context, info in let context = try cast(context, to: Context.self) - return try await T.Query.users(context: context, info: info) + return try await Resolvers.Query.users(context: context, info: info) } ), "post": GraphQLField( @@ -221,7 +221,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - resolve: { source, args, context, info in let id = try MapDecoder().decode(String.self, from: args["id"]) let context = try cast(context, to: Context.self) - return try await T.Query.post(id: id, context: context, info: info) + return try await Resolvers.Query.post(id: id, context: context, info: info) } ), "posts": GraphQLField( @@ -237,7 +237,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - resolve: { source, args, context, info in let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil let context = try cast(context, to: Context.self) - return try await T.Query.posts(limit: limit, context: context, info: info) + return try await Resolvers.Query.posts(limit: limit, context: context, info: info) } ), ] @@ -258,7 +258,7 @@ public func buildGraphQLSchema(Resolvers: T.Type) throws - resolve: { source, args, context, info in let userInfoInput = try MapDecoder().decode(UserInfoInput.self, from: args["userInfo"]) let context = try cast(context, to: Context.self) - return try await T.Mutation.upsertUser(userInfo: userInfoInput, context: context, info: info) + return try await Resolvers.Mutation.upsertUser(userInfo: userInfoInput, context: context, info: info) } ), ] diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index a61ee83..00db903 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -16,8 +16,9 @@ public class Context: @unchecked Sendable { } } // Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol -struct DateTime: Scalar { } +struct DateTime: Scalar {} +// Now create types that conform to the expected protocols struct Resolvers: ResolversProtocol { typealias Query = HelloWorldServer.Query typealias Mutation = HelloWorldServer.Mutation @@ -112,7 +113,7 @@ struct Mutation: MutationProtocol { } } -let schema = try buildGraphQLSchema(Resolvers: Resolvers.self) +let schema = try buildGraphQLSchema(resolvers: Resolvers.self) let context = Context( users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], From 995aa1377f43897fc00421e07a546ab3b53188dc Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 27 Dec 2025 15:25:20 -0600 Subject: [PATCH 22/38] feat: Combine Resolvers and Types --- .../Sources/HelloWorldServer/Gen/Resolvers.swift | 8 -------- .../Sources/HelloWorldServer/Gen/Types.swift | 6 ++++++ 2 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift deleted file mode 100644 index 388a518..0000000 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Resolvers.swift +++ /dev/null @@ -1,8 +0,0 @@ - -import Foundation -import GraphQL - -public protocol ResolversProtocol { - associatedtype Query: QueryProtocol - associatedtype Mutation: MutationProtocol -} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift index b5971f0..a4424b1 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift @@ -4,6 +4,12 @@ import Foundation import GraphQL +// Resolvers +public protocol ResolversProtocol { + associatedtype Query: QueryProtocol + associatedtype Mutation: MutationProtocol +} + // Enums /// User role enumeration From 2af6ef938718dc47472696af3ff7d72f1abd2167 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 27 Dec 2025 17:03:17 -0600 Subject: [PATCH 23/38] feat: Adds complete type generator --- .../Generator/CodeGenerator.swift | 4 - .../Generator/ResolverGenerator.swift | 145 ------- .../Generator/SchemaGenerator.swift | 8 +- .../Generator/TypeGenerator.swift | 384 ++++++++++++++++-- .../Utilities/swiftTypeName.swift | 43 +- .../ResolverGeneratorTests.swift | 61 --- .../TypeGeneratorTests.swift | 145 +++++-- 7 files changed, 499 insertions(+), 291 deletions(-) delete mode 100644 Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift delete mode 100644 Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift diff --git a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift index c2d41fa..860421b 100644 --- a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -14,10 +14,6 @@ package struct CodeGenerator { let typeGenerator = TypeGenerator() files["Types.swift"] = try typeGenerator.generate(schema: schema) - // Generate Resolvers.swift - let resolverGenerator = ResolverGenerator() - files["Resolvers.swift"] = try resolverGenerator.generate(schema: schema) - // Generate Schema.swift let schemaGenerator = SchemaGenerator() files["Schema.swift"] = try schemaGenerator.generate(schema: schema) diff --git a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift deleted file mode 100644 index 4b45537..0000000 --- a/Sources/GraphQLGeneratorCore/Generator/ResolverGenerator.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import GraphQL - -/// Generates resolver protocol from GraphQL schema -package struct ResolverGenerator { - let nameGenerator: SafeNameGenerator = .idiomatic - - package func generate(schema: GraphQLSchema) throws -> String { - var output = """ - // Generated by GraphQL Generator - // DO NOT EDIT - This file is automatically generated - - import Foundation - import GraphQL - import GraphQLGeneratorRuntime - - /// Protocol defining all resolver methods for your GraphQL schema - public protocol GraphQLResolvers: Sendable { - - """ - - // Generate resolver methods for Query type - if let queryType = schema.queryType { - output += try generateResolverMethods(for: queryType, rootType: "Query") - } - - // Generate resolver methods for Mutation type - if let mutationType = schema.mutationType { - output += try generateResolverMethods(for: mutationType, rootType: "Mutation") - } - - // Generate resolver methods for nested fields - let typeMap = schema.typeMap - let objectTypes = typeMap.values.compactMap { $0 as? GraphQLObjectType } - - for objectType in objectTypes { - // Skip introspection types and root operation types - if objectType.name.hasPrefix("__") || - objectType.name == "Query" || - objectType.name == "Mutation" || - objectType.name == "Subscription" { - continue - } - - output += try generateNestedResolverMethods(for: objectType) - } - - output += "}\n" - - return output - } - - private func generateResolverMethods(for type: GraphQLObjectType, rootType: String) throws -> String { - var output = "" - - output += " // MARK: - \(rootType) Resolvers\n\n" - - let fields = try type.fields() - for (fieldName, field) in fields { - if let description = field.description { - output += " /// \(description)\n" - } - - let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) - - // Generate parameter list - var params: [String] = [] - - // Add arguments - for (argName, arg) in field.args { - let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) - params.append("\(argName): \(argType)") - } - - // Add context parameter - params.append("context: ResolverContext") - - // Add resolve info parameter - params.append("info: GraphQLResolveInfo") - - let paramString = params.joined(separator: ", ") - - output += " func \(fieldName)(\(paramString)) async throws -> \(returnType)\n\n" - } - - return output - } - - private func generateNestedResolverMethods(for type: GraphQLObjectType) throws -> String { - var output = "" - let fields = try type.fields() - - // Only generate nested resolvers for fields that reference other object types - let nestedFields = fields.filter { (_, field) in - let unwrappedType = unwrapType(field.type) - return unwrappedType is GraphQLObjectType && !(unwrappedType is GraphQLScalarType) - } - - if nestedFields.isEmpty { - return "" - } - - output += " // MARK: - \(type.name) Field Resolvers\n\n" - - for (fieldName, field) in nestedFields { - if let description = field.description { - output += " /// \(description)\n" - } - - let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) - - // Parent parameter is the type itself - var params: [String] = ["parent: \(type.name)"] - - // Add arguments if any - for (argName, arg) in field.args { - let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) - params.append("\(argName): \(argType)") - } - - // Add context parameter - params.append("context: ResolverContext") - - // Add resolve info parameter - params.append("info: GraphQLResolveInfo") - - let paramString = params.joined(separator: ", ") - - output += " func \(type.name.lowercased())\(fieldName.capitalized)(\(paramString)) async throws -> \(returnType)\n\n" - } - - return output - } - - /// Unwrap GraphQL type to get the base type - private func unwrapType(_ type: GraphQLType) -> GraphQLType { - if let nonNull = type as? GraphQLNonNull { - return unwrapType(nonNull.ofType) - } - if let list = type as? GraphQLList { - return unwrapType(list.ofType) - } - return type - } -} diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 11f38c3..d2a7ac0 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -514,7 +514,7 @@ package struct SchemaGenerator { // Add field arguments for (argName, arg) in field.args { let safeArgName = nameGenerator.swiftMemberName(for: argName) - let swiftType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) + let swiftType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) // Extract value from Map based on type output += """ @@ -526,9 +526,9 @@ package struct SchemaGenerator { // Add context output += """ - guard let context = context as? ResolverContext else { + guard let context = context as? Context else { throw GraphQLError( - message: "Expected context type ResolverContext but got \\(type(of: context))" + message: "Expected context type Context but got \\(type(of: context))" ) } """ @@ -596,7 +596,7 @@ package struct SchemaGenerator { // For list types, map over the array if let list = type as? GraphQLList { - return "try \(valueName).arrayValue?.map { try \(try swiftTypeName(for: list.ofType, nameGenerator: nameGenerator))($0) }" + return "try \(valueName).arrayValue?.map { try \(try swiftTypeReference(for: list.ofType, nameGenerator: nameGenerator))($0) }" } // For named types, convert based on scalar type diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index b97980a..224e5c2 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -11,109 +11,411 @@ package struct TypeGenerator { // DO NOT EDIT - This file is automatically generated import Foundation + import GraphQL + """ + + // Generate ResolversProtocol + + output += """ + + public protocol ResolversProtocol: Sendable { + """ + if schema.queryType != nil { + output += """ + + associatedtype Query: QueryProtocol + """ + } + if schema.mutationType != nil { + output += """ + + associatedtype Mutation: MutationProtocol + """ + } + output += """ + } """ - // Generate struct for each object type (excluding Query, Mutation, Subscription) - let typeMap = schema.typeMap - let objectTypes = typeMap.values.compactMap { + // Ignore any internal types (which have prefix "__") + let types = schema.typeMap.values.filter { + !$0.name.hasPrefix("__") + } + + // Generate Enum types + let enumTypes = types.compactMap { + $0 as? GraphQLEnumType + } + for type in enumTypes { + output += """ + + \(try generateEnum(for: type)) + """ + } + + // Generate Input types + let inputTypes = types.compactMap { + $0 as? GraphQLInputObjectType + } + for type in inputTypes { + output += """ + + \(try generateInputStruct(for: type)) + """ + } + + // Generate Union types + var unionTypeMap = [String: [GraphQLUnionType]]() + let unionTypes = types.compactMap { + $0 as? GraphQLUnionType + } + for type in unionTypes { + // Unions are represented by a marker protocol, with associated types conforming + + // Add description if available + if let description = type.description { + output += """ + + /// \(description) + """ + } + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ + + public protocol \(swiftTypeName)Union {} + """ + + // Record which types need to be conformed + for conformingType in try type.types() { + if unionTypeMap[conformingType.name] != nil { + unionTypeMap[conformingType.name]!.append(type) + } else { + unionTypeMap[conformingType.name] = [type] + } + } + } + + // Generate Interface types + let interfaceTypes = types.compactMap { + $0 as? GraphQLInterfaceType + } + for type in interfaceTypes { + output += """ + + \(try generateInterfaceProtocol(for: type)) + """ + } + + // Generate Object types (excluding Query, Mutation, Subscription) + let objectTypes = types.compactMap { $0 as? GraphQLObjectType }.filter { - // Skip introspection types and root operation types - !$0.name.hasPrefix("__") && + // Skip root operation types $0.name != "Query" && $0.name != "Mutation" && $0.name != "Subscription" } - for objectType in objectTypes { - output += "\n" - output += try generateTypeProtocol(for: objectType) + for type in objectTypes { + output += """ + + \(try generateTypeProtocol(for: type, unionTypeMap: unionTypeMap)) + """ + } + + // Generate Query type + if let queryType = schema.queryType { + output += """ + + \(try generateRootTypeProtocol(for: queryType)) + """ + } + + // Generate Mutation type + if let mutationType = schema.mutationType { + output += """ + + \(try generateRootTypeProtocol(for: mutationType)) + """ + } + + // TODO: Add subscription types + + return output + } + + func generateEnum(for type: GraphQLEnumType) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += """ + + /// \(description) + """ + } + + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ + + public enum \(swiftTypeName): String, Codable, Sendable { + """ + + // Generate cases + for value in type.values { + if let description = value.description { + output += """ + + /// \(description) + """ + } + // Use safe name generator for case names + let safeCaseName = nameGenerator.swiftMemberName(for: value.name) + output += """ + + case \(safeCaseName) = "\(value.name)" + """ + } + + output += """ + + } + """ + + return output + } + + + func generateInputStruct(for type: GraphQLInputObjectType) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += """ + + /// \(description) + """ + } + + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += "public struct \(swiftTypeName): Codable, Sendable {\n" + + // Generate properties + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += """ + + /// \(description) + """ + } + + let returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + + output += """ + + public let \(fieldName.lowercased()): \(returnType) + """ + } + + // Swift auto-generates memberwise initializers for structs, so we don't need to generate one + output += """ + } + """ + + return output + } + + func generateInterfaceProtocol(for type: GraphQLInterfaceType) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += """ + + /// \(description) + """ + } + + let interfaces = try type.interfaces().map { + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + }.joined(separator: "") + + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ + + public protocol \(swiftTypeName): \(interfaces)Sendable { + """ + + // Generate properties + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += """ + + /// \(description) + """ + } + + let returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + + var params: [String] = [] - // Generate enums - let enumTypes = typeMap.values.compactMap { $0 as? GraphQLEnumType } - for enumType in enumTypes { - // Skip GraphQL internal enums (prefixed with __) - if enumType.name.hasPrefix("__") { - continue + // Add arguments if any + for (argName, arg) in field.args { + let argType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) + params.append("\(argName): \(argType)") } - output += "\n" - output += try generateEnum(for: enumType) + // Add context parameter + params.append("context: Context") + + // Add resolve info parameter + params.append("info: GraphQLResolveInfo") + + let paramString = params.joined(separator: ", ") + + output += """ + + public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + + """ } + output += """ + + } + """ + return output } - func generateTypeProtocol(for type: GraphQLObjectType) throws -> String { + func generateTypeProtocol(for type: GraphQLObjectType, unionTypeMap: [String: [GraphQLUnionType]]) throws -> String { var output = "" // Add description if available if let description = type.description { - output += "/// \(description)\n" + output += """ + + /// \(description) + """ } - // Use safe name generator for type name - let safeTypeName = nameGenerator.swiftTypeName(for: type.name) - output += "public protocol \(safeTypeName)Protocol: Sendable {\n" + let unions = try unionTypeMap[type.name]?.map { + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + }.joined(separator: "") ?? "" + + let interfaces = try type.interfaces().map { + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + }.joined(separator: "") + + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ + + public protocol \(swiftTypeName): \(unions)\(interfaces)Sendable { + """ // Generate properties let fields = try type.fields() for (fieldName, field) in fields { if let description = field.description { - output += " /// \(description)\n" + output += """ + + /// \(description) + """ } - let returnType = try swiftTypeName(for: field.type, nameGenerator: nameGenerator) + let returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) var params: [String] = [] // Add arguments if any for (argName, arg) in field.args { - let argType = try swiftTypeName(for: arg.type, nameGenerator: nameGenerator) + let argType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) params.append("\(argName): \(argType)") } // Add context parameter - params.append("context: ResolverContext") + params.append("context: Context") // Add resolve info parameter params.append("info: GraphQLResolveInfo") let paramString = params.joined(separator: ", ") - output += " public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType)\n\n" + output += """ + + public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + + """ } - // Swift auto-generates memberwise initializers for structs, so we don't need to generate one - output += "}\n" + output += """ + + } + """ return output } - func generateEnum(for type: GraphQLEnumType) throws -> String { + /// Root types are the same as normal types, except that their functions are static and they cannot + /// inherit from interfaces or unions + func generateRootTypeProtocol(for type: GraphQLObjectType) throws -> String { var output = "" // Add description if available if let description = type.description { - output += "/// \(description)\n" + output += """ + + /// \(description) + """ } - // Use safe name generator for enum name - let safeEnumName = nameGenerator.swiftTypeName(for: type.name) - output += "public enum \(safeEnumName): String, Codable, Sendable {\n" + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ - // Generate cases - for value in type.values { - if let description = value.description { - output += " /// \(description)\n" + public protocol \(swiftTypeName): Sendable { + """ + + // Generate properties + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += """ + + /// \(description) + """ } - // Use safe name generator for case names - let safeCaseName = nameGenerator.swiftMemberName(for: value.name) - output += " case \(safeCaseName) = \"\(value.name)\"\n" + + let returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + + var params: [String] = [] + + // Add arguments if any + for (argName, arg) in field.args { + let argType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) + params.append("\(argName): \(argType)") + } + + // Add context parameter + params.append("context: Context") + + // Add resolve info parameter + params.append("info: GraphQLResolveInfo") + + let paramString = params.joined(separator: ", ") + + output += """ + + static func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + + """ } - output += "}\n" + output += """ + + } + """ return output } diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift index 0a02323..3c27b5c 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -1,18 +1,23 @@ import GraphQL /// Convert GraphQL type to Swift type name -func swiftTypeName(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { +func swiftTypeReference(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { if let nonNull = type as? GraphQLNonNull { - let innerType = try swiftTypeName(for: nonNull.ofType, nameGenerator: nameGenerator) + let innerType = try swiftTypeReference(for: nonNull.ofType, nameGenerator: nameGenerator) // Remove the optional marker if present if innerType.hasSuffix("?") { + if innerType.hasPrefix("(") { + // Remove parentheses around "(any X)?" + return String(innerType.dropFirst().dropLast()) + } + // Remove trailing ? on "X?" return String(innerType.dropLast()) } return innerType } if let list = type as? GraphQLList { - let innerType = try swiftTypeName(for: list.ofType, nameGenerator: nameGenerator) + let innerType = try swiftTypeReference(for: list.ofType, nameGenerator: nameGenerator) if innerType.hasSuffix("?") { let baseType = String(innerType.dropLast()) return "[\(baseType)]?" @@ -21,15 +26,39 @@ func swiftTypeName(for type: GraphQLType, nameGenerator: SafeNameGenerator) thro } if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - let swiftType = mapScalarType(typeName, nameGenerator: nameGenerator) - // By default, GraphQL fields are nullable, so add optional marker - return "\(swiftType)?" + let baseName = try swiftTypeDeclaration(for: namedType, nameGenerator: nameGenerator) + // By default, GraphQL fields are nullable, so add "?" + if type is GraphQLUnionType || type is GraphQLInterfaceType || type is GraphQLObjectType { + // These are all interfaces, so we must wrap them in 'any' and parentheses for optionals. + return "(any \(baseName))?" + } else if type is GraphQLScalarType { + let swiftScalar = mapScalarType(namedType.name, nameGenerator: nameGenerator) + return "\(swiftScalar)?" + } + return "\(baseName)?" } throw GeneratorError.unsupportedType("Unknown type: \(type)") } +/// Convert GraphQL type to Swift type name +func swiftTypeDeclaration(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { + guard let namedType = type as? GraphQLNamedType else { + throw GeneratorError.unsupportedType("Declarations must reference unmodified object types (not non-nulls or lists)") + } + let baseName = nameGenerator.swiftTypeName(for: namedType.name) + if type is GraphQLInputObjectType { + return "\(baseName)Input" + } else if type is GraphQLInterfaceType { + return "\(baseName)Interface" + } else if type is GraphQLObjectType { + return "\(baseName)Protocol" + } else if type is GraphQLUnionType { + return "\(baseName)Union" + } + return baseName +} + /// Map GraphQL scalar types to Swift types func mapScalarType(_ graphQLType: String, nameGenerator: SafeNameGenerator) -> String { switch graphQLType { diff --git a/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift b/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift deleted file mode 100644 index 48e8e05..0000000 --- a/Tests/GraphQLGeneratorTests/ResolverGeneratorTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import GraphQL -@testable import GraphQLGeneratorCore -import Testing - -@Suite -struct ResolverGeneratorTests { - @Test func generate() async throws { - let bar = try GraphQLObjectType( - name: "Bar", - description: "bar", - fields: [ - "foo": .init( - type: GraphQLString, - description: "foo", - ), - ] - ) - let schema = try GraphQLSchema( - query: .init( - name: "Query", - fields: [ - "foo": .init( - type: GraphQLString, - description: "foo", - ), - "bar": .init( - type: bar, - description: "bar", - ) - ] - ), - types: [ - bar - ] - ) - let actual = try ResolverGenerator().generate(schema: schema) - #expect( - actual == """ - // Generated by GraphQL Generator - // DO NOT EDIT - This file is automatically generated - - import Foundation - import GraphQL - import GraphQLGeneratorRuntime - - /// Protocol defining all resolver methods for your GraphQL schema - public protocol GraphQLResolvers: Sendable { - // MARK: - Query Resolvers - - /// foo - func foo(context: ResolverContext, info: GraphQLResolveInfo) async throws -> String? - - /// bar - func bar(context: ResolverContext, info: GraphQLResolveInfo) async throws -> Bar? - - } - - """ - ) - } -} diff --git a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift index c1fd7e6..ca1961d 100644 --- a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -23,6 +23,7 @@ struct TypeGeneratorTests { ) #expect( actual == """ + /// foo public enum Foo: String, Codable, Sendable { /// foo @@ -30,53 +31,139 @@ struct TypeGeneratorTests { /// bar case bar = "bar" } + """ + ) + } + + @Test func interfaceType() async throws { + let interfaceA = try GraphQLInterfaceType( + name: "A", + description: "A" + ) + let interfaceB = try GraphQLInterfaceType( + name: "B", + description: "B", + interfaces: [ + interfaceA, + ], + fields: [ + "foo": .init( + type: GraphQLNonNull(GraphQLString), + description: "foo" + ), + "baz": .init( + type: GraphQLString, + description: "baz" + ) + ] + ) + let actual = try TypeGenerator().generateInterfaceProtocol(for: interfaceB) + #expect( + actual == """ + + /// B + public protocol BInterface: AInterface, Sendable { + /// foo + public func foo(context: Context, info: GraphQLResolveInfo) async throws -> String + + /// baz + public func baz(context: Context, info: GraphQLResolveInfo) async throws -> String? + } """ ) } @Test func objectType() async throws { + let interfaceA = try GraphQLInterfaceType( + name: "A", + description: "A" + ) + let typeFoo = try GraphQLObjectType( + name: "Foo", + description: "Foo", + fields: [ + "foo": .init( + type: GraphQLNonNull(GraphQLString), + description: "foo" + ), + "bar": .init( + type: GraphQLString, + description: "bar", + args: [ + "foo": .init( + type: GraphQLNonNull(GraphQLString), + description: "foo", + ), + "bar": .init( + type: GraphQLString, + description: "bar", + defaultValue: .string("bar"), + ), + ] + ) + ], + interfaces: [interfaceA], + ) let actual = try TypeGenerator().generateTypeProtocol( - for: .init( - name: "Foo", - description: "Foo", - fields: [ - "foo": .init( - type: GraphQLNonNull(GraphQLString), - description: "foo" - ), - "bar": .init( - type: GraphQLString, - description: "bar", - args: [ - "foo": .init( - type: GraphQLNonNull(GraphQLString), - description: "foo", - ), - "bar": .init( - type: GraphQLString, - description: "bar", - defaultValue: .string("bar"), - ), - ] - ) - ] - ) + for: typeFoo, + unionTypeMap: [ + "Foo": [GraphQLUnionType(name: "X", types: [typeFoo])] + ] ) #expect( actual == """ + /// Foo - public protocol FooProtocol: Sendable { + public protocol FooProtocol: XUnion, AInterface, Sendable { /// foo - public func foo(context: ResolverContext, info: GraphQLResolveInfo) async throws -> String + public func foo(context: Context, info: GraphQLResolveInfo) async throws -> String /// bar - public func bar(foo: String, bar: String?, context: ResolverContext, info: GraphQLResolveInfo) async throws -> String? + public func bar(foo: String, bar: String?, context: Context, info: GraphQLResolveInfo) async throws -> String? } - """ ) } + @Test func rootType() async throws { + let bar = try GraphQLObjectType( + name: "Bar", + description: "bar", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + ] + ) + let query = try GraphQLObjectType( + name: "Query", + fields: [ + "foo": .init( + type: GraphQLString, + description: "foo", + ), + "bar": .init( + type: bar, + description: "bar", + ) + ] + ) + let actual = try TypeGenerator().generateRootTypeProtocol(for: query) + #expect( + actual == """ + + public protocol QueryProtocol: Sendable { + /// foo + static func foo(context: Context, info: GraphQLResolveInfo) async throws -> String? + + /// bar + static func bar(context: Context, info: GraphQLResolveInfo) async throws -> (any BarProtocol)? + + } + """ + ) + } } From 8734422c8d91a9e075cee052301cab3de98031fe Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 27 Dec 2025 17:33:07 -0600 Subject: [PATCH 24/38] feat: Schema updates --- .../Generator/SchemaGenerator.swift | 492 +++++++++++------- .../Utilities/indent.swift | 2 +- .../ResolverContext.swift | 16 - .../ResolverHelpers.swift | 10 + Sources/GraphQLGeneratorRuntime/Scalar.swift | 77 +++ .../SchemaGeneratorTests.swift | 35 +- 6 files changed, 417 insertions(+), 215 deletions(-) delete mode 100644 Sources/GraphQLGeneratorRuntime/ResolverContext.swift create mode 100644 Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift create mode 100644 Sources/GraphQLGeneratorRuntime/Scalar.swift diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index d2a7ac0..c4d7d1f 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -15,92 +15,136 @@ package struct SchemaGenerator { import GraphQLGeneratorRuntime /// Build a GraphQL schema with the provided resolvers - public func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { - + public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { """ - let typeMap = schema.typeMap + // Ignore any internal types (which have prefix "__") + let types = schema.typeMap.values.filter { + !$0.name.hasPrefix("__") + } - // TODO: Scalars + // Generate scalar type definitions + let scalarTypes = types.compactMap { + $0 as? GraphQLScalarType + }.filter { + // Exclude the standard scalars + !["Int", "Float", "String", "Boolean", "ID"].contains($0.name) + } + for scalarType in scalarTypes { + output += """ + + \(try generateScalarTypeDefinition(for: scalarType).indent(1)) + """ + } // Generate enum type definitions - let enumTypes = typeMap.values.compactMap { $0 as? GraphQLEnumType } + let enumTypes = types.compactMap { $0 as? GraphQLEnumType } for enumType in enumTypes { - // Skip GraphQL internal enums - if enumType.name.hasPrefix("__") { - continue - } + output += """ - output += try generateEnumTypeDefinition(for: enumType).indent(1) - output += "\n" + \(try generateEnumTypeDefinition(for: enumType).indent(1)) + """ } // Generate type definitions for all object types - let interfaceTypes = typeMap.values.compactMap { + let interfaceTypes = types.compactMap { $0 as? GraphQLInterfaceType - }.filter { - // Skip introspection types and root operation types - !$0.name.hasPrefix("__") } - for interfaceType in interfaceTypes { - output += try generateInterfaceTypeDefinition(for: interfaceType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateInterfaceTypeDefinition(for: interfaceType, resolvers: "resolvers").indent(1)) + """ + } + + // Generate type definitions for all input object types + let inputTypes = types.compactMap { + $0 as? GraphQLInputObjectType } + for inputType in inputTypes { + output += """ - // TODO: Input Objects + \(try generateInputTypeDefinition(for: inputType).indent(1)) + """ + } // Generate type definitions for all object types - let objectTypes = typeMap.values.compactMap { + + // Generate GraphQLObjectType definitions for non-root types + let objectTypes = types.compactMap { $0 as? GraphQLObjectType }.filter { - // Skip introspection types and root operation types - !$0.name.hasPrefix("__") && + // Skip root operation types $0.name != "Query" && $0.name != "Mutation" && $0.name != "Subscription" } - - // Generate GraphQLObjectType definitions for non-root types for objectType in objectTypes { - output += try generateObjectTypeDefinition(for: objectType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateObjectTypeDefinition(for: objectType).indent(1)) + """ } + // Generate type definitions for all union object types + let unionTypes = types.compactMap { + $0 as? GraphQLUnionType + } + for unionType in unionTypes { + output += """ + + \(try generateUnionTypeDefinition(for: unionType).indent(1)) + """ + } + + // Generate field and interface definitions for non-root types + for inputType in inputTypes { + output += """ - // Generate GraphQLObjectType field definitions for non-root types + \(try generateInputTypeFieldDefinition(for: inputType).indent(1)) + """ + } for interfaceType in interfaceTypes { - output += try generateInterfaceTypeFieldDefinition(for: interfaceType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateInterfaceTypeFieldDefinition(for: interfaceType).indent(1)) + """ } for objectType in objectTypes { - output += try generateObjectTypeFieldDefinition(for: objectType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateObjectTypeFieldDefinition(for: objectType, resolvers: "parent").indent(1)) + """ } // Generate Query type if let queryType = schema.queryType { - output += try generateQueryTypeDefinition(for: queryType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateQueryTypeDefinition(for: queryType).indent(1)) + """ } // Generate Mutation type if it exists if let mutationType = schema.mutationType { - output += try generateMutationTypeDefinition(for: mutationType, resolvers: "resolvers").indent(1) - output += "\n" + output += """ + + \(try generateMutationTypeDefinition(for: mutationType).indent(1)) + """ } // TODO: Subscription // Build and return the schema output += """ + return try GraphQLSchema( - query: queryType + query: query """ if schema.mutationType != nil { output += """ , - mutation: mutationType + mutation: mutation """ } @@ -114,8 +158,29 @@ package struct SchemaGenerator { return output } + private func generateScalarTypeDefinition(for type: GraphQLScalarType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + + // We expect the user to define a type of the same name that conforms to provided "Scalar" protocol + return """ + + let \(varName) = try GraphQLScalarType( + name: "\(type.name)", + serialize: { any in + try \(type.name).serialize(any: any) + }, + parseValue: { map in + try \(type.name).parseValue(map: map) + }, + parseLiteral: { value in + try \(type.name).parseLiteral(value: value) + } + ) + """ + } + private func generateEnumTypeDefinition(for type: GraphQLEnumType) throws -> String { - let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" + let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ @@ -174,8 +239,85 @@ package struct SchemaGenerator { return output } + private func generateInputTypeDefinition(for type: GraphQLInputObjectType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + + var output = """ + let \(varName) = try GraphQLInputObjectType( + name: "\(type.name)", + """ + + if let description = type.description { + output += """ + + description: \"\"\" + \(description) + \"\"\", + """ + } + + // Delay field generation to support recursive type systems + output += """ + + ) + """ + + return output + } + + private func generateInputTypeFieldDefinition(for type: GraphQLInputObjectType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + + var output = """ + \(varName).fields = { + [ + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + output += """ + + "\(fieldName)": InputObjectField( + type: \(try graphQLTypeReference(for: field.type)), + """ + + // TODO: Default value support + + if let description = field.description { + output += """ + + description: \"\"\" + \(description) + \"\"\", + """ + } + if let deprecationReason = field.deprecationReason { + output += """ + + deprecationReason: \"\"\" + \(deprecationReason) + \"\"\" + """ + } + + output += """ + + ) + """ + } + + output += """ + + ] + } + """ + + return output + } + private func generateInterfaceTypeDefinition(for type: GraphQLInterfaceType, resolvers: String) throws -> String { - let varName = nameGenerator.swiftMemberName(for: type.name) + "Interface" + let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ let \(varName) = try GraphQLInterfaceType( @@ -201,8 +343,8 @@ package struct SchemaGenerator { return output } - private func generateInterfaceTypeFieldDefinition(for type: GraphQLInterfaceType, resolvers: String) throws -> String { - let varName = nameGenerator.swiftMemberName(for: type.name) + "Interface" + private func generateInterfaceTypeFieldDefinition(for type: GraphQLInterfaceType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ \(varName).fields = { @@ -212,17 +354,16 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - // For non-root types, only generate resolver callbacks for fields that return object types - let needsResolver = isObjectType(field.type) - output += try generateFieldDefinition( + output += """ + + \(try generateFieldDefinition( fieldName: fieldName, field: field, - parentTypeName: type.name, - resolvers: resolvers, - isRootType: false, - needsResolver: needsResolver - ).indent(2) + target: .parent, + parentType: type + ).indent(2)) + """ } output += """ @@ -243,7 +384,7 @@ package struct SchemaGenerator { for interface in interfaces { output += """ - \(nameGenerator.swiftMemberName(for: interface.name) + "Interface") + \(nameGenerator.swiftMemberName(for: interface.name)) """ } @@ -254,13 +395,11 @@ package struct SchemaGenerator { """ } - return output } - - private func generateObjectTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { - let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" + private func generateObjectTypeDefinition(for type: GraphQLObjectType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ let \(varName) = try GraphQLObjectType( @@ -277,7 +416,6 @@ package struct SchemaGenerator { } // Delay field generation to support recursive type systems - output += """ ) @@ -287,7 +425,7 @@ package struct SchemaGenerator { } private func generateObjectTypeFieldDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { - let varName = nameGenerator.swiftMemberName(for: type.name) + "Type" + let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ \(varName).fields = { @@ -297,17 +435,15 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - // For non-root types, only generate resolver callbacks for fields that return object types - let needsResolver = isObjectType(field.type) + output += """ - output += try generateFieldDefinition( + \(try generateFieldDefinition( fieldName: fieldName, field: field, - parentTypeName: type.name, - resolvers: resolvers, - isRootType: false, - needsResolver: needsResolver - ).indent(2) + target: .parent, + parentType: type + ).indent(2)) + """ } output += """ @@ -316,13 +452,74 @@ package struct SchemaGenerator { } """ + let interfaces = try type.interfaces() + if !interfaces.isEmpty { + output += """ + + \(varName).interfaces = { + [ + """ + + // Generate fields + for interface in interfaces { + output += """ + + \(nameGenerator.swiftMemberName(for: interface.name)) + """ + } + + output += """ + + ] + } + """ + } + + return output + } + + private func generateUnionTypeDefinition(for type: GraphQLUnionType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + + var output = """ + let \(varName) = try GraphQLUnionType( + name: "\(type.name)", + """ + + if let description = type.description { + output += """ + + description: \"\"\" + \(description) + \"\"\", + """ + } + + output += """ + + types: [ + """ + for type in try type.types() { + output += """ + + \(try graphQLTypeReference(for: type)), + """ + } + + // Delay type generation to support recursive type systems + output += """ + + ] + ) + """ + return output } - private func generateQueryTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + private func generateQueryTypeDefinition(for type: GraphQLObjectType) throws -> String { var output = """ - let queryType = try GraphQLObjectType( + let query = try GraphQLObjectType( name: "Query", """ @@ -343,13 +540,15 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - output += try generateFieldDefinition( + output += """ + + \(try generateFieldDefinition( fieldName: fieldName, field: field, - parentTypeName: "Query", - resolvers: resolvers, - isRootType: true - ).indent(2) + target: .query, + parentType: type + ).indent(2)) + """ } output += """ @@ -361,10 +560,10 @@ package struct SchemaGenerator { return output } - private func generateMutationTypeDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + private func generateMutationTypeDefinition(for type: GraphQLObjectType) throws -> String { var output = """ - let mutationType = try GraphQLObjectType( + let mutation = try GraphQLObjectType( name: "Mutation", """ @@ -385,13 +584,15 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - output += try generateFieldDefinition( + output += """ + + \(try generateFieldDefinition( fieldName: fieldName, field: field, - parentTypeName: "Mutation", - resolvers: resolvers, - isRootType: true - ).indent(2) + target: .mutation, + parentType: type + ).indent(2)) + """ } output += """ @@ -406,10 +607,8 @@ package struct SchemaGenerator { private func generateFieldDefinition( fieldName: String, field: GraphQLField, - parentTypeName: String, - resolvers: String, - isRootType: Bool, - needsResolver: Bool = true + target: ResolverTarget, + parentType: GraphQLType ) throws -> String { var output = """ @@ -426,6 +625,15 @@ package struct SchemaGenerator { """ } + if let deprecationReason = field.deprecationReason { + output += """ + + deprecationReason: \"\"\" + \(deprecationReason) + \"\"\", + """ + } + // Add arguments if any if !field.args.isEmpty { output += """ @@ -461,19 +669,14 @@ package struct SchemaGenerator { """ } - // Generate resolver function only if needed - if needsResolver { - output += try generateResolverCallback( - fieldName: fieldName, - field: field, - parentTypeName: parentTypeName, - resolvers: resolvers, - isRootType: isRootType - ).indent(1) - } - output += """ + \(try generateResolverCallback( + fieldName: fieldName, + field: field, + target: target, + parentType: parentType + ).indent(1)) ), """ @@ -483,9 +686,8 @@ package struct SchemaGenerator { private func generateResolverCallback( fieldName: String, field: GraphQLField, - parentTypeName: String, - resolvers: String, - isRootType: Bool + target: ResolverTarget, + parentType: GraphQLType ) throws -> String { let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) @@ -497,18 +699,13 @@ package struct SchemaGenerator { // Build argument list var argsList: [String] = [] - if !isRootType { - // For nested resolvers, first argument is the parent object - let safeParentTypeName = nameGenerator.swiftTypeName(for: parentTypeName) + if target == .parent { + // For nested resolvers, we decode and call the method on the parent instance + let parentCastType = try swiftTypeReference(for: parentType, nameGenerator: nameGenerator) output += """ - guard let parent = source as? \(safeParentTypeName) else { - throw GraphQLError( - message: "Expected source type \(safeParentTypeName) but got \\(type(of: source))" - ) - } + let parent = try cast(source, to: (\(parentCastType)).self) """ - argsList.append("parent: parent") } // Add field arguments @@ -518,7 +715,7 @@ package struct SchemaGenerator { // Extract value from Map based on type output += """ - let \(safeArgName) = try MapDecoder().decode(\(swiftType).self, from: args["\(argName)"]) + let \(safeArgName) = try MapDecoder().decode((\(swiftType)).self, from: args["\(argName)"]) """ argsList.append("\(safeArgName): \(safeArgName)") } @@ -526,11 +723,7 @@ package struct SchemaGenerator { // Add context output += """ - guard let context = context as? Context else { - throw GraphQLError( - message: "Expected context type Context but got \\(type(of: context))" - ) - } + let context = try cast(context, to: Context.self) """ argsList.append("context: context") @@ -538,18 +731,14 @@ package struct SchemaGenerator { argsList.append("info: info") // Call the resolver - let resolverMethodName: String - if isRootType { - resolverMethodName = safeFieldName - } else { - let parentName = nameGenerator.swiftMemberName(for: parentTypeName) - let fieldNameCapitalized = safeFieldName.prefix(1).uppercased() + safeFieldName.dropFirst() - resolverMethodName = "\(parentName)\(fieldNameCapitalized)" + let targetName = switch target { + case .parent: "parent" + case .query: "Resolvers.Query" + case .mutation: "Resolvers.Mutation" } - output += """ - return try await \(resolvers).\(resolverMethodName)(\(argsList.joined(separator: ", "))) + return try await \(targetName).\(safeFieldName)(\(argsList.joined(separator: ", "))) } """ @@ -578,7 +767,7 @@ package struct SchemaGenerator { case "Boolean": return "GraphQLBoolean" default: // Reference to a custom type variable - let varName = nameGenerator.swiftMemberName(for: typeName) + "Type" + let varName = nameGenerator.swiftMemberName(for: typeName) return varName } } @@ -586,62 +775,9 @@ package struct SchemaGenerator { throw GeneratorError.unsupportedType("Unknown type: \(type)") } - /// Generate code to convert a Map value to a Swift type - private func mapConversionCode(for type: GraphQLType, valueName: String, swiftType: String) throws -> String { - // For non-null types, unwrap and convert - if let nonNull = type as? GraphQLNonNull { - let innerCode = try mapConversionCode(for: nonNull.ofType, valueName: valueName, swiftType: String(swiftType.dropLast())) - return innerCode - } - - // For list types, map over the array - if let list = type as? GraphQLList { - return "try \(valueName).arrayValue?.map { try \(try swiftTypeReference(for: list.ofType, nameGenerator: nameGenerator))($0) }" - } - - // For named types, convert based on scalar type - if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - - switch typeName { - case "ID", "String": - return "\(valueName).string!" - case "Int": - return "\(valueName).int!" - case "Float": - return "\(valueName).double!" - case "Boolean": - return "\(valueName).bool!" - default: - // For custom types (enums, etc.), try to decode - return "try \(swiftType)(\(valueName))" - } - } - - throw GeneratorError.unsupportedType("Unknown type: \(type)") - } - - /// Check if a GraphQL type is a composite object type (not a scalar, enum, etc.) - private func isObjectType(_ type: GraphQLType) -> Bool { - // Unwrap NonNull and List - if let nonNull = type as? GraphQLNonNull { - return isObjectType(nonNull.ofType) - } - if let list = type as? GraphQLList { - return isObjectType(list.ofType) - } - - // Check if it's a named object type (not a scalar or enum) - if let namedType = type as? GraphQLNamedType { - let typeName = namedType.name - // Built-in scalars are not object types - if ["ID", "String", "Int", "Float", "Boolean"].contains(typeName) { - return false - } - // Check if it's a composite type (not an enum or scalar) - return namedType is GraphQLCompositeType - } - - return false + private enum ResolverTarget { + case parent + case query + case mutation } } diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift index be9592b..98d67ac 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/indent.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -5,7 +5,7 @@ extension String { return self.split(separator: "\n").map { line in var result = line if firstLine { - firstLine == false + firstLine = false } if !line.isEmpty { if !firstLine || includeFirst { diff --git a/Sources/GraphQLGeneratorRuntime/ResolverContext.swift b/Sources/GraphQLGeneratorRuntime/ResolverContext.swift deleted file mode 100644 index c211156..0000000 --- a/Sources/GraphQLGeneratorRuntime/ResolverContext.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// Protocol for resolver context that can be passed to resolver functions -/// Implement this protocol to provide dependencies and services to resolvers -public protocol ResolverContext { - // Add common context properties here - // For example: - // var currentUser: User? { get } - // var database: Database { get } - // var cache: Cache { get } -} - -/// A basic empty context implementation -public struct EmptyResolverContext: ResolverContext { - public init() {} -} diff --git a/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift new file mode 100644 index 0000000..07418d5 --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift @@ -0,0 +1,10 @@ +import GraphQL + +func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { + guard let result = anySendable as? T else { + throw GraphQLError( + message: "Expected source type \(T.self) but got \(type(of: anySendable))" + ) + } + return result +} diff --git a/Sources/GraphQLGeneratorRuntime/Scalar.swift b/Sources/GraphQLGeneratorRuntime/Scalar.swift new file mode 100644 index 0000000..7d75a95 --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/Scalar.swift @@ -0,0 +1,77 @@ +import GraphQL +import OrderedCollections + +public protocol Scalar: Sendable { + static func serialize(any: Any) throws -> Map + static func parseValue(map: Map) throws -> Map + static func parseLiteral(value: any Value) throws -> Map +} + +extension Scalar { + static func serialize(any: Any) throws -> Map { + return try Map(any: any) + } + static func parseValue(map: Map) throws -> Map { + return map + } + static func parseLiteral(value: any Value) throws -> Map { + return value.map + } +} + + +extension GraphQL.Value { + var map: Map { + if + let value = self as? BooleanValue + { + return .bool(value.value) + } + + if + let value = self as? IntValue, + let int = Int(value.value) + { + return .int(int) + } + + if + let value = self as? FloatValue, + let double = Double(value.value) + { + return .double(double) + } + + if + let value = self as? StringValue + { + return .string(value.value) + } + + if + let value = self as? EnumValue + { + return .string(value.value) + } + + if + let value = self as? ListValue + { + let array = value.values.map { $0.map } + return .array(array) + } + + if + let value = self as? ObjectValue + { + let dictionary: OrderedDictionary = value.fields + .reduce(into: [:]) { result, field in + result[field.name.value] = field.value.map + } + + return .dictionary(dictionary) + } + + return .null + } +} diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift index 4545cc0..a3c1640 100644 --- a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -43,26 +43,29 @@ struct SchemaGeneratorTests { import GraphQLGeneratorRuntime /// Build a GraphQL schema with the provided resolvers - public func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { - - let barType = try GraphQLObjectType( + public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { + let bar = try GraphQLObjectType( name: "Bar", description: """ bar """, ) - barType.fields = { + bar.fields = { [ "foo": GraphQLField( type: GraphQLString, description: """ foo """, + resolve: { source, args, context, info in + let parent = try cast(source, to: ((any BarProtocol)?).self) + let context = try cast(context, to: Context.self) + return try await parent.foo(context: context, info: info) + } ), ] } - - let queryType = try GraphQLObjectType( + let query = try GraphQLObjectType( name: "Query", fields: [ "foo": GraphQLField( @@ -71,32 +74,24 @@ struct SchemaGeneratorTests { foo """, resolve: { source, args, context, info in - guard let context = context as? ResolverContext else { - throw GraphQLError( - message: "Expected context type ResolverContext but got \(type(of: context))" - ) - } - return try await resolvers.foo(context: context, info: info) + let context = try cast(context, to: Context.self) + return try await Resolvers.Query.foo(context: context, info: info) } ), "bar": GraphQLField( - type: barType, + type: bar, description: """ bar """, resolve: { source, args, context, info in - guard let context = context as? ResolverContext else { - throw GraphQLError( - message: "Expected context type ResolverContext but got \(type(of: context))" - ) - } - return try await resolvers.bar(context: context, info: info) + let context = try cast(context, to: Context.self) + return try await Resolvers.Query.bar(context: context, info: info) } ), ] ) return try GraphQLSchema( - query: queryType + query: query ) } From e4fba9a61852688ed6ff849be9b63c5784e8f75f Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 11:02:33 -0700 Subject: [PATCH 25/38] feat: Fully working HelloWorldServer example --- Examples/HelloWorldServer/Package.resolved | 11 +- Examples/HelloWorldServer/Package.swift | 6 +- .../Sources/HelloWorldServer/Gen/Schema.swift | 279 ------------------ .../Sources/HelloWorldServer/Gen/Types.swift | 92 ------ .../Helper/ResolverHelpers.swift | 10 - .../HelloWorldServer/Helper/Scalar.swift | 77 ----- .../Sources/HelloWorldServer/main.swift | 1 + .../Sources/HelloWorldServer/schema.graphql | 2 +- Plugins/GraphQLGeneratorPlugin.swift | 4 - .../Generator/SchemaGenerator.swift | 24 +- .../Generator/TypeGenerator.swift | 10 +- .../Utilities/indent.swift | 3 - .../Utilities/swiftTypeName.swift | 37 ++- .../ResolverHelpers.swift | 2 +- Sources/GraphQLGeneratorRuntime/Scalar.swift | 2 +- .../SchemaGeneratorTests.swift | 2 +- .../TypeGeneratorTests.swift | 8 +- 17 files changed, 65 insertions(+), 505 deletions(-) delete mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift delete mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift delete mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift delete mode 100644 Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved index 169194a..95499ae 100644 --- a/Examples/HelloWorldServer/Package.resolved +++ b/Examples/HelloWorldServer/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e541794e4c1d6067e84640d47eee9266b8a8b887699f439ac836c3bed05af6d1", + "originHash" : "d4877b8785eefa795e134008a400eca8f128998f26cd5badab8c8cb557525cf8", "pins" : [ { "identity" : "graphql", @@ -10,6 +10,15 @@ "version" : "4.0.3" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift index 9f1f86b..50890e6 100644 --- a/Examples/HelloWorldServer/Package.swift +++ b/Examples/HelloWorldServer/Package.swift @@ -8,7 +8,7 @@ let package = Package( .macOS(.v13) ], dependencies: [ - // .package(name: "graphql-generator", path: "../.."), + .package(name: "graphql-generator", path: "../.."), .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), ], targets: [ @@ -16,10 +16,10 @@ let package = Package( name: "HelloWorldServer", dependencies: [ .product(name: "GraphQL", package: "GraphQL"), - // .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), ], plugins: [ - // .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") ] ), ] diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift deleted file mode 100644 index 541b1b9..0000000 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Schema.swift +++ /dev/null @@ -1,279 +0,0 @@ -// Generated by GraphQL Generator -// DO NOT EDIT - This file is automatically generated - -import Foundation -import GraphQL - -/// Build a GraphQL schema with the provided resolvers -public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { - - let roleType = try GraphQLEnumType( - name: "Role", - description: """ - User role enumeration - """, - values: [ - "ADMIN": GraphQLEnumValue( - value: .string("ADMIN") - ), - "USER": GraphQLEnumValue( - value: .string("USER") - ), - "GUEST": GraphQLEnumValue( - value: .string("GUEST") - ), - ] - ) - - let datetimeScalar = try GraphQLScalarType( - name: "DateTime", - serialize: { any in - try DateTime.serialize(any: any) - }, - parseValue: { map in - try DateTime.parseValue(map: map) - }, - parseLiteral: { value in - try DateTime.parseLiteral(value: value) - } - ) - let hasEmailInterface = try GraphQLInterfaceType( - name: "HasEmail" - ) - let userInfoInput = try GraphQLInputObjectType(name: "UserInfo") - let userType = try GraphQLObjectType( - name: "User", - description: """ - A simple user type - """, - ) - let contactType = try GraphQLObjectType( - name: "Contact", - ) - let postType = try GraphQLObjectType( - name: "Post", - description: """ - A blog post - """, - ) - let userOrPostUnion = try GraphQLUnionType( - name: "UserOrPost", - description: "Get a user or post by ID", - types: { - [userType, postType] - } - ) - hasEmailInterface.fields = { - [ - "email": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The user's email address - """, - ), - ] - } - userInfoInput.fields = { - [ - "id": InputObjectField( - type: GraphQLNonNull(GraphQLID), - ), - "name": InputObjectField( - type: GraphQLNonNull(GraphQLString), - ), - "email": InputObjectField( - type: GraphQLNonNull(GraphQLString), - ), - "age": InputObjectField( - type: GraphQLInt, - ), - "role": InputObjectField( - type: roleType, - ), - ] - } - userType.fields = { - [ - "id": GraphQLField( - type: GraphQLNonNull(GraphQLID), - description: """ - The unique identifier for the user - """, - ), - "name": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The user's display name - """, - ), - "email": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The user's email address - """, - ), - "age": GraphQLField( - type: GraphQLInt, - description: """ - The user's age - """, - ), - "role": GraphQLField( - type: roleType, - description: """ - The user's age - """, - ), - ] - } - userType.interfaces = { - [hasEmailInterface] - } - contactType.fields = { - [ - "email": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The user's email address - """, - ), - ] - } - contactType.interfaces = { - [hasEmailInterface] - } - postType.fields = { - [ - "id": GraphQLField( - type: GraphQLNonNull(GraphQLID), - description: """ - The unique identifier for the post - """, - ), - "title": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The post title - """, - ), - "content": GraphQLField( - type: GraphQLNonNull(GraphQLString), - description: """ - The post content - """, - ), - "author": GraphQLField( - type: GraphQLNonNull(userType), - description: """ - The author of the post - """, - resolve: { source, args, context, info in - let parent = try cast(source, to: (any PostProtocol).self) - let context = try cast(context, to: Context.self) - return try await parent.author(context: context, info: info) - } - ), - ] - } - - let queryType = try GraphQLObjectType( - name: "Query", - description: """ - Root query type - """, - fields: [ - "user": GraphQLField( - type: userType, - description: """ - Get a user by ID - """, - args: [ - "id": GraphQLArgument( - type: GraphQLNonNull(GraphQLID) - ), - ], - resolve: { source, args, context, info in - let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try cast(context, to: Context.self) - return try await Resolvers.Query.user(id: id, context: context, info: info) - } - ), - "users": GraphQLField( - type: GraphQLNonNull(GraphQLList(GraphQLNonNull(userType))), - description: """ - Get all users - """, - resolve: { source, args, context, info in - let context = try cast(context, to: Context.self) - return try await Resolvers.Query.users(context: context, info: info) - } - ), - "post": GraphQLField( - type: postType, - description: """ - Get a post by ID - """, - args: [ - "id": GraphQLArgument( - type: GraphQLNonNull(GraphQLID) - ), - ], - resolve: { source, args, context, info in - let id = try MapDecoder().decode(String.self, from: args["id"]) - let context = try cast(context, to: Context.self) - return try await Resolvers.Query.post(id: id, context: context, info: info) - } - ), - "posts": GraphQLField( - type: GraphQLNonNull(GraphQLList(GraphQLNonNull(postType))), - description: """ - Get recent posts - """, - args: [ - "limit": GraphQLArgument( - type: GraphQLInt - ), - ], - resolve: { source, args, context, info in - let limit = args["limit"] != .undefined ? try MapDecoder().decode(Int?.self, from: args["limit"]): nil - let context = try cast(context, to: Context.self) - return try await Resolvers.Query.posts(limit: limit, context: context, info: info) - } - ), - ] - ) - let mutationType = try GraphQLObjectType( - name: "Mutation", - description: """ - Root mutation type - """, - fields: [ - "upsertUser": GraphQLField( - type: userType, - args: [ - "userInfo": GraphQLArgument( - type: GraphQLNonNull(userInfoInput) - ), - ], - resolve: { source, args, context, info in - let userInfoInput = try MapDecoder().decode(UserInfoInput.self, from: args["userInfo"]) - let context = try cast(context, to: Context.self) - return try await Resolvers.Mutation.upsertUser(userInfo: userInfoInput, context: context, info: info) - } - ), - ] - ) - return try GraphQLSchema( - query: queryType, - mutation: mutationType, - types: [ - roleType, - datetimeScalar, - hasEmailInterface, - userType, - contactType, - postType, - userOrPostUnion, - ] - ) -} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift deleted file mode 100644 index a4424b1..0000000 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Gen/Types.swift +++ /dev/null @@ -1,92 +0,0 @@ -// Generated by GraphQL Generator -// DO NOT EDIT - This file is automatically generated - -import Foundation -import GraphQL - -// Resolvers -public protocol ResolversProtocol { - associatedtype Query: QueryProtocol - associatedtype Mutation: MutationProtocol -} - -// Enums - -/// User role enumeration -public enum Role: String, Codable, Sendable { - case admin = "ADMIN" - case user = "USER" - case guest = "GUEST" -} - -// TODO: Directives - -// Input objects are just codable structs -public struct UserInfoInput: Codable, Sendable { - let id: String - let name: String - let email: String - let age: Int? - let role: Role? -} - -// Unions are represented by a marker protocol, with associated types conforming -public protocol UserOrPostUnion {} - -public protocol HasEmailInterface: Sendable { - /// An email address - func email(context: Context, info: GraphQLResolveInfo) async throws -> String -} - -// Object Types - -/// A simple user type -public protocol UserProtocol: HasEmailInterface, UserOrPostUnion, Sendable { - /// The unique identifier for the user - func id(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The user's display name - func name(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The user's email address - func email(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The user's age - func age(context: Context, info: GraphQLResolveInfo) async throws -> Int? - /// The user's age - func role(context: Context, info: GraphQLResolveInfo) async throws -> Role? -} - -public protocol ContactProtocol: HasEmailInterface, Sendable { - func email(context: Context, info: GraphQLResolveInfo) async throws -> String -} - -/// A blog post -public protocol PostProtocol: UserOrPostUnion, Sendable { - /// The unique identifier for the post - func id(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The post title - func title(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The post content - func content(context: Context, info: GraphQLResolveInfo) async throws -> String - /// The author of the post - func author(context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol -} - -public protocol QueryProtocol: Sendable { - /// Get a user by ID - static func user(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserProtocol)? - - /// Get all users - static func users(context: Context, info: GraphQLResolveInfo) async throws -> [any UserProtocol] - - /// Get a post by ID - static func post(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any PostProtocol)? - - /// Get recent posts - static func posts(limit: Int?, context: Context, info: GraphQLResolveInfo) async throws -> [any PostProtocol] - - /// Get a user or post by ID - static func userOrPost(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? -} - -public protocol MutationProtocol: Sendable { - static func upsertUser(userInfo: UserInfoInput, context: Context, info: GraphQLResolveInfo) async throws -> any UserProtocol -} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift deleted file mode 100644 index 07418d5..0000000 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/ResolverHelpers.swift +++ /dev/null @@ -1,10 +0,0 @@ -import GraphQL - -func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { - guard let result = anySendable as? T else { - throw GraphQLError( - message: "Expected source type \(T.self) but got \(type(of: anySendable))" - ) - } - return result -} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift deleted file mode 100644 index 7d75a95..0000000 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Helper/Scalar.swift +++ /dev/null @@ -1,77 +0,0 @@ -import GraphQL -import OrderedCollections - -public protocol Scalar: Sendable { - static func serialize(any: Any) throws -> Map - static func parseValue(map: Map) throws -> Map - static func parseLiteral(value: any Value) throws -> Map -} - -extension Scalar { - static func serialize(any: Any) throws -> Map { - return try Map(any: any) - } - static func parseValue(map: Map) throws -> Map { - return map - } - static func parseLiteral(value: any Value) throws -> Map { - return value.map - } -} - - -extension GraphQL.Value { - var map: Map { - if - let value = self as? BooleanValue - { - return .bool(value.value) - } - - if - let value = self as? IntValue, - let int = Int(value.value) - { - return .int(int) - } - - if - let value = self as? FloatValue, - let double = Double(value.value) - { - return .double(double) - } - - if - let value = self as? StringValue - { - return .string(value.value) - } - - if - let value = self as? EnumValue - { - return .string(value.value) - } - - if - let value = self as? ListValue - { - let array = value.values.map { $0.map } - return .array(array) - } - - if - let value = self as? ObjectValue - { - let dictionary: OrderedDictionary = value.fields - .reduce(into: [:]) { result, field in - result[field.name.value] = field.value.map - } - - return .dictionary(dictionary) - } - - return .null - } -} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift index 00db903..dee101a 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift @@ -1,5 +1,6 @@ import Foundation import GraphQL +import GraphQLGeneratorRuntime // Must be created by user and named `Context`. public class Context: @unchecked Sendable { diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 0c2863a..42acf7d 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -9,7 +9,7 @@ union UserOrPost = User | Post input UserInfo { id: ID! - name: name! + name: String! email: String! age: Int role: Role diff --git a/Plugins/GraphQLGeneratorPlugin.swift b/Plugins/GraphQLGeneratorPlugin.swift index ed76e28..5a1ed52 100644 --- a/Plugins/GraphQLGeneratorPlugin.swift +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -26,10 +26,8 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { // (We could also generate per-file, but typically GraphQL schemas are combined) let schemaInputs = schemaFiles.map(\.url) - // Output files: Types.swift, Resolvers.swift, Schema.swift let outputFiles = [ outputDirectory.appendingPathComponent("Types.swift"), - outputDirectory.appendingPathComponent("Resolvers.swift"), outputDirectory.appendingPathComponent("Schema.swift") ] @@ -71,10 +69,8 @@ extension GraphQLGeneratorPlugin: XcodeBuildToolPlugin { let schemaInputs = schemaFiles.map(\.url) - // Output files: Types.swift, Resolvers.swift, Schema.swift let outputFiles = [ outputDirectory.appendingPathComponent("Types.swift"), - outputDirectory.appendingPathComponent("Resolvers.swift"), outputDirectory.appendingPathComponent("Schema.swift") ] diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index c4d7d1f..d4a2e29 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -303,7 +303,7 @@ package struct SchemaGenerator { output += """ - ) + ), """ } @@ -384,7 +384,7 @@ package struct SchemaGenerator { for interface in interfaces { output += """ - \(nameGenerator.swiftMemberName(for: interface.name)) + \(nameGenerator.swiftMemberName(for: interface.name)), """ } @@ -464,7 +464,7 @@ package struct SchemaGenerator { for interface in interfaces { output += """ - \(nameGenerator.swiftMemberName(for: interface.name)) + \(nameGenerator.swiftMemberName(for: interface.name)), """ } @@ -689,8 +689,6 @@ package struct SchemaGenerator { target: ResolverTarget, parentType: GraphQLType ) throws -> String { - let safeFieldName = nameGenerator.swiftMemberName(for: fieldName) - var output = """ resolve: { source, args, context, info in @@ -701,10 +699,12 @@ package struct SchemaGenerator { if target == .parent { // For nested resolvers, we decode and call the method on the parent instance - let parentCastType = try swiftTypeReference(for: parentType, nameGenerator: nameGenerator) + // We use the type Declaration name, since this should always be a non-list, non-nullable instance, + // and add 'any' because all intermediate types are represented as protocols + let parentCastType = try swiftTypeDeclaration(for: parentType, nameGenerator: nameGenerator) output += """ - let parent = try cast(source, to: (\(parentCastType)).self) + let parent = try cast(source, to: (any \(parentCastType)).self) """ } @@ -713,9 +713,14 @@ package struct SchemaGenerator { let safeArgName = nameGenerator.swiftMemberName(for: argName) let swiftType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) // Extract value from Map based on type + var decodeStatement = "try MapDecoder().decode((\(swiftType)).self, from: args[\"\(argName)\"])" + if !(arg.type is GraphQLNonNull) { + // If the arg is nullable, we get errors if we try to decode an `undefined` map. This protects against that. + decodeStatement = "args[\"\(argName)\"] != .undefined ? \(decodeStatement) : nil" + } output += """ - let \(safeArgName) = try MapDecoder().decode((\(swiftType)).self, from: args["\(argName)"]) + let \(safeArgName) = \(decodeStatement) """ argsList.append("\(safeArgName): \(safeArgName)") } @@ -736,9 +741,10 @@ package struct SchemaGenerator { case .query: "Resolvers.Query" case .mutation: "Resolvers.Mutation" } + let functionName = nameGenerator.swiftMemberName(for: fieldName) output += """ - return try await \(targetName).\(safeFieldName)(\(argsList.joined(separator: ", "))) + return try await \(targetName).\(functionName)(\(argsList.joined(separator: ", "))) } """ diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 224e5c2..2a730b8 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -82,7 +82,7 @@ package struct TypeGenerator { let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) output += """ - public protocol \(swiftTypeName)Union {} + public protocol \(swiftTypeName): Sendable {} """ // Record which types need to be conformed @@ -213,7 +213,7 @@ package struct TypeGenerator { output += """ - public let \(fieldName.lowercased()): \(returnType) + public let \(nameGenerator.swiftMemberName(for: fieldName)): \(returnType) """ } @@ -277,7 +277,7 @@ package struct TypeGenerator { output += """ - public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + func \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) """ } @@ -345,7 +345,7 @@ package struct TypeGenerator { output += """ - public func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + func \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) """ } @@ -407,7 +407,7 @@ package struct TypeGenerator { output += """ - static func \(fieldName.lowercased())(\(paramString)) async throws -> \(returnType) + static func \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) """ } diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift index 98d67ac..c155f04 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/indent.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -4,9 +4,6 @@ extension String { var firstLine = true return self.split(separator: "\n").map { line in var result = line - if firstLine { - firstLine = false - } if !line.isEmpty { if !firstLine || includeFirst { result = indent + line diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift index 3c27b5c..6fbdf3d 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -7,8 +7,8 @@ func swiftTypeReference(for type: GraphQLType, nameGenerator: SafeNameGenerator) // Remove the optional marker if present if innerType.hasSuffix("?") { if innerType.hasPrefix("(") { - // Remove parentheses around "(any X)?" - return String(innerType.dropFirst().dropLast()) + // Remove parentheses and trailing ? around "(any X)?" + return String(innerType.dropFirst().dropLast().dropLast()) } // Remove trailing ? on "X?" return String(innerType.dropLast()) @@ -43,20 +43,29 @@ func swiftTypeReference(for type: GraphQLType, nameGenerator: SafeNameGenerator) /// Convert GraphQL type to Swift type name func swiftTypeDeclaration(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { - guard let namedType = type as? GraphQLNamedType else { - throw GeneratorError.unsupportedType("Declarations must reference unmodified object types (not non-nulls or lists)") + if let nonNull = type as? GraphQLNonNull { + return try swiftTypeDeclaration(for: nonNull.ofType, nameGenerator: nameGenerator) } - let baseName = nameGenerator.swiftTypeName(for: namedType.name) - if type is GraphQLInputObjectType { - return "\(baseName)Input" - } else if type is GraphQLInterfaceType { - return "\(baseName)Interface" - } else if type is GraphQLObjectType { - return "\(baseName)Protocol" - } else if type is GraphQLUnionType { - return "\(baseName)Union" + + if let list = type as? GraphQLList { + return try swiftTypeDeclaration(for: list.ofType, nameGenerator: nameGenerator) } - return baseName + + if let namedType = type as? GraphQLNamedType { + let baseName = nameGenerator.swiftTypeName(for: namedType.name) + if type is GraphQLInputObjectType { + return "\(baseName)Input" + } else if type is GraphQLInterfaceType { + return "\(baseName)Interface" + } else if type is GraphQLObjectType { + return "\(baseName)Protocol" + } else if type is GraphQLUnionType { + return "\(baseName)Union" + } + return baseName + } + + throw GeneratorError.unsupportedType("Unknown type: \(type)") } /// Map GraphQL scalar types to Swift types diff --git a/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift index 07418d5..7ab40ba 100644 --- a/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift +++ b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift @@ -1,6 +1,6 @@ import GraphQL -func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { +public func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { guard let result = anySendable as? T else { throw GraphQLError( message: "Expected source type \(T.self) but got \(type(of: anySendable))" diff --git a/Sources/GraphQLGeneratorRuntime/Scalar.swift b/Sources/GraphQLGeneratorRuntime/Scalar.swift index 7d75a95..8e4ea42 100644 --- a/Sources/GraphQLGeneratorRuntime/Scalar.swift +++ b/Sources/GraphQLGeneratorRuntime/Scalar.swift @@ -7,7 +7,7 @@ public protocol Scalar: Sendable { static func parseLiteral(value: any Value) throws -> Map } -extension Scalar { +public extension Scalar { static func serialize(any: Any) throws -> Map { return try Map(any: any) } diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift index a3c1640..29b3dd8 100644 --- a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -58,7 +58,7 @@ struct SchemaGeneratorTests { foo """, resolve: { source, args, context, info in - let parent = try cast(source, to: ((any BarProtocol)?).self) + let parent = try cast(source, to: (any BarProtocol).self) let context = try cast(context, to: Context.self) return try await parent.foo(context: context, info: info) } diff --git a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift index ca1961d..b032afd 100644 --- a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -64,10 +64,10 @@ struct TypeGeneratorTests { /// B public protocol BInterface: AInterface, Sendable { /// foo - public func foo(context: Context, info: GraphQLResolveInfo) async throws -> String + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String /// baz - public func baz(context: Context, info: GraphQLResolveInfo) async throws -> String? + func baz(context: Context, info: GraphQLResolveInfo) async throws -> String? } """ @@ -117,10 +117,10 @@ struct TypeGeneratorTests { /// Foo public protocol FooProtocol: XUnion, AInterface, Sendable { /// foo - public func foo(context: Context, info: GraphQLResolveInfo) async throws -> String + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String /// bar - public func bar(foo: String, bar: String?, context: Context, info: GraphQLResolveInfo) async throws -> String? + func bar(foo: String, bar: String?, context: Context, info: GraphQLResolveInfo) async throws -> String? } """ From a389c8c466044faa25bfef96db57fd49ea8987fe Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 11:57:39 -0700 Subject: [PATCH 26/38] chore: Deletes AI cruft --- Package.swift | 3 +- README.md | 191 +++++------------- .../PlaceholderTests.swift | 1 - 3 files changed, 51 insertions(+), 144 deletions(-) delete mode 100644 Tests/GraphQLGeneratorTests/PlaceholderTests.swift diff --git a/Package.swift b/Package.swift index 9c98ac8..3104149 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ -// swift-tools-version: 6.2 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 6.0 import PackageDescription diff --git a/README.md b/README.md index 3a71eb8..d561148 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,8 @@ # GraphQL Generator for Swift -A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [swift-openapi-generator](https://github.com/apple/swift-openapi-generator). +A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema). -This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code from your GraphQL schemas, eliminating boilerplate while maintaining full control over your business logic. - -## Status - -🚧 **Phase 1 Complete** - Foundation is in place with basic code generation - -Currently implemented: -- βœ… Build plugin for SPM integration -- βœ… GraphQL schema parsing using GraphQL Swift's `buildSchema` -- βœ… Type generation (Swift structs from GraphQL types) -- βœ… Resolver protocol generation -- βœ… Basic runtime library with ResolverContext -- βœ… CLI tool for code generation - -Still in development (see [plan.md](plan.md)): -- ⏳ Complete schema builder generation (Phase 5) -- ⏳ Mutations and subscriptions support -- ⏳ Custom scalar mappings -- ⏳ Configuration file support -- ⏳ Complete test coverage -- ⏳ Working end-to-end examples +This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while maintaining full control over your business logic. ## Features @@ -32,12 +12,6 @@ Still in development (see [plan.md](plan.md)): - **Modern Swift**: Uses async/await for all resolver functions - **Minimal boilerplate**: Generates only ceremony code - you write the business logic -## Requirements - -- Swift 6.2+ -- macOS 13+, iOS 16+, tvOS 16+, or watchOS 9+ -- GraphQL Swift 4.0+ - ## Installation Add the package to your `Package.swift`: @@ -45,7 +19,7 @@ Add the package to your `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), - .package(url: "https://github.com/YourOrg/graphql-generator", from: "1.0.0") + .package(url: "https://github.com/GraphQLSwift/graphql-generator", from: "1.0.0") ], targets: [ .target( @@ -84,143 +58,78 @@ type Query { ### 2. Build Your Project When you build, the plugin will automatically generate Swift code: -- `Types.swift` - Swift structs for your GraphQL types -- `Resolvers.swift` - Protocol defining resolver methods -- `Schema.swift` - Schema builder (coming in Phase 5) +- `Types.swift` - Swift protocols for your GraphQL types +- `Schema.swift` - Schema -### 3. Implement the Resolver Protocol +### 3. Create required types -```swift -import GraphQL -import GraphQLGeneratorRuntime - -struct MyResolvers: GraphQLResolvers { - func user(id: String, context: ResolverContext) async throws -> User? { - // Your business logic here - return User(id: id, name: "John Doe", email: "john@example.com") - } +Create a type named `Context`: - func users(context: ResolverContext) async throws -> [User] { - // Your business logic here - return [ - User(id: "1", name: "Alice", email: "alice@example.com"), - User(id: "2", name: "Bob", email: "bob@example.com"), - ] - } +```swift +public actor Context { + // Add any features you like } ``` -### 4. Execute GraphQL Queries +Create any scalar types (with names matching GraphQL), and conform them to `Scalar`: ```swift -import GraphQL - -// Create resolvers -let resolvers = MyResolvers() - -// Build schema (Phase 5 - not yet implemented) -// let schema = try buildGraphQLSchema(resolvers: resolvers) - -// Execute a query -// let result = try await graphql(schema: schema, request: "{ users { name email } }") -// print(result) -``` - -## Project Structure - -``` -graphql-generator/ -β”œβ”€β”€ Package.swift -β”œβ”€β”€ README.md -β”œβ”€β”€ plan.md # Detailed implementation plan -β”œβ”€β”€ Plugins/ -β”‚ └── GraphQLGeneratorPlugin.swift # SPM build plugin -β”œβ”€β”€ Sources/ -β”‚ β”œβ”€β”€ GraphQLGenerator/ # CLI executable -β”‚ β”œβ”€β”€ GraphQLGeneratorCore/ # Parsing and generation logic -β”‚ β”‚ β”œβ”€β”€ Parser/ -β”‚ β”‚ β”‚ └── SchemaParser.swift -β”‚ β”‚ └── Generator/ -β”‚ β”‚ β”œβ”€β”€ CodeGenerator.swift -β”‚ β”‚ β”œβ”€β”€ TypeGenerator.swift -β”‚ β”‚ β”œβ”€β”€ ResolverGenerator.swift -β”‚ β”‚ └── SchemaGenerator.swift -β”‚ └── GraphQLGeneratorRuntime/ # Runtime support library -β”‚ └── ResolverContext.swift -β”œβ”€β”€ Tests/ -β”‚ └── GraphQLGeneratorTests/ -└── Examples/ - └── HelloWorldServer/ +struct DateTime: Scalar {} ``` -## Generated Code Examples - -### From this GraphQL schema: - -```graphql -type User { - id: ID! - name: String! - email: String! +Create a resolvers struct with the required typealiases: +```swift +struct Resolvers: ResolversProtocol { + typealias Query = ExamplePackage.Query + typealias Mutation = ExamplePackage.Mutation } ``` -### Generates this Swift code: +As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol. +Here's a small example of a schema that allows querying for the current user, who is only identified by an email address: ```swift -// Types.swift -public struct User: Codable { - public let id: String - public let name: String - public let email: String - - public init( - id: String, - name: String, - email: String - ) { - self.id = id - self.name = name - self.email = email +struct Query: QueryProtocol { + // This is required by `QueryProtocol`, and used by GraphQL query resolution. + static func user(context: Context, info: GraphQLResolveInfo) async throws -> (any UserProtocol)? { + // You can implement resolution logic however you like. + return context.user } } -// Resolvers.swift -public protocol GraphQLResolvers { - func user(id: String, context: ResolverContext) async throws -> User? - func users(context: ResolverContext) async throws -> [User] +struct User: UserProtocol { + // You can define the type internals however you like + let email: String + + // This is required by `UserProtocol`, and used by GraphQL field resolution. + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + // You can implement resolution logic however you like. + return email + } } ``` -## Development Roadmap - -See [plan.md](plan.md) for the complete implementation plan across 8 phases: - -- **Phase 1: Foundation** βœ… - Basic infrastructure and plugin setup -- **Phase 2: Schema Parsing** - Complete SDL parsing (in progress) -- **Phase 3: Type Generation** - Full type generation with all GraphQL constructs -- **Phase 4: Resolver Generation** - Complete resolver protocol generation -- **Phase 5: Schema Builder** - Generate executable GraphQL schema -- **Phase 6: Advanced Features** - Mutations, subscriptions, custom scalars -- **Phase 7: Runtime & Ergonomics** - Helper utilities and patterns -- **Phase 8: Examples & Documentation** - Complete examples and guides - -## Contributing +### 4. Execute GraphQL Queries -This project is in active development. Contributions are welcome! +```swift +import GraphQL -## License +let schema = try buildGraphQLSchema(resolvers: Resolvers.self) -TBD +// Execute a query +let result = try await graphql(schema: schema, request: "{ users { name email } }") +print(result) +``` -## Inspiration +## Development Roadmap -This project is inspired by: -- [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) - Build plugin architecture -- [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) - Runtime GraphQL implementation +1. Default values: Default values are currently ignored +2. Directives: Directives are currently not supported +3. Subscription: Subscription definitions are currently ignored +4. Improved testing: Generator tests should cover much more of the functionality +5. Additional examples: Ideally large ones that cover significant GraphQL features +6. Executable Schema: To work around the immutability of some Schema components, we generate Swift code to fully recreate the defined schema. Instead, we could just add resolver logic to the schema parsed from the `.graphql` file SDL. -## Related Projects +## Contributing -- [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) - The Swift GraphQL implementation -- [Vapor](https://github.com/vapor/vapor) - Server-side Swift framework -- [Hummingbird](https://github.com/hummingbird-project/hummingbird) - Lightweight server framework +This project is in active development. Contributions are welcome! diff --git a/Tests/GraphQLGeneratorTests/PlaceholderTests.swift b/Tests/GraphQLGeneratorTests/PlaceholderTests.swift deleted file mode 100644 index 0199977..0000000 --- a/Tests/GraphQLGeneratorTests/PlaceholderTests.swift +++ /dev/null @@ -1 +0,0 @@ -// Test placeholder From c6d872ad635bceb459bace7fe846a801abec7d17 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 12:22:00 -0700 Subject: [PATCH 27/38] test: Makes HelloWorldServer example testable --- Examples/HelloWorldServer/Package.swift | 9 +- .../{main.swift => Resolvers.swift} | 47 ---------- .../HelloWorldServerTests.swift | 89 +++++++++++++++++++ 3 files changed, 97 insertions(+), 48 deletions(-) rename Examples/HelloWorldServer/Sources/HelloWorldServer/{main.swift => Resolvers.swift} (78%) create mode 100644 Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift index 50890e6..630aca4 100644 --- a/Examples/HelloWorldServer/Package.swift +++ b/Examples/HelloWorldServer/Package.swift @@ -12,7 +12,7 @@ let package = Package( .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), ], targets: [ - .executableTarget( + .target( name: "HelloWorldServer", dependencies: [ .product(name: "GraphQL", package: "GraphQL"), @@ -22,5 +22,12 @@ let package = Package( .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") ] ), + .testTarget( + name: "HelloWorldServerTests", + dependencies: [ + "HelloWorldServer", + .product(name: "GraphQL", package: "GraphQL"), + ] + ) ] ) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift similarity index 78% rename from Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift rename to Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift index dee101a..25bd4dc 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/main.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -113,50 +113,3 @@ struct Mutation: MutationProtocol { return user } } - -let schema = try buildGraphQLSchema(resolvers: Resolvers.self) - -let context = Context( - users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], - posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] -) -print( - try await graphql( - schema: schema, - request: """ - { - posts { - id - title - content - author { - id - name - email - age - role - } - } - } - """, - context: context - ) -) - -print( - try await graphql( - schema: schema, - request: """ - mutation { - upsertUser(userInfo: {id: "2", name: "Jane", email: "jane@example.com"}) { - id - name - email - age - role - } - } - """, - context: context - ) -) diff --git a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift new file mode 100644 index 0000000..e108b51 --- /dev/null +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -0,0 +1,89 @@ +import GraphQL +import Testing + +@testable import HelloWorldServer + +@Suite +struct HelloWorldServerTests { + @Test func query() async throws { + let schema = try buildGraphQLSchema(resolvers: Resolvers.self) + let context = Context( + users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] + ) + #expect( + try await graphql( + schema: schema, + request: """ + { + posts { + id + title + content + author { + id + name + email + age + role + } + } + } + """, + context: context + ) == .init( + data: [ + "posts": [ + [ + "id": "1", + "title": "Foo", + "content": "bar", + "author": [ + "id": "1", + "name": "John", + "email": "john@example.com", + "age": 18, + "role": "USER" + ] + ] + ] + ] + ) + ) + } + + @Test func mutation() async throws { + let schema = try buildGraphQLSchema(resolvers: Resolvers.self) + let context = Context( + users: [:], + posts: [:] + ) + #expect( + try await graphql( + schema: schema, + request: """ + mutation { + upsertUser(userInfo: {id: "2", name: "Jane", email: "jane@example.com"}) { + id + name + email + age + role + } + } + """, + context: context + ) == .init( + data: [ + "upsertUser": [ + "id": "2", + "name": "Jane", + "email": "jane@example.com", + "age": nil, + "role": nil + ] + ] + ) + ) + } +} From 09a0777e833df23df5806b03e3cd7ce525f7372e Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 14:30:53 -0700 Subject: [PATCH 28/38] feat: Revise Scalar and remove default impl The default is very unperformant, as it requires a full encode/decode step. --- .../Sources/HelloWorldServer/Resolvers.swift | 48 ++++++++++-- .../Sources/HelloWorldServer/schema.graphql | 10 ++- README.md | 75 ++++++++++++++---- Sources/GraphQLGeneratorRuntime/Scalar.swift | 78 ++++--------------- 4 files changed, 121 insertions(+), 90 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift index 25bd4dc..1d77784 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -17,7 +17,43 @@ public class Context: @unchecked Sendable { } } // Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol -struct DateTime: Scalar {} +public struct EmailAddress: Scalar { + let email: String + + init(email: String) { + self.email = email + } + + // Codability conformance. Required for usage in InputObject + public init(from decoder: any Decoder) throws { + self.email = try decoder.singleValueContainer().decode(String.self) + } + public func encode(to encoder: any Encoder) throws { + try self.email.encode(to: encoder) + } + + // Scalar conformance. Not necessary, but default methods are very inefficient. + public static func serialize(this: Self) throws -> Map { + return .string(this.email) + } + public static func parseValue(map: Map) throws -> Map { + switch map { + case .string: + return map + default: + throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)") + } + } + public static func parseLiteral(value: any Value) throws -> Map { + guard let ast = value as? StringValue else { + throw GraphQLError( + message: "EmailAddress cannot represent non-string value: \(print(ast: value))", + nodes: [value] + ) + } + return .string(ast.value) + } +} // Now create types that conform to the expected protocols struct Resolvers: ResolversProtocol { @@ -39,8 +75,8 @@ struct User: UserProtocol { func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { return name } - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { - return email + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { + return EmailAddress(email: email) } func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { return age @@ -54,8 +90,8 @@ struct Contact: ContactProtocol { let email: String // Required implementations - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { - return email + func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { + return EmailAddress(email: email) } } struct Post: PostProtocol { @@ -105,7 +141,7 @@ struct Mutation: MutationProtocol { let user = User( id: userInfo.id, name: userInfo.name, - email: userInfo.email, + email: userInfo.email.email, age: userInfo.age, role: userInfo.role ) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 42acf7d..25088ea 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -1,8 +1,10 @@ +scalar EmailAddress + interface HasEmail { """ An email address """ - email: String! + email: EmailAddress! } union UserOrPost = User | Post @@ -10,7 +12,7 @@ union UserOrPost = User | Post input UserInfo { id: ID! name: String! - email: String! + email: EmailAddress! age: Int role: Role } @@ -32,7 +34,7 @@ type User implements HasEmail { """ The user's email address """ - email: String! + email: EmailAddress! """ The user's age @@ -46,7 +48,7 @@ type User implements HasEmail { } type Contact implements HasEmail { - email: String! + email: EmailAddress! } """ diff --git a/README.md b/README.md index d561148..4275bb9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema). -This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while maintaining full control over your business logic. +This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while allowing you full control over your business logic. ## Features @@ -44,14 +44,12 @@ Create a `.graphql` file in your target's `Sources` directory: **Sources/YourTarget/schema.graphql**: ```graphql type User { - id: ID! name: String! - email: String! + email: EmailAddress! } type Query { - user(id: ID!): User - users: [User!]! + user: User } ``` @@ -71,11 +69,7 @@ public actor Context { } ``` -Create any scalar types (with names matching GraphQL), and conform them to `Scalar`: - -```swift -struct DateTime: Scalar {} -``` +Create any scalar types (with names matching GraphQL), and conform them to `Scalar`. See the `Scalars` usage section below for details. Create a resolvers struct with the required typealiases: ```swift @@ -85,8 +79,7 @@ struct Resolvers: ResolversProtocol { } ``` -As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol. -Here's a small example of a schema that allows querying for the current user, who is only identified by an email address: +As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol: ```swift struct Query: QueryProtocol { @@ -99,12 +92,16 @@ struct Query: QueryProtocol { struct User: UserProtocol { // You can define the type internals however you like + let name: String let email: String - // This is required by `UserProtocol`, and used by GraphQL field resolution. - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + // These are required by `UserProtocol`, and used by GraphQL field resolution. + func name(context: Context, info: GraphQLResolveInfo) async throws -> String { + return name + } + func email(context: Context, info: GraphQLResolveInfo) async throws -> EmailAddress { // You can implement resolution logic however you like. - return email + return EmailAddress(email: self.email) } } ``` @@ -121,6 +118,54 @@ let result = try await graphql(schema: schema, request: "{ users { name email } print(result) ``` +## Detailed Usage + +### Scalars + +Scalar types must be provided for each GraphQL scalar. Since GraphQL uses a different serialization system than Swift, you must conform the type to Swift's `Codable` and GraphQL's `Scalar`, and have them agree on a representation. + +Below is an example that represents a scalar struct as a raw String: + +```swift +public struct EmailAddress: Scalar { + let email: String + + init(email: String) { + self.email = email + } + + // Codability conformance. Represent simply as `email` string. + public init(from decoder: any Decoder) throws { + self.email = try decoder.singleValueContainer().decode(String.self) + } + public func encode(to encoder: any Encoder) throws { + try self.email.encode(to: encoder) + } + + // Scalar conformance. Parse & serialize simply as `email` string. + public static func serialize(this: Self) throws -> Map { + return .string(this.email) + } + public static func parseValue(map: Map) throws -> Map { + switch map { + case .string: + return map + default: + throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)") + } + } + public static func parseLiteral(value: any Value) throws -> Map { + guard let ast = value as? StringValue else { + throw GraphQLError( + message: "EmailAddress cannot represent non-string value: \(print(ast: value))", + nodes: [value] + ) + } + return .string(ast.value) + } +} +``` + ## Development Roadmap 1. Default values: Default values are currently ignored diff --git a/Sources/GraphQLGeneratorRuntime/Scalar.swift b/Sources/GraphQLGeneratorRuntime/Scalar.swift index 8e4ea42..28780dc 100644 --- a/Sources/GraphQLGeneratorRuntime/Scalar.swift +++ b/Sources/GraphQLGeneratorRuntime/Scalar.swift @@ -1,77 +1,25 @@ import GraphQL import OrderedCollections -public protocol Scalar: Sendable { - static func serialize(any: Any) throws -> Map +public protocol Scalar: Sendable, Codable { + static func serialize(this: Self) throws -> Map static func parseValue(map: Map) throws -> Map static func parseLiteral(value: any Value) throws -> Map } +// Graphiti provides default serializations that the underlying type's Codability requirements, but they are very +// inefficient. They typically pass through a full serialize/deserialize step on each call. +// Because of this, we have chosen not to vend defaults to force the user to implement more performant versions. + public extension Scalar { + /// This wraps the GraphQLScalar definition in a type-safe one static func serialize(any: Any) throws -> Map { - return try Map(any: any) - } - static func parseValue(map: Map) throws -> Map { - return map - } - static func parseLiteral(value: any Value) throws -> Map { - return value.map - } -} - - -extension GraphQL.Value { - var map: Map { - if - let value = self as? BooleanValue - { - return .bool(value.value) - } - - if - let value = self as? IntValue, - let int = Int(value.value) - { - return .int(int) - } - - if - let value = self as? FloatValue, - let double = Double(value.value) - { - return .double(double) + // We should always get a value of `Self` for custom scalars. + guard let scalar = any as? Self else { + throw GraphQLError( + message: "Serialize expected type \(Self.self) but got \(type(of: any))" + ) } - - if - let value = self as? StringValue - { - return .string(value.value) - } - - if - let value = self as? EnumValue - { - return .string(value.value) - } - - if - let value = self as? ListValue - { - let array = value.values.map { $0.map } - return .array(array) - } - - if - let value = self as? ObjectValue - { - let dictionary: OrderedDictionary = value.fields - .reduce(into: [:]) { result, field in - result[field.name.value] = field.value.map - } - - return .dictionary(dictionary) - } - - return .null + return try Self.serialize(this: scalar) } } From 1944d7ac7feffc1d093c6a38967b69ae47d115ca Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 14:39:37 -0700 Subject: [PATCH 29/38] feat: Centralize decoder --- .../GraphQLGeneratorCore/Generator/SchemaGenerator.swift | 7 +++++-- Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index d4a2e29..281d897 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -15,7 +15,10 @@ package struct SchemaGenerator { import GraphQLGeneratorRuntime /// Build a GraphQL schema with the provided resolvers - public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { + public func buildGraphQLSchema( + resolvers: Resolvers.Type, + decoder: MapDecoder = .init(), + ) throws -> GraphQLSchema { """ // Ignore any internal types (which have prefix "__") @@ -713,7 +716,7 @@ package struct SchemaGenerator { let safeArgName = nameGenerator.swiftMemberName(for: argName) let swiftType = try swiftTypeReference(for: arg.type, nameGenerator: nameGenerator) // Extract value from Map based on type - var decodeStatement = "try MapDecoder().decode((\(swiftType)).self, from: args[\"\(argName)\"])" + var decodeStatement = "try decoder.decode((\(swiftType)).self, from: args[\"\(argName)\"])" if !(arg.type is GraphQLNonNull) { // If the arg is nullable, we get errors if we try to decode an `undefined` map. This protects against that. decodeStatement = "args[\"\(argName)\"] != .undefined ? \(decodeStatement) : nil" diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift index 29b3dd8..f089392 100644 --- a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -43,7 +43,10 @@ struct SchemaGeneratorTests { import GraphQLGeneratorRuntime /// Build a GraphQL schema with the provided resolvers - public func buildGraphQLSchema(resolvers: Resolvers.Type) throws -> GraphQLSchema { + public func buildGraphQLSchema( + resolvers: Resolvers.Type, + decoder: MapDecoder = .init(), + ) throws -> GraphQLSchema { let bar = try GraphQLObjectType( name: "Bar", description: """ From bd23d6c428b3674210f29970fd74f4c53ed3fa6c Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 21:42:54 -0700 Subject: [PATCH 30/38] docs: Adds design details for each type --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4275bb9..aacdbe2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +***WARNING***: This package is in beta. It's API is still evolving and is subject to breaking changes. + # GraphQL Generator for Swift A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema). @@ -57,7 +59,7 @@ type Query { When you build, the plugin will automatically generate Swift code: - `Types.swift` - Swift protocols for your GraphQL types -- `Schema.swift` - Schema +- `Schema.swift` - Defines `buildGraphQLSchema` function that builds an executable schema ### 3. Create required types @@ -118,11 +120,58 @@ let result = try await graphql(schema: schema, request: "{ users { name email } print(result) ``` -## Detailed Usage +## Design + +### Root Types +Root types (Query, Mutation, and Subscription) are modeled as Swift protocols with static method requirements for each field. The user must implement these types and provide them to the `buildGraphQLSchema` function. + +### Object Types +Object types are modeled as Swift protocols with instance method requirements for each field. This is to enable maximum implementation flexibility. Internally, GraphQL passes result objects directly through to subsequent resolvers. By only specifying the interface, we allow the backing types to be incredibly dynamic - they can be simple codable structs or complex stateful actors, reference or values types, or any other type configuration. + +Furthermore, by only referencing protocols, we can have multiple Swift types back a particular GraphQL type, and can easily mock portions of the schema. As an example, consider the following schema snippet: +```graphql +type A { + foo: String +} +``` + +This would result in the following protocol: +```swift +public protocol AProtocol: Sendable { + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String +} +``` + +You could define two conforming types. To use `ATest` in tests, simply return it from the relevant resolvers. +```swift +struct A: AProtocol { + let foo: String + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String { + return foo + } +} +struct ATest: AProtocol { + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String { + return "test" + } +} +``` + + +### Interface Types +Interfaces are modeled as a protocol with required methods for each relevant field. Implementing objects and interfaces are marked as requiring conformance to the interface protocol. + +### Union Types +Union types are modeled as a marker protocol, with no required properties or functions. Related objects are marked as requiring conformance to the union protocol. + +### Input Object Types +Input object types are modeled as a deterministic Codable struct with the declared fields. If more complex objects must be created from the codable struct, this can be done in the resolver itself, since input objects only relevant for their associated resolver (they are not passed to downstream resolvers). -### Scalars +### Enum Types +Enum types are modeled as a deterministic String enum with values matching the declared fields and associated representations. If you need different values or more complex implementations, simply convert to/from a different representation inside your resolvers. -Scalar types must be provided for each GraphQL scalar. Since GraphQL uses a different serialization system than Swift, you must conform the type to Swift's `Codable` and GraphQL's `Scalar`, and have them agree on a representation. +### Scalar Types +Scalar types are not modeled by the generator. They are simply referenced using the Scalar's name, and you are expected to implement the required type. Since GraphQL uses a different serialization system than Swift, you must conform the type to Swift's `Codable` and GraphQL's `Scalar`, and have them agree on a representation. Below is an example that represents a scalar struct as a raw String: From 36c16c68a12b6b1ed5168f6c6a58cd1dfdeb2c9a Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 28 Dec 2025 22:03:39 -0700 Subject: [PATCH 31/38] feat: Adds default value support --- .../Sources/HelloWorldServer/schema.graphql | 2 +- .../HelloWorldServerTests.swift | 110 +++++++++--------- README.md | 11 +- .../Generator/SchemaGenerator.swift | 14 ++- .../Utilities/swiftTypeName.swift | 34 ++++++ 5 files changed, 108 insertions(+), 63 deletions(-) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 25088ea..7d5fbf3 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -14,7 +14,7 @@ input UserInfo { name: String! email: EmailAddress! age: Int - role: Role + role: Role = USER } """ diff --git a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift index e108b51..1e39e09 100644 --- a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -11,45 +11,45 @@ struct HelloWorldServerTests { users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] ) - #expect( - try await graphql( - schema: schema, - request: """ - { - posts { + let actual = try await graphql( + schema: schema, + request: """ + { + posts { + id + title + content + author { id - title - content - author { - id - name - email - age - role - } + name + email + age + role } } - """, - context: context - ) == .init( - data: [ - "posts": [ - [ + } + """, + context: context + ) + let expected = GraphQLResult( + data: [ + "posts": [ + [ + "id": "1", + "title": "Foo", + "content": "bar", + "author": [ "id": "1", - "title": "Foo", - "content": "bar", - "author": [ - "id": "1", - "name": "John", - "email": "john@example.com", - "age": 18, - "role": "USER" - ] + "name": "John", + "email": "john@example.com", + "age": 18, + "role": "USER" ] ] ] - ) + ] ) + #expect(actual == expected) } @Test func mutation() async throws { @@ -58,32 +58,32 @@ struct HelloWorldServerTests { users: [:], posts: [:] ) - #expect( - try await graphql( - schema: schema, - request: """ - mutation { - upsertUser(userInfo: {id: "2", name: "Jane", email: "jane@example.com"}) { - id - name - email - age - role - } + let actual = try await graphql( + schema: schema, + request: """ + mutation { + upsertUser(userInfo: {id: "2", name: "Jane", email: "jane@example.com"}) { + id + name + email + age + role } - """, - context: context - ) == .init( - data: [ - "upsertUser": [ - "id": "2", - "name": "Jane", - "email": "jane@example.com", - "age": nil, - "role": nil - ] + } + """, + context: context + ) + let expected = GraphQLResult( + data: [ + "upsertUser": [ + "id": "2", + "name": "Jane", + "email": "jane@example.com", + "age": nil, + "role": "USER" ] - ) + ] ) + #expect(actual == expected) } } diff --git a/README.md b/README.md index aacdbe2..1fec25c 100644 --- a/README.md +++ b/README.md @@ -217,12 +217,11 @@ public struct EmailAddress: Scalar { ## Development Roadmap -1. Default values: Default values are currently ignored -2. Directives: Directives are currently not supported -3. Subscription: Subscription definitions are currently ignored -4. Improved testing: Generator tests should cover much more of the functionality -5. Additional examples: Ideally large ones that cover significant GraphQL features -6. Executable Schema: To work around the immutability of some Schema components, we generate Swift code to fully recreate the defined schema. Instead, we could just add resolver logic to the schema parsed from the `.graphql` file SDL. +1. Directives: Directives are currently not supported +2. Subscription: Subscription definitions are currently ignored +3. Improved testing: Generator tests should cover much more of the functionality +4. Additional examples: Ideally large ones that cover significant GraphQL features +5. Executable Schema: To work around the immutability of some Schema components, we generate Swift code to fully recreate the defined schema. Instead, we could just add resolver logic to the schema parsed from the `.graphql` file SDL. ## Contributing diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 281d897..85a03e1 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -285,7 +285,12 @@ package struct SchemaGenerator { type: \(try graphQLTypeReference(for: field.type)), """ - // TODO: Default value support + if let defaultValue = field.defaultValue { + output += """ + + defaultValue: \(mapToSwiftCode(defaultValue)), + """ + } if let description = field.description { output += """ @@ -660,6 +665,13 @@ package struct SchemaGenerator { """ } + if let defaultValue = arg.defaultValue { + output += """ + , + defaultValue: \(mapToSwiftCode(defaultValue)) + """ + } + output += """ ), diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift index 6fbdf3d..b9f39a7 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -81,3 +81,37 @@ func mapScalarType(_ graphQLType: String, nameGenerator: SafeNameGenerator) -> S return nameGenerator.swiftTypeName(for: graphQLType) } } + +/// Converts a Map value to valid Swift code representation +func mapToSwiftCode(_ map: Map) -> String { + switch map { + case .undefined: + return ".undefined" + case .null: + return ".null" + case .bool(let value): + return ".bool(\(value))" + case .number(let value): + return ".number(Number(\(value)))" + case .string(let value): + // Escape special characters for Swift string literal + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + return ".string(\"\(escaped)\")" + case .array(let values): + let elements = values.map { mapToSwiftCode($0) }.joined(separator: ", ") + return ".array([\(elements)])" + case .dictionary(let dict): + let pairs = dict.map { key, value in + let escapedKey = key + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escapedKey)\": \(mapToSwiftCode(value))" + }.joined(separator: ", ") + return ".dictionary([\(pairs)])" + } +} From 0e4881d90076f9b867b9c2f65236ad916cbf2a97 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:15:24 -0700 Subject: [PATCH 32/38] feat: Adds subscription support --- .../Sources/HelloWorldServer/Resolvers.swift | 17 +++ .../Sources/HelloWorldServer/schema.graphql | 4 + .../HelloWorldServerTests.swift | 55 +++++++++ .../Generator/SchemaGenerator.swift | 112 +++++++++--------- .../Generator/TypeGenerator.swift | 21 +++- .../AnyAsyncSequence.swift | 39 ++++++ .../TypeGeneratorTests.swift | 31 ++++- 7 files changed, 223 insertions(+), 56 deletions(-) create mode 100644 Sources/GraphQLGeneratorRuntime/AnyAsyncSequence.swift diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift index 1d77784..562760c 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -7,6 +7,7 @@ public class Context: @unchecked Sendable { // User can choose structure var users: [String: User] var posts: [String: Post] + var onTriggerWatch: () -> Void = {} init( users: [String: User], @@ -15,6 +16,10 @@ public class Context: @unchecked Sendable { self.users = users self.posts = posts } + + func triggerWatch() { + onTriggerWatch() + } } // Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol public struct EmailAddress: Scalar { @@ -59,6 +64,7 @@ public struct EmailAddress: Scalar { struct Resolvers: ResolversProtocol { typealias Query = HelloWorldServer.Query typealias Mutation = HelloWorldServer.Mutation + typealias Subscription = HelloWorldServer.Subscription } struct User: UserProtocol { // User can choose structure @@ -149,3 +155,14 @@ struct Mutation: MutationProtocol { return user } } + +struct Subscription: SubscriptionProtocol { + // Required implementations + static func watchUser(id: String, context: Context, info: GraphQLResolveInfo) async throws -> AnyAsyncSequence<(any UserProtocol)?> { + return AsyncStream<(any UserProtocol)?> { continuation in + context.onTriggerWatch = { [weak context] in + continuation.yield(context?.users[id]) + } + }.any() + } +} diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 7d5fbf3..4778f48 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -118,3 +118,7 @@ type Query { type Mutation { upsertUser(userInfo: UserInfo!): User! } + +type Subscription { + watchUser(id: ID!): User +} diff --git a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift index 1e39e09..32d49eb 100644 --- a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -86,4 +86,59 @@ struct HelloWorldServerTests { ) #expect(actual == expected) } + + @Test func subscription() async throws { + let schema = try buildGraphQLSchema(resolvers: Resolvers.self) + let context = Context( + users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + posts: [:] + ) + let stream = try await graphqlSubscribe( + schema: schema, + request: """ + subscription { + watchUser(id: "1") { + id + name + email + age + role + } + } + """, + context: context + ).get() + + var iterator = stream.makeAsyncIterator() + + context.triggerWatch() + #expect( + try await iterator.next() == GraphQLResult( + data: [ + "watchUser": [ + "id": "1", + "name": "John", + "email": "john@example.com", + "age": 18, + "role": "USER" + ] + ] + ) + ) + + context.triggerWatch() + #expect( + try await iterator.next() == GraphQLResult( + data: [ + "watchUser": [ + "id": "1", + "name": "John", + "email": "john@example.com", + "age": 18, + "role": "USER" + ] + ] + ) + ) + } } diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 85a03e1..d87ddf3 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -123,7 +123,7 @@ package struct SchemaGenerator { if let queryType = schema.queryType { output += """ - \(try generateQueryTypeDefinition(for: queryType).indent(1)) + \(try generateRootTypeDefinition(for: queryType, rootType: .query).indent(1)) """ } @@ -131,7 +131,15 @@ package struct SchemaGenerator { if let mutationType = schema.mutationType { output += """ - \(try generateMutationTypeDefinition(for: mutationType).indent(1)) + \(try generateRootTypeDefinition(for: mutationType, rootType: .mutation).indent(1)) + """ + } + + // Generate Subscription type if it exists + if let subscriptionType = schema.subscriptionType { + output += """ + + \(try generateRootTypeDefinition(for: subscriptionType, rootType: .subscription).indent(1)) """ } @@ -151,6 +159,13 @@ package struct SchemaGenerator { """ } + if schema.subscriptionType != nil { + output += """ + , + subscription: subscription + """ + } + output += """ ) @@ -524,55 +539,25 @@ package struct SchemaGenerator { return output } - private func generateQueryTypeDefinition(for type: GraphQLObjectType) throws -> String { - var output = """ - - let query = try GraphQLObjectType( - name: "Query", - """ - - if let description = type.description { - output += """ - - description: \"\"\" - \(description.indent(1, includeFirst: false)) - \"\"\", - """ + private func generateRootTypeDefinition(for type: GraphQLObjectType, rootType: RootType) throws -> String { + let variableName: String + let target: ResolverTarget + switch rootType { + case .query: + variableName = "query" + target = .query + case .mutation: + variableName = "mutation" + target = .mutation + case .subscription: + variableName = "subscription" + target = .subscription } - output += """ - - fields: [ - """ - - // Generate fields - let fields = try type.fields() - for (fieldName, field) in fields { - output += """ - - \(try generateFieldDefinition( - fieldName: fieldName, - field: field, - target: .query, - parentType: type - ).indent(2)) - """ - } - - output += """ - - ] - ) - """ - - return output - } - - private func generateMutationTypeDefinition(for type: GraphQLObjectType) throws -> String { var output = """ - let mutation = try GraphQLObjectType( - name: "Mutation", + let \(variableName) = try GraphQLObjectType( + name: "\(type.name)", """ if let description = type.description { @@ -597,7 +582,7 @@ package struct SchemaGenerator { \(try generateFieldDefinition( fieldName: fieldName, field: field, - target: .mutation, + target: target, parentType: type ).indent(2)) """ @@ -616,7 +601,7 @@ package struct SchemaGenerator { fieldName: String, field: GraphQLField, target: ResolverTarget, - parentType: GraphQLType + parentType: GraphQLNamedType ) throws -> String { var output = """ @@ -704,10 +689,23 @@ package struct SchemaGenerator { target: ResolverTarget, parentType: GraphQLType ) throws -> String { - var output = """ + var output = "" + + if target == .subscription { + output += """ + + resolve: { source, _, _, _ in + return source + }, + subscribe: { source, args, context, info in + """ + } else { + output += """ + + resolve: { source, args, context, info in + """ + } - resolve: { source, args, context, info in - """ // Build argument list var argsList: [String] = [] @@ -754,7 +752,8 @@ package struct SchemaGenerator { let targetName = switch target { case .parent: "parent" case .query: "Resolvers.Query" - case .mutation: "Resolvers.Mutation" + case .mutation: "Resolvers.Mutation" + case .subscription: "Resolvers.Subscription" } let functionName = nameGenerator.swiftMemberName(for: fieldName) output += """ @@ -796,9 +795,16 @@ package struct SchemaGenerator { throw GeneratorError.unsupportedType("Unknown type: \(type)") } + private enum RootType { + case query + case mutation + case subscription + } + private enum ResolverTarget { case parent case query case mutation + case subscription } } diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 2a730b8..1a0b3e9 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -12,6 +12,8 @@ package struct TypeGenerator { import Foundation import GraphQL + import GraphQLGeneratorRuntime + """ // Generate ResolversProtocol @@ -32,6 +34,12 @@ package struct TypeGenerator { associatedtype Mutation: MutationProtocol """ } + if schema.subscriptionType != nil { + output += """ + + associatedtype Subscription: SubscriptionProtocol + """ + } output += """ } @@ -138,7 +146,13 @@ package struct TypeGenerator { """ } - // TODO: Add subscription types + // Generate Mutation type + if let subscriptionType = schema.subscriptionType { + output += """ + + \(try generateRootTypeProtocol(for: subscriptionType)) + """ + } return output } @@ -387,7 +401,10 @@ package struct TypeGenerator { """ } - let returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + var returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + if type.name == "Subscription" { + returnType = "AnyAsyncSequence<\(returnType)>" + } var params: [String] = [] diff --git a/Sources/GraphQLGeneratorRuntime/AnyAsyncSequence.swift b/Sources/GraphQLGeneratorRuntime/AnyAsyncSequence.swift new file mode 100644 index 0000000..bef9c0f --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/AnyAsyncSequence.swift @@ -0,0 +1,39 @@ + +/// A type-erased AsyncSequence. This exists because we cannot qualify `AsyncSequence` opaque types with `Element` +/// constraints in our SubscriptionProtocol. +public struct AnyAsyncSequence: AsyncSequence, Sendable { + public typealias Element = Element + public typealias AsyncIterator = AnyAsyncIterator + + private let makeAsyncIteratorClosure: @Sendable () -> AsyncIterator + + public init(_ sequence: T) where T.Element == Element, T: Sendable { + makeAsyncIteratorClosure = { + AnyAsyncIterator(sequence.makeAsyncIterator()) + } + } + + public func makeAsyncIterator() -> AsyncIterator { + AnyAsyncIterator(makeAsyncIteratorClosure()) + } + + public struct AnyAsyncIterator: AsyncIteratorProtocol, @unchecked Sendable { + private let nextClosure: () async throws -> Element? + + public init(_ iterator: T) where T.Element == Element { + var iterator = iterator + nextClosure = { try await iterator.next() } + } + + public func next() async throws -> Element? { + try await nextClosure() + } + } +} + +public extension AsyncSequence where Self: Sendable, Element: Sendable { + /// Create a type erased version of this sequence + func any() -> AnyAsyncSequence { + AnyAsyncSequence(self) + } +} diff --git a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift index b032afd..7993de7 100644 --- a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -127,7 +127,7 @@ struct TypeGeneratorTests { ) } - @Test func rootType() async throws { + @Test func queryType() async throws { let bar = try GraphQLObjectType( name: "Bar", description: "bar", @@ -166,4 +166,33 @@ struct TypeGeneratorTests { """ ) } + + + @Test func subscriptionType() async throws { + let subscription = try GraphQLObjectType( + name: "Subscription", + fields: [ + "watchThis": .init( + type: GraphQLString, + description: "foo", + args: [ + "id": .init( + type: GraphQLString, + ) + ] + ) + ] + ) + let actual = try TypeGenerator().generateRootTypeProtocol(for: subscription) + #expect( + actual == """ + + public protocol SubscriptionProtocol: Sendable { + /// foo + static func watchThis(id: String?, context: Context, info: GraphQLResolveInfo) async throws -> AnyAsyncSequence + + } + """ + ) + } } From 5268a81a7a61bb3cad0337ffbf1f2d92405e9fdf Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:28:32 -0700 Subject: [PATCH 33/38] docs: Documentation updates --- README.md | 21 +- .../Generator/TypeGenerator.swift | 1 - plan.md | 380 ------------------ 3 files changed, 9 insertions(+), 393 deletions(-) delete mode 100644 plan.md diff --git a/README.md b/README.md index 1fec25c..c7f5d5d 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,17 @@ # GraphQL Generator for Swift -A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema). - -This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while allowing you full control over your business logic. +A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema) and [Swift's OpenAPI Generator](https://github.com/apple/swift-openapi-generator). ## Features -- **Build-time code generation**: Code is generated at build time and never needs to be committed +- **Build-time code generation**: Code is generated at build time and doesn't need to be committed - **Type-safe**: Leverages Swift's type system for compile-time safety -- **Framework-agnostic**: Generated code works with any Swift server framework (Vapor, Hummingbird, etc.) -- **Modern Swift**: Uses async/await for all resolver functions -- **Minimal boilerplate**: Generates only ceremony code - you write the business logic +- **Minimal boilerplate**: Generates all GraphQL definition code - you write the business logic ## Installation -Add the package to your `Package.swift`: +Add the package to your `Package.swift`. Be sure to add the `GraphQLGeneratorRuntime` dependency to your package, and add the `GraphQLGeneratorPlugin` to the plugins section: ```swift dependencies: [ @@ -78,10 +74,11 @@ Create a resolvers struct with the required typealiases: struct Resolvers: ResolversProtocol { typealias Query = ExamplePackage.Query typealias Mutation = ExamplePackage.Mutation + typealias Subscription = ExamplePackage.Subscription } ``` -As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol: +As you build the `Query`, `Mutation`, and `Subscription` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol: ```swift struct Query: QueryProtocol { @@ -218,9 +215,9 @@ public struct EmailAddress: Scalar { ## Development Roadmap 1. Directives: Directives are currently not supported -2. Subscription: Subscription definitions are currently ignored -3. Improved testing: Generator tests should cover much more of the functionality -4. Additional examples: Ideally large ones that cover significant GraphQL features +2. Improved testing: Generator tests should cover much more of the functionality +3. Additional examples: Ideally large ones that cover significant GraphQL features +4. Enhanced configuration: There should be configuration options for the build plugin itself 5. Executable Schema: To work around the immutability of some Schema components, we generate Swift code to fully recreate the defined schema. Instead, we could just add resolver logic to the schema parsed from the `.graphql` file SDL. ## Contributing diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 1a0b3e9..738abf8 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -17,7 +17,6 @@ package struct TypeGenerator { """ // Generate ResolversProtocol - output += """ public protocol ResolversProtocol: Sendable { diff --git a/plan.md b/plan.md deleted file mode 100644 index 8bcb343..0000000 --- a/plan.md +++ /dev/null @@ -1,380 +0,0 @@ -# GraphQL Generator for Swift - Implementation Plan - -## Project Overview - -Create a Swift package plugin that generates server-side GraphQL API code from GraphQL schema files (.graphql), similar to how swift-openapi-generator works with OpenAPI specs. The generator will produce Swift code using the GraphQL Swift package for implementing GraphQL servers. - -## Architecture - -### Core Components - -1. **Build Plugin** - Swift Package Manager build tool plugin that discovers `.graphql` files and invokes the generator -2. **Generator Executable** - CLI tool that parses GraphQL schemas and generates Swift code -3. **Generator Core** - Shared logic for parsing and code generation -4. **Runtime Library** (optional) - Helper types and utilities for generated code - -### Package Structure - -``` -graphql-generator/ -β”œβ”€β”€ Package.swift -β”œβ”€β”€ Plugins/ -β”‚ └── GraphQLGeneratorPlugin/ # Build plugin -β”‚ └── plugin.swift -β”œβ”€β”€ Sources/ -β”‚ β”œβ”€β”€ GraphQLGenerator/ # CLI executable -β”‚ β”‚ └── main.swift -β”‚ β”œβ”€β”€ GraphQLGeneratorCore/ # Shared logic -β”‚ β”‚ β”œβ”€β”€ Parser/ -β”‚ β”‚ β”‚ β”œβ”€β”€ GraphQLSchemaParser.swift -β”‚ β”‚ β”‚ └── SchemaModels.swift -β”‚ β”‚ β”œβ”€β”€ Generator/ -β”‚ β”‚ β”‚ β”œβ”€β”€ SwiftCodeEmitter.swift -β”‚ β”‚ β”‚ β”œβ”€β”€ TypeGenerator.swift -β”‚ β”‚ β”‚ β”œβ”€β”€ ResolverGenerator.swift -β”‚ β”‚ β”‚ └── SchemaGenerator.swift -β”‚ β”‚ └── Config/ -β”‚ β”‚ └── GeneratorConfig.swift -β”‚ └── GraphQLGeneratorRuntime/ # Runtime support (optional) -β”‚ β”œβ”€β”€ ResolverContext.swift -β”‚ └── Helpers.swift -β”œβ”€β”€ Tests/ -β”‚ └── GraphQLGeneratorTests/ -β”‚ β”œβ”€β”€ ParserTests/ -β”‚ β”œβ”€β”€ GeneratorTests/ -β”‚ └── IntegrationTests/ -└── Examples/ - β”œβ”€β”€ HelloWorldServer/ - └── AdvancedSchema/ -``` - -### Code Generation Strategy - -#### Input: GraphQL Schema -```graphql -type Query { - user(id: ID!): User - posts(limit: Int = 10): [Post!]! -} - -type User { - id: ID! - name: String! - email: String! -} - -type Post { - id: ID! - title: String! - author: User! -} -``` - -#### Generated Output - -**Types.swift** - Swift structs matching GraphQL types -```swift -struct User: Codable { - let id: String - let name: String - let email: String -} - -struct Post: Codable { - let id: String - let title: String - let authorId: String -} -``` - -**Resolvers.swift** - Protocol for implementing business logic -```swift -protocol GraphQLResolvers { - func user(id: String, context: ResolverContext) async throws -> User? - func posts(limit: Int, context: ResolverContext) async throws -> [Post] - func postAuthor(post: Post, context: ResolverContext) async throws -> User -} -``` - -**Schema.swift** - GraphQL schema builder using GraphQL Swift -```swift -func buildGraphQLSchema(resolvers: GraphQLResolvers) throws -> GraphQLSchema { - // Generated GraphQLObjectType definitions with resolver callbacks -} -``` - -## Implementation Phases - -### Phase 1: Foundation βœ“ (Current Phase) - -**Goal**: Set up the basic package structure and build plugin - -- [x] Set up Package.swift with proper dependencies -- [x] Create build plugin structure -- [x] Implement .graphql file discovery -- [x] Create basic schema parser foundation -- [x] Set up test infrastructure - -**Deliverables**: -- Working build plugin that discovers .graphql files -- Basic GraphQL SDL tokenizer and parser -- Project structure ready for code generation - -### Phase 2: Schema Parsing (Weeks 1-2) - -**Goal**: Complete GraphQL schema parsing with full SDL support - -Tasks: -1. Implement complete SDL parser - - Object types, fields, arguments - - Scalar types (built-in and custom) - - Enum types - - Interface types - - Union types - - Input object types - - Directives -2. Create AST/IR models for schema representation -3. Add validation and error reporting -4. Write comprehensive parser tests - -**Deliverables**: -- Full-featured GraphQL SDL parser -- Schema representation models -- Parser test suite - -### Phase 3: Type Generation (Weeks 3-4) - -**Goal**: Generate Swift types from GraphQL schema - -Tasks: -1. Build Swift code emitter infrastructure -2. Generate Swift structs from GraphQL object types - - Handle field types and nullability - - Handle lists/arrays - - Add Codable conformance -3. Generate Swift enums from GraphQL enums -4. Handle custom scalar mappings (ID -> String, etc.) -5. Generate input types for mutations -6. Add code formatting and documentation comments - -**Deliverables**: -- Types.swift generation -- Type mapping configuration -- Type generation tests - -### Phase 4: Resolver Protocol Generation (Weeks 5-6) - -**Goal**: Generate resolver protocols with proper signatures - -Tasks: -1. Generate protocol with resolver methods - - Query field resolvers - - Nested field resolvers (e.g., Post.author) - - Mutation resolvers -2. Handle async/await patterns -3. Include proper argument types -4. Add default argument values -5. Generate resolver context protocol - -**Deliverables**: -- Resolvers.swift generation -- ResolverContext protocol -- Resolver generation tests - -### Phase 5: Schema Builder Generation (Weeks 7-8) - -**Goal**: Generate GraphQL Swift schema construction code - -Tasks: -1. Generate GraphQLObjectType definitions -2. Wire up resolver callbacks to protocol methods -3. Handle field arguments and return types -4. Support interfaces and unions -5. Add directive handling -6. Generate complete schema builder function - -**Deliverables**: -- Schema.swift generation -- Working end-to-end generation -- Integration tests - -### Phase 6: Advanced Features (Weeks 9-10) - -**Goal**: Add mutations, subscriptions, and advanced patterns - -Tasks: -1. Mutation support - - Input types - - Mutation resolvers -2. Subscription support (if needed) - - Async sequences - - Subscription resolvers -3. Custom scalar configuration -4. Directive support for code generation -5. Configuration file support (graphql-generator-config.yaml) - -**Deliverables**: -- Mutation/subscription support -- Config file parsing -- Advanced feature tests - -### Phase 7: Runtime Library & Ergonomics (Week 11) - -**Goal**: Create runtime helpers for better developer experience - -Tasks: -1. ResolverContext protocol and implementations -2. Error handling utilities -3. Common patterns (pagination, connections) -4. Authentication/authorization helpers -5. Testing utilities for resolvers - -**Deliverables**: -- GraphQLGeneratorRuntime module -- Helper utilities -- Documentation - -### Phase 8: Examples & Documentation (Week 12) - -**Goal**: Provide examples and comprehensive documentation - -Tasks: -1. Hello world server example -2. CRUD API example -3. Integration with Vapor example -4. Integration with Hummingbird example -5. Write README with quickstart -6. API documentation -7. Migration guide from manual GraphQL Swift usage - -**Deliverables**: -- 4+ working examples -- Complete documentation -- Tutorial content - -## Configuration - -**graphql-generator-config.yaml** (optional) -```yaml -# What to generate -generate: - - types # Swift type definitions - - resolvers # Resolver protocol - - schema # Schema builder - -# Custom scalar mappings -scalarMappings: - DateTime: Foundation.Date - UUID: Foundation.UUID - URL: Foundation.URL - -# Additional options -options: - accessControl: public - includeDocumentation: true -``` - -## Developer Usage - -### Setup - -**Package.swift**: -```swift -let package = Package( - name: "MyGraphQLAPI", - dependencies: [ - .package(url: "https://github.com/GraphQLSwift/GraphQL", from: "2.0.0"), - .package(url: "https://github.com/YourOrg/graphql-generator", from: "1.0.0") - ], - targets: [ - .target( - name: "MyGraphQLAPI", - dependencies: [ - .product(name: "GraphQL", package: "GraphQL"), - .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator") - ], - plugins: [ - .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") - ] - ) - ] -) -``` - -### Workflow - -1. Add schema file: `Sources/MyGraphQLAPI/schema.graphql` -2. (Optional) Add config: `Sources/MyGraphQLAPI/graphql-generator-config.yaml` -3. Build project β†’ code auto-generates into build directory -4. Implement resolver protocol with business logic -5. Create schema and integrate with server framework - -### Example Implementation - -```swift -import GraphQL -import GraphQLGeneratorRuntime - -// Implement generated protocol -struct MyResolvers: GraphQLResolvers { - func user(id: String, context: ResolverContext) async throws -> User? { - // Business logic here - return await database.findUser(id: id) - } - - func posts(limit: Int, context: ResolverContext) async throws -> [Post] { - return await database.fetchPosts(limit: limit) - } - - func postAuthor(post: Post, context: ResolverContext) async throws -> User { - return await database.findUser(id: post.authorId) - } -} - -// Build schema from generated function -let resolvers = MyResolvers() -let schema = try buildGraphQLSchema(resolvers: resolvers) - -// Execute queries -let result = try await graphql(schema: schema, request: "{ user(id: \"1\") { name } }") -``` - -## Key Design Decisions - -1. **Follow swift-openapi-generator patterns**: Build plugin architecture, build-time generation -2. **Type-safe by default**: Leverage Swift's type system for compile-time safety -3. **Framework-agnostic**: Generated code works with any Swift server framework (Vapor, Hummingbird, etc.) -4. **Async/await native**: All resolver functions use modern Swift concurrency -5. **Minimal generated code**: Generate only ceremony code, developers write business logic -6. **Extensible**: Allow custom scalar mappings and directive handling -7. **Zero runtime overhead**: Generated code is straightforward, no reflection or dynamic dispatch - -## Dependencies - -### Required -- **GraphQL Swift** (`https://github.com/GraphQLSwift/GraphQL`): Runtime dependency for schema execution -- **Swift Argument Parser**: For CLI tool argument parsing - -### Optional -- **Swift Syntax**: For more robust Swift code generation (consider for future) - -## Success Criteria - -1. Plugin successfully discovers and processes .graphql files -2. Parses all standard GraphQL SDL constructs -3. Generates valid, compilable Swift code -4. Generated code integrates cleanly with GraphQL Swift -5. Developer experience matches swift-openapi-generator quality -6. Comprehensive test coverage (>80%) -7. Working examples for common use cases -8. Complete documentation - -## Future Enhancements - -- Xcode previews for generated code -- Watch mode for development -- GraphQL client generation (queries/mutations) -- Federation support -- Performance optimizations (caching, incremental generation) -- IDE integration (syntax highlighting, autocomplete) -- Migration tools from other GraphQL Swift patterns From 6769f2d7885d2a8688528e10e5e68a63ce64aec8 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:28:38 -0700 Subject: [PATCH 34/38] feat: Adds MIT license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ccf09b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Jay Herron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From ad54a71feab10340b3c4aa8f766e711ba81970f7 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:31:50 -0700 Subject: [PATCH 35/38] feat: Adds CI --- .github/workflows/test.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..b8ad13a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,16 @@ +name: test +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: +jobs: + lint: + uses: graphqlswift/ci/.github/workflows/lint.yaml@main + test: + uses: graphqlswift/ci/.github/workflows/test.yaml@main + test-example: + uses: graphqlswift/ci/.github/workflows/test.yaml@main + with: + package_path: "Examples/HelloWorldServer" From 3d781519ab597bd9d4552861e4eaaa042f19b07f Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:37:59 -0700 Subject: [PATCH 36/38] refactor: Formatting --- Examples/HelloWorldServer/Package.swift | 8 +- .../Sources/HelloWorldServer/Resolvers.swift | 56 +++++--- .../HelloWorldServerTests.swift | 26 ++-- Package.swift | 2 +- Plugins/GraphQLGeneratorPlugin.swift | 86 ++++++------- Sources/GraphQLGenerator/main.swift | 2 +- .../Generator/CodeGenerator.swift | 2 +- .../Generator/SchemaGenerator.swift | 120 +++++++++--------- .../Generator/TypeGenerator.swift | 41 +++--- .../Utilities/SafeNameGenerator.swift | 12 +- .../Utilities/indent.swift | 4 +- .../Utilities/swiftTypeName.swift | 10 +- .../ResolverHelpers.swift | 2 +- Tests/GraphQLGeneratorTests/IndentTests.swift | 20 +-- .../SchemaGeneratorTests.swift | 10 +- .../TypeGeneratorTests.swift | 33 +++-- 16 files changed, 225 insertions(+), 209 deletions(-) diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift index 630aca4..6e3617a 100644 --- a/Examples/HelloWorldServer/Package.swift +++ b/Examples/HelloWorldServer/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.0 import PackageDescription let package = Package( name: "HelloWorldServer", platforms: [ - .macOS(.v13) + .macOS(.v13), ], dependencies: [ .package(name: "graphql-generator", path: "../.."), @@ -19,7 +19,7 @@ let package = Package( .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), ], plugins: [ - .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator") + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator"), ] ), .testTarget( @@ -28,6 +28,6 @@ let package = Package( "HelloWorldServer", .product(name: "GraphQL", package: "GraphQL"), ] - ) + ), ] ) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift index 562760c..9a4c3f5 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -21,6 +21,7 @@ public class Context: @unchecked Sendable { onTriggerWatch() } } + // Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol public struct EmailAddress: Scalar { let email: String @@ -31,16 +32,18 @@ public struct EmailAddress: Scalar { // Codability conformance. Required for usage in InputObject public init(from decoder: any Decoder) throws { - self.email = try decoder.singleValueContainer().decode(String.self) + email = try decoder.singleValueContainer().decode(String.self) } + public func encode(to encoder: any Encoder) throws { - try self.email.encode(to: encoder) + try email.encode(to: encoder) } // Scalar conformance. Not necessary, but default methods are very inefficient. public static func serialize(this: Self) throws -> Map { return .string(this.email) } + public static func parseValue(map: Map) throws -> Map { switch map { case .string: @@ -49,6 +52,7 @@ public struct EmailAddress: Scalar { throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)") } } + public static func parseLiteral(value: any Value) throws -> Map { guard let ast = value as? StringValue else { throw GraphQLError( @@ -66,6 +70,7 @@ struct Resolvers: ResolversProtocol { typealias Mutation = HelloWorldServer.Mutation typealias Subscription = HelloWorldServer.Subscription } + struct User: UserProtocol { // User can choose structure let id: String @@ -75,31 +80,37 @@ struct User: UserProtocol { let role: Role? // Required implementations - func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func id(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + + func name(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> String { return name } - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { + + func email(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { return EmailAddress(email: email) } - func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? { + + func age(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> Int? { return age } - func role(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Role? { + + func role(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> Role? { return role } } + struct Contact: ContactProtocol { // User can choose structure let email: String // Required implementations - func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { + func email(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { return EmailAddress(email: email) } } + struct Post: PostProtocol { // User can choose structure let id: String @@ -108,42 +119,49 @@ struct Post: PostProtocol { let authorId: String // Required implementations - func id(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + func id(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> String { return id } - func title(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + + func title(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> String { return title } - func content(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String { + + func content(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> String { return content } - func author(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> any UserProtocol { + + func author(context: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> any UserProtocol { return context.users[authorId]! } } struct Query: QueryProtocol { // Required implementations - static func user(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> (any UserProtocol)? { + static func user(id: String, context: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> (any UserProtocol)? { return context.users[id] } - static func users(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [any UserProtocol] { + + static func users(context: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> [any UserProtocol] { return context.users.values.map { $0 as any UserProtocol } } - static func post(id: String, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> (any PostProtocol)? { + + static func post(id: String, context: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> (any PostProtocol)? { return context.posts[id] } - static func posts(limit: Int?, context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> [any PostProtocol] { + + static func posts(limit _: Int?, context: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> [any PostProtocol] { return context.posts.values.map { $0 as any PostProtocol } } - static func userOrPost(id: String, context: Context, info: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { + + static func userOrPost(id: String, context: Context, info _: GraphQLResolveInfo) async throws -> (any UserOrPostUnion)? { return context.users[id] ?? context.posts[id] } } struct Mutation: MutationProtocol { // Required implementations - static func upsertUser(userInfo: UserInfoInput, context: Context, info: GraphQLResolveInfo) -> any UserProtocol { + static func upsertUser(userInfo: UserInfoInput, context: Context, info _: GraphQLResolveInfo) -> any UserProtocol { let user = User( id: userInfo.id, name: userInfo.name, @@ -158,7 +176,7 @@ struct Mutation: MutationProtocol { struct Subscription: SubscriptionProtocol { // Required implementations - static func watchUser(id: String, context: Context, info: GraphQLResolveInfo) async throws -> AnyAsyncSequence<(any UserProtocol)?> { + static func watchUser(id: String, context: Context, info _: GraphQLResolveInfo) async throws -> AnyAsyncSequence<(any UserProtocol)?> { return AsyncStream<(any UserProtocol)?> { continuation in context.onTriggerWatch = { [weak context] in continuation.yield(context?.users[id]) diff --git a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift index 32d49eb..240db52 100644 --- a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -8,8 +8,8 @@ struct HelloWorldServerTests { @Test func query() async throws { let schema = try buildGraphQLSchema(resolvers: Resolvers.self) let context = Context( - users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], - posts: ["1" : .init(id: "1", title: "Foo", content: "bar", authorId: "1")] + users: ["1": .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + posts: ["1": .init(id: "1", title: "Foo", content: "bar", authorId: "1")] ) let actual = try await graphql( schema: schema, @@ -43,10 +43,10 @@ struct HelloWorldServerTests { "name": "John", "email": "john@example.com", "age": 18, - "role": "USER" - ] - ] - ] + "role": "USER", + ], + ], + ], ] ) #expect(actual == expected) @@ -80,8 +80,8 @@ struct HelloWorldServerTests { "name": "Jane", "email": "jane@example.com", "age": nil, - "role": "USER" - ] + "role": "USER", + ], ] ) #expect(actual == expected) @@ -90,7 +90,7 @@ struct HelloWorldServerTests { @Test func subscription() async throws { let schema = try buildGraphQLSchema(resolvers: Resolvers.self) let context = Context( - users: ["1" : .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + users: ["1": .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], posts: [:] ) let stream = try await graphqlSubscribe( @@ -120,8 +120,8 @@ struct HelloWorldServerTests { "name": "John", "email": "john@example.com", "age": 18, - "role": "USER" - ] + "role": "USER", + ], ] ) ) @@ -135,8 +135,8 @@ struct HelloWorldServerTests { "name": "John", "email": "john@example.com", "age": 18, - "role": "USER" - ] + "role": "USER", + ], ] ) ) diff --git a/Package.swift b/Package.swift index 3104149..07cac08 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .macOS(.v13), .iOS(.v16), .tvOS(.v16), - .watchOS(.v9) + .watchOS(.v9), ], products: [ .plugin( diff --git a/Plugins/GraphQLGeneratorPlugin.swift b/Plugins/GraphQLGeneratorPlugin.swift index 5a1ed52..16fafe2 100644 --- a/Plugins/GraphQLGeneratorPlugin.swift +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -1,5 +1,5 @@ -import PackagePlugin import Foundation +import PackagePlugin @main struct GraphQLGeneratorPlugin: BuildToolPlugin { @@ -28,11 +28,11 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { let outputFiles = [ outputDirectory.appendingPathComponent("Types.swift"), - outputDirectory.appendingPathComponent("Schema.swift") + outputDirectory.appendingPathComponent("Schema.swift"), ] let arguments = schemaInputs.flatMap { ["\($0.path)"] } + [ - "--output-directory", outputDirectory.path + "--output-directory", outputDirectory.path, ] return [ @@ -42,52 +42,52 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { arguments: arguments, inputFiles: schemaInputs, outputFiles: outputFiles - ) + ), ] } } #if canImport(XcodeProjectPlugin) -import XcodeProjectPlugin - -extension GraphQLGeneratorPlugin: XcodeBuildToolPlugin { - /// Entry point for creating build commands for targets in Xcode projects. - 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" + import XcodeProjectPlugin + + extension GraphQLGeneratorPlugin: XcodeBuildToolPlugin { + /// Entry point for creating build commands for targets in Xcode projects. + 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" + } + + // If no schema files found, return early + guard !schemaFiles.isEmpty else { return [] } + + // Find the generator tool + let generatorTool = try context.tool(named: "GraphQLGenerator") + + // 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, + ] + + return [ + .buildCommand( + displayName: "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", + executable: generatorTool.url, + arguments: arguments, + inputFiles: schemaInputs, + outputFiles: outputFiles + ), + ] } - - // If no schema files found, return early - guard !schemaFiles.isEmpty else { return [] } - - // Find the generator tool - let generatorTool = try context.tool(named: "GraphQLGenerator") - - // 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 - ] - - return [ - .buildCommand( - displayName: "Generating GraphQL Swift code from \(schemaFiles.count) schema file(s)", - executable: generatorTool.url, - arguments: arguments, - inputFiles: schemaInputs, - outputFiles: outputFiles - ) - ] } -} #endif diff --git a/Sources/GraphQLGenerator/main.swift b/Sources/GraphQLGenerator/main.swift index 0febe8e..8ad28ef 100644 --- a/Sources/GraphQLGenerator/main.swift +++ b/Sources/GraphQLGenerator/main.swift @@ -1,5 +1,5 @@ -import Foundation import ArgumentParser +import Foundation import GraphQLGeneratorCore @main diff --git a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift index 860421b..50a85d2 100644 --- a/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -3,7 +3,7 @@ import GraphQL /// Main code generator that orchestrates generation of all Swift files package struct CodeGenerator { - package init() { } + package init() {} /// Generate all Swift files from the schema /// Returns a dictionary of filename -> file content diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index d87ddf3..58555d7 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -34,18 +34,18 @@ package struct SchemaGenerator { !["Int", "Float", "String", "Boolean", "ID"].contains($0.name) } for scalarType in scalarTypes { - output += """ + output += try""" - \(try generateScalarTypeDefinition(for: scalarType).indent(1)) + \(generateScalarTypeDefinition(for: scalarType).indent(1)) """ } // Generate enum type definitions let enumTypes = types.compactMap { $0 as? GraphQLEnumType } for enumType in enumTypes { - output += """ + output += try""" - \(try generateEnumTypeDefinition(for: enumType).indent(1)) + \(generateEnumTypeDefinition(for: enumType).indent(1)) """ } @@ -54,9 +54,9 @@ package struct SchemaGenerator { $0 as? GraphQLInterfaceType } for interfaceType in interfaceTypes { - output += """ + output += try""" - \(try generateInterfaceTypeDefinition(for: interfaceType, resolvers: "resolvers").indent(1)) + \(generateInterfaceTypeDefinition(for: interfaceType, resolvers: "resolvers").indent(1)) """ } @@ -65,9 +65,9 @@ package struct SchemaGenerator { $0 as? GraphQLInputObjectType } for inputType in inputTypes { - output += """ + output += try""" - \(try generateInputTypeDefinition(for: inputType).indent(1)) + \(generateInputTypeDefinition(for: inputType).indent(1)) """ } @@ -79,13 +79,13 @@ package struct SchemaGenerator { }.filter { // Skip root operation types $0.name != "Query" && - $0.name != "Mutation" && - $0.name != "Subscription" + $0.name != "Mutation" && + $0.name != "Subscription" } for objectType in objectTypes { - output += """ + output += try""" - \(try generateObjectTypeDefinition(for: objectType).indent(1)) + \(generateObjectTypeDefinition(for: objectType).indent(1)) """ } // Generate type definitions for all union object types @@ -93,53 +93,53 @@ package struct SchemaGenerator { $0 as? GraphQLUnionType } for unionType in unionTypes { - output += """ + output += try""" - \(try generateUnionTypeDefinition(for: unionType).indent(1)) + \(generateUnionTypeDefinition(for: unionType).indent(1)) """ } // Generate field and interface definitions for non-root types for inputType in inputTypes { - output += """ + output += try""" - \(try generateInputTypeFieldDefinition(for: inputType).indent(1)) + \(generateInputTypeFieldDefinition(for: inputType).indent(1)) """ } for interfaceType in interfaceTypes { - output += """ + output += try""" - \(try generateInterfaceTypeFieldDefinition(for: interfaceType).indent(1)) + \(generateInterfaceTypeFieldDefinition(for: interfaceType).indent(1)) """ } for objectType in objectTypes { - output += """ + output += try""" - \(try generateObjectTypeFieldDefinition(for: objectType, resolvers: "parent").indent(1)) + \(generateObjectTypeFieldDefinition(for: objectType, resolvers: "parent").indent(1)) """ } // Generate Query type if let queryType = schema.queryType { - output += """ + output += try""" - \(try generateRootTypeDefinition(for: queryType, rootType: .query).indent(1)) + \(generateRootTypeDefinition(for: queryType, rootType: .query).indent(1)) """ } // Generate Mutation type if it exists if let mutationType = schema.mutationType { - output += """ + output += try""" - \(try generateRootTypeDefinition(for: mutationType, rootType: .mutation).indent(1)) + \(generateRootTypeDefinition(for: mutationType, rootType: .mutation).indent(1)) """ } // Generate Subscription type if it exists if let subscriptionType = schema.subscriptionType { - output += """ + output += try""" - \(try generateRootTypeDefinition(for: subscriptionType, rootType: .subscription).indent(1)) + \(generateRootTypeDefinition(for: subscriptionType, rootType: .subscription).indent(1)) """ } @@ -294,10 +294,10 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - output += """ + output += try """ "\(fieldName)": InputObjectField( - type: \(try graphQLTypeReference(for: field.type)), + type: \(graphQLTypeReference(for: field.type)), """ if let defaultValue = field.defaultValue { @@ -339,7 +339,7 @@ package struct SchemaGenerator { return output } - private func generateInterfaceTypeDefinition(for type: GraphQLInterfaceType, resolvers: String) throws -> String { + private func generateInterfaceTypeDefinition(for type: GraphQLInterfaceType, resolvers _: String) throws -> String { let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ @@ -377,10 +377,9 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { + output += try""" - output += """ - - \(try generateFieldDefinition( + \(generateFieldDefinition( fieldName: fieldName, field: field, target: .parent, @@ -447,7 +446,7 @@ package struct SchemaGenerator { return output } - private func generateObjectTypeFieldDefinition(for type: GraphQLObjectType, resolvers: String) throws -> String { + private func generateObjectTypeFieldDefinition(for type: GraphQLObjectType, resolvers _: String) throws -> String { let varName = nameGenerator.swiftMemberName(for: type.name) var output = """ @@ -458,9 +457,9 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - output += """ + output += try""" - \(try generateFieldDefinition( + \(generateFieldDefinition( fieldName: fieldName, field: field, target: .parent, @@ -523,9 +522,9 @@ package struct SchemaGenerator { types: [ """ for type in try type.types() { - output += """ + output += try""" - \(try graphQLTypeReference(for: type)), + \(graphQLTypeReference(for: type)), """ } @@ -543,15 +542,15 @@ package struct SchemaGenerator { let variableName: String let target: ResolverTarget switch rootType { - case .query: - variableName = "query" - target = .query - case .mutation: - variableName = "mutation" - target = .mutation - case .subscription: - variableName = "subscription" - target = .subscription + case .query: + variableName = "query" + target = .query + case .mutation: + variableName = "mutation" + target = .mutation + case .subscription: + variableName = "subscription" + target = .subscription } var output = """ @@ -577,9 +576,9 @@ package struct SchemaGenerator { // Generate fields let fields = try type.fields() for (fieldName, field) in fields { - output += """ + output += try""" - \(try generateFieldDefinition( + \(generateFieldDefinition( fieldName: fieldName, field: field, target: target, @@ -603,10 +602,10 @@ package struct SchemaGenerator { target: ResolverTarget, parentType: GraphQLNamedType ) throws -> String { - var output = """ + var output = try """ "\(fieldName)": GraphQLField( - type: \(try graphQLTypeReference(for: field.type)), + type: \(graphQLTypeReference(for: field.type)), """ if let description = field.description { @@ -635,10 +634,10 @@ package struct SchemaGenerator { """ for (argName, arg) in field.args { - output += """ + output += try """ "\(argName)": GraphQLArgument( - type: \(try graphQLTypeReference(for: arg.type)) + type: \(graphQLTypeReference(for: arg.type)) """ if let description = arg.description { @@ -669,9 +668,9 @@ package struct SchemaGenerator { """ } - output += """ + output += try""" - \(try generateResolverCallback( + \(generateResolverCallback( fieldName: fieldName, field: field, target: target, @@ -706,7 +705,6 @@ package struct SchemaGenerator { """ } - // Build argument list var argsList: [String] = [] @@ -750,10 +748,10 @@ package struct SchemaGenerator { // Call the resolver let targetName = switch target { - case .parent: "parent" - case .query: "Resolvers.Query" - case .mutation: "Resolvers.Mutation" - case .subscription: "Resolvers.Subscription" + case .parent: "parent" + case .query: "Resolvers.Query" + case .mutation: "Resolvers.Mutation" + case .subscription: "Resolvers.Subscription" } let functionName = nameGenerator.swiftMemberName(for: fieldName) output += """ @@ -768,11 +766,11 @@ package struct SchemaGenerator { /// Generate GraphQL type reference string (e.g., "GraphQLString", "GraphQLNonNull(GraphQLString)") private func graphQLTypeReference(for type: GraphQLType) throws -> String { if let nonNull = type as? GraphQLNonNull { - return "GraphQLNonNull(\(try graphQLTypeReference(for: nonNull.ofType)))" + return try "GraphQLNonNull(\(graphQLTypeReference(for: nonNull.ofType)))" } if let list = type as? GraphQLList { - return "GraphQLList(\(try graphQLTypeReference(for: list.ofType)))" + return try "GraphQLList(\(graphQLTypeReference(for: list.ofType)))" } if let namedType = type as? GraphQLNamedType { diff --git a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift index 738abf8..e56f55a 100644 --- a/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -54,9 +54,9 @@ package struct TypeGenerator { $0 as? GraphQLEnumType } for type in enumTypes { - output += """ + output += try""" - \(try generateEnum(for: type)) + \(generateEnum(for: type)) """ } @@ -65,9 +65,9 @@ package struct TypeGenerator { $0 as? GraphQLInputObjectType } for type in inputTypes { - output += """ + output += try""" - \(try generateInputStruct(for: type)) + \(generateInputStruct(for: type)) """ } @@ -107,9 +107,9 @@ package struct TypeGenerator { $0 as? GraphQLInterfaceType } for type in interfaceTypes { - output += """ + output += try""" - \(try generateInterfaceProtocol(for: type)) + \(generateInterfaceProtocol(for: type)) """ } @@ -119,37 +119,37 @@ package struct TypeGenerator { }.filter { // Skip root operation types $0.name != "Query" && - $0.name != "Mutation" && - $0.name != "Subscription" + $0.name != "Mutation" && + $0.name != "Subscription" } for type in objectTypes { - output += """ + output += try""" - \(try generateTypeProtocol(for: type, unionTypeMap: unionTypeMap)) + \(generateTypeProtocol(for: type, unionTypeMap: unionTypeMap)) """ } // Generate Query type if let queryType = schema.queryType { - output += """ + output += try""" - \(try generateRootTypeProtocol(for: queryType)) + \(generateRootTypeProtocol(for: queryType)) """ } // Generate Mutation type if let mutationType = schema.mutationType { - output += """ + output += try""" - \(try generateRootTypeProtocol(for: mutationType)) + \(generateRootTypeProtocol(for: mutationType)) """ } // Generate Mutation type if let subscriptionType = schema.subscriptionType { - output += """ + output += try""" - \(try generateRootTypeProtocol(for: subscriptionType)) + \(generateRootTypeProtocol(for: subscriptionType)) """ } @@ -197,7 +197,6 @@ package struct TypeGenerator { return output } - func generateInputStruct(for type: GraphQLInputObjectType) throws -> String { var output = "" @@ -251,7 +250,7 @@ package struct TypeGenerator { } let interfaces = try type.interfaces().map { - try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator) + ", " }.joined(separator: "") let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) @@ -315,11 +314,11 @@ package struct TypeGenerator { } let unions = try unionTypeMap[type.name]?.map { - try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator) + ", " }.joined(separator: "") ?? "" let interfaces = try type.interfaces().map { - try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator)+", " + try swiftTypeDeclaration(for: $0, nameGenerator: nameGenerator) + ", " }.joined(separator: "") let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) @@ -442,7 +441,7 @@ package enum GeneratorError: Error, CustomStringConvertible { package var description: String { switch self { - case .unsupportedType(let message): + case let .unsupportedType(message): return "Unsupported type: \(message)" } } diff --git a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift index 3750a5c..2563016 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -17,7 +17,6 @@ import Foundation /// Computes a string sanitized to be usable as a Swift identifier in various contexts. protocol SafeNameGenerator { - /// Returns a string sanitized to be usable as a Swift type name in a general context. /// - Parameter documentedName: The input unsanitized string from the OpenAPI document. /// - Returns: The sanitized string. @@ -68,11 +67,15 @@ struct DefensiveSafeNameGenerator: SafeNameGenerator { } else { sanitizedScalars.append("_") if let entityName = Self.specialCharsMap[scalar] { - for char in entityName.unicodeScalars { sanitizedScalars.append(char) } + for char in entityName.unicodeScalars { + sanitizedScalars.append(char) + } } else { sanitizedScalars.append("x") let hexString = String(scalar.value, radix: 16, uppercase: true) - for char in hexString.unicodeScalars { sanitizedScalars.append(char) } + for char in hexString.unicodeScalars { + sanitizedScalars.append(char) + } } sanitizedScalars.append("_") continue @@ -123,7 +126,6 @@ extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { /// /// Check out [SOAR-0013](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0013) for details. struct IdiomaticSafeNameGenerator: SafeNameGenerator { - /// The defensive strategy to use as fallback. var defensive: DefensiveSafeNameGenerator @@ -181,7 +183,7 @@ struct IdiomaticSafeNameGenerator: SafeNameGenerator { state = .accumulatingFirstWord(.init(isAccumulatingInitialUppercase: false)) buffer.append(char) } - case .accumulatingFirstWord(var context): + case var .accumulatingFirstWord(context): if char.isLetter || char.isNumber { if isAllUppercase { buffer.append(contentsOf: char.lowercased()) diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift index c155f04..a61ebc9 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/indent.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -1,8 +1,8 @@ extension String { func indent(_ num: Int, includeFirst: Bool = true) -> String { - let indent = String.init(repeating: " ", count: num) + let indent = String(repeating: " ", count: num) var firstLine = true - return self.split(separator: "\n").map { line in + return split(separator: "\n").map { line in var result = line if !line.isEmpty { if !firstLine || includeFirst { diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift index b9f39a7..951f8bd 100644 --- a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -89,11 +89,11 @@ func mapToSwiftCode(_ map: Map) -> String { return ".undefined" case .null: return ".null" - case .bool(let value): + case let .bool(value): return ".bool(\(value))" - case .number(let value): + case let .number(value): return ".number(Number(\(value)))" - case .string(let value): + case let .string(value): // Escape special characters for Swift string literal let escaped = value .replacingOccurrences(of: "\\", with: "\\\\") @@ -102,10 +102,10 @@ func mapToSwiftCode(_ map: Map) -> String { .replacingOccurrences(of: "\r", with: "\\r") .replacingOccurrences(of: "\t", with: "\\t") return ".string(\"\(escaped)\")" - case .array(let values): + case let .array(values): let elements = values.map { mapToSwiftCode($0) }.joined(separator: ", ") return ".array([\(elements)])" - case .dictionary(let dict): + case let .dictionary(dict): let pairs = dict.map { key, value in let escapedKey = key .replacingOccurrences(of: "\\", with: "\\\\") diff --git a/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift index 7ab40ba..4ec440f 100644 --- a/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift +++ b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift @@ -1,6 +1,6 @@ import GraphQL -public func cast(_ anySendable: any Sendable, to resultType: T.Type) throws -> T { +public func cast(_ anySendable: any Sendable, to _: T.Type) throws -> T { guard let result = anySendable as? T else { throw GraphQLError( message: "Expected source type \(T.self) but got \(type(of: anySendable))" diff --git a/Tests/GraphQLGeneratorTests/IndentTests.swift b/Tests/GraphQLGeneratorTests/IndentTests.swift index 16f2eac..513ac6a 100644 --- a/Tests/GraphQLGeneratorTests/IndentTests.swift +++ b/Tests/GraphQLGeneratorTests/IndentTests.swift @@ -15,22 +15,22 @@ struct IndentTests { abc def """.indent(1, includeFirst: false) - == - """ - abc - def - """ + == + """ + abc + def + """ ) #expect( """ abc def """.indent(1, includeFirst: true) - == - """ - abc - def - """ + == + """ + abc + def + """ ) } } diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift index f089392..ba70121 100644 --- a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -11,7 +11,7 @@ struct SchemaGeneratorTests { fields: [ "foo": .init( type: GraphQLString, - description: "foo", + description: "foo" ), ] ) @@ -21,16 +21,16 @@ struct SchemaGeneratorTests { fields: [ "foo": .init( type: GraphQLString, - description: "foo", + description: "foo" ), "bar": .init( type: bar, - description: "bar", - ) + description: "bar" + ), ] ), types: [ - bar + bar, ] ) let actual = try SchemaGenerator().generate(schema: schema) diff --git a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift index 7993de7..32a729d 100644 --- a/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -12,12 +12,12 @@ struct TypeGeneratorTests { values: [ "foo": .init( value: .string("foo"), - description: "foo", + description: "foo" ), "bar": .init( value: .string("bar"), - description: "bar", - ) + description: "bar" + ), ] ) ) @@ -54,7 +54,7 @@ struct TypeGeneratorTests { "baz": .init( type: GraphQLString, description: "baz" - ) + ), ] ) let actual = try TypeGenerator().generateInterfaceProtocol(for: interfaceB) @@ -93,22 +93,22 @@ struct TypeGeneratorTests { args: [ "foo": .init( type: GraphQLNonNull(GraphQLString), - description: "foo", + description: "foo" ), "bar": .init( type: GraphQLString, description: "bar", - defaultValue: .string("bar"), + defaultValue: .string("bar") ), ] - ) + ), ], - interfaces: [interfaceA], + interfaces: [interfaceA] ) let actual = try TypeGenerator().generateTypeProtocol( for: typeFoo, unionTypeMap: [ - "Foo": [GraphQLUnionType(name: "X", types: [typeFoo])] + "Foo": [GraphQLUnionType(name: "X", types: [typeFoo])], ] ) #expect( @@ -134,7 +134,7 @@ struct TypeGeneratorTests { fields: [ "foo": .init( type: GraphQLString, - description: "foo", + description: "foo" ), ] ) @@ -143,12 +143,12 @@ struct TypeGeneratorTests { fields: [ "foo": .init( type: GraphQLString, - description: "foo", + description: "foo" ), "bar": .init( type: bar, - description: "bar", - ) + description: "bar" + ), ] ) let actual = try TypeGenerator().generateRootTypeProtocol(for: query) @@ -167,7 +167,6 @@ struct TypeGeneratorTests { ) } - @Test func subscriptionType() async throws { let subscription = try GraphQLObjectType( name: "Subscription", @@ -177,10 +176,10 @@ struct TypeGeneratorTests { description: "foo", args: [ "id": .init( - type: GraphQLString, - ) + type: GraphQLString + ), ] - ) + ), ] ) let actual = try TypeGenerator().generateRootTypeProtocol(for: subscription) From 287e41268209c98a001a87ed4307839edb4080c4 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 00:51:02 -0700 Subject: [PATCH 37/38] fix: Fixes trailing commas for Swift < v6.1 --- .../Generator/SchemaGenerator.swift | 54 +++++++++---------- .../SchemaGeneratorTests.swift | 4 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift index 58555d7..146b730 100644 --- a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -17,7 +17,7 @@ package struct SchemaGenerator { /// Build a GraphQL schema with the provided resolvers public func buildGraphQLSchema( resolvers: Resolvers.Type, - decoder: MapDecoder = .init(), + decoder: MapDecoder = .init() ) throws -> GraphQLSchema { """ @@ -203,20 +203,20 @@ package struct SchemaGenerator { var output = """ let \(varName) = try GraphQLEnumType( - name: "\(type.name)", + name: "\(type.name)" """ if let description = type.description { output += """ - + , description: \"\"\" \(description.indent(1, includeFirst: false)) - \"\"\", + \"\"\" """ } output += """ - + , values: [ """ @@ -262,15 +262,15 @@ package struct SchemaGenerator { var output = """ let \(varName) = try GraphQLInputObjectType( - name: "\(type.name)", + name: "\(type.name)" """ if let description = type.description { output += """ - + , description: \"\"\" \(description) - \"\"\", + \"\"\" """ } @@ -297,27 +297,27 @@ package struct SchemaGenerator { output += try """ "\(fieldName)": InputObjectField( - type: \(graphQLTypeReference(for: field.type)), + type: \(graphQLTypeReference(for: field.type)) """ if let defaultValue = field.defaultValue { output += """ - - defaultValue: \(mapToSwiftCode(defaultValue)), + , + defaultValue: \(mapToSwiftCode(defaultValue)) """ } if let description = field.description { output += """ - + , description: \"\"\" \(description) - \"\"\", + \"\"\" """ } if let deprecationReason = field.deprecationReason { output += """ - + , deprecationReason: \"\"\" \(deprecationReason) \"\"\" @@ -344,12 +344,12 @@ package struct SchemaGenerator { var output = """ let \(varName) = try GraphQLInterfaceType( - name: "\(type.name)", + name: "\(type.name)" """ if let description = type.description { output += """ - + , description: \"\"\" \(description) \"\"\", @@ -425,15 +425,15 @@ package struct SchemaGenerator { var output = """ let \(varName) = try GraphQLObjectType( - name: "\(type.name)", + name: "\(type.name)" """ if let description = type.description { output += """ - + , description: \"\"\" \(description) - \"\"\", + \"\"\" """ } @@ -605,31 +605,31 @@ package struct SchemaGenerator { var output = try """ "\(fieldName)": GraphQLField( - type: \(graphQLTypeReference(for: field.type)), + type: \(graphQLTypeReference(for: field.type)) """ if let description = field.description { output += """ - + , description: \"\"\" \(description) - \"\"\", + \"\"\" """ } if let deprecationReason = field.deprecationReason { output += """ - + , deprecationReason: \"\"\" \(deprecationReason) - \"\"\", + \"\"\" """ } // Add arguments if any if !field.args.isEmpty { output += """ - + , args: [ """ @@ -664,12 +664,12 @@ package struct SchemaGenerator { output += """ - ], + ] """ } output += try""" - + , \(generateResolverCallback( fieldName: fieldName, field: field, diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift index ba70121..0e1b732 100644 --- a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -45,13 +45,13 @@ struct SchemaGeneratorTests { /// Build a GraphQL schema with the provided resolvers public func buildGraphQLSchema( resolvers: Resolvers.Type, - decoder: MapDecoder = .init(), + decoder: MapDecoder = .init() ) throws -> GraphQLSchema { let bar = try GraphQLObjectType( name: "Bar", description: """ bar - """, + """ ) bar.fields = { [ From cb732e79c39f57f59f76167e5fbea12241555813 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 29 Dec 2025 01:07:10 -0700 Subject: [PATCH 38/38] ci: Disable android because it has caching trouble --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b8ad13a..fcdd5e1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,7 +10,10 @@ jobs: uses: graphqlswift/ci/.github/workflows/lint.yaml@main test: uses: graphqlswift/ci/.github/workflows/test.yaml@main + with: + include_android: false test-example: uses: graphqlswift/ci/.github/workflows/test.yaml@main with: package_path: "Examples/HelloWorldServer" + include_android: false