diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..fcdd5e1 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,19 @@ +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 + with: + include_android: false + test-example: + uses: graphqlswift/ci/.github/workflows/test.yaml@main + with: + package_path: "Examples/HelloWorldServer" + include_android: false 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..f8c5fd7 --- /dev/null +++ b/Examples/HelloWorldServer/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +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 new file mode 100644 index 0000000..95499ae --- /dev/null +++ b/Examples/HelloWorldServer/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "d4877b8785eefa795e134008a400eca8f128998f26cd5badab8c8cb557525cf8", + "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..6e3617a --- /dev/null +++ b/Examples/HelloWorldServer/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.0 + +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: [ + .target( + name: "HelloWorldServer", + dependencies: [ + .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), + ], + plugins: [ + .plugin(name: "GraphQLGeneratorPlugin", package: "graphql-generator"), + ] + ), + .testTarget( + name: "HelloWorldServerTests", + dependencies: [ + "HelloWorldServer", + .product(name: "GraphQL", package: "GraphQL"), + ] + ), + ] +) diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift new file mode 100644 index 0000000..9a4c3f5 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -0,0 +1,186 @@ +import Foundation +import GraphQL +import GraphQLGeneratorRuntime + +// 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] + var onTriggerWatch: () -> Void = {} + + init( + users: [String: User], + posts: [String: Post] + ) { + 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 { + let email: String + + init(email: String) { + self.email = email + } + + // Codability conformance. Required for usage in InputObject + public init(from decoder: any Decoder) throws { + email = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + 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: + 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 { + typealias Query = HelloWorldServer.Query + typealias Mutation = HelloWorldServer.Mutation + typealias Subscription = HelloWorldServer.Subscription +} + +struct User: UserProtocol { + // User can choose structure + let id: String + let name: String + let email: String + let age: Int? + let role: Role? + + // Required implementations + 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 -> EmailAddress { + return EmailAddress(email: 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 Contact: ContactProtocol { + // User can choose structure + let email: String + + // Required implementations + func email(context _: Context, info _: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress { + return EmailAddress(email: email) + } +} + +struct Post: PostProtocol { + // User can choose structure + let id: String + let title: String + let content: String + let authorId: String + + // Required implementations + 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 + } + + 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)? { + return context.users[id] + } + + 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)? { + return context.posts[id] + } + + 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)? { + return context.users[id] ?? context.posts[id] + } +} + +struct Mutation: MutationProtocol { + // Required implementations + static func upsertUser(userInfo: UserInfoInput, context: Context, info _: GraphQLResolveInfo) -> any UserProtocol { + let user = User( + id: userInfo.id, + name: userInfo.name, + email: userInfo.email.email, + age: userInfo.age, + role: userInfo.role + ) + context.users[userInfo.id] = user + 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 new file mode 100644 index 0000000..4778f48 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -0,0 +1,124 @@ +scalar EmailAddress + +interface HasEmail { + """ + An email address + """ + email: EmailAddress! +} + +union UserOrPost = User | Post + +input UserInfo { + id: ID! + name: String! + email: EmailAddress! + age: Int + role: Role = USER +} + +""" +A simple user type +""" +type User implements HasEmail { + """ + The unique identifier for the user + """ + id: ID! + + """ + The user's display name + """ + name: String! + + """ + The user's email address + """ + email: EmailAddress! + + """ + The user's age + """ + age: Int + + """ + The user's age + """ + role: Role +} + +type Contact implements HasEmail { + email: EmailAddress! +} + +""" +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!]! + + """ + Get a user or post by ID + """ + userOrPost(id: ID!): UserOrPost +} + +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 new file mode 100644 index 0000000..240db52 --- /dev/null +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -0,0 +1,144 @@ +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")] + ) + let actual = try await graphql( + schema: schema, + request: """ + { + posts { + id + title + content + author { + id + name + email + age + role + } + } + } + """, + context: context + ) + let expected = GraphQLResult( + data: [ + "posts": [ + [ + "id": "1", + "title": "Foo", + "content": "bar", + "author": [ + "id": "1", + "name": "John", + "email": "john@example.com", + "age": 18, + "role": "USER", + ], + ], + ], + ] + ) + #expect(actual == expected) + } + + @Test func mutation() async throws { + let schema = try buildGraphQLSchema(resolvers: Resolvers.self) + let context = Context( + users: [:], + posts: [:] + ) + 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 + ) + let expected = GraphQLResult( + data: [ + "upsertUser": [ + "id": "2", + "name": "Jane", + "email": "jane@example.com", + "age": nil, + "role": "USER", + ], + ] + ) + #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/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. 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..07cac08 100644 --- a/Package.swift +++ b/Package.swift @@ -1,23 +1,68 @@ -// 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 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..16fafe2 --- /dev/null +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -0,0 +1,93 @@ +import Foundation +import PackagePlugin + +@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) + + 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 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) + + 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/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..c7f5d5d --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +***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) and [Swift's OpenAPI Generator](https://github.com/apple/swift-openapi-generator). + +## Features + +- **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 +- **Minimal boilerplate**: Generates all GraphQL definition code - you write the business logic + +## Installation + +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: [ + .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.0.0"), + .package(url: "https://github.com/GraphQLSwift/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 { + name: String! + email: EmailAddress! +} + +type Query { + user: User +} +``` + +### 2. Build Your Project + +When you build, the plugin will automatically generate Swift code: +- `Types.swift` - Swift protocols for your GraphQL types +- `Schema.swift` - Defines `buildGraphQLSchema` function that builds an executable schema + +### 3. Create required types + +Create a type named `Context`: + +```swift +public actor Context { + // Add any features you like +} +``` + +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 +struct Resolvers: ResolversProtocol { + typealias Query = ExamplePackage.Query + typealias Mutation = ExamplePackage.Mutation + typealias Subscription = ExamplePackage.Subscription +} +``` + +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 { + // 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 + } +} + +struct User: UserProtocol { + // You can define the type internals however you like + let name: String + let email: 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 EmailAddress(email: self.email) + } +} +``` + +### 4. Execute GraphQL Queries + +```swift +import GraphQL + +let schema = try buildGraphQLSchema(resolvers: Resolvers.self) + +// Execute a query +let result = try await graphql(schema: schema, request: "{ users { name email } }") +print(result) +``` + +## 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). + +### 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 +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: + +```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. Directives: Directives are currently not supported +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 + +This project is in active development. Contributions are welcome! diff --git a/Sources/GraphQLGenerator/main.swift b/Sources/GraphQLGenerator/main.swift new file mode 100644 index 0000000..8ad28ef --- /dev/null +++ b/Sources/GraphQLGenerator/main.swift @@ -0,0 +1,72 @@ +import ArgumentParser +import Foundation +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() + let generatedFiles = try generator.generate(schema: schema) + + // 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..50a85d2 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/CodeGenerator.swift @@ -0,0 +1,23 @@ +import Foundation +import GraphQL + +/// Main code generator that orchestrates generation of all Swift files +package struct CodeGenerator { + package init() {} + + /// Generate all Swift files from the schema + /// Returns a dictionary of filename -> file content + package func generate(schema: GraphQLSchema) throws -> [String: String] { + var files: [String: String] = [:] + + // Generate Types.swift + let typeGenerator = TypeGenerator() + files["Types.swift"] = try typeGenerator.generate(schema: schema) + + // Generate Schema.swift + let schemaGenerator = SchemaGenerator() + files["Schema.swift"] = try schemaGenerator.generate(schema: schema) + + return files + } +} diff --git a/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift new file mode 100644 index 0000000..146b730 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/SchemaGenerator.swift @@ -0,0 +1,808 @@ +import Foundation +import GraphQL + +/// Generates the GraphQL schema builder function +package struct SchemaGenerator { + 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 + + /// Build a GraphQL schema with the provided resolvers + public func buildGraphQLSchema( + resolvers: Resolvers.Type, + decoder: MapDecoder = .init() + ) throws -> GraphQLSchema { + """ + + // Ignore any internal types (which have prefix "__") + let types = schema.typeMap.values.filter { + !$0.name.hasPrefix("__") + } + + // 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 = types.compactMap { $0 as? GraphQLEnumType } + for enumType in enumTypes { + output += try""" + + \(generateEnumTypeDefinition(for: enumType).indent(1)) + """ + } + + // Generate type definitions for all object types + let interfaceTypes = types.compactMap { + $0 as? GraphQLInterfaceType + } + for interfaceType in interfaceTypes { + 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 += try""" + + \(generateInputTypeDefinition(for: inputType).indent(1)) + """ + } + + // Generate type definitions for all object types + + // Generate GraphQLObjectType definitions for non-root types + let objectTypes = types.compactMap { + $0 as? GraphQLObjectType + }.filter { + // Skip root operation types + $0.name != "Query" && + $0.name != "Mutation" && + $0.name != "Subscription" + } + for objectType in objectTypes { + 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 += try""" + + \(generateInputTypeFieldDefinition(for: inputType).indent(1)) + """ + } + for interfaceType in interfaceTypes { + output += try""" + + \(generateInterfaceTypeFieldDefinition(for: interfaceType).indent(1)) + """ + } + for objectType in objectTypes { + output += try""" + + \(generateObjectTypeFieldDefinition(for: objectType, resolvers: "parent").indent(1)) + """ + } + + // Generate Query type + if let queryType = schema.queryType { + output += try""" + + \(generateRootTypeDefinition(for: queryType, rootType: .query).indent(1)) + """ + } + + // Generate Mutation type if it exists + if let mutationType = schema.mutationType { + output += 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)) + """ + } + + // TODO: Subscription + + // Build and return the schema + output += """ + + return try GraphQLSchema( + query: query + """ + + if schema.mutationType != nil { + output += """ + , + mutation: mutation + """ + } + + if schema.subscriptionType != nil { + output += """ + , + subscription: subscription + """ + } + + output += """ + + ) + } + + """ + + 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) + + var output = """ + + let \(varName) = try GraphQLEnumType( + name: "\(type.name)" + """ + + if let description = type.description { + output += """ + , + description: \"\"\" + \(description.indent(1, includeFirst: false)) + \"\"\" + """ + } + + 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.indent(3, includeFirst: false)) + \"\"\", + """ + } else { + output += """ + + value: .string("\(value.name)") + """ + } + + output += """ + + ), + """ + } + + output += """ + + ] + ) + """ + + 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 += try """ + + "\(fieldName)": InputObjectField( + type: \(graphQLTypeReference(for: field.type)) + """ + + if let defaultValue = field.defaultValue { + output += """ + , + defaultValue: \(mapToSwiftCode(defaultValue)) + """ + } + + 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) + + 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) 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 += try""" + + \(generateFieldDefinition( + fieldName: fieldName, + field: field, + target: .parent, + parentType: type + ).indent(2)) + """ + } + + output += """ + + ] + } + """ + + 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 generateObjectTypeDefinition(for type: GraphQLObjectType) throws -> String { + let varName = nameGenerator.swiftMemberName(for: type.name) + + var output = """ + let \(varName) = try GraphQLObjectType( + name: "\(type.name)" + """ + + if let description = type.description { + output += """ + , + description: \"\"\" + \(description) + \"\"\" + """ + } + + // Delay field generation to support recursive type systems + output += """ + + ) + """ + + return output + } + + private func generateObjectTypeFieldDefinition(for type: GraphQLObjectType, resolvers _: String) 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 += try""" + + \(generateFieldDefinition( + fieldName: fieldName, + field: field, + target: .parent, + parentType: type + ).indent(2)) + """ + } + + output += """ + + ] + } + """ + + 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 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 + } + + var output = """ + + let \(variableName) = try GraphQLObjectType( + name: "\(type.name)", + """ + + if let description = type.description { + output += """ + + description: \"\"\" + \(description.indent(1, includeFirst: false)) + \"\"\", + """ + } + + output += """ + + fields: [ + """ + + // Generate fields + let fields = try type.fields() + for (fieldName, field) in fields { + output += try""" + + \(generateFieldDefinition( + fieldName: fieldName, + field: field, + target: target, + parentType: type + ).indent(2)) + """ + } + + output += """ + + ] + ) + """ + + return output + } + + private func generateFieldDefinition( + fieldName: String, + field: GraphQLField, + target: ResolverTarget, + parentType: GraphQLNamedType + ) throws -> String { + var output = try """ + + "\(fieldName)": GraphQLField( + 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: [ + """ + + for (argName, arg) in field.args { + output += try """ + + "\(argName)": GraphQLArgument( + type: \(graphQLTypeReference(for: arg.type)) + """ + + if let description = arg.description { + output += """ + , + description: \"\"\" + \(description) + \"\"\" + """ + } + + if let defaultValue = arg.defaultValue { + output += """ + , + defaultValue: \(mapToSwiftCode(defaultValue)) + """ + } + + output += """ + + ), + """ + } + + output += """ + + ] + """ + } + + output += try""" + , + \(generateResolverCallback( + fieldName: fieldName, + field: field, + target: target, + parentType: parentType + ).indent(1)) + ), + """ + + return output + } + + private func generateResolverCallback( + fieldName: String, + field: GraphQLField, + target: ResolverTarget, + parentType: GraphQLType + ) throws -> String { + 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 + """ + } + + // Build argument list + var argsList: [String] = [] + + if target == .parent { + // For nested resolvers, we decode and call the method on the parent instance + // 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: (any \(parentCastType)).self) + """ + } + + // Add field arguments + for (argName, arg) in field.args { + 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 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" + } + output += """ + + let \(safeArgName) = \(decodeStatement) + """ + argsList.append("\(safeArgName): \(safeArgName)") + } + + // Add context + output += """ + + let context = try cast(context, to: Context.self) + """ + argsList.append("context: context") + + // Add resolver info + argsList.append("info: info") + + // Call the resolver + let targetName = switch target { + case .parent: "parent" + case .query: "Resolvers.Query" + case .mutation: "Resolvers.Mutation" + case .subscription: "Resolvers.Subscription" + } + let functionName = nameGenerator.swiftMemberName(for: fieldName) + output += """ + + return try await \(targetName).\(functionName)(\(argsList.joined(separator: ", "))) + } + """ + + 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 try "GraphQLNonNull(\(graphQLTypeReference(for: nonNull.ofType)))" + } + + if let list = type as? GraphQLList { + return try "GraphQLList(\(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) + return varName + } + } + + 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 new file mode 100644 index 0000000..e56f55a --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Generator/TypeGenerator.swift @@ -0,0 +1,448 @@ +import Foundation +import GraphQL + +/// Generates Swift type definitions from GraphQL types +package struct TypeGenerator { + 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 + + """ + + // Generate ResolversProtocol + output += """ + + public protocol ResolversProtocol: Sendable { + """ + if schema.queryType != nil { + output += """ + + associatedtype Query: QueryProtocol + """ + } + if schema.mutationType != nil { + output += """ + + associatedtype Mutation: MutationProtocol + """ + } + if schema.subscriptionType != nil { + output += """ + + associatedtype Subscription: SubscriptionProtocol + """ + } + output += """ + + } + """ + + // 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): Sendable {} + """ + + // 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 root operation types + $0.name != "Query" && + $0.name != "Mutation" && + $0.name != "Subscription" + } + 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)) + """ + } + + // Generate Mutation type + if let subscriptionType = schema.subscriptionType { + output += try""" + + \(generateRootTypeProtocol(for: subscriptionType)) + """ + } + + 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 \(nameGenerator.swiftMemberName(for: fieldName)): \(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] = [] + + // 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 += """ + + func \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) + + """ + } + + output += """ + + } + """ + + return output + } + + func generateTypeProtocol(for type: GraphQLObjectType, unionTypeMap: [String: [GraphQLUnionType]]) throws -> String { + var output = "" + + // Add description if available + if let description = type.description { + output += """ + + /// \(description) + """ + } + + 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) + """ + } + + 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 += """ + + func \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) + + """ + } + + output += """ + + } + """ + + return output + } + + /// 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) + """ + } + + let swiftTypeName = try swiftTypeDeclaration(for: type, nameGenerator: nameGenerator) + output += """ + + public protocol \(swiftTypeName): Sendable { + """ + + // Generate properties + let fields = try type.fields() + for (fieldName, field) in fields { + if let description = field.description { + output += """ + + /// \(description) + """ + } + + var returnType = try swiftTypeReference(for: field.type, nameGenerator: nameGenerator) + if type.name == "Subscription" { + returnType = "AnyAsyncSequence<\(returnType)>" + } + + 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 \(nameGenerator.swiftMemberName(for: fieldName))(\(paramString)) async throws -> \(returnType) + + """ + } + + output += """ + + } + """ + + return output + } +} + +package enum GeneratorError: Error, CustomStringConvertible { + case unsupportedType(String) + + package var description: String { + switch self { + case let .unsupportedType(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/SafeNameGenerator.swift b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift new file mode 100644 index 0000000..2563016 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/SafeNameGenerator.swift @@ -0,0 +1,306 @@ +// 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 +// +// 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. +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. +/// +/// 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. +struct DefensiveSafeNameGenerator: SafeNameGenerator { + func swiftTypeName(for documentedName: String) -> String { + swiftName(for: documentedName) + } + + 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)" + } + + /// 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", + ] +} + +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. +struct IdiomaticSafeNameGenerator: SafeNameGenerator { + /// The defensive strategy to use as fallback. + var defensive: DefensiveSafeNameGenerator + + 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_" } + + // 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 var .accumulatingFirstWord(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)) + } + + /// A list of word separator characters for the idiomatic naming strategy. + private static let wordSeparators: Set = ["_", "-", " ", "/", "+"] +} + +extension SafeNameGenerator where Self == DefensiveSafeNameGenerator { + static var idiomatic: IdiomaticSafeNameGenerator { IdiomaticSafeNameGenerator(defensive: .defensive) } +} diff --git a/Sources/GraphQLGeneratorCore/Utilities/indent.swift b/Sources/GraphQLGeneratorCore/Utilities/indent.swift new file mode 100644 index 0000000..a61ebc9 --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/indent.swift @@ -0,0 +1,16 @@ +extension String { + func indent(_ num: Int, includeFirst: Bool = true) -> String { + let indent = String(repeating: " ", count: num) + var firstLine = true + return split(separator: "\n").map { line in + var result = line + if !line.isEmpty { + if !firstLine || includeFirst { + result = indent + line + } + } + firstLine = false + return result + }.joined(separator: "\n") + } +} diff --git a/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift new file mode 100644 index 0000000..951f8bd --- /dev/null +++ b/Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift @@ -0,0 +1,117 @@ +import GraphQL + +/// Convert GraphQL type to Swift type name +func swiftTypeReference(for type: GraphQLType, nameGenerator: SafeNameGenerator) throws -> String { + if let nonNull = type as? GraphQLNonNull { + let innerType = try swiftTypeReference(for: nonNull.ofType, nameGenerator: nameGenerator) + // Remove the optional marker if present + if innerType.hasSuffix("?") { + if innerType.hasPrefix("(") { + // Remove parentheses and trailing ? around "(any X)?" + return String(innerType.dropFirst().dropLast().dropLast()) + } + // Remove trailing ? on "X?" + return String(innerType.dropLast()) + } + return innerType + } + + if let list = type as? GraphQLList { + let innerType = try swiftTypeReference(for: list.ofType, nameGenerator: nameGenerator) + if innerType.hasSuffix("?") { + let baseType = String(innerType.dropLast()) + return "[\(baseType)]?" + } + return "[\(innerType)]?" + } + + if let namedType = type as? GraphQLNamedType { + 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 { + if let nonNull = type as? GraphQLNonNull { + return try swiftTypeDeclaration(for: nonNull.ofType, nameGenerator: nameGenerator) + } + + if let list = type as? GraphQLList { + return try swiftTypeDeclaration(for: list.ofType, nameGenerator: nameGenerator) + } + + 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 +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) + } +} + +/// 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 let .bool(value): + return ".bool(\(value))" + case let .number(value): + return ".number(Number(\(value)))" + case let .string(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 let .array(values): + let elements = values.map { mapToSwiftCode($0) }.joined(separator: ", ") + return ".array([\(elements)])" + case let .dictionary(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)])" + } +} 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/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift new file mode 100644 index 0000000..4ec440f --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/ResolverHelpers.swift @@ -0,0 +1,10 @@ +import GraphQL + +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))" + ) + } + return result +} diff --git a/Sources/GraphQLGeneratorRuntime/Scalar.swift b/Sources/GraphQLGeneratorRuntime/Scalar.swift new file mode 100644 index 0000000..28780dc --- /dev/null +++ b/Sources/GraphQLGeneratorRuntime/Scalar.swift @@ -0,0 +1,25 @@ +import GraphQL +import OrderedCollections + +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 { + // 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))" + ) + } + return try Self.serialize(this: scalar) + } +} diff --git a/Tests/GraphQLGeneratorTests/IndentTests.swift b/Tests/GraphQLGeneratorTests/IndentTests.swift new file mode 100644 index 0000000..513ac6a --- /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 + """ + ) + } +} diff --git a/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift new file mode 100644 index 0000000..0e1b732 --- /dev/null +++ b/Tests/GraphQLGeneratorTests/SchemaGeneratorTests.swift @@ -0,0 +1,107 @@ +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: Resolvers.Type, + decoder: MapDecoder = .init() + ) throws -> GraphQLSchema { + let bar = try GraphQLObjectType( + name: "Bar", + description: """ + bar + """ + ) + 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 query = try GraphQLObjectType( + name: "Query", + fields: [ + "foo": GraphQLField( + type: GraphQLString, + description: """ + foo + """, + resolve: { source, args, context, info in + let context = try cast(context, to: Context.self) + return try await Resolvers.Query.foo(context: context, info: info) + } + ), + "bar": GraphQLField( + type: bar, + description: """ + bar + """, + resolve: { source, args, context, info in + let context = try cast(context, to: Context.self) + return try await Resolvers.Query.bar(context: context, info: info) + } + ), + ] + ) + return try GraphQLSchema( + query: query + ) + } + + """# + 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..32a729d --- /dev/null +++ b/Tests/GraphQLGeneratorTests/TypeGeneratorTests.swift @@ -0,0 +1,197 @@ +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 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 + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String + + /// baz + 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: typeFoo, + unionTypeMap: [ + "Foo": [GraphQLUnionType(name: "X", types: [typeFoo])], + ] + ) + #expect( + actual == """ + + /// Foo + public protocol FooProtocol: XUnion, AInterface, Sendable { + /// foo + func foo(context: Context, info: GraphQLResolveInfo) async throws -> String + + /// bar + func bar(foo: String, bar: String?, context: Context, info: GraphQLResolveInfo) async throws -> String? + + } + """ + ) + } + + @Test func queryType() 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)? + + } + """ + ) + } + + @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 + + } + """ + ) + } +}