diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 85b47f5b..661e66eb 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -31,11 +31,30 @@ When reviewing, check these project-specific rules: See [CLAUDE.md](../../CLAUDE.md) and [knowledge/internal/](../../knowledge/internal/) for full conventions. -## Reply Format Rules +## Reply Format Rules (CRITICAL) When replying to PR comments: -- **Commit hash references**: Write commit hashes as plain text, NOT in code blocks - - CORRECT: `Fixed in f3b5fec.` - - WRONG: `Fixed in \`f3b5fec\`.` - - This ensures GitHub auto-links the commit hash to the actual commit +### Commit Hash Formatting + +**NEVER wrap commit hashes in backticks or code blocks.** GitHub only auto-links plain text commit hashes. + +| Format | Example | Result | +|--------|---------|--------| +| ✅ CORRECT | `Fixed in f3b5fec.` | Clickable link to commit | +| ❌ WRONG | `Fixed in \`f3b5fec\`.` | Plain text, no link | + +**Examples of correct replies:** + +```text +Fixed in f3b5fec. + +**Changes:** +- Updated header to use bun run generate +``` + +```text +Fixed in abc1234 along with other review items. +``` + +**Do NOT use backticks around the commit hash** - this breaks GitHub's auto-linking feature. diff --git a/.claude/commands/sync-godot-iap.md b/.claude/commands/sync-godot-iap.md index 46ced50f..570a6530 100644 --- a/.claude/commands/sync-godot-iap.md +++ b/.claude/commands/sync-godot-iap.md @@ -76,9 +76,10 @@ Synchronize OpenIAP changes to the [godot-iap](https://github.com/hyochan/godot- ## Type Generation Source -**OpenIAP has a built-in GDScript type generator:** +**OpenIAP has a built-in GDScript type generator (IR-based):** -- **Generator:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/scripts/generate-gdscript-types.mjs` +- **Generator:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/codegen/plugins/gdscript.ts` +- **Entry Point:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/codegen/index.ts` - **Output:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/src/generated/types.gd` --- @@ -713,6 +714,7 @@ Now update `docs/docs/` with new API documentation for the new version. ## References -- **OpenIAP GDScript Generator:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/scripts/generate-gdscript-types.mjs` +- **OpenIAP GDScript Generator:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/codegen/plugins/gdscript.ts` +- **OpenIAP Codegen Entry:** `/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/gql/codegen/index.ts` - **OpenIAP Docs:** https://openiap.dev/docs - **Godot IAP Docs:** Check README.md diff --git a/CLAUDE.md b/CLAUDE.md index c3a9b84b..3ffc93c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,9 +54,30 @@ openiap/ - `packages/apple/Sources/Models/Types.swift` - `packages/google/openiap/src/main/Types.kt` +- `packages/gql/src/generated/*` - All generated type files - `openiap-versions.json` - Managed by CI/CD workflows only -Regenerate types with: `./scripts/generate-types.sh` +Regenerate types: `cd packages/gql && bun run generate` + +### GQL Code Generation System + +The type generation uses an **IR-based (Intermediate Representation)** architecture: + +```text +GraphQL Schema → Parser → IR → Language Plugins → Generated Code + ↓ + codegen/core/ codegen/plugins/ + ├── types.ts ├── swift.ts + ├── parser.ts ├── kotlin.ts + └── transformer.ts├── dart.ts + └── gdscript.ts +``` + +**Language plugins handle:** +- **Swift**: Codable protocol, ErrorCode custom initializer, platform defaults +- **Kotlin**: sealed interface, fromJson/toJson with nullable patterns +- **Dart**: sealed class, factory constructors, extends/implements +- **GDScript**: _init() pattern, Variant type for unions ### Git Commit Format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25ee2c3d..9d51c256 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ bun install ### `@hyodotdev/openiap-gql` -GraphQL schema and type generation for all platforms. +GraphQL schema and **IR-based type generation** for all platforms. ```bash cd packages/gql @@ -51,12 +51,20 @@ cd packages/gql bun run generate # Generate for specific platform -bun run generate:ts # TypeScript -bun run generate:swift # Swift -bun run generate:kotlin # Kotlin -bun run generate:dart # Dart +bun run generate:ts # TypeScript (graphql-codegen) +bun run generate:swift # Swift (IR-based plugin) +bun run generate:kotlin # Kotlin (IR-based plugin) +bun run generate:dart # Dart (IR-based plugin) +bun run generate:gdscript # GDScript (IR-based plugin) ``` +**Architecture:** +```text +GraphQL Schema → Parser → IR (Intermediate Representation) → Language Plugins → Generated Code +``` + +See [Code Generation Architecture](#code-generation-architecture) for details. + ### `@hyodotdev/openiap-docs` Documentation website at [openiap.dev](https://openiap.dev) @@ -275,23 +283,83 @@ Each package has its own scripts. See individual `package.json` files for detail ```text GraphQL Schema (packages/gql/src/*.graphql) ↓ - Type Generation (bun run generate) + [1] Parser (codegen/core/parser.ts) + ↓ + [2] Transformer → IR (codegen/core/transformer.ts) + ↓ + [3] Language Plugins (codegen/plugins/*.ts) + ↓ + ├─→ TypeScript (src/generated/types.ts) [graphql-codegen] + ├─→ Swift (src/generated/Types.swift) [IR plugin] + ├─→ Kotlin (src/generated/Types.kt) [IR plugin] + ├─→ Dart (src/generated/types.dart) [IR plugin] + └─→ GDScript (src/generated/types.gd) [IR plugin] + ↓ + Auto Sync (bun run sync) ↓ - ├─→ TypeScript (src/generated/types.ts) - ├─→ Swift (src/generated/Types.swift) ──┐ - ├─→ Kotlin (src/generated/Types.kt) ──┐ │ - └─→ Dart (src/generated/types.dart) │ │ - │ │ - Auto Sync ───────────────┘ │ - │ - ┌───────────────────────────────────────┘ - ↓ ├─→ packages/apple/Sources/Models/Types.swift - └─→ packages/google/.../openiap/Types.kt (+ post-processing) + └─→ packages/google/.../openiap/Types.kt ``` **Key Feature:** One `generate` command updates all platforms automatically! +## 🏗️ Code Generation Architecture + +The GQL package uses an **IR-based (Intermediate Representation) code generation system**: + +### Directory Structure + +```text +packages/gql/codegen/ +├── index.ts # Main entry point +├── core/ +│ ├── types.ts # IR type definitions (IREnum, IRObject, etc.) +│ ├── parser.ts # GraphQL schema parser +│ ├── transformer.ts # AST → IR transformer +│ └── utils.ts # Case conversion, keyword escaping +└── plugins/ + ├── base-plugin.ts # Abstract base class + ├── swift.ts # Swift: Codable, ErrorCode handling + ├── kotlin.ts # Kotlin: sealed interface, fromJson/toJson + ├── dart.ts # Dart: sealed class, factory constructors + └── gdscript.ts # GDScript: Godot engine types +``` + +### IR Types + +| IR Type | Description | +|---------|-------------| +| `IREnum` | Enum with values, raw values (kebab-case), legacy aliases | +| `IRInterface` | Protocol/Interface with typed fields | +| `IRObject` | Struct/Class with fields, implements, union membership | +| `IRInput` | Input type with required field tracking | +| `IRUnion` | Union with members, nested union support | +| `IROperation` | Query/Mutation/Subscription definitions | + +### Language Plugin Features + +| Plugin | Key Features | +|--------|--------------| +| **Swift** | Codable protocol, ErrorCode custom init, platform defaults (ProductIOS) | +| **Kotlin** | sealed interface, fromJson/toJson, nullable patterns, type casting | +| **Dart** | extends/implements, factory constructors, sealed class, @override | +| **GDScript** | _init() pattern, from_json/to_json, Variant for unions | + +### Schema Markers + +Special comments in GraphQL SDL: + +```graphql +# => Union +type RequestPurchaseResult { + purchase: Purchase # Generates union variant + purchases: [Purchase!] +} + +# Future +fetchProducts(...): FetchProductsResult # Wraps in Promise/async +``` + ## 🔄 Common Workflows ### Adding a New Feature diff --git a/bun.lock b/bun.lock index 43588a62..9e78d072 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "1.2.2", + "version": "1.0.0", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -61,12 +61,13 @@ }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "1.2.2", + "version": "1.0.0", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", "@graphql-codegen/typescript": "^5.0.0", "graphql": "^16.11.0", + "handlebars": "^4.7.8", "ts-node": "^10.9.2", "typescript": "^5.9.2", }, @@ -719,6 +720,8 @@ "graphql-ws": ["graphql-ws@6.0.6", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "uWebSockets.js": "^20", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "uWebSockets.js", "ws"] }, "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "header-case": ["header-case@2.0.4", "", { "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" } }, "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q=="], @@ -857,6 +860,8 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -869,6 +874,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -1019,6 +1026,8 @@ "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sponge-case": ["sponge-case@1.0.1", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA=="], @@ -1067,6 +1076,8 @@ "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "unc-path-regex": ["unc-path-regex@0.1.2", "", {}, "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg=="], "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], @@ -1101,6 +1112,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 9336b78c..85956e06 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -213,29 +213,129 @@ Meta Horizon has different APIs from Google Play: Before writing or editing anything, **ALWAYS** review: - [`packages/gql/CONVENTION.md`](../../packages/gql/CONVENTION.md) +### Code Generation Architecture + +The GQL package uses an **IR-based (Intermediate Representation) code generation system**: + +```text +GraphQL Schema (src/*.graphql) + ↓ + [1] Parser (codegen/core/parser.ts) + ↓ + [2] Transformer → IR (codegen/core/transformer.ts) + ↓ + [3] Language Plugins (codegen/plugins/*.ts) + ↓ + Generated Files (src/generated/*) +``` + +#### Directory Structure + +```text +packages/gql/codegen/ +├── index.ts # Main entry point +├── core/ +│ ├── types.ts # IR type definitions +│ ├── parser.ts # GraphQL schema parser +│ ├── transformer.ts # AST → IR transformer +│ └── utils.ts # Common utilities (case conversion, keywords) +├── plugins/ +│ ├── base-plugin.ts # Abstract base class +│ ├── swift.ts # Swift plugin (Codable, ErrorCode handling) +│ ├── kotlin.ts # Kotlin plugin (sealed interface, fromJson/toJson) +│ ├── dart.ts # Dart plugin (sealed class, factory constructors) +│ └── gdscript.ts # GDScript plugin (Godot engine) +└── templates/ # Handlebars templates (optional) +``` + +#### IR (Intermediate Representation) + +The IR is a language-agnostic representation of the GraphQL schema: + +| IR Type | Description | +|---------|-------------| +| `IREnum` | Enum with values, raw values, legacy aliases | +| `IRInterface` | Protocol/Interface with fields | +| `IRObject` | Struct/Class with fields, implements, unions | +| `IRInput` | Input type with fields, required field tracking | +| `IRUnion` | Union with members, nested union handling | +| `IROperation` | Query/Mutation/Subscription with fields | + +#### Language Plugins + +Each plugin handles language-specific requirements: + +| Plugin | Features | +|--------|----------| +| **Swift** | Codable protocol, ErrorCode custom initializer, platform defaults | +| **Kotlin** | sealed interface, fromJson/toJson with nullable patterns | +| **Dart** | extends/implements, factory constructors, sealed class | +| **GDScript** | _init(), from_json/to_json, Variant type | + ### Scripts | Script | Description | |--------|-------------| -| `generate:ts` | Generate TypeScript types | -| `generate:swift` | Generate Swift types | -| `generate:kotlin` | Generate Kotlin types | -| `generate:dart` | Generate Dart types | -| `generate` | Generate all types | +| `generate:ts` | Generate TypeScript types (graphql-codegen) | +| `generate:swift` | Generate Swift types (IR-based plugin) | +| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | +| `generate:dart` | Generate Dart types (IR-based plugin) | +| `generate:gdscript` | Generate GDScript types (IR-based plugin) | +| `generate` | Generate all types + sync to platforms | | `sync` | Sync generated types to platform packages | ### Generating Types ```bash cd packages/gql + +# Generate all platform types bun run generate + +# Generate specific platform +bun run generate:swift +bun run generate:kotlin +bun run generate:dart +bun run generate:gdscript ``` -This generates: -- TypeScript types: `src/generated/types.ts` -- Swift types: `dist/swift/Types.swift` -- Kotlin types: `dist/kotlin/Types.kt` -- Dart types: `dist/dart/types.dart` +### Generated Files + +| File | Platform | Description | +|------|----------|-------------| +| `src/generated/types.ts` | TypeScript | Type definitions | +| `src/generated/Types.swift` | iOS/macOS | Codable structs & enums | +| `src/generated/Types.kt` | Android | Data classes & sealed interfaces | +| `src/generated/types.dart` | Flutter | Classes & sealed classes | +| `src/generated/types.gd` | Godot | GDScript classes | + +### Adding a New Language + +1. Create `codegen/plugins/.ts` extending `CodegenPlugin` +2. Implement abstract methods: + - `mapScalar()` - Map GraphQL scalars to language types + - `mapType()` - Map IR types to language type strings + - `generateEnum()`, `generateObject()`, etc. +3. Register in `codegen/index.ts` +4. Add script to `package.json` + +### Schema Markers + +Special comments in GraphQL SDL trigger codegen behavior: + +| Marker | Effect | +|--------|--------| +| `# => Union` | Generates result union wrapper (e.g., `FetchProductsResult`) | +| `# Future` | Wraps return type in Promise/async | + +Example: +```graphql +# => Union +type RequestPurchaseResult { + purchase: Purchase + purchases: [Purchase!] +} +``` --- diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 0ce716da..a1ff1be1 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Run `bun run generate` after updating any *.graphql schema file. // ============================================================================ import Foundation diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index c44e3c95..a896fccc 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Run `bun run generate` after updating any *.graphql schema file. // ============================================================================ // Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure diff --git a/packages/gql/CONVENTION.md b/packages/gql/CONVENTION.md index c29611ea..6fcd1a14 100644 --- a/packages/gql/CONVENTION.md +++ b/packages/gql/CONVENTION.md @@ -97,3 +97,88 @@ This repo standardizes schema and identifier naming to improve clarity across pl - When feeding new APIs into the openiap.dev docs, always add this `# Future` comment so the codegen post-processing rewrites the generated types to return `Promise<…>` and the documentation stays accurate. + +--- + +## Code Generation Architecture + +The GQL package uses an **IR-based (Intermediate Representation)** code generation system. + +### Generation Flow + +```text +GraphQL Schema (src/*.graphql) + ↓ + [1] Parser (codegen/core/parser.ts) + ↓ + [2] Transformer → IR (codegen/core/transformer.ts) + ↓ + [3] Language Plugins (codegen/plugins/*.ts) + ↓ + Generated Files (src/generated/*) + ↓ + [4] Sync (scripts/sync-to-platforms.mjs) + ↓ + Platform Packages (packages/apple, packages/google) +``` + +### Directory Structure + +```text +codegen/ +├── index.ts # Main entry point +├── core/ +│ ├── types.ts # IR type definitions +│ ├── parser.ts # GraphQL schema parser +│ ├── transformer.ts # AST → IR transformer +│ └── utils.ts # Case conversion, keyword escaping +└── plugins/ + ├── base-plugin.ts # Abstract base class + ├── swift.ts # Swift plugin + ├── kotlin.ts # Kotlin plugin + ├── dart.ts # Dart plugin + └── gdscript.ts # GDScript plugin +``` + +### IR Types + +| IR Type | Description | +|---------|-------------| +| `IREnum` | Enum with values, raw values (kebab-case), legacy aliases | +| `IRInterface` | Protocol/Interface with typed fields | +| `IRObject` | Struct/Class with fields, implements, union membership | +| `IRInput` | Input type with required field tracking | +| `IRUnion` | Union with members, nested union support | +| `IROperation` | Query/Mutation/Subscription definitions | + +### Language Plugin Features + +| Plugin | Key Features | +|--------|--------------| +| **Swift** | Codable protocol, ErrorCode custom initializer, platform defaults | +| **Kotlin** | sealed interface, fromJson/toJson, nullable patterns | +| **Dart** | sealed class, factory constructors, extends/implements | +| **GDScript** | _init() pattern, from_json/to_json, Variant type | + +### Scripts + +```bash +# Generate all platform types +bun run generate + +# Generate specific platform +bun run generate:swift +bun run generate:kotlin +bun run generate:dart +bun run generate:gdscript +``` + +### Adding a New Language + +1. Create `codegen/plugins/.ts` extending `CodegenPlugin` +2. Implement abstract methods: + - `mapScalar(name)` - Map GraphQL scalars to language types + - `mapType(type)` - Map IR types to language type strings + - `generateEnum()`, `generateObject()`, `generateUnion()`, etc. +3. Register in `codegen/index.ts` +4. Add script to `package.json` diff --git a/packages/gql/codegen/README.md b/packages/gql/codegen/README.md new file mode 100644 index 00000000..a57ac4b7 --- /dev/null +++ b/packages/gql/codegen/README.md @@ -0,0 +1,121 @@ +# Code Generation System + +IR-based code generation system for multiple target languages. + +## Architecture + +``` +codegen/ +├── index.ts # Main entry point +├── core/ +│ ├── types.ts # Intermediate Representation (IR) types +│ ├── parser.ts # GraphQL schema parser +│ ├── transformer.ts # AST → IR transformer +│ └── utils.ts # Common utilities (case conversion, keywords) +├── plugins/ +│ ├── base-plugin.ts # Abstract base class +│ ├── swift.ts # Swift plugin (~700 lines) +│ ├── kotlin.ts # Kotlin plugin (~850 lines) +│ ├── dart.ts # Dart plugin (~870 lines) +│ └── gdscript.ts # GDScript plugin (~610 lines) +├── templates/ # Handlebars templates (optional) +│ ├── swift/ +│ ├── kotlin/ +│ ├── dart/ +│ └── gdscript/ +└── test-output.ts # Output comparison test +``` + +## How It Works + +1. **Parse**: GraphQL schema is parsed into AST +2. **Transform**: AST is transformed into language-agnostic IR +3. **Generate**: Language-specific plugins convert IR to target code + +## IR (Intermediate Representation) + +The IR includes: +- `IREnum` - Enum types with values and legacy aliases +- `IRInterface` - Protocol/Interface definitions +- `IRObject` - Struct/Class definitions with fields +- `IRInput` - Input type definitions +- `IRUnion` - Union types with member tracking +- `IROperation` - Query/Mutation/Subscription definitions + +## Plugin Structure + +Each plugin implements: +- Type mapping (`mapScalar`, `mapType`, `getPropertyType`) +- Keyword escaping (`keywords`, `escapeKeyword`) +- Name conversion (`enumValueCase`, `fieldNameCase`) +- Code generation (`generateEnum`, `generateObject`, etc.) +- JSON serialization (`buildFromJsonExpression`, `buildToJsonExpression`) + +## Usage + +```bash +# Generate all languages +bun codegen/index.ts + +# Generate specific language +bun codegen/index.ts swift +bun codegen/index.ts kotlin dart + +# Test output matches original generators +bun codegen/test-output.ts + +# NPM scripts +npm run generate:new:all # Generate all +npm run test:codegen # Test comparison +``` + +## Why Not Pure Templates? + +Each language has complex, specific requirements: + +### Swift +- Codable protocol conformance +- Custom initializer for ErrorCode (legacy alias handling) +- Platform-specific defaults (ProductIOS, ProductAndroid) + +### Kotlin +- Sealed interfaces for unions +- Complex fromJson/toJson with nullable patterns +- Type casting for JSON deserialization + +### Dart +- extends/implements clauses +- Constructor parameter patterns ({required this.field}) +- Union wrapper classes with interface field forwarding + +### GDScript +- _init() constructor pattern +- Variant type for union values +- from_json/to_json static/instance methods + +These patterns are difficult to express cleanly in templates. The current plugin-based approach: +- Keeps logic in TypeScript (type-safe, debuggable) +- Uses IR for shared schema representation +- Allows language-specific customization + +## Adding a New Language + +1. Create `plugins/.ts` extending `CodegenPlugin` +2. Implement abstract methods +3. Register in `index.ts` +4. Optionally create templates in `templates//` + +## Testing + +```bash +# Run comparison test +npm run test:codegen + +# Expected output: +# ✓ swift: MATCH +# ✓ kotlin: MATCH +# ✓ dart: MATCH +# ✓ gdscript: MATCH +``` + +The test compares new plugin output against original generator scripts to ensure 100% compatibility. diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts new file mode 100644 index 00000000..86e9a5ec --- /dev/null +++ b/packages/gql/codegen/core/parser.ts @@ -0,0 +1,191 @@ +/** + * GraphQL Schema Parser + * + * Parses GraphQL schema files and extracts SDL markers (# => Union, # Future). + */ + +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + buildASTSchema, + parse, + type DocumentNode, + type GraphQLSchema, +} from 'graphql'; +import type { SchemaMarkers } from './types.js'; + +// ============================================================================ +// Configuration +// ============================================================================ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Default schema paths relative to the gql package */ +const DEFAULT_SCHEMA_PATHS = [ + '../src/schema.graphql', + '../src/type.graphql', + '../src/type-ios.graphql', + '../src/type-android.graphql', + '../src/api.graphql', + '../src/api-ios.graphql', + '../src/api-android.graphql', + '../src/error.graphql', + '../src/event.graphql', +]; + +// ============================================================================ +// Parser Interface +// ============================================================================ + +export interface ParsedSchema { + /** The built GraphQL schema */ + schema: GraphQLSchema; + /** Markers extracted from SDL comments */ + markers: SchemaMarkers; + /** Raw SDL content for each file */ + sdlContents: Map; +} + +export interface ParserConfig { + /** Schema file paths (absolute or relative to scripts directory) */ + schemaPaths?: string[]; + /** Base directory for resolving relative paths */ + baseDir?: string; +} + +// ============================================================================ +// Schema Parser +// ============================================================================ + +export class SchemaParser { + private schemaPaths: string[]; + private baseDir: string; + + constructor(config: ParserConfig = {}) { + // Default base directory is the gql/scripts folder + this.baseDir = config.baseDir ?? resolve(__dirname, '../../scripts'); + + this.schemaPaths = (config.schemaPaths ?? DEFAULT_SCHEMA_PATHS).map( + (relativePath) => resolve(this.baseDir, relativePath) + ); + } + + /** + * Parse all schema files and build a unified schema + */ + parse(): ParsedSchema { + const sdlContents = new Map(); + + // Load all SDL files + for (const schemaPath of this.schemaPaths) { + const content = readFileSync(schemaPath, 'utf8'); + sdlContents.set(schemaPath, content); + } + + // Build combined document + const documentNode: DocumentNode = { + kind: 'Document', + definitions: this.schemaPaths.flatMap((schemaPath) => { + const sdl = sdlContents.get(schemaPath)!; + return parse(sdl).definitions; + }), + }; + + // Build schema + const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); + + // Extract markers from SDL comments + const markers = this.extractMarkers(sdlContents); + + return { schema, markers, sdlContents }; + } + + /** + * Extract markers from SDL comments + * + * Supported markers: + * - `# => Union` - Marks the following type as a union wrapper + * - `# Future` - Marks the following field as async (wrap in Promise) + */ + private extractMarkers(sdlContents: Map): SchemaMarkers { + const unionWrappers = new Set(); + const futureFields = new Set(); + + for (const sdl of sdlContents.values()) { + const lines = sdl.split(/\r?\n/); + let expectUnionType = false; + let expectFutureField = false; + let currentTypeName: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Track current type context + const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); + if (typeMatch) { + currentTypeName = typeMatch[1]; + if (expectUnionType) { + unionWrappers.add(currentTypeName); + expectUnionType = false; + } + continue; + } + + // Check for # => Union marker + if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { + expectUnionType = true; + continue; + } + + // Check for # Future marker (strict matching to avoid false positives) + if (/^#\s*future\b/i.test(trimmed)) { + expectFutureField = true; + continue; + } + + // Handle field after # Future marker + if (expectFutureField && currentTypeName) { + const fieldMatch = trimmed.match(/^([A-Za-z0-9_]+)\s*[:(]/); + if (fieldMatch) { + futureFields.add(`${currentTypeName}.${fieldMatch[1]}`); + expectFutureField = false; + } + // Skip empty lines and comments while waiting for field + if (trimmed.length === 0 || trimmed.startsWith('#')) { + continue; + } + // Reset if we hit something unexpected + expectFutureField = false; + } + + // Reset union expectation if we hit non-empty, non-comment, non-type line + if (expectUnionType && trimmed.length > 0 && !trimmed.startsWith('#')) { + expectUnionType = false; + } + } + } + + return { unionWrappers, futureFields }; + } + + /** + * Get the schema file paths + */ + getSchemaPaths(): string[] { + return [...this.schemaPaths]; + } +} + +// ============================================================================ +// Convenience Function +// ============================================================================ + +/** + * Parse the default schema configuration + */ +export function parseSchema(config?: ParserConfig): ParsedSchema { + const parser = new SchemaParser(config); + return parser.parse(); +} diff --git a/packages/gql/codegen/core/template-engine.ts b/packages/gql/codegen/core/template-engine.ts new file mode 100644 index 00000000..744f9976 --- /dev/null +++ b/packages/gql/codegen/core/template-engine.ts @@ -0,0 +1,315 @@ +/** + * Template Engine for Code Generation + * + * Provides Handlebars-based template rendering with language-specific helpers. + */ + +import Handlebars from 'handlebars'; +import type { IRType, IRField, IREnum, IREnumValue, IROperationField } from './types.js'; + +// ============================================================================ +// Template Context Types +// ============================================================================ + +export interface EnumContext { + name: string; + description?: string; + values: EnumValueContext[]; + isErrorCode: boolean; +} + +export interface EnumValueContext { + name: string; + caseName: string; + rawValue: string; + description?: string; + legacyAliases: string[]; + isLast: boolean; +} + +export interface FieldContext { + name: string; + propertyName: string; + type: string; + description?: string; + nullable: boolean; + isOverride: boolean; + defaultValue: string; + isLast: boolean; +} + +export interface InterfaceContext { + name: string; + description?: string; + fields: FieldContext[]; +} + +export interface ObjectContext { + name: string; + description?: string; + fields: FieldContext[]; + conformances: string[]; + hasFields: boolean; + isResultUnion: boolean; + resultUnionEntries?: ResultUnionEntryContext[]; +} + +export interface ResultUnionEntryContext { + fieldName: string; + caseName: string; + type: string; + isLast: boolean; +} + +export interface InputContext { + name: string; + description?: string; + fields: FieldContext[]; + hasRequiredFields: boolean; + isCustomType: boolean; + customTypeKind?: string; +} + +export interface UnionContext { + name: string; + description?: string; + members: UnionMemberContext[]; + sharedInterfaces: string[]; + conformances: string; + hasNestedUnions: boolean; + nestedUnionWrappers: NestedUnionWrapperContext[]; + concreteMembers: ConcreteMemberContext[]; +} + +export interface UnionMemberContext { + name: string; + caseName: string; + isNested: boolean; +} + +export interface NestedUnionWrapperContext { + wrapperName: string; + unionName: string; + parentUnionName: string; +} + +export interface ConcreteMemberContext { + typeName: string; + delegateTo: string; + isNested: boolean; + wrapperName?: string; +} + +export interface OperationContext { + kind: 'Query' | 'Mutation' | 'Subscription'; + name: string; + description?: string; + protocolName: string; + fields: OperationFieldContext[]; +} + +export interface OperationFieldContext { + name: string; + escapedName: string; + description?: string; + returnType: string; + args: ArgContext[]; + hasArgs: boolean; + hasSingleArg: boolean; + hasMultipleArgs: boolean; + aliasName: string; + argsSignature: string; + paramsSignature: string; + isLast: boolean; +} + +export interface ArgContext { + name: string; + type: string; + defaultValue: string; + isLast: boolean; +} + +// ============================================================================ +// Template Engine +// ============================================================================ + +export class TemplateEngine { + private handlebars: typeof Handlebars; + private templates: Map = new Map(); + + constructor() { + this.handlebars = Handlebars.create(); + this.registerBuiltinHelpers(); + } + + private registerBuiltinHelpers(): void { + // Conditional helpers + this.handlebars.registerHelper('if_eq', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { + return a === b ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('unless_eq', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { + return a !== b ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('if_gt', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { + return (a as number) > (b as number) ? options.fn(this) : options.inverse(this); + }); + + // String helpers + this.handlebars.registerHelper('capitalize', (str: string) => { + return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; + }); + + this.handlebars.registerHelper('lowercase', (str: string) => { + return str ? str.toLowerCase() : ''; + }); + + // GDScript doc comment helper - prefixes each line with ## + this.handlebars.registerHelper('gd_doc', (str: string) => { + if (!str) return ''; + return str.split('\n').map(line => `## ${line}`).join('\n'); + }); + + // Equality helper for use in subexpressions + this.handlebars.registerHelper('eq', (a: unknown, b: unknown) => { + return a === b; + }); + + // Array helpers + this.handlebars.registerHelper('join', (arr: string[], separator: string) => { + return Array.isArray(arr) ? arr.join(separator) : ''; + }); + + this.handlebars.registerHelper('length', (arr: unknown[]) => { + return Array.isArray(arr) ? arr.length : 0; + }); + + // Logic helpers - use regular functions for correct 'this' binding in Handlebars + this.handlebars.registerHelper('and', function (...args: unknown[]) { + const options = args.pop() as Handlebars.HelperOptions; + return args.every(Boolean) ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('or', function (...args: unknown[]) { + const options = args.pop() as Handlebars.HelperOptions; + return args.some(Boolean) ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('not', (value: unknown) => { + return !value; + }); + + // Index helpers + this.handlebars.registerHelper('is_last', function (index: number, array: unknown[], options: Handlebars.HelperOptions) { + return index === array.length - 1 ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('is_not_last', function (index: number, array: unknown[], options: Handlebars.HelperOptions) { + return index !== array.length - 1 ? options.fn(this) : options.inverse(this); + }); + } + + /** + * Register a custom helper function + */ + registerHelper(name: string, fn: Handlebars.HelperDelegate): void { + this.handlebars.registerHelper(name, fn); + } + + /** + * Register a template string + */ + registerTemplate(name: string, template: string): void { + this.templates.set(name, this.handlebars.compile(template)); + } + + /** + * Register a partial template + */ + registerPartial(name: string, template: string): void { + this.handlebars.registerPartial(name, template); + } + + /** + * Render a registered template with context + */ + render(templateName: string, context: Record): string { + const template = this.templates.get(templateName); + if (!template) { + throw new Error(`Template not found: ${templateName}`); + } + return template(context); + } + + /** + * Render a template string directly + */ + renderString(template: string, context: Record): string { + const compiled = this.handlebars.compile(template); + return compiled(context); + } +} + +// ============================================================================ +// Context Builders +// ============================================================================ + +export interface ContextBuilderConfig { + mapType: (type: IRType) => string; + mapScalar: (name: string) => string; + escapeKeyword: (name: string) => string; + enumValueCase: (name: string) => string; + fieldNameCase: (name: string) => string; + getPropertyType: (type: IRType) => string; +} + +export function buildEnumContext( + irEnum: IREnum, + config: ContextBuilderConfig +): EnumContext { + return { + name: irEnum.name, + description: irEnum.description, + isErrorCode: irEnum.isErrorCode, + values: irEnum.values.map((value, index) => ({ + name: value.name, + caseName: config.escapeKeyword(config.enumValueCase(value.name)), + rawValue: value.rawValue, + description: value.description, + legacyAliases: value.legacyAliases, + isLast: index === irEnum.values.length - 1, + })), + }; +} + +export function buildFieldContext( + field: IRField, + config: ContextBuilderConfig, + isLast: boolean +): FieldContext { + return { + name: field.name, + propertyName: config.escapeKeyword(config.fieldNameCase(field.name)), + type: config.getPropertyType(field.type), + description: field.description, + nullable: field.type.nullable, + isOverride: field.isOverride, + defaultValue: field.defaultValue || '', + isLast, + }; +} + +export function buildFieldsContext( + fields: IRField[], + config: ContextBuilderConfig, + sort: boolean = false +): FieldContext[] { + const sortedFields = sort + ? [...fields].sort((a, b) => a.name.localeCompare(b.name)) + : fields; + return sortedFields.map((field, index) => + buildFieldContext(field, config, index === sortedFields.length - 1) + ); +} diff --git a/packages/gql/codegen/core/transformer.ts b/packages/gql/codegen/core/transformer.ts new file mode 100644 index 00000000..36a538c9 --- /dev/null +++ b/packages/gql/codegen/core/transformer.ts @@ -0,0 +1,585 @@ +/** + * AST to IR Transformer + * + * Transforms a GraphQL schema into the language-agnostic Intermediate Representation (IR). + */ + +import { + GraphQLSchema, + GraphQLList, + GraphQLNonNull, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType, + type GraphQLEnumType, + type GraphQLInputObjectType, + type GraphQLInterfaceType, + type GraphQLObjectType, + type GraphQLUnionType, + type GraphQLType, + type GraphQLField, + type GraphQLInputField, + type GraphQLArgument, +} from 'graphql'; +import type { + IRSchema, + IRSchemaMetadata, + IREnum, + IREnumValue, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IRArg, + IROperationField, + IRResultUnionEntry, + IRPlatformDefault, + SchemaMarkers, +} from './types.js'; +import { + toKebabCase, + toConstantCase, + CUSTOM_INPUT_TYPES, + PLATFORM_TYPE_DEFAULTS, + ERROR_CODE_LEGACY_ALIASES, +} from './utils.js'; +import type { ParsedSchema } from './parser.js'; + +// ============================================================================ +// Transformer +// ============================================================================ + +export class SchemaTransformer { + private schema: GraphQLSchema; + private markers: SchemaMarkers; + private typeMap: ReturnType; + private typeNames: string[]; + + // Computed metadata + private enumNames = new Set(); + private interfaceNames = new Set(); + private objectNames = new Set(); + private inputNames = new Set(); + private unionNames = new Set(); + private unionMembership = new Map>(); + private singleFieldObjects = new Map(); + private inputsWithRequiredFields = new Set(); + + constructor(parsedSchema: ParsedSchema) { + this.schema = parsedSchema.schema; + this.markers = parsedSchema.markers; + this.typeMap = this.schema.getTypeMap(); + this.typeNames = Object.keys(this.typeMap) + .filter((name) => !name.startsWith('__')) + .sort((a, b) => a.localeCompare(b)); + } + + /** + * Transform the GraphQL schema to IR + */ + transform(): IRSchema { + // First pass: categorize types and build name sets + const categorized = this.categorizeTypes(); + + // Build union membership map + for (const unionType of categorized.unions) { + for (const member of unionType.getTypes()) { + if (!this.unionMembership.has(member.name)) { + this.unionMembership.set(member.name, new Set()); + } + this.unionMembership.get(member.name)!.add(unionType.name); + } + } + + // Identify single-field Args objects + for (const objectType of categorized.objects) { + const fields = Object.values(objectType.getFields()); + if (fields.length === 1 && objectType.name.endsWith('Args')) { + this.singleFieldObjects.set(objectType.name, this.transformType(fields[0].type)); + } + } + + // Identify inputs with required fields + for (const inputType of categorized.inputs) { + const fields = Object.values(inputType.getFields()); + const hasRequired = fields.some((field) => field.type instanceof GraphQLNonNull); + if (hasRequired) { + this.inputsWithRequiredFields.add(inputType.name); + } + } + + // Transform each category + const enums = categorized.enums.map((e) => this.transformEnum(e)); + const interfaces = categorized.interfaces.map((i) => this.transformInterface(i)); + const objects = categorized.objects.map((o) => this.transformObject(o)); + const inputs = categorized.inputs.map((i) => this.transformInput(i)); + const unions = categorized.unions.map((u) => this.transformUnion(u)); + const operations = categorized.operations.map((o) => this.transformOperation(o)); + + // Build metadata + const metadata = this.buildMetadata(); + + return { + enums: enums.sort((a, b) => a.name.localeCompare(b.name)), + interfaces: interfaces.sort((a, b) => a.name.localeCompare(b.name)), + objects: objects.sort((a, b) => a.name.localeCompare(b.name)), + inputs: inputs.sort((a, b) => a.name.localeCompare(b.name)), + unions: unions.sort((a, b) => a.name.localeCompare(b.name)), + operations: operations.sort((a, b) => a.name.localeCompare(b.name)), + metadata, + }; + } + + // ============================================================================ + // Type Categorization + // ============================================================================ + + private categorizeTypes(): { + enums: GraphQLEnumType[]; + interfaces: GraphQLInterfaceType[]; + objects: GraphQLObjectType[]; + inputs: GraphQLInputObjectType[]; + unions: GraphQLUnionType[]; + operations: GraphQLObjectType[]; + } { + const enums: GraphQLEnumType[] = []; + const interfaces: GraphQLInterfaceType[] = []; + const objects: GraphQLObjectType[] = []; + const inputs: GraphQLInputObjectType[] = []; + const unions: GraphQLUnionType[] = []; + const operations: GraphQLObjectType[] = []; + + for (const name of this.typeNames) { + const type = this.typeMap[name]; + + if (isScalarType(type)) { + continue; + } + if (isEnumType(type)) { + enums.push(type); + this.enumNames.add(type.name); + continue; + } + if (isInterfaceType(type)) { + interfaces.push(type); + this.interfaceNames.add(type.name); + continue; + } + if (isUnionType(type)) { + unions.push(type); + this.unionNames.add(type.name); + continue; + } + if (isObjectType(type)) { + if (['Query', 'Mutation', 'Subscription'].includes(name)) { + operations.push(type); + } else { + objects.push(type); + this.objectNames.add(type.name); + } + continue; + } + if (isInputObjectType(type)) { + inputs.push(type); + this.inputNames.add(type.name); + } + } + + return { enums, interfaces, objects, inputs, unions, operations }; + } + + // ============================================================================ + // Type Transformation + // ============================================================================ + + private transformType(graphqlType: GraphQLType): IRType { + if (graphqlType instanceof GraphQLNonNull) { + const inner = this.transformType(graphqlType.ofType); + return { ...inner, nullable: false }; + } + if (graphqlType instanceof GraphQLList) { + const elementType = this.transformType(graphqlType.ofType); + return { + kind: 'list', + nullable: true, + elementType, + }; + } + + // Named type + const typeName = (graphqlType as { name: string }).name; + let kind: IRType['kind'] = 'object'; + + if (this.enumNames.has(typeName)) { + kind = 'enum'; + } else if (this.interfaceNames.has(typeName)) { + kind = 'interface'; + } else if (this.inputNames.has(typeName)) { + kind = 'input'; + } else if (this.unionNames.has(typeName)) { + kind = 'union'; + } else if (this.objectNames.has(typeName)) { + kind = 'object'; + } else { + // Scalar + kind = 'scalar'; + } + + return { + kind, + name: typeName, + nullable: true, + }; + } + + // ============================================================================ + // Enum Transformation + // ============================================================================ + + private transformEnum(enumType: GraphQLEnumType): IREnum { + const values: IREnumValue[] = enumType.getValues().map((value) => { + const rawValue = toKebabCase(value.name); + // For Swift compatibility: only use PascalCase name as legacy alias (no CONSTANT_CASE) + // The enum case matching in Swift uses: kebab-case + PascalCase + const legacyAliases: string[] = []; + + // Add special legacy aliases for ErrorCode + // receipt-failed -> purchaseVerificationFailed + if (enumType.name === 'ErrorCode') { + const caseNameLower = value.name.charAt(0).toLowerCase() + value.name.slice(1); + const extraAliases = Object.entries(ERROR_CODE_LEGACY_ALIASES) + .filter(([_, target]) => target === caseNameLower) + .map(([alias]) => alias); + legacyAliases.push(...extraAliases); + } + + return { + name: value.name, + rawValue, + description: value.description ?? undefined, + legacyAliases: [...new Set(legacyAliases)], + }; + }); + + return { + name: enumType.name, + description: enumType.description ?? undefined, + values, + isErrorCode: enumType.name === 'ErrorCode', + }; + } + + // ============================================================================ + // Interface Transformation + // ============================================================================ + + private transformInterface(interfaceType: GraphQLInterfaceType): IRInterface { + // Preserve schema field order - individual plugins can sort if needed + const graphqlFields = Object.values(interfaceType.getFields()); + + const fields: IRField[] = graphqlFields.map((field) => ({ + name: field.name, + description: field.description ?? undefined, + type: this.transformType(field.type), + isOverride: false, + })); + + return { + name: interfaceType.name, + description: interfaceType.description ?? undefined, + fields, + }; + } + + // ============================================================================ + // Object Transformation + // ============================================================================ + + private transformObject(objectType: GraphQLObjectType): IRObject { + const interfacesForObject = objectType.getInterfaces().map((i) => i.name); + const unionsForObject = this.unionMembership.get(objectType.name) + ? [...this.unionMembership.get(objectType.name)!] + : []; + + // Collect interface fields for override detection + const interfaceFieldNames = new Set(); + for (const iface of objectType.getInterfaces()) { + for (const fieldName of Object.keys(iface.getFields())) { + interfaceFieldNames.add(fieldName); + } + } + + // Preserve schema field order - individual plugins can sort if needed + const graphqlFields = Object.values(objectType.getFields()); + + const fields: IRField[] = graphqlFields.map((field) => { + const irField: IRField = { + name: field.name, + description: field.description ?? undefined, + type: this.transformType(field.type), + isOverride: interfaceFieldNames.has(field.name), + }; + + // Add platform defaults for discriminated union types + const defaults = PLATFORM_TYPE_DEFAULTS[objectType.name]; + if (defaults) { + if (field.name === 'platform') { + irField.defaultValue = defaults.platform; + } else if (field.name === 'type') { + irField.defaultValue = defaults.type; + } + } + + return irField; + }); + + // Check if this is a result union wrapper + const isResultUnion = this.markers.unionWrappers.has(objectType.name); + let resultUnionEntries: IRResultUnionEntry[] | undefined; + + if (isResultUnion) { + const allOptional = graphqlFields.every( + (field) => !(field.type instanceof GraphQLNonNull) + ); + if (allOptional && graphqlFields.length > 0) { + resultUnionEntries = graphqlFields.map((field) => ({ + fieldName: field.name, + type: this.transformType(field.type), + })); + } + } + + // Check if single-field Args type + const isSingleFieldArgs = + graphqlFields.length === 1 && objectType.name.endsWith('Args'); + const singleFieldType = isSingleFieldArgs + ? this.transformType(graphqlFields[0].type) + : undefined; + + return { + name: objectType.name, + description: objectType.description ?? undefined, + fields, + interfaces: interfacesForObject, + unions: unionsForObject, + isResultUnion: isResultUnion && !!resultUnionEntries, + resultUnionEntries, + isSingleFieldArgs, + singleFieldType, + }; + } + + // ============================================================================ + // Input Transformation + // ============================================================================ + + private transformInput(inputType: GraphQLInputObjectType): IRInput { + // Preserve schema field order - individual plugins can sort if needed + const graphqlFields = Object.values(inputType.getFields()); + + const fields: IRField[] = graphqlFields.map((field) => ({ + name: field.name, + description: field.description ?? undefined, + type: this.transformType(field.type), + isOverride: false, + })); + + const hasRequiredFields = graphqlFields.some( + (field) => field.type instanceof GraphQLNonNull + ); + + const isCustomType = CUSTOM_INPUT_TYPES.has(inputType.name); + let customTypeKind: IRInput['customTypeKind']; + if (inputType.name === 'RequestPurchaseProps') { + customTypeKind = 'RequestPurchaseProps'; + } else if (inputType.name === 'DiscountOfferInputIOS') { + customTypeKind = 'DiscountOfferInputIOS'; + } else if (inputType.name === 'PurchaseInput') { + customTypeKind = 'PurchaseInput'; + } + + return { + name: inputType.name, + description: inputType.description ?? undefined, + fields, + hasRequiredFields, + isCustomType, + customTypeKind, + }; + } + + // ============================================================================ + // Union Transformation + // ============================================================================ + + private transformUnion(unionType: GraphQLUnionType): IRUnion { + const memberTypes = unionType.getTypes(); + + // Find shared interfaces across all members + let sharedInterfaceNames: string[] = []; + if (memberTypes.length > 0) { + const [firstMember, ...otherMembers] = memberTypes; + if (typeof (firstMember as GraphQLObjectType).getInterfaces === 'function') { + const firstInterfaces = new Set( + (firstMember as GraphQLObjectType).getInterfaces().map((i) => i.name) + ); + let allMembersHaveInterfaces = true; + + for (const member of otherMembers) { + if (typeof (member as GraphQLObjectType).getInterfaces === 'function') { + const memberInterfaces = new Set( + (member as GraphQLObjectType).getInterfaces().map((i) => i.name) + ); + for (const ifaceName of [...firstInterfaces]) { + if (!memberInterfaces.has(ifaceName)) { + firstInterfaces.delete(ifaceName); + } + } + } else { + allMembersHaveInterfaces = false; + break; + } + } + + if (allMembersHaveInterfaces) { + sharedInterfaceNames = [...firstInterfaces].sort(); + } + } + } + + // Keep original schema order for union members (don't sort alphabetically) + const members = memberTypes.map((member) => ({ + name: member.name, + isNestedUnion: isUnionType(member), + })); + + return { + name: unionType.name, + description: unionType.description ?? undefined, + members, // Preserve schema order + sharedInterfaces: sharedInterfaceNames, + }; + } + + // ============================================================================ + // Operation Transformation + // ============================================================================ + + private transformOperation(operationType: GraphQLObjectType): IROperation { + const kind = operationType.name as 'Query' | 'Mutation' | 'Subscription'; + + // Preserve schema field order - individual plugins can sort and filter as needed + const graphqlFields = Object.values(operationType.getFields()); + + const fields: IROperationField[] = graphqlFields.map((field) => { + const args: IRArg[] = field.args.map((arg) => ({ + name: arg.name, + description: arg.description ?? undefined, + type: this.transformType(arg.type), + })); + + const returnType = this.transformType(field.type); + const isFuture = this.markers.futureFields.has( + `${operationType.name}.${field.name}` + ); + + // Resolve return type (VoidResult -> Void, single-field Args inlining) + const resolvedReturnType = this.resolveOperationReturnType(field.type); + + return { + name: field.name, + description: field.description ?? undefined, + args, + returnType, + isFuture, + resolvedReturnType, + }; + }); + + return { + kind, + name: operationType.name, + description: operationType.description ?? undefined, + fields, + }; + } + + private resolveOperationReturnType(graphqlType: GraphQLType): IRType { + const baseType = this.transformType(graphqlType); + + // Handle list types as-is + if (baseType.kind === 'list') { + return baseType; + } + + // Check for VoidResult + const namedType = this.unwrapNonNull(graphqlType); + if (namedType && (namedType as { name: string }).name === 'VoidResult') { + return { + kind: 'scalar', + name: 'Void', + nullable: !(graphqlType instanceof GraphQLNonNull), + }; + } + + // Check for single-field Args types + if (namedType) { + const typeName = (namedType as { name: string }).name; + const singleFieldType = this.singleFieldObjects.get(typeName); + if (singleFieldType) { + return { + ...singleFieldType, + nullable: baseType.nullable || singleFieldType.nullable, + }; + } + } + + return baseType; + } + + private unwrapNonNull(graphqlType: GraphQLType): GraphQLType | null { + let current = graphqlType; + while (current instanceof GraphQLNonNull) { + current = current.ofType; + } + if (current instanceof GraphQLList) { + return null; + } + return current; + } + + // ============================================================================ + // Metadata + // ============================================================================ + + private buildMetadata(): IRSchemaMetadata { + const platformDefaults = new Map(); + for (const [typeName, defaults] of Object.entries(PLATFORM_TYPE_DEFAULTS)) { + platformDefaults.set(typeName, defaults); + } + + return { + unionWrapperNames: this.markers.unionWrappers, + futureFieldNames: this.markers.futureFields, + platformDefaults, + singleFieldObjects: this.singleFieldObjects, + unionMembership: this.unionMembership, + inputsWithRequiredFields: this.inputsWithRequiredFields, + }; + } +} + +// ============================================================================ +// Convenience Function +// ============================================================================ + +export function transformSchema(parsedSchema: ParsedSchema): IRSchema { + const transformer = new SchemaTransformer(parsedSchema); + return transformer.transform(); +} diff --git a/packages/gql/codegen/core/types.ts b/packages/gql/codegen/core/types.ts new file mode 100644 index 00000000..6ed0458b --- /dev/null +++ b/packages/gql/codegen/core/types.ts @@ -0,0 +1,250 @@ +/** + * Intermediate Representation (IR) Types for GraphQL Code Generation + * + * These types represent a language-agnostic intermediate representation + * of GraphQL schema types, which can be transformed into any target language. + */ + +// ============================================================================ +// Type System +// ============================================================================ + +export type IRTypeKind = + | 'scalar' + | 'enum' + | 'object' + | 'input' + | 'interface' + | 'union' + | 'list'; + +export interface IRType { + /** The kind of type */ + kind: IRTypeKind; + /** The GraphQL type name (for named types) */ + name?: string; + /** Whether this type is nullable */ + nullable: boolean; + /** For list types, the element type */ + elementType?: IRType; +} + +// ============================================================================ +// Enums +// ============================================================================ + +export interface IREnumValue { + /** Original GraphQL name (PascalCase) */ + name: string; + /** Raw value for serialization (kebab-case) */ + rawValue: string; + /** Description from GraphQL schema */ + description?: string; + /** Legacy aliases for backwards compatibility */ + legacyAliases: string[]; +} + +export interface IREnum { + /** Type name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Enum values */ + values: IREnumValue[]; + /** Whether this is the special ErrorCode enum that needs custom handling */ + isErrorCode: boolean; +} + +// ============================================================================ +// Fields +// ============================================================================ + +export interface IRField { + /** Field name in GraphQL */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Field type */ + type: IRType; + /** Whether this field overrides an interface field */ + isOverride: boolean; + /** Default value for discriminated unions (e.g., platform: 'ios') */ + defaultValue?: string; +} + +// ============================================================================ +// Interfaces +// ============================================================================ + +export interface IRInterface { + /** Type name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Interface fields */ + fields: IRField[]; +} + +// ============================================================================ +// Objects +// ============================================================================ + +export interface IRObject { + /** Type name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Object fields */ + fields: IRField[]; + /** Interfaces this object implements */ + interfaces: string[]; + /** Unions this object belongs to */ + unions: string[]; + /** Whether this is a result union wrapper (has # => Union marker) */ + isResultUnion: boolean; + /** For result unions, the variant entries */ + resultUnionEntries?: IRResultUnionEntry[]; + /** Whether this is a single-field Args type that can be inlined */ + isSingleFieldArgs: boolean; + /** For single-field Args, the inlined field type */ + singleFieldType?: IRType; +} + +export interface IRResultUnionEntry { + /** Field name */ + fieldName: string; + /** Field type */ + type: IRType; +} + +// ============================================================================ +// Inputs +// ============================================================================ + +export interface IRInput { + /** Type name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Input fields */ + fields: IRField[]; + /** Whether this input has required (non-nullable) fields */ + hasRequiredFields: boolean; + /** Whether this is a special type that needs custom handling */ + isCustomType: boolean; + /** Custom type kind for special handling */ + customTypeKind?: 'RequestPurchaseProps' | 'DiscountOfferInputIOS' | 'PurchaseInput'; +} + +// ============================================================================ +// Unions +// ============================================================================ + +export interface IRUnionMember { + /** Member type name */ + name: string; + /** Whether this member is itself a union (nested union) */ + isNestedUnion: boolean; +} + +export interface IRUnion { + /** Type name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Member types */ + members: IRUnionMember[]; + /** Shared interfaces across all members */ + sharedInterfaces: string[]; +} + +// ============================================================================ +// Operations +// ============================================================================ + +export interface IRArg { + /** Argument name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Argument type */ + type: IRType; +} + +export interface IROperationField { + /** Field name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Arguments */ + args: IRArg[]; + /** Return type */ + returnType: IRType; + /** Whether this is a future field (wrap in Promise) */ + isFuture: boolean; + /** Resolved return type (after VoidResult -> Void, Args inlining) */ + resolvedReturnType: IRType; +} + +export interface IROperation { + /** Operation kind */ + kind: 'Query' | 'Mutation' | 'Subscription'; + /** Operation name */ + name: string; + /** Description from GraphQL schema */ + description?: string; + /** Operation fields */ + fields: IROperationField[]; +} + +// ============================================================================ +// Schema (Root) +// ============================================================================ + +export interface IRSchema { + /** All enum types */ + enums: IREnum[]; + /** All interface types */ + interfaces: IRInterface[]; + /** All object types */ + objects: IRObject[]; + /** All input types */ + inputs: IRInput[]; + /** All union types */ + unions: IRUnion[]; + /** Root operation types (Query, Mutation, Subscription) */ + operations: IROperation[]; + /** Schema metadata */ + metadata: IRSchemaMetadata; +} + +export interface IRSchemaMetadata { + /** Types marked with # => Union comment */ + unionWrapperNames: Set; + /** Types marked with # Future comment (for Promise wrapping) */ + futureFieldNames: Set; + /** Platform-specific type defaults for discriminated unions */ + platformDefaults: Map; + /** Single-field Args types that can be inlined */ + singleFieldObjects: Map; + /** Union membership map (object name -> set of union names) */ + unionMembership: Map>; + /** Input types with required fields */ + inputsWithRequiredFields: Set; +} + +export interface IRPlatformDefault { + platform: string; + type: string; +} + +// ============================================================================ +// Schema Markers (from SDL comments) +// ============================================================================ + +export interface SchemaMarkers { + /** Types marked with # => Union */ + unionWrappers: Set; + /** Fields marked with # Future */ + futureFields: Set; +} diff --git a/packages/gql/codegen/core/utils.ts b/packages/gql/codegen/core/utils.ts new file mode 100644 index 00000000..0d5997ab --- /dev/null +++ b/packages/gql/codegen/core/utils.ts @@ -0,0 +1,567 @@ +/** + * Shared Utilities for GraphQL Code Generation + * + * Case conversion, keyword escaping, and other common utilities + * used across all language plugins. + */ + +// ============================================================================ +// Case Conversion +// ============================================================================ + +/** + * Convert to PascalCase (e.g., "my_value" -> "MyValue") + */ +export function toPascalCase(value: string): string { + const tokens = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_\-\s]+/g, ' ') + .split(' ') + .filter(Boolean) + .map((token) => token.toLowerCase()); + if (tokens.length === 0) return value; + return tokens.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(''); +} + +/** + * Convert to camelCase (e.g., "my_value" -> "myValue") + */ +export function toCamelCase(value: string): string { + const pascal = toPascalCase(value); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +/** + * Convert to lowerCamelCase (same as camelCase but preserves more context) + */ +export function toLowerCamelCase(value: string): string { + const parts = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_\-]+/g, ' ') + .split(/\s+/) + .filter(Boolean) + .map((segment) => segment.toLowerCase()); + if (parts.length === 0) return value; + return ( + parts[0] + + parts + .slice(1) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('') + ); +} + +/** + * Convert to kebab-case (e.g., "MyValue" -> "my-value") + */ +export function toKebabCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .replace(/[_\s]+/g, '-') + .replace(/-+/g, '-') + .toLowerCase(); +} + +/** + * Convert to snake_case (e.g., "MyValue" -> "my_value") + */ +export function toSnakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase(); +} + +/** + * Convert to CONSTANT_CASE (e.g., "MyValue" -> "MY_VALUE") + */ +export function toConstantCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toUpperCase(); +} + +/** + * Capitalize first letter + */ +export function capitalize(value: string): string { + return value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1); +} + +/** + * Uncapitalize first letter + */ +export function uncapitalize(value: string): string { + return value.length === 0 ? value : value.charAt(0).toLowerCase() + value.slice(1); +} + +/** + * Convert to camelCase preserving IOS suffix (for Dart/GDScript) + * e.g., "daysUntilExpirationIOS" stays "daysUntilExpirationIOS" + */ +export function toCamelCasePreserveIOS(value: string): string { + const tokens = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_\-\s]+/g, ' ') + .split(' ') + .filter(Boolean) + .map((token) => token.toLowerCase()); + if (tokens.length === 0) return value; + const normalized = tokens.map((token) => (token === 'ios' ? 'IOS' : token)); + const [first, ...rest] = normalized; + const formatFirst = () => { + if (first === 'IOS') { + return 'ios'; + } + return first; + }; + const firstToken = formatFirst(); + const restTokens = rest.map((token) => + token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1) + ); + return [firstToken, ...restTokens].join(''); +} + +/** + * Convert to PascalCase preserving IOS suffix (for Dart/GDScript) + * e.g., "promoted_product_ios" -> "PromotedProductIOS" + */ +export function toPascalCasePreserveIOS(value: string): string { + const tokens = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_\-\s]+/g, ' ') + .split(' ') + .filter(Boolean) + .map((token) => token.toLowerCase()); + if (tokens.length === 0) return value; + const normalized = tokens.map((token) => (token === 'ios' ? 'IOS' : token)); + return normalized.map((token) => + token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1) + ).join(''); +} + +// ============================================================================ +// Language Keywords +// ============================================================================ + +export const SWIFT_KEYWORDS = new Set([ + 'associatedtype', + 'class', + 'deinit', + 'enum', + 'extension', + 'func', + 'import', + 'init', + 'inout', + 'internal', + 'let', + 'operator', + 'private', + 'protocol', + 'public', + 'static', + 'struct', + 'subscript', + 'typealias', + 'var', + 'break', + 'case', + 'continue', + 'default', + 'defer', + 'do', + 'else', + 'fallthrough', + 'for', + 'guard', + 'if', + 'in', + 'repeat', + 'return', + 'switch', + 'where', + 'while', + 'as', + 'catch', + 'false', + 'is', + 'nil', + 'rethrows', + 'super', + 'self', + 'Self', + 'throw', + 'throws', + 'true', + 'try', + 'Any', + 'Protocol', +]); + +export const KOTLIN_KEYWORDS = new Set([ + 'as', + 'break', + 'class', + 'continue', + 'do', + 'else', + 'false', + 'for', + 'fun', + 'if', + 'in', + 'interface', + 'is', + 'null', + 'object', + 'package', + 'return', + 'super', + 'this', + 'throw', + 'true', + 'try', + 'typealias', + 'val', + 'var', + 'when', + 'while', +]); + +export const DART_KEYWORDS = new Set([ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'while', + 'with', + 'yield', +]); + +export const GDSCRIPT_KEYWORDS = new Set([ + 'if', + 'elif', + 'else', + 'for', + 'while', + 'match', + 'break', + 'continue', + 'pass', + 'return', + 'class', + 'class_name', + 'extends', + 'is', + 'as', + 'self', + 'signal', + 'func', + 'static', + 'const', + 'enum', + 'var', + 'onready', + 'export', + 'setget', + 'tool', + 'yield', + 'assert', + 'breakpoint', + 'preload', + 'await', + 'in', + 'not', + 'and', + 'or', + 'true', + 'false', + 'null', + 'PI', + 'TAU', + 'INF', + 'NAN', +]); + +export const TYPESCRIPT_RESERVED = new Set([ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'enum', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'null', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + // Strict mode reserved + 'implements', + 'interface', + 'let', + 'package', + 'private', + 'protected', + 'public', + 'static', + 'yield', +]); + +// ============================================================================ +// Keyword Escaping +// ============================================================================ + +export function escapeSwiftKeyword(name: string): string { + return SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name; +} + +export function escapeKotlinKeyword(name: string): string { + return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name; +} + +export function escapeDartKeyword(name: string): string { + return DART_KEYWORDS.has(name) ? `${name}_` : name; +} + +export function escapeGDScriptKeyword(name: string): string { + return GDSCRIPT_KEYWORDS.has(name) ? `${name}_` : name; +} + +export function escapeTypeScriptKeyword(name: string): string { + // TypeScript generally doesn't need escaping for property names + return name; +} + +// ============================================================================ +// Scalar Mappings +// ============================================================================ + +export const GRAPHQL_TO_SWIFT: Record = { + ID: 'String', + String: 'String', + Boolean: 'Bool', + Int: 'Int', + Float: 'Double', +}; + +export const GRAPHQL_TO_KOTLIN: Record = { + ID: 'String', + String: 'String', + Boolean: 'Boolean', + Int: 'Int', + Float: 'Double', +}; + +export const GRAPHQL_TO_DART: Record = { + ID: 'String', + String: 'String', + Boolean: 'bool', + Int: 'int', + Float: 'double', +}; + +export const GRAPHQL_TO_GDSCRIPT: Record = { + ID: 'String', + String: 'String', + Boolean: 'bool', + Int: 'int', + Float: 'float', +}; + +export const GRAPHQL_TO_TYPESCRIPT: Record = { + ID: 'string', + String: 'string', + Boolean: 'boolean', + Int: 'number', + Float: 'number', +}; + +// ============================================================================ +// Platform Defaults for Discriminated Unions +// ============================================================================ + +export const PLATFORM_TYPE_DEFAULTS: Record< + string, + { platform: string; type: string } +> = { + ProductIOS: { platform: 'ios', type: 'in-app' }, + ProductAndroid: { platform: 'android', type: 'in-app' }, + ProductSubscriptionIOS: { platform: 'ios', type: 'subs' }, + ProductSubscriptionAndroid: { platform: 'android', type: 'subs' }, +}; + +// ============================================================================ +// Custom Types +// ============================================================================ + +export const CUSTOM_INPUT_TYPES = new Set([ + 'RequestPurchaseProps', + 'DiscountOfferInputIOS', + 'PurchaseInput', +]); + +export const TYPE_ALIASES: Record = { + PurchaseInput: 'Purchase', + VoidResult: 'Void', +}; + +// ============================================================================ +// Legacy Aliases for ErrorCode +// ============================================================================ + +export const ERROR_CODE_LEGACY_ALIASES: Record = { + 'receipt-failed': 'purchaseVerificationFailed', + ReceiptFailed: 'purchaseVerificationFailed', +}; + +// ============================================================================ +// File Header +// ============================================================================ + +export function generateFileHeader(language: string): string[] { + const header = [ + '// ============================================================================', + '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', + '// Run `npm run generate` after updating any *.graphql schema file.', + '// ============================================================================', + '', + ]; + + switch (language) { + case 'swift': + header.push('import Foundation', ''); + break; + case 'kotlin': + header.push( + '// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure', + '@file:Suppress("UNCHECKED_CAST")', + '' + ); + break; + case 'dart': + header.push("import 'dart:convert';", ''); + break; + } + + return header; +} + +// ============================================================================ +// Documentation Comments +// ============================================================================ + +export function formatDocComment( + description: string | undefined, + indent: string, + style: 'swift' | 'kotlin' | 'typescript' | 'dart' | 'gdscript' +): string[] { + if (!description) return []; + + const lines = description.split(/\r?\n/); + + switch (style) { + case 'swift': + return lines.map((line) => `${indent}/// ${line}`); + case 'kotlin': + case 'typescript': + case 'dart': + if (lines.length === 1) { + return [`${indent}/** ${lines[0]} */`]; + } + return [ + `${indent}/**`, + ...lines.map((line) => `${indent} * ${line}`), + `${indent} */`, + ]; + case 'gdscript': + return lines.map((line) => `${indent}## ${line}`); + default: + return lines.map((line) => `${indent}// ${line}`); + } +} diff --git a/packages/gql/codegen/index.ts b/packages/gql/codegen/index.ts new file mode 100644 index 00000000..431e1b4d --- /dev/null +++ b/packages/gql/codegen/index.ts @@ -0,0 +1,167 @@ +/** + * OpenIAP GraphQL Code Generation + * + * Unified entry point for generating typed code from GraphQL schema. + */ + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseSchema } from './core/parser.js'; +import { transformSchema } from './core/transformer.js'; +import { SwiftPlugin } from './plugins/swift.js'; +import { KotlinPlugin } from './plugins/kotlin.js'; +import { DartPlugin } from './plugins/dart.js'; +import { GDScriptPlugin } from './plugins/gdscript.js'; +import type { CodegenPlugin } from './plugins/base-plugin.js'; +import type { IRSchema } from './core/types.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ============================================================================ +// Configuration +// ============================================================================ + +export interface GenerateConfig { + /** Languages to generate (default: all) */ + languages?: Array<'swift' | 'kotlin' | 'dart' | 'gdscript'>; + /** Output directory (default: packages/gql/src/generated) */ + outputDir?: string; + /** Whether to log progress */ + verbose?: boolean; +} + +// ============================================================================ +// Main Generator +// ============================================================================ + +export class CodeGenerator { + private config: GenerateConfig; + private schema: IRSchema | null = null; + + constructor(config: GenerateConfig = {}) { + this.config = { + languages: config.languages ?? ['swift', 'kotlin'], + outputDir: config.outputDir ?? resolve(__dirname, '../src/generated'), + verbose: config.verbose ?? true, + }; + } + + /** + * Generate code for all configured languages + */ + async generate(): Promise { + // Parse and transform schema + this.log('Parsing GraphQL schema...'); + const parsedSchema = parseSchema(); + this.schema = transformSchema(parsedSchema); + this.log(`Found ${this.schema.enums.length} enums, ${this.schema.objects.length} objects, ${this.schema.unions.length} unions`); + + // Generate for each language + for (const language of this.config.languages!) { + await this.generateForLanguage(language); + } + + this.log('Code generation complete!'); + } + + /** + * Generate code for a specific language + */ + private async generateForLanguage(language: string): Promise { + const plugin = this.createPlugin(language); + if (!plugin) { + this.log(`Skipping ${language} - plugin not implemented`); + return; + } + + this.log(`Generating ${language}...`); + const output = plugin.generate(this.schema!); + + const outputPath = plugin.getOutputPath(); + const fullPath = resolve(this.config.outputDir!, outputPath); + + // Ensure directory exists + mkdirSync(dirname(fullPath), { recursive: true }); + + // Write file + writeFileSync(fullPath, output); + this.log(` Wrote ${fullPath}`); + } + + /** + * Create a plugin for the given language + */ + private createPlugin(language: string): CodegenPlugin | null { + switch (language) { + case 'swift': + return new SwiftPlugin({ + outputPath: 'Types.swift', + }); + case 'kotlin': + return new KotlinPlugin({ + outputPath: 'Types.kt', + }); + case 'dart': + return new DartPlugin({ + outputPath: 'types.dart', + }); + case 'gdscript': + return new GDScriptPlugin({ + outputPath: 'types.gd', + }); + default: + return null; + } + } + + /** + * Log a message if verbose mode is enabled + */ + private log(message: string): void { + if (this.config.verbose) { + // eslint-disable-next-line no-console + console.log(`[codegen] ${message}`); + } + } +} + +// ============================================================================ +// CLI Entry Point +// ============================================================================ + +async function main() { + const args = process.argv.slice(2); + const languages = args.length > 0 + ? args as Array<'swift' | 'kotlin' | 'dart' | 'gdscript'> + : ['swift', 'kotlin', 'dart', 'gdscript']; + + const generator = new CodeGenerator({ languages }); + await generator.generate(); +} + +// Run if executed directly (Bun-compatible check) +const isMain = + typeof Bun !== 'undefined' + ? Bun.main === import.meta.path + : import.meta.url === `file://${process.argv[1]}`; + +if (isMain) { + main().catch((err) => { + console.error('Code generation failed:', err); + process.exit(1); + }); +} + +// ============================================================================ +// Exports +// ============================================================================ + +export { parseSchema } from './core/parser.js'; +export { transformSchema } from './core/transformer.js'; +export { SwiftPlugin } from './plugins/swift.js'; +export { KotlinPlugin } from './plugins/kotlin.js'; +export { DartPlugin } from './plugins/dart.js'; +export { GDScriptPlugin } from './plugins/gdscript.js'; +export type { IRSchema, IREnum, IRObject, IRUnion, IRType } from './core/types.js'; diff --git a/packages/gql/codegen/plugins/base-plugin.ts b/packages/gql/codegen/plugins/base-plugin.ts new file mode 100644 index 00000000..556baf39 --- /dev/null +++ b/packages/gql/codegen/plugins/base-plugin.ts @@ -0,0 +1,251 @@ +/** + * Base Plugin for Code Generation + * + * Abstract base class that defines the interface for all language-specific plugins. + * Each plugin must implement the abstract methods to generate code for their target language. + */ + +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, +} from '../core/types.js'; + +// ============================================================================ +// Plugin Interface +// ============================================================================ + +export interface CodegenPluginConfig { + /** Output file path (relative to package root) */ + outputPath: string; + /** Package name for languages that require it (e.g., Kotlin) */ + packageName?: string; +} + +export abstract class CodegenPlugin { + /** Plugin name (e.g., 'swift', 'kotlin') */ + abstract readonly name: string; + + /** File extension (e.g., '.swift', '.kt') */ + abstract readonly fileExtension: string; + + /** Plugin configuration */ + protected config: CodegenPluginConfig; + + /** Output lines buffer */ + protected lines: string[] = []; + + constructor(config: CodegenPluginConfig) { + this.config = config; + } + + // ============================================================================ + // Abstract Methods - Must be implemented by each plugin + // ============================================================================ + + /** Map GraphQL scalar to language type */ + abstract mapScalar(name: string): string; + + /** Map IR type to language type string */ + abstract mapType(type: IRType): string; + + /** Set of language keywords that need escaping */ + abstract readonly keywords: Set; + + /** Escape a name if it conflicts with language keywords */ + abstract escapeKeyword(name: string): string; + + /** Convert enum value name to language convention */ + abstract enumValueCase(name: string): string; + + /** Convert field name to language convention */ + abstract fieldNameCase(name: string): string; + + /** Generate file header (imports, package declaration, etc.) */ + abstract generateHeader(): void; + + /** Generate enum type */ + abstract generateEnum(irEnum: IREnum): void; + + /** Generate interface/protocol type */ + abstract generateInterface(irInterface: IRInterface): void; + + /** Generate object/struct/data class type */ + abstract generateObject(irObject: IRObject): void; + + /** Generate input type */ + abstract generateInput(irInput: IRInput): void; + + /** Generate union type */ + abstract generateUnion(irUnion: IRUnion): void; + + /** Generate operation resolver interface and helpers */ + abstract generateOperation(irOperation: IROperation): void; + + /** Post-process the generated output (optional) */ + postProcess(output: string): string { + return output; + } + + // ============================================================================ + // Common Methods + // ============================================================================ + + /** + * Generate code for the entire schema + */ + generate(schema: IRSchema): string { + this.lines = []; + + // Header + this.generateHeader(); + + // Enums + if (schema.enums.length > 0) { + this.addSectionComment('Enums'); + for (const irEnum of schema.enums) { + this.generateEnum(irEnum); + } + } + + // Interfaces + if (schema.interfaces.length > 0) { + this.addSectionComment('Interfaces'); + for (const irInterface of schema.interfaces) { + this.generateInterface(irInterface); + } + } + + // Objects + if (schema.objects.length > 0) { + this.addSectionComment('Objects'); + for (const irObject of schema.objects) { + this.generateObject(irObject); + } + } + + // Inputs + if (schema.inputs.length > 0) { + this.addSectionComment('Input Objects'); + for (const irInput of schema.inputs) { + this.generateInput(irInput); + } + } + + // Unions + if (schema.unions.length > 0) { + this.addSectionComment('Unions'); + for (const irUnion of schema.unions) { + this.generateUnion(irUnion); + } + } + + // Operations + if (schema.operations.length > 0) { + this.addSectionComment('Root Operations'); + for (const irOperation of schema.operations) { + this.generateOperation(irOperation); + } + } + + const output = this.lines.join('\n'); + return this.postProcess(output); + } + + /** + * Add a line to the output + */ + protected emit(line: string = ''): void { + this.lines.push(line); + } + + /** + * Add multiple lines to the output + */ + protected emitLines(lines: string[]): void { + for (const line of lines) { + this.emit(line); + } + } + + /** + * Add a section comment + */ + protected addSectionComment(title: string): void { + this.emit(`// MARK: - ${title}`); + this.emit(''); + } + + /** + * Get the output file path + */ + getOutputPath(): string { + return this.config.outputPath; + } + + // ============================================================================ + // Helper Methods for Subclasses + // ============================================================================ + + /** + * Generate documentation comment + */ + protected generateDocComment( + description: string | undefined, + indent: string = '' + ): void { + // Override in subclasses for language-specific doc comments + if (!description) return; + for (const line of description.split(/\r?\n/)) { + this.emit(`${indent}// ${line}`); + } + } + + /** + * Check if a type is nullable + */ + protected isNullable(type: IRType): boolean { + return type.nullable; + } + + /** + * Get the element type for a list type + */ + protected getListElementType(type: IRType): IRType | undefined { + return type.kind === 'list' ? type.elementType : undefined; + } + + /** + * Check if type is a scalar + */ + protected isScalar(type: IRType): boolean { + return type.kind === 'scalar'; + } + + /** + * Check if type is an enum + */ + protected isEnum(type: IRType): boolean { + return type.kind === 'enum'; + } + + /** + * Check if type is a list + */ + protected isList(type: IRType): boolean { + return type.kind === 'list'; + } + + /** + * Get the base type name for a named type + */ + protected getTypeName(type: IRType): string | undefined { + return type.name; + } +} diff --git a/packages/gql/codegen/plugins/dart.ts b/packages/gql/codegen/plugins/dart.ts new file mode 100644 index 00000000..07b8c961 --- /dev/null +++ b/packages/gql/codegen/plugins/dart.ts @@ -0,0 +1,895 @@ +/** + * Dart Code Generation Plugin + * + * Generates Dart types with fromJson/toJson methods from GraphQL schema. + * Uses the IR (Intermediate Representation) for maintainable code generation. + */ + +import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IROperationField, +} from '../core/types.js'; +import { + DART_KEYWORDS, + GRAPHQL_TO_DART, + toPascalCasePreserveIOS, + toKebabCase, + PLATFORM_TYPE_DEFAULTS, +} from '../core/utils.js'; + +export class DartPlugin extends CodegenPlugin { + readonly name = 'dart'; + readonly fileExtension = '.dart'; + readonly keywords = DART_KEYWORDS; + + private schema!: IRSchema; + private enumNames = new Set(); + private objectNames = new Set(); + private inputNames = new Set(); + private unionNames = new Set(); + private interfaceNames = new Set(); + + constructor(config: CodegenPluginConfig) { + super(config); + } + + // ============================================================================ + // Type Mapping + // ============================================================================ + + mapScalar(name: string): string { + return GRAPHQL_TO_DART[name] ?? 'dynamic'; + } + + mapType(type: IRType): string { + if (type.kind === 'list') { + const elementType = this.mapType(type.elementType!); + const element = type.elementType!.nullable ? `${elementType}?` : elementType; + return `List<${element}>`; + } + if (type.kind === 'scalar') { + return this.mapScalar(type.name!); + } + return type.name!; + } + + escapeKeyword(name: string): string { + return this.keywords.has(name) ? `_${name}` : name; + } + + enumValueCase(name: string): string { + return toPascalCasePreserveIOS(name); + } + + fieldNameCase(name: string): string { + return name; // Dart uses camelCase which matches GraphQL field names + } + + // ============================================================================ + // Code Generation + // ============================================================================ + + generate(schema: IRSchema): string { + this.schema = schema; + + // Build type name sets for reference + for (const e of schema.enums) this.enumNames.add(e.name); + for (const o of schema.objects) this.objectNames.add(o.name); + for (const i of schema.inputs) this.inputNames.add(i.name); + for (const u of schema.unions) this.unionNames.add(u.name); + for (const i of schema.interfaces) this.interfaceNames.add(i.name); + + this.lines = []; + this.generateHeader(); + + // Enums + if (schema.enums.length > 0) { + this.emit('// MARK: - Enums'); + this.emit(''); + for (const irEnum of schema.enums) { + this.generateEnum(irEnum); + } + } + + // Interfaces + if (schema.interfaces.length > 0) { + this.emit('// MARK: - Interfaces'); + this.emit(''); + for (const irInterface of schema.interfaces) { + this.generateInterface(irInterface); + } + } + + // Objects + if (schema.objects.length > 0) { + this.emit('// MARK: - Objects'); + this.emit(''); + for (const irObject of schema.objects) { + this.generateObject(irObject); + } + } + + // Inputs + if (schema.inputs.length > 0) { + this.emit('// MARK: - Input Objects'); + this.emit(''); + for (const irInput of schema.inputs) { + this.generateInput(irInput); + } + } + + // Unions + if (schema.unions.length > 0) { + this.emit('// MARK: - Unions'); + this.emit(''); + for (const irUnion of schema.unions) { + this.generateUnion(irUnion); + } + } + + // Operations + if (schema.operations.length > 0) { + this.emit('// MARK: - Root Operations'); + this.emit(''); + for (const irOperation of schema.operations) { + this.generateOperation(irOperation); + } + + this.emit('// MARK: - Root Operation Helpers'); + this.emit(''); + for (const irOperation of schema.operations) { + this.generateOperationHelpers(irOperation); + } + } + + return this.lines.join('\n'); + } + + generateHeader(): void { + this.emit('// ============================================================================'); + this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); + this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); + this.emit('// ============================================================================'); + this.emit(''); + this.emit('// ignore_for_file: unused_element, unused_field'); + this.emit(''); + this.emit("import 'dart:async';"); + this.emit(''); + } + + protected generateDocComment(description: string | undefined, indent: string = ''): void { + if (!description) return; + for (const line of description.split(/\r?\n/)) { + this.emit(`${indent}/// ${line}`); + } + } + + // ============================================================================ + // Enums + // ============================================================================ + + generateEnum(irEnum: IREnum): void { + this.generateDocComment(irEnum.description); + this.emit(`enum ${irEnum.name} {`); + + const values = irEnum.values; + values.forEach((value, index) => { + this.generateDocComment(value.description, ' '); + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + const suffix = index === values.length - 1 ? ';' : ','; + this.emit(` ${caseName}('${value.rawValue}')${suffix}`); + }); + + this.emit(''); + this.emit(` const ${irEnum.name}(this.value);`); + this.emit(' final String value;'); + this.emit(''); + this.emit(` factory ${irEnum.name}.fromJson(String value) {`); + this.emit(" final normalized = value.toLowerCase().replaceAll('_', '-');"); + this.emit(' switch (normalized) {'); + + for (const value of values) { + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + this.emit(` case '${value.rawValue}':`); + this.emit(` return ${irEnum.name}.${caseName};`); + } + + this.emit(' }'); + this.emit(` throw ArgumentError('Unknown ${irEnum.name} value: \$value');`); + this.emit(' }'); + this.emit(''); + this.emit(' String toJson() => value;'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Interfaces + // ============================================================================ + + generateInterface(irInterface: IRInterface): void { + this.generateDocComment(irInterface.description); + this.emit(`abstract class ${irInterface.name} {`); + + // Sort fields alphabetically for Dart + const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(field.name); + this.emit(` ${propertyType} get ${propertyName};`); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Objects + // ============================================================================ + + generateObject(irObject: IRObject): void { + // Handle VoidResult + if (irObject.name === 'VoidResult') { + this.emit('typedef VoidResult = void;'); + this.emit(''); + return; + } + + // Handle result union wrappers (FetchProductsResult, etc.) + if (irObject.isResultUnion && irObject.resultUnionEntries) { + this.generateResultUnionObject(irObject); + return; + } + + this.generateDocComment(irObject.description); + + // Build extends and implements clauses + const unionsForObject = irObject.unions || []; + const baseUnion = unionsForObject.length > 0 ? unionsForObject[0] : null; + const otherUnions = unionsForObject.slice(1); + const implementsTargets = [...irObject.interfaces, ...otherUnions]; + + const extendsClause = baseUnion ? ` extends ${baseUnion}` : ''; + const implementsClause = implementsTargets.length > 0 + ? ` implements ${implementsTargets.join(', ')}` + : ''; + + this.emit(`class ${irObject.name}${extendsClause}${implementsClause} {`); + this.emit(` const ${irObject.name}({`); + + // Sort fields alphabetically for Dart + const sortedFields = [...irObject.fields].sort((a, b) => a.name.localeCompare(b.name)); + + // Constructor parameters + for (const field of sortedFields) { + const defaults = PLATFORM_TYPE_DEFAULTS[irObject.name]; + let defaultValue = ''; + + if (defaults) { + if (field.name === 'platform') { + const platformEnum = defaults.platform === 'ios' ? 'IapPlatform.IOS' : 'IapPlatform.Android'; + defaultValue = ` = ${platformEnum}`; + } else if (field.name === 'type') { + const typeEnum = defaults.type === 'in-app' ? 'ProductType.InApp' : 'ProductType.Subs'; + defaultValue = ` = ${typeEnum}`; + } + } + + if (defaultValue) { + this.emit(` this.${this.escapeKeyword(field.name)}${defaultValue},`); + } else if (field.type.nullable) { + this.emit(` this.${this.escapeKeyword(field.name)},`); + } else { + this.emit(` required this.${this.escapeKeyword(field.name)},`); + } + } + + // Special handling for PurchaseAndroid and PurchaseIOS + const needsAlternativeBilling = (irObject.name === 'PurchaseAndroid' || irObject.name === 'PurchaseIOS') + && !sortedFields.some(f => f.name === 'isAlternativeBilling'); + if (needsAlternativeBilling) { + this.emit(' this.isAlternativeBilling,'); + } + + this.emit(' });'); + this.emit(''); + + // Field declarations + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + this.emit(` final ${propertyType} ${this.escapeKeyword(field.name)};`); + } + if (needsAlternativeBilling) { + this.emit(' final bool? isAlternativeBilling;'); + } + + // fromJson factory + this.emit(''); + this.emit(` factory ${irObject.name}.fromJson(Map json) {`); + this.emit(` return ${irObject.name}(`); + for (const field of sortedFields) { + const jsonExpr = this.buildFromJsonExpression(field.type, `json['${field.name}']`); + this.emit(` ${this.escapeKeyword(field.name)}: ${jsonExpr},`); + } + if (needsAlternativeBilling) { + this.emit(` isAlternativeBilling: json['isAlternativeBilling'] as bool?,`); + } + this.emit(' );'); + this.emit(' }'); + + // toJson method + this.emit(''); + if (baseUnion) { + this.emit(' @override'); + } + this.emit(' Map toJson() {'); + this.emit(' return {'); + this.emit(` '__typename': '${irObject.name}',`); + for (const field of sortedFields) { + const toJsonExpr = this.buildToJsonExpression(field.type, this.escapeKeyword(field.name)); + this.emit(` '${field.name}': ${toJsonExpr},`); + } + if (needsAlternativeBilling) { + this.emit(` 'isAlternativeBilling': isAlternativeBilling,`); + } + this.emit(' };'); + this.emit(' }'); + + this.emit('}'); + this.emit(''); + } + + private generateResultUnionObject(irObject: IRObject): void { + this.generateDocComment(irObject.description); + this.emit(`abstract class ${irObject.name} {`); + this.emit(` const ${irObject.name}();`); + this.emit('}'); + this.emit(''); + + // Sort entries alphabetically + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => + a.fieldName.localeCompare(b.fieldName) + ); + for (const entry of sortedEntries) { + const className = `${irObject.name}${toPascalCasePreserveIOS(entry.fieldName)}`; + const valueType = this.getPropertyType(entry.type); + this.emit(`class ${className} extends ${irObject.name} {`); + this.emit(` const ${className}(this.value);`); + this.emit(` final ${valueType} value;`); + this.emit('}'); + this.emit(''); + } + } + + // ============================================================================ + // Inputs + // ============================================================================ + + generateInput(irInput: IRInput): void { + // Handle PurchaseInput alias + if (irInput.name === 'PurchaseInput') { + this.emit('typedef PurchaseInput = Purchase;'); + this.emit(''); + return; + } + + // Handle RequestPurchaseProps special case + if (irInput.name === 'RequestPurchaseProps') { + this.generateRequestPurchaseProps(irInput); + return; + } + + this.generateDocComment(irInput.description); + this.emit(`class ${irInput.name} {`); + this.emit(` const ${irInput.name}({`); + + // Sort fields alphabetically for Dart + const sortedFields = [...irInput.fields].sort((a, b) => a.name.localeCompare(b.name)); + + for (const field of sortedFields) { + if (field.type.nullable) { + this.emit(` this.${this.escapeKeyword(field.name)},`); + } else { + this.emit(` required this.${this.escapeKeyword(field.name)},`); + } + } + + this.emit(' });'); + this.emit(''); + + // Field declarations + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + this.emit(` final ${propertyType} ${this.escapeKeyword(field.name)};`); + } + + // fromJson factory + this.emit(''); + this.emit(` factory ${irInput.name}.fromJson(Map json) {`); + this.emit(` return ${irInput.name}(`); + for (const field of sortedFields) { + const jsonExpr = this.buildFromJsonExpression(field.type, `json['${field.name}']`); + this.emit(` ${this.escapeKeyword(field.name)}: ${jsonExpr},`); + } + this.emit(' );'); + this.emit(' }'); + + // toJson method + this.emit(''); + this.emit(' Map toJson() {'); + this.emit(' return {'); + for (const field of sortedFields) { + const toJsonExpr = this.buildToJsonExpression(field.type, this.escapeKeyword(field.name)); + this.emit(` '${field.name}': ${toJsonExpr},`); + } + this.emit(' };'); + this.emit(' }'); + + this.emit('}'); + this.emit(''); + } + + private generateRequestPurchaseProps(irInput: IRInput): void { + this.generateDocComment(irInput.description); + + // Find the platform-specific types from schema + const purchaseByPlatforms = this.schema.inputs.find(i => i.name === 'RequestPurchasePropsByPlatforms'); + const subsByPlatforms = this.schema.inputs.find(i => i.name === 'RequestSubscriptionPropsByPlatforms'); + + // Log warnings if fallback types are used (schema drift detection) + if (!purchaseByPlatforms) { + console.warn('[dart] RequestPurchasePropsByPlatforms not found in schema, using fallback types'); + } + if (!subsByPlatforms) { + console.warn('[dart] RequestSubscriptionPropsByPlatforms not found in schema, using fallback types'); + } + + const appleName = 'apple'; + const googleName = 'google'; + const appleType = purchaseByPlatforms?.fields.find(f => f.name === 'apple') + ? this.mapType(purchaseByPlatforms.fields.find(f => f.name === 'apple')!.type) + : 'RequestPurchaseIosProps'; + const googleType = purchaseByPlatforms?.fields.find(f => f.name === 'google') + ? this.mapType(purchaseByPlatforms.fields.find(f => f.name === 'google')!.type) + : 'RequestPurchaseAndroidProps'; + const appleSubsType = subsByPlatforms?.fields.find(f => f.name === 'apple') + ? this.mapType(subsByPlatforms.fields.find(f => f.name === 'apple')!.type) + : 'RequestSubscriptionIosProps'; + const googleSubsType = subsByPlatforms?.fields.find(f => f.name === 'google') + ? this.mapType(subsByPlatforms.fields.find(f => f.name === 'google')!.type) + : 'RequestSubscriptionAndroidProps'; + + this.emit('sealed class RequestPurchaseProps {'); + this.emit(' const RequestPurchaseProps._();'); + this.emit(''); + this.emit(' const factory RequestPurchaseProps.inApp(({'); + this.emit(` ${appleType}? ${appleName},`); + this.emit(` ${googleType}? ${googleName},`); + this.emit(' bool? useAlternativeBilling,'); + this.emit(' }) props) = _InAppPurchase;'); + this.emit(''); + this.emit(' const factory RequestPurchaseProps.subs(({'); + this.emit(` ${appleSubsType}? ${appleName},`); + this.emit(` ${googleSubsType}? ${googleName},`); + this.emit(' bool? useAlternativeBilling,'); + this.emit(' }) props) = _SubsPurchase;'); + this.emit(''); + this.emit(' Map toJson();'); + this.emit('}'); + this.emit(''); + + // _InAppPurchase implementation + this.emit('class _InAppPurchase extends RequestPurchaseProps {'); + this.emit(' const _InAppPurchase(this.props) : super._();'); + this.emit(' final ({'); + this.emit(` ${appleType}? ${appleName},`); + this.emit(` ${googleType}? ${googleName},`); + this.emit(' bool? useAlternativeBilling,'); + this.emit(' }) props;'); + this.emit(''); + this.emit(' @override'); + this.emit(' Map toJson() {'); + this.emit(' return {'); + this.emit(" 'requestPurchase': {"); + this.emit(` if (props.${appleName} != null) 'ios': props.${appleName}!.toJson(),`); + this.emit(` if (props.${googleName} != null) 'android': props.${googleName}!.toJson(),`); + this.emit(' },'); + this.emit(" 'type': ProductQueryType.InApp.toJson(),"); + this.emit(" if (props.useAlternativeBilling != null) 'useAlternativeBilling': props.useAlternativeBilling,"); + this.emit(' };'); + this.emit(' }'); + this.emit('}'); + this.emit(''); + + // _SubsPurchase implementation + this.emit('class _SubsPurchase extends RequestPurchaseProps {'); + this.emit(' const _SubsPurchase(this.props) : super._();'); + this.emit(' final ({'); + this.emit(` ${appleSubsType}? ${appleName},`); + this.emit(` ${googleSubsType}? ${googleName},`); + this.emit(' bool? useAlternativeBilling,'); + this.emit(' }) props;'); + this.emit(''); + this.emit(' @override'); + this.emit(' Map toJson() {'); + this.emit(' return {'); + this.emit(" 'requestSubscription': {"); + this.emit(` if (props.${appleName} != null) 'ios': props.${appleName}!.toJson(),`); + this.emit(` if (props.${googleName} != null) 'android': props.${googleName}!.toJson(),`); + this.emit(' },'); + this.emit(" 'type': ProductQueryType.Subs.toJson(),"); + this.emit(" if (props.useAlternativeBilling != null) 'useAlternativeBilling': props.useAlternativeBilling,"); + this.emit(' };'); + this.emit(' }'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Unions + // ============================================================================ + + generateUnion(irUnion: IRUnion): void { + this.generateDocComment(irUnion.description); + + // Find shared interfaces + const sharedInterfaces = irUnion.sharedInterfaces || []; + const implementsClause = sharedInterfaces.length > 0 + ? ` implements ${sharedInterfaces.join(', ')}` + : ''; + + this.emit(`sealed class ${irUnion.name}${implementsClause} {`); + this.emit(` const ${irUnion.name}();`); + this.emit(''); + + // fromJson factory + this.emit(` factory ${irUnion.name}.fromJson(Map json) {`); + this.emit(` final typeName = json['__typename'] as String?;`); + this.emit(' switch (typeName) {'); + + // Handle nested unions by getting concrete members + const concreteMembers: string[] = []; + const nestedUnionWrappers = new Map(); + + for (const member of irUnion.members) { + const nestedUnion = this.schema.unions.find(u => u.name === member.name); + if (nestedUnion) { + // This member is a union - add its concrete members + for (const nestedMember of nestedUnion.members) { + concreteMembers.push(nestedMember.name); + nestedUnionWrappers.set(nestedMember.name, member.name); + } + } else { + concreteMembers.push(member.name); + } + } + + for (const member of concreteMembers.sort()) { + const nestedUnionName = nestedUnionWrappers.get(member); + if (nestedUnionName) { + const wrapperName = `${irUnion.name}${nestedUnionName}`; + this.emit(` case '${member}':`); + this.emit(` return ${wrapperName}(${nestedUnionName}.fromJson(json));`); + } else { + this.emit(` case '${member}':`); + this.emit(` return ${member}.fromJson(json);`); + } + } + + this.emit(' }'); + this.emit(` throw ArgumentError('Unknown __typename for ${irUnion.name}: \$typeName');`); + this.emit(' }'); + + // Generate interface getters if there are shared interfaces + if (sharedInterfaces.length > 0) { + this.emit(''); + for (const interfaceName of sharedInterfaces) { + const iface = this.schema.interfaces.find(i => i.name === interfaceName); + if (iface) { + // Sort fields alphabetically + const sortedFields = [...iface.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + this.emit(' @override'); + this.emit(` ${propertyType} get ${this.escapeKeyword(field.name)};`); + } + } + } + } + + this.emit(''); + this.emit(' Map toJson();'); + this.emit('}'); + this.emit(''); + + // Generate wrapper classes for nested unions + const nestedUnionNames = new Set(nestedUnionWrappers.values()); + for (const nestedUnionName of Array.from(nestedUnionNames).sort()) { + const wrapperName = `${irUnion.name}${nestedUnionName}`; + this.emit(`class ${wrapperName} extends ${irUnion.name} {`); + this.emit(` const ${wrapperName}(this.value);`); + this.emit(` final ${nestedUnionName} value;`); + this.emit(''); + this.emit(' @override'); + this.emit(' Map toJson() => value.toJson();'); + this.emit('}'); + this.emit(''); + } + } + + // ============================================================================ + // Operations + // ============================================================================ + + generateOperation(irOperation: IROperation): void { + const interfaceName = `${irOperation.name}Resolver`; + this.generateDocComment(irOperation.description ?? `GraphQL root ${irOperation.name.toLowerCase()} operations.`); + this.emit(`abstract class ${interfaceName} {`); + + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const returnType = this.getOperationReturnType(field); + + if (field.args.length === 0) { + this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}();`); + continue; + } + + // Check if we should expand params + const expandableParams = ['params', 'options', 'config', 'props']; + const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); + + if (expandableArg && expandableArg.type.name) { + const inputType = this.schema.inputs.find(i => i.name === expandableArg.type.name); + if (inputType && inputType.name !== 'RequestPurchaseProps') { + const otherArgs = field.args.filter(arg => arg !== expandableArg); + this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}({`); + + // Sort expanded fields alphabetically + const sortedInputFields = [...inputType.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const f of sortedInputFields) { + const argType = this.getPropertyType(f.type); + const prefix = f.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(f.name)},`); + } + + for (const arg of otherArgs) { + const argType = this.getPropertyType(arg.type); + const prefix = arg.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(arg.name)},`); + } + + this.emit(' });'); + continue; + } + } + + if (field.args.length === 1) { + const arg = field.args[0]; + const argType = this.getPropertyType(arg.type); + const argName = this.escapeKeyword(arg.name); + if (arg.type.nullable) { + this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}([${argType} ${argName}]);`); + } else { + this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}(${argType} ${argName});`); + } + continue; + } + + this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}({`); + for (const arg of field.args) { + const argType = this.getPropertyType(arg.type); + const prefix = arg.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(arg.name)},`); + } + this.emit(' });'); + } + + this.emit('}'); + this.emit(''); + } + + private generateOperationHelpers(irOperation: IROperation): void { + const rootName = irOperation.name; + + this.emit(`// MARK: - ${rootName} Helpers`); + this.emit(''); + + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const field of sortedFields) { + const pascalField = toPascalCasePreserveIOS(field.name); + const aliasName = `${rootName}${pascalField}Handler`; + const returnType = this.getOperationReturnType(field); + + if (field.args.length === 0) { + this.emit(`typedef ${aliasName} = Future<${returnType}> Function();`); + continue; + } + + // Check if we should expand params + const expandableParams = ['params', 'options', 'config', 'props']; + const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); + + if (expandableArg && expandableArg.type.name) { + const inputType = this.schema.inputs.find(i => i.name === expandableArg.type.name); + if (inputType && inputType.name !== 'RequestPurchaseProps') { + const otherArgs = field.args.filter(arg => arg !== expandableArg); + this.emit(`typedef ${aliasName} = Future<${returnType}> Function({`); + + // Sort expanded fields alphabetically + const sortedInputFields = [...inputType.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const f of sortedInputFields) { + const argType = this.getPropertyType(f.type); + const prefix = f.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(f.name)},`); + } + + for (const arg of otherArgs) { + const argType = this.getPropertyType(arg.type); + const prefix = arg.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(arg.name)},`); + } + + this.emit('});'); + continue; + } + } + + if (field.args.length === 1) { + const arg = field.args[0]; + const argType = this.getPropertyType(arg.type); + const argName = this.escapeKeyword(arg.name); + if (arg.type.nullable) { + this.emit(`typedef ${aliasName} = Future<${returnType}> Function([${argType} ${argName}]);`); + } else { + this.emit(`typedef ${aliasName} = Future<${returnType}> Function(${argType} ${argName});`); + } + continue; + } + + this.emit(`typedef ${aliasName} = Future<${returnType}> Function({`); + for (const arg of field.args) { + const argType = this.getPropertyType(arg.type); + const prefix = arg.type.nullable ? '' : 'required '; + this.emit(` ${prefix}${argType} ${this.escapeKeyword(arg.name)},`); + } + this.emit('});'); + } + + // Handler class + const helperClass = `${rootName}Handlers`; + this.emit(''); + this.emit(`class ${helperClass} {`); + this.emit(` const ${helperClass}({`); + + for (const field of sortedFields) { + this.emit(` this.${this.escapeKeyword(field.name)},`); + } + + this.emit(' });'); + this.emit(''); + + for (const field of sortedFields) { + const pascalField = toPascalCasePreserveIOS(field.name); + const aliasName = `${rootName}${pascalField}Handler`; + this.emit(` final ${aliasName}? ${this.escapeKeyword(field.name)};`); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Helpers + // ============================================================================ + + private getPropertyType(type: IRType): string { + const baseType = this.mapType(type); + return type.nullable ? `${baseType}?` : baseType; + } + + private getOperationReturnType(field: IROperationField): string { + // Handle VoidResult + if (field.returnType.name === 'VoidResult') { + return 'void'; // void cannot be nullable in Dart + } + + // Handle single-field wrapper types (e.g., ProductsArgs -> List) + if (field.returnType.name && field.returnType.name.endsWith('Args')) { + const wrapperObj = this.schema.objects.find(o => o.name === field.returnType.name); + if (wrapperObj && wrapperObj.fields.length === 1) { + const innerType = this.getPropertyType(wrapperObj.fields[0].type); + return field.returnType.nullable ? `${innerType}?` : innerType; + } + } + + return this.getPropertyType(field.returnType); + } + + private buildFromJsonExpression(type: IRType, sourceExpr: string): string { + if (type.kind === 'list') { + const listCast = `(${sourceExpr} as List${type.nullable ? '?' : ''})`; + const elementExpr = this.buildFromJsonExpression(type.elementType!, 'e'); + const mapCall = (target: string) => `${target}.map((e) => ${elementExpr}).toList()`; + if (type.nullable) { + return `${listCast} == null ? null : ${mapCall(`${listCast}!`)}`; + } + return mapCall(listCast); + } + + if (type.kind === 'scalar') { + switch (type.name) { + case 'Float': + return type.nullable + ? `(${sourceExpr} as num?)?.toDouble()` + : `(${sourceExpr} as num).toDouble()`; + case 'Int': + return type.nullable ? `${sourceExpr} as int?` : `${sourceExpr} as int`; + case 'Boolean': + return type.nullable ? `${sourceExpr} as bool?` : `${sourceExpr} as bool`; + case 'ID': + case 'String': + return type.nullable ? `${sourceExpr} as String?` : `${sourceExpr} as String`; + default: + return sourceExpr; + } + } + + if (type.kind === 'enum') { + return type.nullable + ? `${sourceExpr} != null ? ${type.name}.fromJson(${sourceExpr} as String) : null` + : `${type.name}.fromJson(${sourceExpr} as String)`; + } + + if (['object', 'input', 'interface', 'union'].includes(type.kind)) { + return type.nullable + ? `${sourceExpr} != null ? ${type.name}.fromJson(${sourceExpr} as Map) : null` + : `${type.name}.fromJson(${sourceExpr} as Map)`; + } + + return sourceExpr; + } + + private buildToJsonExpression(type: IRType, accessorExpr: string): string { + if (type.kind === 'list') { + const inner = this.buildToJsonExpression(type.elementType!, 'e'); + if (inner === 'e') return accessorExpr; + if (type.nullable) { + return `${accessorExpr} == null ? null : ${accessorExpr}!.map((e) => ${inner}).toList()`; + } + return `${accessorExpr}.map((e) => ${inner}).toList()`; + } + + if (type.kind === 'enum') { + return type.nullable ? `${accessorExpr}?.toJson()` : `${accessorExpr}.toJson()`; + } + + if (['object', 'input', 'interface', 'union'].includes(type.kind)) { + return type.nullable ? `${accessorExpr}?.toJson()` : `${accessorExpr}.toJson()`; + } + + return accessorExpr; + } +} diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts new file mode 100644 index 00000000..7e7a78cc --- /dev/null +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -0,0 +1,637 @@ +/** + * GDScript Code Generation Plugin + * + * Generates GDScript types with from_dict/to_dict methods from GraphQL schema. + * Uses the IR (Intermediate Representation) for maintainable code generation. + */ + +import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IROperationField, +} from '../core/types.js'; +import { + GDSCRIPT_KEYWORDS, + GRAPHQL_TO_GDSCRIPT, + toSnakeCase, + toConstantCase, + toKebabCase, +} from '../core/utils.js'; + +export class GDScriptPlugin extends CodegenPlugin { + readonly name = 'gdscript'; + readonly fileExtension = '.gd'; + readonly keywords = GDSCRIPT_KEYWORDS; + + private schema!: IRSchema; + private enumNames = new Set(); + private objectNames = new Set(); + private inputNames = new Set(); + private unionNames = new Set(); + + // Field name aliases for cleaner API + private readonly fieldNameAliases = new Map([ + ['RequestPurchaseProps.requestPurchase', 'request'], + ['RequestSubscriptionProps.requestSubscription', 'request'], + ]); + + constructor(config: CodegenPluginConfig) { + super(config); + } + + // ============================================================================ + // Type Mapping + // ============================================================================ + + mapScalar(name: string): string { + return GRAPHQL_TO_GDSCRIPT[name] ?? 'Variant'; + } + + mapType(type: IRType): string { + if (type.kind === 'list') { + const elementType = this.mapType(type.elementType!); + return `Array[${elementType}]`; + } + if (type.kind === 'scalar') { + return this.mapScalar(type.name!); + } + if (type.kind === 'union') { + return 'Variant'; + } + return type.name!; + } + + escapeKeyword(name: string): string { + return this.keywords.has(name.toLowerCase()) ? `_${name}` : name; + } + + enumValueCase(name: string): string { + return toConstantCase(name); + } + + fieldNameCase(name: string): string { + return toSnakeCase(name); + } + + private getGdscriptFieldName(fieldName: string, typeName: string | null = null): string { + if (typeName) { + const key = `${typeName}.${fieldName}`; + if (this.fieldNameAliases.has(key)) { + return toSnakeCase(this.escapeKeyword(this.fieldNameAliases.get(key)!)); + } + } + return toSnakeCase(this.escapeKeyword(fieldName)); + } + + // ============================================================================ + // Code Generation + // ============================================================================ + + generate(schema: IRSchema): string { + this.schema = schema; + + // Clear caches to support plugin reuse + this.enumNames.clear(); + this.objectNames.clear(); + this.inputNames.clear(); + this.unionNames.clear(); + + // Build type name sets for reference + for (const e of schema.enums) this.enumNames.add(e.name); + for (const o of schema.objects) this.objectNames.add(o.name); + for (const i of schema.inputs) this.inputNames.add(i.name); + for (const u of schema.unions) this.unionNames.add(u.name); + + this.lines = []; + this.generateHeader(); + + // Enums + this.emit('# ============================================================================'); + this.emit('# Enums'); + this.emit('# ============================================================================'); + this.emit(''); + for (const irEnum of schema.enums) { + this.generateEnum(irEnum); + } + + // Objects (Types) + this.emit('# ============================================================================'); + this.emit('# Types'); + this.emit('# ============================================================================'); + this.emit(''); + for (const irObject of schema.objects) { + // Skip union wrapper types + if (irObject.isResultUnion) continue; + this.generateObject(irObject); + } + + // Inputs + this.emit('# ============================================================================'); + this.emit('# Input Types'); + this.emit('# ============================================================================'); + this.emit(''); + for (const irInput of schema.inputs) { + this.generateInput(irInput); + } + + // Enum helpers + this.emit('# ============================================================================'); + this.emit('# Enum String Helpers'); + this.emit('# ============================================================================'); + this.emit(''); + for (const irEnum of schema.enums) { + this.generateEnumValueHelper(irEnum); + } + + this.emit('# ============================================================================'); + this.emit('# Enum Reverse Lookup (string -> enum for deserialization)'); + this.emit('# ============================================================================'); + this.emit(''); + for (const irEnum of schema.enums) { + this.generateEnumReverseLookup(irEnum); + } + + // Operations + if (schema.operations.length > 0) { + this.emit('# ============================================================================'); + this.emit('# Query Types'); + this.emit('# ============================================================================'); + this.emit(''); + const queryOp = schema.operations.find(op => op.name === 'Query'); + if (queryOp) { + this.generateOperation(queryOp); + } + + this.emit('# ============================================================================'); + this.emit('# Mutation Types'); + this.emit('# ============================================================================'); + this.emit(''); + const mutationOp = schema.operations.find(op => op.name === 'Mutation'); + if (mutationOp) { + this.generateOperation(mutationOp); + } + + this.emit('# ============================================================================'); + this.emit('# API Wrapper Functions'); + this.emit('# These typed functions can be used by godot-iap wrapper'); + this.emit('# ============================================================================'); + this.emit(''); + + if (queryOp) { + this.emit('# Query API helpers'); + this.emit(''); + this.generateApiHelpers(queryOp); + } + + if (mutationOp) { + this.emit('# Mutation API helpers'); + this.emit(''); + this.generateApiHelpers(mutationOp); + } + } + + return this.lines.join('\n'); + } + + generateHeader(): void { + this.emit('# ============================================================================'); + this.emit('# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); + this.emit('# Generated from OpenIAP GraphQL schema (https://openiap.dev)'); + this.emit('# Run `bun run generate` to regenerate this file.'); + this.emit('# ============================================================================'); + this.emit('# Usage: const Types = preload("types.gd")'); + this.emit('# var store: Types.IapStore = Types.IapStore.APPLE'); + this.emit('# ============================================================================'); + this.emit(''); + } + + protected generateDocComment(description: string | undefined, indent: string = ''): void { + if (!description) return; + const singleLine = description.replace(/\r?\n/g, ' ').trim(); + this.emit(`${indent}## ${singleLine}`); + } + + // ============================================================================ + // Enums + // ============================================================================ + + generateEnum(irEnum: IREnum): void { + this.generateDocComment(irEnum.description); + this.emit(`enum ${irEnum.name} {`); + + irEnum.values.forEach((value, index) => { + if (value.description) { + const singleLine = value.description.replace(/\r?\n/g, ' ').trim(); + this.emit(`\t## ${singleLine}`); + } + this.emit(`\t${this.enumValueCase(value.name)} = ${index},`); + }); + + this.emit('}'); + this.emit(''); + } + + private generateEnumValueHelper(irEnum: IREnum): void { + this.emit(`const ${toConstantCase(irEnum.name)}_VALUES = {`); + irEnum.values.forEach((value, index) => { + const comma = index < irEnum.values.length - 1 ? ',' : ''; + this.emit(`\t${irEnum.name}.${this.enumValueCase(value.name)}: "${value.rawValue}"${comma}`); + }); + this.emit('}'); + this.emit(''); + } + + private generateEnumReverseLookup(irEnum: IREnum): void { + this.emit(`const ${toConstantCase(irEnum.name)}_FROM_STRING = {`); + irEnum.values.forEach((value, index) => { + const comma = index < irEnum.values.length - 1 ? ',' : ''; + this.emit(`\t"${value.rawValue}": ${irEnum.name}.${this.enumValueCase(value.name)}${comma}`); + }); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Interfaces (not used in GDScript, but required by base class) + // ============================================================================ + + generateInterface(irInterface: IRInterface): void { + // GDScript doesn't have interfaces, skip + } + + // ============================================================================ + // Objects + // ============================================================================ + + generateObject(irObject: IRObject): void { + this.generateDocComment(irObject.description); + this.emit(`class ${irObject.name}:`); + + const fields = irObject.fields; + if (fields.length === 0) { + this.emit('\tpass'); + } else { + // Field declarations + for (const field of fields) { + if (field.description) { + this.emit(`\t## ${field.description.split('\n')[0]}`); + } + const gdType = this.mapType(field.type); + const fieldName = this.getGdscriptFieldName(field.name, irObject.name); + this.emit(`\tvar ${fieldName}: ${gdType}`); + } + + // from_dict method + this.emit(''); + this.emit(`\tstatic func from_dict(data: Dictionary) -> ${irObject.name}:`); + this.emit(`\t\tvar obj = ${irObject.name}.new()`); + for (const field of fields) { + const fieldName = this.getGdscriptFieldName(field.name, irObject.name); + this.generateFromDictField(field, fieldName); + } + this.emit('\t\treturn obj'); + + // to_dict method + this.emit(''); + this.emit('\tfunc to_dict() -> Dictionary:'); + this.emit('\t\tvar dict = {}'); + for (const field of fields) { + const fieldName = this.getGdscriptFieldName(field.name, irObject.name); + this.generateToDictField(field, fieldName); + } + this.emit('\t\treturn dict'); + } + + this.emit(''); + } + + private generateFromDictField(field: IRField, fieldName: string): void { + const graphqlName = field.name; + const type = field.type; + + this.emit(`\t\tif data.has("${graphqlName}") and data["${graphqlName}"] != null:`); + + if (this.isObjectOrInput(type) && type.kind === 'list') { + const elementTypeName = type.elementType!.name!; + this.emit(`\t\t\tvar arr = []`); + this.emit(`\t\t\tfor item in data["${graphqlName}"]:`); + this.emit(`\t\t\t\tif item is Dictionary:`); + this.emit(`\t\t\t\t\tarr.append(${elementTypeName}.from_dict(item))`); + this.emit(`\t\t\t\telse:`); + this.emit(`\t\t\t\t\tarr.append(item)`); + this.emit(`\t\t\tobj.${fieldName} = arr`); + } else if (this.isObjectOrInput(type)) { + const typeName = type.name!; + this.emit(`\t\t\tif data["${graphqlName}"] is Dictionary:`); + this.emit(`\t\t\t\tobj.${fieldName} = ${typeName}.from_dict(data["${graphqlName}"])`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tobj.${fieldName} = data["${graphqlName}"]`); + } else if (type.kind === 'enum') { + const enumReverseLookup = toConstantCase(type.name!) + '_FROM_STRING'; + this.emit(`\t\t\tvar enum_str = data["${graphqlName}"]`); + this.emit(`\t\t\tif enum_str is String and ${enumReverseLookup}.has(enum_str):`); + this.emit(`\t\t\t\tobj.${fieldName} = ${enumReverseLookup}[enum_str]`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tobj.${fieldName} = enum_str`); + } else { + this.emit(`\t\t\tobj.${fieldName} = data["${graphqlName}"]`); + } + } + + private generateToDictField(field: IRField, fieldName: string): void { + const graphqlName = field.name; + const type = field.type; + const enumConstName = type.name ? toConstantCase(type.name) + '_VALUES' : ''; + + if (this.isObjectOrInput(type) && type.kind === 'list') { + this.emit(`\t\tif ${fieldName} != null:`); + this.emit(`\t\t\tvar arr = []`); + this.emit(`\t\t\tfor item in ${fieldName}:`); + this.emit(`\t\t\t\tif item != null and item.has_method("to_dict"):`); + this.emit(`\t\t\t\t\tarr.append(item.to_dict())`); + this.emit(`\t\t\t\telse:`); + this.emit(`\t\t\t\t\tarr.append(item)`); + this.emit(`\t\t\tdict["${graphqlName}"] = arr`); + this.emit(`\t\telse:`); + this.emit(`\t\t\tdict["${graphqlName}"] = null`); + } else if (this.isObjectOrInput(type)) { + this.emit(`\t\tif ${fieldName} != null and ${fieldName}.has_method("to_dict"):`); + this.emit(`\t\t\tdict["${graphqlName}"] = ${fieldName}.to_dict()`); + this.emit(`\t\telse:`); + this.emit(`\t\t\tdict["${graphqlName}"] = ${fieldName}`); + } else if (type.kind === 'enum') { + this.emit(`\t\tif ${enumConstName}.has(${fieldName}):`); + this.emit(`\t\t\tdict["${graphqlName}"] = ${enumConstName}[${fieldName}]`); + this.emit(`\t\telse:`); + this.emit(`\t\t\tdict["${graphqlName}"] = ${fieldName}`); + } else { + this.emit(`\t\tdict["${graphqlName}"] = ${fieldName}`); + } + } + + private isObjectOrInput(type: IRType): boolean { + if (type.kind === 'list') { + return type.elementType ? this.isObjectOrInput(type.elementType) : false; + } + return this.objectNames.has(type.name!) || this.inputNames.has(type.name!); + } + + // ============================================================================ + // Inputs + // ============================================================================ + + generateInput(irInput: IRInput): void { + this.generateDocComment(irInput.description); + this.emit(`class ${irInput.name}:`); + + const fields = irInput.fields; + if (fields.length === 0) { + this.emit('\tpass'); + } else { + // Field declarations + for (const field of fields) { + if (field.description) { + this.emit(`\t## ${field.description.split('\n')[0]}`); + } + const gdType = this.mapType(field.type); + const fieldName = this.getGdscriptFieldName(field.name, irInput.name); + this.emit(`\tvar ${fieldName}: ${gdType}`); + } + + // from_dict method + this.emit(''); + this.emit(`\tstatic func from_dict(data: Dictionary) -> ${irInput.name}:`); + this.emit(`\t\tvar obj = ${irInput.name}.new()`); + for (const field of fields) { + const fieldName = this.getGdscriptFieldName(field.name, irInput.name); + this.generateInputFromDictField(field, fieldName); + } + this.emit('\t\treturn obj'); + + // to_dict method + this.emit(''); + this.emit('\tfunc to_dict() -> Dictionary:'); + this.emit('\t\tvar dict = {}'); + for (const field of fields) { + const fieldName = this.getGdscriptFieldName(field.name, irInput.name); + this.generateInputToDictField(field, fieldName); + } + this.emit('\t\treturn dict'); + } + + this.emit(''); + } + + private generateInputFromDictField(field: IRField, fieldName: string): void { + const graphqlName = field.name; + const type = field.type; + + this.emit(`\t\tif data.has("${graphqlName}") and data["${graphqlName}"] != null:`); + + if (this.isObjectOrInput(type) && type.kind === 'list') { + const elementTypeName = type.elementType!.name!; + this.emit(`\t\t\tvar arr = []`); + this.emit(`\t\t\tfor item in data["${graphqlName}"]:`); + this.emit(`\t\t\t\tif item is Dictionary:`); + this.emit(`\t\t\t\t\tarr.append(${elementTypeName}.from_dict(item))`); + this.emit(`\t\t\t\telse:`); + this.emit(`\t\t\t\t\tarr.append(item)`); + this.emit(`\t\t\tobj.${fieldName} = arr`); + } else if (this.isObjectOrInput(type)) { + const typeName = type.name!; + this.emit(`\t\t\tif data["${graphqlName}"] is Dictionary:`); + this.emit(`\t\t\t\tobj.${fieldName} = ${typeName}.from_dict(data["${graphqlName}"])`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tobj.${fieldName} = data["${graphqlName}"]`); + } else if (type.kind === 'enum') { + const enumReverseLookup = toConstantCase(type.name!) + '_FROM_STRING'; + this.emit(`\t\t\tvar enum_str = data["${graphqlName}"]`); + this.emit(`\t\t\tif enum_str is String and ${enumReverseLookup}.has(enum_str):`); + this.emit(`\t\t\t\tobj.${fieldName} = ${enumReverseLookup}[enum_str]`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tobj.${fieldName} = enum_str`); + } else { + this.emit(`\t\t\tobj.${fieldName} = data["${graphqlName}"]`); + } + } + + private generateInputToDictField(field: IRField, fieldName: string): void { + const graphqlName = field.name; + const type = field.type; + const enumConstName = type.name ? toConstantCase(type.name) + '_VALUES' : ''; + + this.emit(`\t\tif ${fieldName} != null:`); + + if (this.isObjectOrInput(type) && type.kind === 'list') { + this.emit(`\t\t\tvar arr = []`); + this.emit(`\t\t\tfor item in ${fieldName}:`); + this.emit(`\t\t\t\tif item.has_method("to_dict"):`); + this.emit(`\t\t\t\t\tarr.append(item.to_dict())`); + this.emit(`\t\t\t\telse:`); + this.emit(`\t\t\t\t\tarr.append(item)`); + this.emit(`\t\t\tdict["${graphqlName}"] = arr`); + } else if (this.isObjectOrInput(type)) { + this.emit(`\t\t\tif ${fieldName}.has_method("to_dict"):`); + this.emit(`\t\t\t\tdict["${graphqlName}"] = ${fieldName}.to_dict()`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tdict["${graphqlName}"] = ${fieldName}`); + } else if (type.kind === 'enum') { + this.emit(`\t\t\tif ${enumConstName}.has(${fieldName}):`); + this.emit(`\t\t\t\tdict["${graphqlName}"] = ${enumConstName}[${fieldName}]`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tdict["${graphqlName}"] = ${fieldName}`); + } else { + this.emit(`\t\t\tdict["${graphqlName}"] = ${fieldName}`); + } + } + + // ============================================================================ + // Unions (not used directly in GDScript) + // ============================================================================ + + generateUnion(irUnion: IRUnion): void { + // GDScript doesn't have unions, use Variant + } + + // ============================================================================ + // Operations + // ============================================================================ + + generateOperation(irOperation: IROperation): void { + this.emit(`class ${irOperation.name}:`); + // Use schema field order, don't filter _placeholder + const fields = irOperation.fields; + + if (fields.length === 0) { + this.emit('\tpass'); + } else { + for (const field of fields) { + if (field.description) { + this.emit(`\t## ${field.description.split('\n')[0]}`); + } + + this.emit(`\tclass ${field.name}Field:`); + this.emit(`\t\tconst name = "${field.name}"`); + this.emit(`\t\tconst snake_name = "${toSnakeCase(field.name)}"`); + + // Args class + if (field.args.length > 0) { + this.emit(`\t\tclass Args:`); + for (const arg of field.args) { + const argType = this.mapType(arg.type); + const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); + if (arg.description) { + this.emit(`\t\t\t## ${arg.description.split('\n')[0]}`); + } + this.emit(`\t\t\tvar ${argSnakeName}: ${argType}`); + } + this.emit(''); + this.emit(`\t\t\tstatic func from_dict(data: Dictionary) -> Args:`); + this.emit(`\t\t\t\tvar obj = Args.new()`); + for (const arg of field.args) { + const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); + this.emit(`\t\t\t\tif data.has("${arg.name}") and data["${arg.name}"] != null:`); + this.emit(`\t\t\t\t\tobj.${argSnakeName} = data["${arg.name}"]`); + } + this.emit(`\t\t\t\treturn obj`); + this.emit(''); + this.emit(`\t\t\tfunc to_dict() -> Dictionary:`); + this.emit(`\t\t\t\tvar dict = {}`); + for (const arg of field.args) { + const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); + this.emit(`\t\t\t\tdict["${arg.name}"] = ${argSnakeName}`); + } + this.emit(`\t\t\t\treturn dict`); + } else { + this.emit(`\t\tclass Args:`); + this.emit(`\t\t\tpass`); + } + + // Return type info + const returnTypeName = field.returnType.kind === 'list' + ? field.returnType.elementType?.name || 'Variant' + : field.returnType.name || 'Variant'; + const isArray = field.returnType.kind === 'list'; + this.emit(`\t\tconst return_type = "${returnTypeName}"`); + this.emit(`\t\tconst is_array = ${isArray}`); + this.emit(''); + } + } + this.emit(''); + } + + private generateApiHelpers(irOperation: IROperation): void { + const fields = irOperation.fields.filter(f => f.name !== '_placeholder'); + + for (const field of fields) { + const snakeName = toSnakeCase(field.name); + + if (field.description) { + this.emit(`## ${field.description.split('\n')[0]}`); + } + + // Build parameters + const params: string[] = []; + for (const arg of field.args) { + const argType = this.mapType(arg.type); + const argSnakeName = toSnakeCase(arg.name); + params.push(`${argSnakeName}: ${argType}`); + } + + const paramStr = params.join(', '); + this.emit(`static func ${snakeName}_args(${paramStr}) -> Dictionary:`); + + if (field.args.length > 0) { + this.emit('\tvar args = {}'); + for (const arg of field.args) { + const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); + const isObjOrInput = this.isObjectOrInputType(arg.type); + if (isObjOrInput && arg.type.kind === 'list') { + // Handle list of objects/inputs + this.emit(`\tif ${argSnakeName} != null:`); + this.emit(`\t\tvar arr = []`); + this.emit(`\t\tfor item in ${argSnakeName}:`); + this.emit(`\t\t\tif item != null and item.has_method("to_dict"):`); + this.emit(`\t\t\t\tarr.append(item.to_dict())`); + this.emit(`\t\t\telse:`); + this.emit(`\t\t\t\tarr.append(item)`); + this.emit(`\t\targs["${arg.name}"] = arr`); + } else if (isObjOrInput) { + // Handle single object/input + this.emit(`\tif ${argSnakeName} != null:`); + this.emit(`\t\tif ${argSnakeName}.has_method("to_dict"):`); + this.emit(`\t\t\targs["${arg.name}"] = ${argSnakeName}.to_dict()`); + this.emit(`\t\telse:`); + this.emit(`\t\t\targs["${arg.name}"] = ${argSnakeName}`); + } else { + this.emit(`\targs["${arg.name}"] = ${argSnakeName}`); + } + } + this.emit('\treturn args'); + } else { + this.emit('\treturn {}'); + } + this.emit(''); + } + } + + private isObjectOrInputByName(typeName: string | undefined): boolean { + if (!typeName) return false; + return this.objectNames.has(typeName) || this.inputNames.has(typeName); + } + + private isObjectOrInputType(type: IRType): boolean { + if (type.kind === 'list' && type.elementType) { + return this.isObjectOrInputByName(type.elementType.name); + } + return this.isObjectOrInputByName(type.name); + } +} diff --git a/packages/gql/codegen/plugins/kotlin.ts b/packages/gql/codegen/plugins/kotlin.ts new file mode 100644 index 00000000..9ec015f4 --- /dev/null +++ b/packages/gql/codegen/plugins/kotlin.ts @@ -0,0 +1,850 @@ +/** + * Kotlin Code Generation Plugin + * + * Generates Kotlin data classes with JSON serialization from GraphQL schema. + */ + +import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IROperationField, +} from '../core/types.js'; +import { + KOTLIN_KEYWORDS, + GRAPHQL_TO_KOTLIN, + toPascalCase, + toKebabCase, + toConstantCase, + capitalize, + PLATFORM_TYPE_DEFAULTS, +} from '../core/utils.js'; + +export class KotlinPlugin extends CodegenPlugin { + readonly name = 'kotlin'; + readonly fileExtension = '.kt'; + readonly keywords = KOTLIN_KEYWORDS; + + private schema!: IRSchema; + + constructor(config: CodegenPluginConfig) { + super(config); + } + + // ============================================================================ + // Type Mapping + // ============================================================================ + + mapScalar(name: string): string { + return GRAPHQL_TO_KOTLIN[name] ?? 'String'; + } + + mapType(type: IRType): string { + if (type.kind === 'list') { + const elementType = this.mapType(type.elementType!); + const element = type.elementType!.nullable ? `${elementType}?` : elementType; + return `List<${element}>`; + } + if (type.kind === 'scalar') { + return this.mapScalar(type.name!); + } + return type.name!; + } + + escapeKeyword(name: string): string { + return this.keywords.has(name) ? `\`${name}\`` : name; + } + + enumValueCase(name: string): string { + return toPascalCase(name); + } + + fieldNameCase(name: string): string { + return name; // Kotlin uses camelCase which matches GraphQL field names + } + + // ============================================================================ + // Code Generation + // ============================================================================ + + /** + * Override generate to match original output order: + * 1. All interfaces first + * 2. All helpers second + */ + generate(schema: IRSchema): string { + this.schema = schema; + this.lines = []; + + // Header + this.generateHeader(); + + // Enums + if (schema.enums.length > 0) { + this.addSectionComment('Enums'); + for (const irEnum of schema.enums) { + this.generateEnum(irEnum); + } + } + + // Interfaces + if (schema.interfaces.length > 0) { + this.addSectionComment('Interfaces'); + for (const irInterface of schema.interfaces) { + this.generateInterface(irInterface); + } + } + + // Objects + if (schema.objects.length > 0) { + this.addSectionComment('Objects'); + for (const irObject of schema.objects) { + this.generateObject(irObject); + } + } + + // Inputs + if (schema.inputs.length > 0) { + this.addSectionComment('Input Objects'); + for (const irInput of schema.inputs) { + this.generateInput(irInput); + } + } + + // Unions + if (schema.unions.length > 0) { + this.addSectionComment('Unions'); + for (const irUnion of schema.unions) { + this.generateUnion(irUnion); + } + } + + // Operations - Interfaces first + if (schema.operations.length > 0) { + this.addSectionComment('Root Operations'); + for (const irOperation of schema.operations) { + this.generateOperationInterface(irOperation); + } + } + + // Operations - Helpers second (matching original order) + if (schema.operations.length > 0) { + this.addSectionComment('Root Operation Helpers'); + for (const irOperation of schema.operations) { + this.generateOperationHelpers(irOperation); + } + } + + const output = this.lines.join('\n'); + return this.postProcess(output); + } + + generateHeader(): void { + this.emit('// ============================================================================'); + this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); + this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); + this.emit('// ============================================================================'); + this.emit(''); + this.emit('// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure'); + this.emit('@file:Suppress("UNCHECKED_CAST")'); + this.emit(''); + } + + // ============================================================================ + // Enums + // ============================================================================ + + generateEnum(irEnum: IREnum): void { + this.generateDocComment(irEnum.description); + this.emit(`public enum class ${irEnum.name}(val rawValue: String) {`); + + irEnum.values.forEach((value, index) => { + this.generateDocComment(value.description, ' '); + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + const suffix = index === irEnum.values.length - 1 ? '' : ','; + this.emit(` ${caseName}("${value.rawValue}")${suffix}`); + }); + + this.emit(''); + this.emit(' companion object {'); + this.emit(` fun fromJson(value: String): ${irEnum.name} = when (value) {`); + + for (const value of irEnum.values) { + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + const rawValue = value.rawValue; + this.emit(` "${rawValue}" -> ${irEnum.name}.${caseName}`); + + // Add legacy aliases (CONSTANT_CASE and PascalCase) + // Use Set to deduplicate (e.g., "None" as both PascalCase and value.name when name is "None") + const legacyValues = new Set([toConstantCase(value.name), value.name]); + for (const legacy of legacyValues) { + if (legacy !== rawValue) { + this.emit(` "${legacy}" -> ${irEnum.name}.${caseName}`); + } + } + } + + this.emit(` else -> throw IllegalArgumentException("Unknown ${irEnum.name} value: $value")`); + this.emit(' }'); + this.emit(' }'); + this.emit(''); + this.emit(' fun toJson(): String = rawValue'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Interfaces + // ============================================================================ + + generateInterface(irInterface: IRInterface): void { + this.generateDocComment(irInterface.description); + this.emit(`public interface ${irInterface.name} {`); + + // Sort fields alphabetically for Kotlin + const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` val ${propertyName}: ${propertyType}`); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Objects (Data Classes) + // ============================================================================ + + generateObject(irObject: IRObject): void { + // Handle VoidResult + if (irObject.name === 'VoidResult') { + this.emit('public typealias VoidResult = Unit'); + this.emit(''); + return; + } + + // Handle result union wrappers + if (irObject.isResultUnion && irObject.resultUnionEntries) { + this.generateResultUnionObject(irObject); + return; + } + + // Sort fields alphabetically for Kotlin + const sortedFields = [...irObject.fields].sort((a, b) => a.name.localeCompare(b.name)); + + // Handle empty objects + if (sortedFields.length === 0) { + this.generateDocComment(irObject.description); + this.emit(`public class ${irObject.name}`); + this.emit(''); + return; + } + + this.generateDocComment(irObject.description); + this.emit(`public data class ${irObject.name}(`); + + sortedFields.forEach((field, index) => { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const suffix = index === sortedFields.length - 1 ? '' : ','; + const overrideKeyword = field.isOverride ? 'override ' : ''; + + // Handle platform defaults + const defaults = PLATFORM_TYPE_DEFAULTS[irObject.name]; + let defaultValue = field.type.nullable ? ' = null' : ''; + if (defaults && field.name === 'platform') { + defaultValue = ` = IapPlatform.${toPascalCase(defaults.platform)}`; + } else if (defaults && field.name === 'type') { + defaultValue = ` = ProductType.${toPascalCase(defaults.type)}`; + } + + this.emit(` ${overrideKeyword}val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); + }); + + const implementsList = [...irObject.interfaces, ...irObject.unions]; + if (implementsList.length > 0) { + this.emit(`) : ${implementsList.join(', ')} {`); + } else { + this.emit(') {'); + } + + this.emit(''); + this.emit(' companion object {'); + this.emit(` fun fromJson(json: Map): ${irObject.name} {`); + this.emit(` return ${irObject.name}(`); + + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`); + this.emit(` ${propertyName} = ${expression},`); + } + + this.emit(' )'); + this.emit(' }'); + this.emit(' }'); + this.emit(''); + + const overrideKeyword = irObject.unions.length > 0 ? 'override ' : ''; + this.emit(` ${overrideKeyword}fun toJson(): Map = mapOf(`); + this.emit(` "__typename" to "${irObject.name}",`); + + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildToJsonExpression(field.type, propertyName); + this.emit(` "${field.name}" to ${expression},`); + } + + this.emit(' )'); + this.emit('}'); + this.emit(''); + } + + private generateResultUnionObject(irObject: IRObject): void { + this.generateDocComment(irObject.description); + this.emit(`public sealed interface ${irObject.name}`); + this.emit(''); + + // Sort entries alphabetically + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => + a.fieldName.localeCompare(b.fieldName) + ); + for (const entry of sortedEntries) { + const className = `${irObject.name}${capitalize(entry.fieldName)}`; + const propertyType = this.getPropertyType(entry.type); + this.emit(`public data class ${className}(val value: ${propertyType}) : ${irObject.name}`); + this.emit(''); + } + } + + // ============================================================================ + // Inputs (Data Classes) + // ============================================================================ + + generateInput(irInput: IRInput): void { + // Handle custom types + if (irInput.isCustomType) { + this.generateCustomInput(irInput); + return; + } + + // Sort fields alphabetically for Kotlin + const sortedFields = [...irInput.fields].sort((a, b) => a.name.localeCompare(b.name)); + + this.generateDocComment(irInput.description); + this.emit(`public data class ${irInput.name}(`); + + sortedFields.forEach((field, index) => { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const suffix = index === sortedFields.length - 1 ? '' : ','; + const defaultValue = field.type.nullable ? ' = null' : ''; + this.emit(` val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); + }); + + this.emit(') {'); + this.emit(' companion object {'); + + // Check if input has required fields + const hasRequiredFields = sortedFields.some((f) => !f.type.nullable); + + if (hasRequiredFields) { + // Nullable fromJson pattern + this.emit(` fun fromJson(json: Map): ${irInput.name}? {`); + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`, false, true); + this.emit(` val ${propertyName} = ${expression}`); + } + + // Null check for required fields (excluding enums which have fallbacks) + const requiredFields = sortedFields.filter( + (f) => !f.type.nullable && f.type.kind !== 'enum' + ); + if (requiredFields.length > 0) { + const nullChecks = requiredFields + .map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`) + .join(' || '); + this.emit(` if (${nullChecks}) return null`); + } + + this.emit(` return ${irInput.name}(`); + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` ${propertyName} = ${propertyName},`); + } + this.emit(' )'); + this.emit(' }'); + } else { + // Non-null fromJson pattern + this.emit(` fun fromJson(json: Map): ${irInput.name} {`); + this.emit(` return ${irInput.name}(`); + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`); + this.emit(` ${propertyName} = ${expression},`); + } + this.emit(' )'); + this.emit(' }'); + } + + this.emit(' }'); + this.emit(''); + this.emit(' fun toJson(): Map = mapOf('); + + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildToJsonExpression(field.type, propertyName); + this.emit(` "${field.name}" to ${expression},`); + } + + this.emit(' )'); + this.emit('}'); + this.emit(''); + } + + private generateCustomInput(irInput: IRInput): void { + switch (irInput.customTypeKind) { + case 'PurchaseInput': + this.emit('public typealias PurchaseInput = Purchase'); + this.emit(''); + break; + case 'RequestPurchaseProps': + this.generateRequestPurchaseProps(irInput); + break; + case 'DiscountOfferInputIOS': + // In Kotlin, DiscountOfferInputIOS uses standard data class generation + // (unlike Swift which needs custom Decodable for String -> Double conversion) + this.generateStandardInput(irInput); + break; + default: + this.generateStandardInput(irInput); + } + } + + private generateStandardInput(irInput: IRInput): void { + this.generateDocComment(irInput.description); + this.emit(`public data class ${irInput.name}(`); + + irInput.fields.forEach((field, index) => { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const suffix = index === irInput.fields.length - 1 ? '' : ','; + const defaultValue = field.type.nullable ? ' = null' : ''; + this.emit(` val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); + }); + + this.emit(') {'); + this.emit(' companion object {'); + + // Check if input has required fields + const hasRequiredFields = irInput.fields.some((f) => !f.type.nullable); + + if (hasRequiredFields) { + // Nullable fromJson pattern + this.emit(` fun fromJson(json: Map): ${irInput.name}? {`); + for (const field of irInput.fields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`, false, true); + this.emit(` val ${propertyName} = ${expression}`); + } + + // Null check for required fields (excluding enums which have fallbacks) + const requiredFields = irInput.fields.filter( + (f) => !f.type.nullable && f.type.kind !== 'enum' + ); + if (requiredFields.length > 0) { + const nullChecks = requiredFields + .map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`) + .join(' || '); + this.emit(` if (${nullChecks}) return null`); + } + + this.emit(` return ${irInput.name}(`); + for (const field of irInput.fields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` ${propertyName} = ${propertyName},`); + } + this.emit(' )'); + this.emit(' }'); + } else { + // Non-null fromJson pattern + this.emit(` fun fromJson(json: Map): ${irInput.name} {`); + this.emit(` return ${irInput.name}(`); + for (const field of irInput.fields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`); + this.emit(` ${propertyName} = ${expression},`); + } + this.emit(' )'); + this.emit(' }'); + } + + this.emit(' }'); + this.emit(''); + this.emit(' fun toJson(): Map = mapOf('); + + for (const field of irInput.fields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const expression = this.buildToJsonExpression(field.type, propertyName); + this.emit(` "${field.name}" to ${expression},`); + } + + this.emit(' )'); + this.emit('}'); + this.emit(''); + } + + private generateRequestPurchaseProps(irInput: IRInput): void { + this.generateDocComment(irInput.description); + this.emit('public data class RequestPurchaseProps('); + this.emit(' val request: Request,'); + this.emit(' val type: ProductQueryType,'); + this.emit(' val useAlternativeBilling: Boolean? = null'); + this.emit(') {'); + this.emit(' init {'); + this.emit(' when (request) {'); + this.emit(' is Request.Purchase -> require(type == ProductQueryType.InApp) { "type must be IN_APP when request is purchase" }'); + this.emit(' is Request.Subscription -> require(type == ProductQueryType.Subs) { "type must be SUBS when request is subscription" }'); + this.emit(' }'); + this.emit(' }'); + this.emit(''); + this.emit(' companion object {'); + this.emit(' fun fromJson(json: Map): RequestPurchaseProps {'); + this.emit(' val rawType = (json["type"] as String?)?.let { ProductQueryType.fromJson(it) }'); + this.emit(' val useAlternativeBilling = json["useAlternativeBilling"] as Boolean?'); + this.emit(' val purchaseJson = json["requestPurchase"] as Map?'); + this.emit(' if (purchaseJson != null) {'); + this.emit(' val request = Request.Purchase(RequestPurchasePropsByPlatforms.fromJson(purchaseJson))'); + this.emit(' val finalType = rawType ?: ProductQueryType.InApp'); + this.emit(' require(finalType == ProductQueryType.InApp) { "type must be IN_APP when requestPurchase is provided" }'); + this.emit(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); + this.emit(' }'); + this.emit(' val subscriptionJson = json["requestSubscription"] as Map?'); + this.emit(' if (subscriptionJson != null) {'); + this.emit(' val request = Request.Subscription(RequestSubscriptionPropsByPlatforms.fromJson(subscriptionJson))'); + this.emit(' val finalType = rawType ?: ProductQueryType.Subs'); + this.emit(' require(finalType == ProductQueryType.Subs) { "type must be SUBS when requestSubscription is provided" }'); + this.emit(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); + this.emit(' }'); + this.emit(' throw IllegalArgumentException("RequestPurchaseProps requires requestPurchase or requestSubscription")'); + this.emit(' }'); + this.emit(' }'); + this.emit(''); + this.emit(' fun toJson(): Map = when (request) {'); + this.emit(' is Request.Purchase -> mapOf('); + this.emit(' "requestPurchase" to request.value.toJson(),'); + this.emit(' "type" to type.toJson(),'); + this.emit(' "useAlternativeBilling" to useAlternativeBilling,'); + this.emit(' )'); + this.emit(' is Request.Subscription -> mapOf('); + this.emit(' "requestSubscription" to request.value.toJson(),'); + this.emit(' "type" to type.toJson(),'); + this.emit(' "useAlternativeBilling" to useAlternativeBilling,'); + this.emit(' )'); + this.emit(' }'); + this.emit(''); + this.emit(' sealed class Request {'); + this.emit(' data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request()'); + this.emit(' data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request()'); + this.emit(' }'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Unions (Sealed Interfaces) + // ============================================================================ + + generateUnion(irUnion: IRUnion): void { + this.generateDocComment(irUnion.description); + + const implementations = irUnion.sharedInterfaces.length > 0 + ? ` : ${irUnion.sharedInterfaces.join(', ')}` + : ''; + this.emit(`public sealed interface ${irUnion.name}${implementations} {`); + this.emit(' fun toJson(): Map'); + this.emit(''); + this.emit(' companion object {'); + this.emit(` fun fromJson(json: Map): ${irUnion.name} {`); + this.emit(' return when (json["__typename"] as String?) {'); + + // Collect all concrete members and their delegate targets + const nestedUnions = new Set(); + const concreteMembers: Array<{ name: string; delegateTo: string; isNested: boolean }> = []; + + for (const member of irUnion.members) { + if (member.isNestedUnion) { + nestedUnions.add(member.name); + // Get concrete members from nested union + const nestedUnion = this.schema.unions.find((u) => u.name === member.name); + if (nestedUnion) { + for (const nestedMember of nestedUnion.members) { + concreteMembers.push({ + name: nestedMember.name, + delegateTo: member.name, + isNested: true, + }); + } + } + } else { + concreteMembers.push({ + name: member.name, + delegateTo: member.name, + isNested: false, + }); + } + } + + // Sort alphabetically (matching original generator) + concreteMembers.sort((a, b) => a.name.localeCompare(b.name)); + + for (const { name, delegateTo, isNested } of concreteMembers) { + if (isNested) { + const wrapperName = `${delegateTo}Item`; + this.emit(` "${name}" -> ${wrapperName}(${delegateTo}.fromJson(json))`); + } else { + this.emit(` "${name}" -> ${delegateTo}.fromJson(json)`); + } + } + + this.emit(` else -> throw IllegalArgumentException("Unknown __typename for ${irUnion.name}: \${json["__typename"]}")`); + this.emit(' }'); + this.emit(' }'); + this.emit(' }'); + + // Generate wrapper classes for nested unions + for (const nestedUnionName of nestedUnions) { + const wrapperName = `${nestedUnionName}Item`; + this.emit(''); + this.emit(` data class ${wrapperName}(val value: ${nestedUnionName}) : ${irUnion.name} {`); + this.emit(' override fun toJson() = value.toJson()'); + this.emit(' }'); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Operations (Interfaces + Helpers) + // ============================================================================ + + generateOperation(irOperation: IROperation): void { + this.generateOperationInterface(irOperation); + this.generateOperationHelpers(irOperation); + } + + private generateOperationInterface(irOperation: IROperation): void { + const interfaceName = `${irOperation.name}Resolver`; + this.generateDocComment(irOperation.description ?? `GraphQL root ${irOperation.name.toLowerCase()} operations.`); + this.emit(`public interface ${interfaceName} {`); + + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const returnType = this.getOperationReturnType(field); + + const args = field.args.map((arg) => { + const argType = this.getPropertyType(arg.type); + const argName = this.escapeKeyword(arg.name); + const defaultValue = arg.type.nullable ? ' = null' : ''; + return `${argName}: ${argType}${defaultValue}`; + }); + const params = args.length > 0 ? args.join(', ') : ''; + const paramSegment = `(${params})`; + this.emit(` suspend fun ${this.escapeKeyword(field.name)}${paramSegment}: ${returnType}`); + } + + this.emit('}'); + this.emit(''); + } + + private generateOperationHelpers(irOperation: IROperation): void { + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + if (sortedFields.length === 0) return; + + this.emit(`// MARK: - ${irOperation.name} Helpers`); + this.emit(''); + + // Generate typealiases for handlers + for (const field of sortedFields) { + const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; + const returnType = this.getOperationReturnType(field); + + if (field.args.length === 0) { + this.emit(`public typealias ${aliasName} = suspend () -> ${returnType}`); + } else { + const argsSignature = field.args + .map((arg) => { + const argType = this.getPropertyType(arg.type); + return `${this.escapeKeyword(arg.name)}: ${argType}`; + }) + .join(', '); + this.emit(`public typealias ${aliasName} = suspend (${argsSignature}) -> ${returnType}`); + } + } + + // Generate handlers data class + const helperClass = `${irOperation.name}Handlers`; + this.emit(''); + this.emit(`public data class ${helperClass}(`); + + sortedFields.forEach((field, index) => { + const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; + const propertyName = this.escapeKeyword(field.name); + const suffix = index === sortedFields.length - 1 ? '' : ','; + this.emit(` val ${propertyName}: ${aliasName}? = null${suffix}`); + }); + + this.emit(')'); + this.emit(''); + } + + // ============================================================================ + // JSON Serialization Helpers + // ============================================================================ + + private buildFromJsonExpression( + type: IRType, + sourceExpr: string, + isListElement: boolean = false, + forNullableFromJson: boolean = false + ): string { + if (type.kind === 'list') { + const element = this.buildFromJsonExpression(type.elementType!, 'it', true, forNullableFromJson); + const mapFn = type.elementType!.nullable ? 'map' : 'mapNotNull'; + if (type.nullable || forNullableFromJson) { + return `(${sourceExpr} as? List<*>)?.${mapFn} { ${element} }`; + } + return `(${sourceExpr} as? List<*>)?.${mapFn} { ${element} } ?: emptyList()`; + } + + if (type.kind === 'scalar') { + const useNullable = type.nullable || isListElement || forNullableFromJson; + switch (type.name) { + case 'Float': + return useNullable + ? `(${sourceExpr} as? Number)?.toDouble()` + : `(${sourceExpr} as? Number)?.toDouble() ?: 0.0`; + case 'Int': + return useNullable + ? `(${sourceExpr} as? Number)?.toInt()` + : `(${sourceExpr} as? Number)?.toInt() ?: 0`; + case 'Boolean': + return useNullable + ? `${sourceExpr} as? Boolean` + : `${sourceExpr} as? Boolean ?: false`; + case 'ID': + case 'String': + default: + return useNullable + ? `${sourceExpr} as? String` + : `${sourceExpr} as? String ?: ""`; + } + } + + if (type.kind === 'enum') { + if (type.nullable) { + return `(${sourceExpr} as? String)?.let { ${type.name}.fromJson(it) }`; + } + // Find if enum has Empty value + const irEnum = this.schema.enums.find((e) => e.name === type.name); + const hasEmpty = irEnum?.values.some((v) => v.name.toLowerCase() === 'empty'); + if (hasEmpty) { + return `(${sourceExpr} as? String)?.let { ${type.name}.fromJson(it) } ?: ${type.name}.Empty`; + } + const firstValue = irEnum?.values[0]; + const fallback = firstValue + ? `${type.name}.${this.escapeKeyword(this.enumValueCase(firstValue.name))}` + : `throw IllegalArgumentException("Missing required enum value for ${type.name}")`; + return `(${sourceExpr} as? String)?.let { ${type.name}.fromJson(it) } ?: ${fallback}`; + } + + if (['object', 'input', 'interface', 'union'].includes(type.kind)) { + const callTarget = type.name!; + if (type.nullable || forNullableFromJson) { + return `(${sourceExpr} as? Map)?.let { ${callTarget}.fromJson(it) }`; + } + // Check if input has required fields (nullable fromJson) + const isInputWithRequired = this.schema.metadata.inputsWithRequiredFields.has(callTarget); + if (isInputWithRequired) { + return `(${sourceExpr} as? Map)?.let { ${callTarget}.fromJson(it) } ?: throw IllegalArgumentException("Missing or invalid required object for ${callTarget}")`; + } + return `(${sourceExpr} as? Map)?.let { ${callTarget}.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ${callTarget}")`; + } + + return type.nullable ? sourceExpr : sourceExpr; + } + + private buildToJsonExpression(type: IRType, accessorExpr: string): string { + if (type.kind === 'list') { + const inner = this.buildToJsonExpression(type.elementType!, 'it'); + if (inner === 'it') { + return accessorExpr; + } + return type.nullable + ? `${accessorExpr}?.map { ${inner} }` + : `${accessorExpr}.map { ${inner} }`; + } + + if (type.kind === 'enum') { + return type.nullable ? `${accessorExpr}?.toJson()` : `${accessorExpr}.toJson()`; + } + + if (['object', 'input', 'interface', 'union'].includes(type.kind)) { + return type.nullable ? `${accessorExpr}?.toJson()` : `${accessorExpr}.toJson()`; + } + + return accessorExpr; + } + + // ============================================================================ + // Helpers + // ============================================================================ + + private getPropertyType(type: IRType): string { + const baseType = this.mapType(type); + return type.nullable ? `${baseType}?` : baseType; + } + + private getOperationReturnType(field: IROperationField): string { + const resolved = field.resolvedReturnType; + + // Handle Unit + if (resolved.kind === 'scalar' && resolved.name === 'Void') { + return resolved.nullable ? 'Unit?' : 'Unit'; + } + + return this.getPropertyType(resolved); + } + + protected generateDocComment(description: string | undefined, indent: string = ''): void { + if (!description) return; + this.emit(`${indent}/**`); + for (const line of description.split(/\r?\n/)) { + this.emit(`${indent} * ${line}`); + } + this.emit(`${indent} */`); + } +} diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts new file mode 100644 index 00000000..fb462796 --- /dev/null +++ b/packages/gql/codegen/plugins/swift.ts @@ -0,0 +1,707 @@ +/** + * Swift Code Generation Plugin + * + * Generates Swift types with Codable conformance from GraphQL schema. + */ + +import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IROperationField, +} from '../core/types.js'; +import { + SWIFT_KEYWORDS, + GRAPHQL_TO_SWIFT, + toLowerCamelCase, + toKebabCase, + capitalize, + PLATFORM_TYPE_DEFAULTS, + ERROR_CODE_LEGACY_ALIASES, +} from '../core/utils.js'; + +export class SwiftPlugin extends CodegenPlugin { + readonly name = 'swift'; + readonly fileExtension = '.swift'; + readonly keywords = SWIFT_KEYWORDS; + + private schema!: IRSchema; + + constructor(config: CodegenPluginConfig) { + super(config); + } + + // ============================================================================ + // Type Mapping + // ============================================================================ + + mapScalar(name: string): string { + return GRAPHQL_TO_SWIFT[name] ?? 'String'; + } + + mapType(type: IRType): string { + if (type.kind === 'list') { + const elementType = this.mapType(type.elementType!); + const element = type.elementType!.nullable ? `${elementType}?` : elementType; + return `[${element}]`; + } + if (type.kind === 'scalar') { + return this.mapScalar(type.name!); + } + return type.name!; + } + + escapeKeyword(name: string): string { + return this.keywords.has(name) ? `\`${name}\`` : name; + } + + enumValueCase(name: string): string { + return toLowerCamelCase(name); + } + + fieldNameCase(name: string): string { + return name; // Swift uses camelCase which matches GraphQL field names + } + + // ============================================================================ + // Code Generation + // ============================================================================ + + generateHeader(): void { + this.emit('// ============================================================================'); + this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); + this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); + this.emit('// ============================================================================'); + this.emit(''); + this.emit('import Foundation'); + this.emit(''); + } + + // ============================================================================ + // Enums + // ============================================================================ + + generateEnum(irEnum: IREnum): void { + this.generateDocComment(irEnum.description); + this.emit(`public enum ${irEnum.name}: String, Codable, CaseIterable {`); + + for (const value of irEnum.values) { + this.generateDocComment(value.description, ' '); + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + this.emit(` case ${caseName} = "${value.rawValue}"`); + } + + // Add custom initializer for ErrorCode to handle legacy aliases + if (irEnum.isErrorCode) { + // Legacy aliases: old error codes that map to new ones + const legacyAliases: Record = { + 'receipt-failed': 'purchaseVerificationFailed', + 'ReceiptFailed': 'purchaseVerificationFailed', + }; + + this.emit(''); + this.emit(' /// Custom initializer to handle both kebab-case and camelCase error codes'); + this.emit(' /// This ensures compatibility with react-native-iap and other libraries that may send camelCase'); + this.emit(' public init?(rawValue: String) {'); + this.emit(' // Try direct match first (kebab-case)'); + this.emit(' switch rawValue {'); + + for (const value of irEnum.values) { + const caseName = this.escapeKeyword(this.enumValueCase(value.name)); + const rawValue = value.rawValue; + const camelCaseName = value.name.charAt(0).toUpperCase() + value.name.slice(1); + + // Check if this case is a legacy alias that should map to another case + const aliasTarget = legacyAliases[rawValue] || legacyAliases[camelCaseName]; + + if (aliasTarget && aliasTarget === caseName) { + // This case IS the target - just use normal handling + this.emit(` case "${rawValue}", "${camelCaseName}":`); + this.emit(` self = .${caseName}`); + } else if (aliasTarget) { + // This is a legacy alias - map to the new case + this.emit(` case "${rawValue}", "${camelCaseName}":`); + this.emit(` self = .${aliasTarget} // Legacy alias`); + } else { + // Normal case + this.emit(` case "${rawValue}", "${camelCaseName}":`); + this.emit(` self = .${caseName}`); + } + } + + this.emit(' default:'); + this.emit(' return nil'); + this.emit(' }'); + this.emit(' }'); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Interfaces (Protocols) + // ============================================================================ + + generateInterface(irInterface: IRInterface): void { + this.generateDocComment(irInterface.description); + this.emit(`public protocol ${irInterface.name}: Codable {`); + + // Sort fields alphabetically for Swift + const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` var ${propertyName}: ${propertyType} { get }`); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Objects (Structs) + // ============================================================================ + + generateObject(irObject: IRObject): void { + // Handle VoidResult + if (irObject.name === 'VoidResult') { + this.emit('public typealias VoidResult = Void'); + this.emit(''); + return; + } + + // Handle result union wrappers + if (irObject.isResultUnion && irObject.resultUnionEntries) { + this.generateResultUnionObject(irObject); + return; + } + + this.generateDocComment(irObject.description); + const conformances = ['Codable', ...irObject.interfaces]; + this.emit(`public struct ${irObject.name}: ${conformances.join(', ')} {`); + + // Sort fields alphabetically for Swift + const sortedFields = [...irObject.fields].sort((a, b) => a.name.localeCompare(b.name)); + + // Properties + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + + // Handle platform defaults + const defaults = PLATFORM_TYPE_DEFAULTS[irObject.name]; + let defaultValue = ''; + if (defaults && field.name === 'platform') { + defaultValue = ` = .${defaults.platform}`; + } else if (defaults && field.name === 'type') { + defaultValue = ` = .${defaults.type === 'in-app' ? 'inApp' : 'subs'}`; + } + + this.emit(` public var ${propertyName}: ${propertyType}${defaultValue}`); + } + + // Generate initializer if there are no fields + if (sortedFields.length === 0) { + this.emit(' public init() {}'); + } + + this.emit('}'); + this.emit(''); + } + + private generateResultUnionObject(irObject: IRObject): void { + this.generateDocComment(irObject.description); + this.emit(`public enum ${irObject.name} {`); + + // Sort entries alphabetically + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => + a.fieldName.localeCompare(b.fieldName) + ); + for (const entry of sortedEntries) { + const caseName = this.escapeKeyword(this.enumValueCase(entry.fieldName)); + const payloadType = this.getPropertyType(entry.type); + this.emit(` case ${caseName}(${payloadType})`); + } + + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Inputs (Structs) + // ============================================================================ + + generateInput(irInput: IRInput): void { + // Handle custom types + if (irInput.isCustomType) { + this.generateCustomInput(irInput); + return; + } + + this.generateDocComment(irInput.description); + this.emit(`public struct ${irInput.name}: Codable {`); + + // Sort fields alphabetically for Swift + const sortedFields = [...irInput.fields].sort((a, b) => a.name.localeCompare(b.name)); + + // Properties + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` public var ${propertyName}: ${propertyType}`); + } + + // Generate public initializer + if (sortedFields.length > 0) { + this.emit(''); + const initParams = sortedFields + .map((field) => { + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const defaultValue = field.type.nullable ? ' = nil' : ''; + return ` ${propertyName}: ${propertyType}${defaultValue}`; + }) + .join(',\n'); + this.emit(' public init('); + this.emit(initParams); + this.emit(' ) {'); + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + this.emit(` self.${propertyName} = ${propertyName}`); + } + this.emit(' }'); + } else { + this.emit(' public init() {}'); + } + + this.emit('}'); + this.emit(''); + } + + private generateCustomInput(irInput: IRInput): void { + switch (irInput.customTypeKind) { + case 'PurchaseInput': + this.emit('public typealias PurchaseInput = Purchase'); + this.emit(''); + break; + case 'DiscountOfferInputIOS': + this.generateDiscountOfferInputIOS(irInput); + break; + case 'RequestPurchaseProps': + this.generateRequestPurchaseProps(irInput); + break; + } + } + + private generateDiscountOfferInputIOS(irInput: IRInput): void { + this.generateDocComment(irInput.description); + this.emit('public struct DiscountOfferInputIOS: Codable {'); + this.emit(' public var identifier: String'); + this.emit(' public var keyIdentifier: String'); + this.emit(' public var nonce: String'); + this.emit(' public var signature: String'); + this.emit(' public var timestamp: Double'); + this.emit(''); + this.emit(' public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) {'); + this.emit(' self.identifier = identifier'); + this.emit(' self.keyIdentifier = keyIdentifier'); + this.emit(' self.nonce = nonce'); + this.emit(' self.signature = signature'); + this.emit(' self.timestamp = timestamp'); + this.emit(' }'); + this.emit(''); + this.emit(' private enum CodingKeys: String, CodingKey {'); + this.emit(' case identifier, keyIdentifier, nonce, signature, timestamp'); + this.emit(' }'); + this.emit(''); + this.emit(' public init(from decoder: Decoder) throws {'); + this.emit(' let container = try decoder.container(keyedBy: CodingKeys.self)'); + this.emit(' identifier = try container.decode(String.self, forKey: .identifier)'); + this.emit(' keyIdentifier = try container.decode(String.self, forKey: .keyIdentifier)'); + this.emit(' nonce = try container.decode(String.self, forKey: .nonce)'); + this.emit(' signature = try container.decode(String.self, forKey: .signature)'); + this.emit(''); + this.emit(' // Flexible timestamp decoding: accept Double or String'); + this.emit(' if let timestampDouble = try? container.decode(Double.self, forKey: .timestamp) {'); + this.emit(' timestamp = timestampDouble'); + this.emit(' } else if let timestampString = try? container.decode(String.self, forKey: .timestamp),'); + this.emit(' let timestampDouble = Double(timestampString) {'); + this.emit(' timestamp = timestampDouble'); + this.emit(' } else {'); + this.emit(' throw DecodingError.dataCorruptedError('); + this.emit(' forKey: .timestamp,'); + this.emit(' in: container,'); + this.emit(' debugDescription: "timestamp must be a number or numeric string"'); + this.emit(' )'); + this.emit(' }'); + this.emit(' }'); + this.emit(''); + this.emit(' public func encode(to encoder: Encoder) throws {'); + this.emit(' var container = encoder.container(keyedBy: CodingKeys.self)'); + this.emit(' try container.encode(identifier, forKey: .identifier)'); + this.emit(' try container.encode(keyIdentifier, forKey: .keyIdentifier)'); + this.emit(' try container.encode(nonce, forKey: .nonce)'); + this.emit(' try container.encode(signature, forKey: .signature)'); + this.emit(' try container.encode(timestamp, forKey: .timestamp)'); + this.emit(' }'); + this.emit('}'); + this.emit(''); + } + + private generateRequestPurchaseProps(irInput: IRInput): void { + this.generateDocComment(irInput.description); + this.emit('public struct RequestPurchaseProps: Codable {'); + this.emit(' public var request: Request'); + this.emit(' public var type: ProductQueryType'); + this.emit(' public var useAlternativeBilling: Bool?'); + this.emit(''); + this.emit(' public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) {'); + this.emit(' switch request {'); + this.emit(' case .purchase:'); + this.emit(' let resolved = type ?? .inApp'); + this.emit(' precondition(resolved == .inApp, "RequestPurchaseProps.type must be .inApp when request is purchase")'); + this.emit(' self.type = resolved'); + this.emit(' case .subscription:'); + this.emit(' let resolved = type ?? .subs'); + this.emit(' precondition(resolved == .subs, "RequestPurchaseProps.type must be .subs when request is subscription")'); + this.emit(' self.type = resolved'); + this.emit(' }'); + this.emit(' self.request = request'); + this.emit(' self.useAlternativeBilling = useAlternativeBilling'); + this.emit(' }'); + this.emit(''); + this.emit(' private enum CodingKeys: String, CodingKey {'); + this.emit(' case requestPurchase'); + this.emit(' case requestSubscription'); + this.emit(' case type'); + this.emit(' case useAlternativeBilling'); + this.emit(' }'); + this.emit(''); + this.emit(' public init(from decoder: Decoder) throws {'); + this.emit(' let container = try decoder.container(keyedBy: CodingKeys.self)'); + this.emit(' let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)'); + this.emit(' self.useAlternativeBilling = try container.decodeIfPresent(Bool.self, forKey: .useAlternativeBilling)'); + this.emit(' if let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase) {'); + this.emit(' let finalType = decodedType ?? .inApp'); + this.emit(' guard finalType == .inApp else {'); + this.emit(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be IN_APP when requestPurchase is provided")'); + this.emit(' }'); + this.emit(' self.request = .purchase(purchase)'); + this.emit(' self.type = finalType'); + this.emit(' return'); + this.emit(' }'); + this.emit(' if let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription) {'); + this.emit(' let finalType = decodedType ?? .subs'); + this.emit(' guard finalType == .subs else {'); + this.emit(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be SUBS when requestSubscription is provided")'); + this.emit(' }'); + this.emit(' self.request = .subscription(subscription)'); + this.emit(' self.type = finalType'); + this.emit(' return'); + this.emit(' }'); + this.emit(' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps requires requestPurchase or requestSubscription.")'); + this.emit(' }'); + this.emit(''); + this.emit(' public func encode(to encoder: Encoder) throws {'); + this.emit(' var container = encoder.container(keyedBy: CodingKeys.self)'); + this.emit(' switch request {'); + this.emit(' case let .purchase(value):'); + this.emit(' try container.encode(value, forKey: .requestPurchase)'); + this.emit(' case let .subscription(value):'); + this.emit(' try container.encode(value, forKey: .requestSubscription)'); + this.emit(' }'); + this.emit(' try container.encode(type, forKey: .type)'); + this.emit(' try container.encodeIfPresent(useAlternativeBilling, forKey: .useAlternativeBilling)'); + this.emit(' }'); + this.emit(''); + this.emit(' public enum Request {'); + this.emit(' case purchase(RequestPurchasePropsByPlatforms)'); + this.emit(' case subscription(RequestSubscriptionPropsByPlatforms)'); + this.emit(' }'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Unions (Enums) + // ============================================================================ + + generateUnion(irUnion: IRUnion): void { + this.generateDocComment(irUnion.description); + + const conformances = ['Codable', ...irUnion.sharedInterfaces]; + const conformanceClause = conformances.length > 0 ? `: ${conformances.join(', ')}` : ''; + + this.emit(`public enum ${irUnion.name}${conformanceClause} {`); + + for (const member of irUnion.members) { + const caseName = this.escapeKeyword(this.enumValueCase(member.name)); + this.emit(` case ${caseName}(${member.name})`); + } + + // Generate shared interface property accessors + if (irUnion.sharedInterfaces.length > 0) { + this.generateUnionInterfaceAccessors(irUnion); + } + + this.emit('}'); + this.emit(''); + } + + private generateUnionInterfaceAccessors(irUnion: IRUnion): void { + // Collect fields from shared interfaces + const interfaceFields = new Map(); + for (const interfaceName of irUnion.sharedInterfaces) { + const irInterface = this.schema.interfaces.find((i) => i.name === interfaceName); + if (!irInterface) continue; + // Sort interface fields alphabetically + const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + for (const field of sortedFields) { + if (!interfaceFields.has(field.name)) { + interfaceFields.set(field.name, field); + } + } + } + + if (interfaceFields.size > 0) { + this.emit(''); + } + + // Sort the final array alphabetically + const interfaceFieldsArray = [...interfaceFields.entries()].sort((a, b) => a[0].localeCompare(b[0])); + interfaceFieldsArray.forEach(([fieldName, field], index) => { + this.generateDocComment(field.description, ' '); + const propertyType = this.getPropertyType(field.type); + const propertyName = this.escapeKeyword(this.fieldNameCase(fieldName)); + + this.emit(` public var ${propertyName}: ${propertyType} {`); + this.emit(' switch self {'); + + for (const member of irUnion.members) { + const caseName = this.escapeKeyword(this.enumValueCase(member.name)); + this.emit(` case let .${caseName}(value):`); + this.emit(` return value.${propertyName}`); + } + + this.emit(' }'); + this.emit(' }'); + // Add blank line between properties (except after the last one) + if (index < interfaceFieldsArray.length - 1) { + this.emit(''); + } + }); + } + + // ============================================================================ + // Operations (Protocols + Helpers) + // ============================================================================ + + generateOperation(irOperation: IROperation): void { + // Note: Protocol and helpers are generated together here, + // but the order matches the original generator's output + this.generateOperationProtocol(irOperation); + } + + /** + * Override generate to match original output order: + * 1. All protocols first + * 2. All helpers second + */ + generate(schema: IRSchema): string { + this.schema = schema; + this.lines = []; + + // Header + this.generateHeader(); + + // Enums + if (schema.enums.length > 0) { + this.addSectionComment('Enums'); + for (const irEnum of schema.enums) { + this.generateEnum(irEnum); + } + } + + // Interfaces + if (schema.interfaces.length > 0) { + this.addSectionComment('Interfaces'); + for (const irInterface of schema.interfaces) { + this.generateInterface(irInterface); + } + } + + // Objects + if (schema.objects.length > 0) { + this.addSectionComment('Objects'); + for (const irObject of schema.objects) { + this.generateObject(irObject); + } + } + + // Inputs + if (schema.inputs.length > 0) { + this.addSectionComment('Input Objects'); + for (const irInput of schema.inputs) { + this.generateInput(irInput); + } + } + + // Unions + if (schema.unions.length > 0) { + this.addSectionComment('Unions'); + for (const irUnion of schema.unions) { + this.generateUnion(irUnion); + } + } + + // Operations - Protocols first + if (schema.operations.length > 0) { + this.addSectionComment('Root Operations'); + for (const irOperation of schema.operations) { + this.generateOperationProtocol(irOperation); + } + } + + // Operations - Helpers second (matching original order) + if (schema.operations.length > 0) { + this.addSectionComment('Root Operation Helpers'); + for (const irOperation of schema.operations) { + this.generateOperationHelpers(irOperation); + } + } + + const output = this.lines.join('\n'); + return this.postProcess(output); + } + + private generateOperationProtocol(irOperation: IROperation): void { + const protocolName = `${irOperation.name}Resolver`; + this.generateDocComment(irOperation.description ?? `GraphQL root ${irOperation.name.toLowerCase()} operations.`); + this.emit(`public protocol ${protocolName} {`); + + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const field of sortedFields) { + this.generateDocComment(field.description, ' '); + const returnType = this.getOperationReturnType(field); + + if (field.args.length === 0) { + this.emit(` func ${this.escapeKeyword(field.name)}() async throws -> ${returnType}`); + } else if (field.args.length === 1) { + const arg = field.args[0]; + const argType = this.getPropertyType(arg.type); + const argName = this.escapeKeyword(arg.name); + this.emit(` func ${this.escapeKeyword(field.name)}(_ ${argName}: ${argType}) async throws -> ${returnType}`); + } else { + const params = field.args + .map((arg) => { + const argType = this.getPropertyType(arg.type); + const argName = this.escapeKeyword(arg.name); + return `${argName}: ${argType}`; + }) + .join(', '); + this.emit(` func ${this.escapeKeyword(field.name)}(${params}) async throws -> ${returnType}`); + } + } + + this.emit('}'); + this.emit(''); + } + + private generateOperationHelpers(irOperation: IROperation): void { + // Sort fields alphabetically and filter _placeholder + const sortedFields = irOperation.fields + .filter((f) => f.name !== '_placeholder') + .sort((a, b) => a.name.localeCompare(b.name)); + + if (sortedFields.length === 0) return; + + this.emit(`// MARK: - ${irOperation.name} Helpers`); + this.emit(''); + + // Generate typealiases for handlers + for (const field of sortedFields) { + const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; + const returnType = this.getOperationReturnType(field); + + if (field.args.length === 0) { + this.emit(`public typealias ${aliasName} = () async throws -> ${returnType}`); + } else { + const params = field.args + .map((arg) => { + const argType = this.getPropertyType(arg.type); + return `_ ${this.escapeKeyword(arg.name)}: ${argType}`; + }) + .join(', '); + this.emit(`public typealias ${aliasName} = (${params}) async throws -> ${returnType}`); + } + } + + // Generate handlers struct + const structName = `${irOperation.name}Handlers`; + this.emit(''); + this.emit(`public struct ${structName} {`); + + for (const field of sortedFields) { + const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; + this.emit(` public var ${this.escapeKeyword(field.name)}: ${aliasName}?`); + } + + this.emit(''); + const initParams = sortedFields + .map((field) => { + const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; + return `${this.escapeKeyword(field.name)}: ${aliasName}? = nil`; + }) + .join(',\n '); + this.emit(` public init(${sortedFields.length > 0 ? `\n ${initParams}\n ` : ''}) {`); + for (const field of sortedFields) { + const propertyName = this.escapeKeyword(field.name); + this.emit(` self.${propertyName} = ${propertyName}`); + } + this.emit(' }'); + this.emit('}'); + this.emit(''); + } + + // ============================================================================ + // Helpers + // ============================================================================ + + private getPropertyType(type: IRType): string { + const baseType = this.mapType(type); + return type.nullable ? `${baseType}?` : baseType; + } + + private getOperationReturnType(field: IROperationField): string { + const resolved = field.resolvedReturnType; + + // Handle Void + if (resolved.kind === 'scalar' && resolved.name === 'Void') { + return resolved.nullable ? 'Void?' : 'Void'; + } + + return this.getPropertyType(resolved); + } + + protected generateDocComment(description: string | undefined, indent: string = ''): void { + if (!description) return; + for (const line of description.split(/\r?\n/)) { + this.emit(`${indent}/// ${line}`); + } + } +} diff --git a/packages/gql/codegen/plugins/template-plugin.ts b/packages/gql/codegen/plugins/template-plugin.ts new file mode 100644 index 00000000..13fda504 --- /dev/null +++ b/packages/gql/codegen/plugins/template-plugin.ts @@ -0,0 +1,309 @@ +/** + * Template-based Plugin for Code Generation + * + * Base class that uses Handlebars templates for common structures + * while allowing language-specific customization through hooks. + */ + +import Handlebars from 'handlebars'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import type { + IRSchema, + IREnum, + IRInterface, + IRObject, + IRInput, + IRUnion, + IROperation, + IRType, + IRField, + IROperationField, +} from '../core/types.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ============================================================================ +// Template Context Types +// ============================================================================ + +export interface EnumValueContext { + name: string; + caseName: string; + rawValue: string; + camelCaseName: string; + description?: string; + legacyValues: string[]; + isLast: boolean; +} + +export interface FieldContext { + name: string; + graphqlName: string; + propertyName: string; + paramName: string; + type: string; + declarationType: string; + description?: string; + nullable: boolean; + isOverride: boolean; + defaultValue: string; + annotation: string; + fromJsonExpr: string; + toJsonExpr: string; + isLast: boolean; +} + +export interface OperationFieldContext { + name: string; + escapedName: string; + propertyName: string; + paramName: string; + description?: string; + returnType: string; + args: ArgContext[]; + hasArgs: boolean; + hasSingleArg: boolean; + hasMultipleArgs: boolean; + aliasName: string; + argsSignature: string; + paramsSignature: string; + isLast: boolean; +} + +export interface ArgContext { + name: string; + escapedName: string; + type: string; + defaultValue: string; + isLast: boolean; +} + +export interface ConcreteMemberContext { + typeName: string; + delegateTo: string; + isNested: boolean; + wrapperName: string; +} + +export interface NestedUnionWrapperContext { + wrapperName: string; + unionName: string; + parentUnionName: string; + interfaceFields: FieldContext[]; +} + +export interface ResultUnionEntryContext { + fieldName: string; + caseName: string; + className: string; + type: string; + isLast: boolean; +} + +// ============================================================================ +// Template Plugin Base Class +// ============================================================================ + +export abstract class TemplatePlugin extends CodegenPlugin { + protected handlebars: typeof Handlebars; + protected templates: Map = new Map(); + protected schema!: IRSchema; + + constructor(config: CodegenPluginConfig) { + super(config); + this.handlebars = Handlebars.create(); + this.registerHelpers(); + this.loadTemplates(); + } + + // ============================================================================ + // Abstract Methods for Language-Specific Logic + // ============================================================================ + + /** Template directory name (e.g., 'swift', 'kotlin') */ + protected abstract get templateDir(): string; + + /** Get property type string with nullability */ + abstract getPropertyType(type: IRType): string; + + /** Build fromJson expression for a field */ + abstract buildFromJsonExpression(type: IRType, sourceExpr: string): string; + + /** Build toJson expression for a field */ + abstract buildToJsonExpression(type: IRType, accessorExpr: string): string; + + /** Get default value for a field (including platform defaults) */ + abstract getFieldDefaultValue(field: IRField, objectName: string): string; + + /** Get field annotation (e.g., @override) */ + abstract getFieldAnnotation(field: IRField): string; + + /** Get declaration type (for Dart final vs var) */ + abstract getDeclarationType(type: IRType): string; + + /** Get constructor params signature */ + abstract getConstructorParams(fields: FieldContext[]): string; + + /** Get init params signature (for GDScript) */ + abstract getInitParams(fields: FieldContext[]): string; + + /** Get implements/extends clause */ + abstract getImplementsClause(interfaces: string[], unions: string[]): string; + + /** Check if fields should be sorted */ + abstract get sortFields(): boolean; + + /** Section comment style */ + abstract getSectionComment(title: string): string; + + // ============================================================================ + // Template Loading + // ============================================================================ + + protected loadTemplates(): void { + const templatePath = join(__dirname, '..', 'templates', this.templateDir); + const templateFiles = [ + 'header', + 'enum', + 'interface', + 'object', + 'result-union', + 'input', + 'union', + 'operation-protocol', + 'operation-helpers', + 'operation', + ]; + + for (const name of templateFiles) { + try { + const content = readFileSync(join(templatePath, `${name}.hbs`), 'utf-8'); + this.templates.set(name, this.handlebars.compile(content)); + } catch { + // Template not found - plugin will use code generation instead + } + } + } + + protected registerHelpers(): void { + this.handlebars.registerHelper('if_eq', function (this: unknown, a: unknown, b: unknown, options: Handlebars.HelperOptions) { + return a === b ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('unless_last', function (this: unknown, index: number, array: unknown[], options: Handlebars.HelperOptions) { + return index !== array.length - 1 ? options.fn(this) : options.inverse(this); + }); + + this.handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); + } + + // ============================================================================ + // Context Builders + // ============================================================================ + + protected buildEnumValueContext(value: IREnumValue, index: number, total: number): EnumValueContext { + return { + name: value.name, + caseName: this.escapeKeyword(this.enumValueCase(value.name)), + rawValue: value.rawValue, + camelCaseName: value.name.charAt(0).toUpperCase() + value.name.slice(1), + description: value.description, + legacyValues: value.legacyAliases, + isLast: index === total - 1, + }; + } + + protected buildFieldContext(field: IRField, objectName: string, index: number, total: number): FieldContext { + const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + return { + name: field.name, + graphqlName: field.name, + propertyName, + paramName: `p_${this.fieldNameCase(field.name)}`, + type: this.getPropertyType(field.type), + declarationType: this.getDeclarationType(field.type), + description: field.description, + nullable: field.type.nullable, + isOverride: field.isOverride, + defaultValue: this.getFieldDefaultValue(field, objectName), + annotation: this.getFieldAnnotation(field), + fromJsonExpr: this.buildFromJsonExpression(field.type, `json["${field.name}"]`), + toJsonExpr: this.buildToJsonExpression(field.type, propertyName), + isLast: index === total - 1, + }; + } + + protected buildFieldsContext(fields: IRField[], objectName: string): FieldContext[] { + const sortedFields = this.sortFields + ? [...fields].sort((a, b) => a.name.localeCompare(b.name)) + : fields; + return sortedFields.map((field, index) => + this.buildFieldContext(field, objectName, index, sortedFields.length) + ); + } + + protected buildOperationFieldContext( + field: IROperationField, + operationName: string, + index: number, + total: number + ): OperationFieldContext { + const escapedName = this.escapeKeyword(field.name); + const args = field.args.map((arg, i) => ({ + name: arg.name, + escapedName: this.escapeKeyword(arg.name), + type: this.getPropertyType(arg.type), + defaultValue: arg.type.nullable ? this.getNullDefault() : '', + isLast: i === field.args.length - 1, + })); + + return { + name: field.name, + escapedName, + propertyName: this.escapeKeyword(this.fieldNameCase(field.name)), + paramName: `p_${this.fieldNameCase(field.name)}`, + description: field.description, + returnType: this.getOperationReturnType(field), + args, + hasArgs: args.length > 0, + hasSingleArg: args.length === 1, + hasMultipleArgs: args.length > 1, + aliasName: `${operationName}${this.capitalize(field.name)}Handler`, + argsSignature: this.buildArgsSignature(args), + paramsSignature: this.buildParamsSignature(args), + isLast: index === total - 1, + }; + } + + protected abstract getNullDefault(): string; + protected abstract getOperationReturnType(field: IROperationField): string; + protected abstract buildArgsSignature(args: ArgContext[]): string; + protected abstract buildParamsSignature(args: ArgContext[]): string; + + protected capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + // ============================================================================ + // Rendering + // ============================================================================ + + protected render(templateName: string, context: Record): string { + const template = this.templates.get(templateName); + if (!template) { + throw new Error(`Template not found: ${templateName}`); + } + return template(context); + } + + protected hasTemplate(name: string): boolean { + return this.templates.has(name); + } +} + +// Re-export IREnumValue for context builders +import type { IREnumValue } from '../core/types.js'; diff --git a/packages/gql/codegen/templates/dart/enum.hbs b/packages/gql/codegen/templates/dart/enum.hbs new file mode 100644 index 00000000..7cdd9bba --- /dev/null +++ b/packages/gql/codegen/templates/dart/enum.hbs @@ -0,0 +1,28 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +enum {{name}} { +{{#each values}} +{{#if description}} + /// {{{description}}} +{{/if}} + {{caseName}}('{{rawValue}}'){{#unless isLast}},{{/unless}}{{#if isLast}};{{/if}} +{{/each}} + + const {{name}}(this.rawValue); + final String rawValue; + + static {{name}} fromJson(String value) { + return switch (value) { +{{#each values}} + '{{rawValue}}' => {{caseName}}, +{{#each legacyValues}} + '{{this}}' => {{../caseName}}, +{{/each}} +{{/each}} + _ => throw ArgumentError('Unknown {{name}} value: $value'), + }; + } + + String toJson() => rawValue; +} diff --git a/packages/gql/codegen/templates/dart/header.hbs b/packages/gql/codegen/templates/dart/header.hbs new file mode 100644 index 00000000..ef845d70 --- /dev/null +++ b/packages/gql/codegen/templates/dart/header.hbs @@ -0,0 +1,4 @@ +// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run `bun run generate` after updating any *.graphql schema file. +// ============================================================================ diff --git a/packages/gql/codegen/templates/dart/input.hbs b/packages/gql/codegen/templates/dart/input.hbs new file mode 100644 index 00000000..0a718a7d --- /dev/null +++ b/packages/gql/codegen/templates/dart/input.hbs @@ -0,0 +1,27 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +class {{name}} { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + {{declarationType}} {{propertyName}}; +{{/each}} + + {{name}}({{constructorParams}}); + + factory {{name}}.fromJson(Map json) { + return {{name}}( +{{#each fields}} + {{propertyName}}: {{fromJsonExpr}}, +{{/each}} + ); + } + + Map toJson() => { +{{#each fields}} + '{{graphqlName}}': {{toJsonExpr}}, +{{/each}} + }; +} diff --git a/packages/gql/codegen/templates/dart/interface.hbs b/packages/gql/codegen/templates/dart/interface.hbs new file mode 100644 index 00000000..286fd0a8 --- /dev/null +++ b/packages/gql/codegen/templates/dart/interface.hbs @@ -0,0 +1,13 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +abstract class {{name}} { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + {{type}} get {{propertyName}}; +{{/each}} + + Map toJson(); +} diff --git a/packages/gql/codegen/templates/dart/object.hbs b/packages/gql/codegen/templates/dart/object.hbs new file mode 100644 index 00000000..28daec95 --- /dev/null +++ b/packages/gql/codegen/templates/dart/object.hbs @@ -0,0 +1,34 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +class {{name}}{{implements}} { +{{#each fields}} +{{#if annotation}} + {{annotation}} +{{/if}} +{{#if description}} + /// {{{description}}} +{{/if}} + {{declarationType}} {{propertyName}}; +{{/each}} + + {{name}}({{constructorParams}}); + + factory {{name}}.fromJson(Map json) { + return {{name}}( +{{#each fields}} + {{propertyName}}: {{fromJsonExpr}}, +{{/each}} + ); + } + +{{#if hasUnionOverride}} + @override +{{/if}} + Map toJson() => { + '__typename': '{{name}}', +{{#each fields}} + '{{graphqlName}}': {{toJsonExpr}}, +{{/each}} + }; +} diff --git a/packages/gql/codegen/templates/dart/operation-helpers.hbs b/packages/gql/codegen/templates/dart/operation-helpers.hbs new file mode 100644 index 00000000..5d0941d7 --- /dev/null +++ b/packages/gql/codegen/templates/dart/operation-helpers.hbs @@ -0,0 +1,13 @@ +// MARK: - {{name}} Helpers + +{{#each fields}} +typedef {{aliasName}} = Future<{{returnType}}> Function({{paramsSignature}}); +{{/each}} + +class {{handlersName}} { +{{#each fields}} + {{aliasName}}? {{escapedName}}; +{{/each}} + + {{handlersName}}({{constructorParams}}); +} diff --git a/packages/gql/codegen/templates/dart/operation-interface.hbs b/packages/gql/codegen/templates/dart/operation-interface.hbs new file mode 100644 index 00000000..c336cc81 --- /dev/null +++ b/packages/gql/codegen/templates/dart/operation-interface.hbs @@ -0,0 +1,11 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +abstract class {{interfaceName}} { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + Future<{{returnType}}> {{escapedName}}({{argsSignature}}); +{{/each}} +} diff --git a/packages/gql/codegen/templates/dart/result-union.hbs b/packages/gql/codegen/templates/dart/result-union.hbs new file mode 100644 index 00000000..816f5369 --- /dev/null +++ b/packages/gql/codegen/templates/dart/result-union.hbs @@ -0,0 +1,12 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +sealed class {{name}} {} + +{{#each entries}} +class {{className}} extends {{../name}} { + final {{type}} value; + {{className}}(this.value); +} + +{{/each}} diff --git a/packages/gql/codegen/templates/dart/union.hbs b/packages/gql/codegen/templates/dart/union.hbs new file mode 100644 index 00000000..6eda5599 --- /dev/null +++ b/packages/gql/codegen/templates/dart/union.hbs @@ -0,0 +1,34 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +sealed class {{name}}{{implements}} { + Map toJson(); + + static {{name}} fromJson(Map json) { + return switch (json['__typename']) { +{{#each concreteMembers}} +{{#if isNested}} + '{{typeName}}' => {{wrapperName}}({{delegateTo}}.fromJson(json)), +{{else}} + '{{typeName}}' => {{delegateTo}}.fromJson(json), +{{/if}} +{{/each}} + _ => throw ArgumentError('Unknown __typename for {{name}}: ${json["__typename"]}'), + }; + } +} + +{{#each nestedUnionWrappers}} +class {{wrapperName}} extends {{parentUnionName}} { +{{#each interfaceFields}} + @override + {{type}} get {{propertyName}} => value.{{propertyName}}; +{{/each}} + final {{unionName}} value; + {{wrapperName}}(this.value); + + @override + Map toJson() => value.toJson(); +} + +{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/enum.hbs b/packages/gql/codegen/templates/gdscript/enum.hbs new file mode 100644 index 00000000..85ad97e0 --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/enum.hbs @@ -0,0 +1,10 @@ +{{#if description}} +{{{gd_doc description}}} +{{/if}} +class {{name}}: +{{#each values}} +{{#if description}} + {{{gd_doc description}}} +{{/if}} + const {{caseName}} = "{{rawValue}}" +{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/header.hbs b/packages/gql/codegen/templates/gdscript/header.hbs new file mode 100644 index 00000000..98d34194 --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/header.hbs @@ -0,0 +1,7 @@ +# ============================================================================ +# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +# Run `bun run generate` after updating any *.graphql schema file. +# ============================================================================ + +class_name Types +extends RefCounted diff --git a/packages/gql/codegen/templates/gdscript/input.hbs b/packages/gql/codegen/templates/gdscript/input.hbs new file mode 100644 index 00000000..69a067bd --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/input.hbs @@ -0,0 +1,29 @@ +{{#if description}} +## {{{description}}} +{{/if}} +class {{name}}: +{{#each fields}} +{{#if description}} + ## {{{description}}} +{{/if}} + var {{propertyName}}: {{type}} +{{/each}} + + func _init({{initParams}}) -> void: +{{#each fields}} + self.{{propertyName}} = {{paramName}} +{{/each}} + + static func from_json(json: Dictionary) -> {{name}}: + return {{name}}.new( +{{#each fields}} + {{fromJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + ) + + func to_json() -> Dictionary: + return { +{{#each fields}} + "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + } diff --git a/packages/gql/codegen/templates/gdscript/interface.hbs b/packages/gql/codegen/templates/gdscript/interface.hbs new file mode 100644 index 00000000..69a067bd --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/interface.hbs @@ -0,0 +1,29 @@ +{{#if description}} +## {{{description}}} +{{/if}} +class {{name}}: +{{#each fields}} +{{#if description}} + ## {{{description}}} +{{/if}} + var {{propertyName}}: {{type}} +{{/each}} + + func _init({{initParams}}) -> void: +{{#each fields}} + self.{{propertyName}} = {{paramName}} +{{/each}} + + static func from_json(json: Dictionary) -> {{name}}: + return {{name}}.new( +{{#each fields}} + {{fromJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + ) + + func to_json() -> Dictionary: + return { +{{#each fields}} + "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + } diff --git a/packages/gql/codegen/templates/gdscript/object.hbs b/packages/gql/codegen/templates/gdscript/object.hbs new file mode 100644 index 00000000..a3bc3ec6 --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/object.hbs @@ -0,0 +1,30 @@ +{{#if description}} +## {{{description}}} +{{/if}} +class {{name}}{{extends}}: +{{#each fields}} +{{#if description}} + ## {{{description}}} +{{/if}} + var {{propertyName}}: {{type}}{{defaultValue}} +{{/each}} + + func _init({{initParams}}) -> void: +{{#each fields}} + self.{{propertyName}} = {{paramName}} +{{/each}} + + static func from_json(json: Dictionary) -> {{name}}: + return {{name}}.new( +{{#each fields}} + {{fromJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + ) + + func to_json() -> Dictionary: + return { + "__typename": "{{name}}"{{#if hasFields}},{{/if}} +{{#each fields}} + "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} +{{/each}} + } diff --git a/packages/gql/codegen/templates/gdscript/operation.hbs b/packages/gql/codegen/templates/gdscript/operation.hbs new file mode 100644 index 00000000..2af00a89 --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/operation.hbs @@ -0,0 +1,17 @@ +# MARK: - {{kind}} + +{{#if description}} +## {{{description}}} +{{/if}} +class {{resolverName}}: +{{#each fields}} +{{#if description}} + ## {{description}} +{{/if}} + var {{propertyName}}: Callable + +{{/each}} + func _init({{initParams}}) -> void: +{{#each fields}} + self.{{propertyName}} = {{paramName}} +{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/result-union.hbs b/packages/gql/codegen/templates/gdscript/result-union.hbs new file mode 100644 index 00000000..97185b75 --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/result-union.hbs @@ -0,0 +1,12 @@ +{{#if description}} +## {{{description}}} +{{/if}} +class {{name}}: +{{#each entries}} + var {{fieldName}}: {{type}} +{{/each}} + + func _init({{initParams}}) -> void: +{{#each entries}} + self.{{fieldName}} = p_{{fieldName}} +{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/union.hbs b/packages/gql/codegen/templates/gdscript/union.hbs new file mode 100644 index 00000000..a3f5c39b --- /dev/null +++ b/packages/gql/codegen/templates/gdscript/union.hbs @@ -0,0 +1,23 @@ +{{#if description}} +## {{{description}}} +{{/if}} +class {{name}}: + var value: Variant + + func _init(p_value: Variant) -> void: + self.value = p_value + + static func from_json(json: Dictionary) -> {{name}}: + var typename = json.get("__typename", "") + match typename: +{{#each concreteMembers}} + "{{typeName}}": + return {{../name}}.new({{delegateTo}}.from_json(json)) +{{/each}} + push_error("Unknown __typename for {{name}}: " + typename) + return null + + func to_json() -> Dictionary: + if value != null and value.has_method("to_json"): + return value.to_json() + return {} diff --git a/packages/gql/codegen/templates/kotlin/enum.hbs b/packages/gql/codegen/templates/kotlin/enum.hbs new file mode 100644 index 00000000..134f84a2 --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/enum.hbs @@ -0,0 +1,31 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public enum class {{name}}(val rawValue: String) { +{{#each values}} +{{#if description}} + /** + * {{{description}}} + */ +{{/if}} + {{caseName}}("{{rawValue}}"){{#unless isLast}},{{/unless}} +{{/each}} + + companion object { + fun fromJson(value: String): {{name}} = when (value) { +{{#each values}} + "{{rawValue}}" -> {{../name}}.{{caseName}} +{{#each legacyValues}} +{{#unless (eq this ../rawValue)}} + "{{this}}" -> {{../../name}}.{{../caseName}} +{{/unless}} +{{/each}} +{{/each}} + else -> throw IllegalArgumentException("Unknown {{name}} value: $value") + } + } + + fun toJson(): String = rawValue +} diff --git a/packages/gql/codegen/templates/kotlin/header.hbs b/packages/gql/codegen/templates/kotlin/header.hbs new file mode 100644 index 00000000..9da6dd4d --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/header.hbs @@ -0,0 +1,7 @@ +// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run `bun run generate` after updating any *.graphql schema file. +// ============================================================================ + +// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure +@file:Suppress("UNCHECKED_CAST") diff --git a/packages/gql/codegen/templates/kotlin/input.hbs b/packages/gql/codegen/templates/kotlin/input.hbs new file mode 100644 index 00000000..0fadcc1b --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/input.hbs @@ -0,0 +1,47 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public data class {{name}}( +{{#each fields}} +{{#if description}} + /** + * {{{description}}} + */ +{{/if}} + val {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} +{{/each}} +) { + companion object { +{{#if hasRequiredFields}} + fun fromJson(json: Map): {{name}}? { +{{#each fields}} + val {{propertyName}} = {{fromJsonExpr}} +{{/each}} +{{#if requiredFieldsNullCheck}} + if ({{requiredFieldsNullCheck}}) return null +{{/if}} + return {{name}}( +{{#each fields}} + {{propertyName}} = {{propertyName}}, +{{/each}} + ) + } +{{else}} + fun fromJson(json: Map): {{name}} { + return {{name}}( +{{#each fields}} + {{propertyName}} = {{fromJsonExpr}}, +{{/each}} + ) + } +{{/if}} + } + + fun toJson(): Map = mapOf( +{{#each fields}} + "{{graphqlName}}" to {{toJsonExpr}}, +{{/each}} + ) +} diff --git a/packages/gql/codegen/templates/kotlin/interface.hbs b/packages/gql/codegen/templates/kotlin/interface.hbs new file mode 100644 index 00000000..a8e69cab --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/interface.hbs @@ -0,0 +1,15 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public interface {{name}} { +{{#each fields}} +{{#if description}} + /** + * {{{description}}} + */ +{{/if}} + val {{propertyName}}: {{type}} +{{/each}} +} diff --git a/packages/gql/codegen/templates/kotlin/object.hbs b/packages/gql/codegen/templates/kotlin/object.hbs new file mode 100644 index 00000000..3ddf2afb --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/object.hbs @@ -0,0 +1,33 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public data class {{name}}( +{{#each fields}} +{{#if description}} + /** + * {{{description}}} + */ +{{/if}} + {{#if isOverride}}override {{/if}}val {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} +{{/each}} +){{#if implements}} : {{implements}}{{/if}} { + + companion object { + fun fromJson(json: Map): {{name}} { + return {{name}}( +{{#each fields}} + {{propertyName}} = {{fromJsonExpr}}, +{{/each}} + ) + } + } + + {{#if hasUnionOverride}}override {{/if}}fun toJson(): Map = mapOf( + "__typename" to "{{name}}", +{{#each fields}} + "{{graphqlName}}" to {{toJsonExpr}}, +{{/each}} + ) +} diff --git a/packages/gql/codegen/templates/kotlin/operation-helpers.hbs b/packages/gql/codegen/templates/kotlin/operation-helpers.hbs new file mode 100644 index 00000000..22bfa8f1 --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/operation-helpers.hbs @@ -0,0 +1,15 @@ +// MARK: - {{name}} Helpers + +{{#each fields}} +{{#if hasArgs}} +public typealias {{aliasName}} = suspend ({{paramsSignature}}) -> {{returnType}} +{{else}} +public typealias {{aliasName}} = suspend () -> {{returnType}} +{{/if}} +{{/each}} + +public data class {{handlersName}}( +{{#each fields}} + val {{escapedName}}: {{aliasName}}? = null{{#unless isLast}},{{/unless}} +{{/each}} +) diff --git a/packages/gql/codegen/templates/kotlin/operation-interface.hbs b/packages/gql/codegen/templates/kotlin/operation-interface.hbs new file mode 100644 index 00000000..9be1a6b2 --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/operation-interface.hbs @@ -0,0 +1,15 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public interface {{interfaceName}} { +{{#each fields}} +{{#if description}} + /** + * {{{description}}} + */ +{{/if}} + suspend fun {{escapedName}}({{argsSignature}}): {{returnType}} +{{/each}} +} diff --git a/packages/gql/codegen/templates/kotlin/result-union.hbs b/packages/gql/codegen/templates/kotlin/result-union.hbs new file mode 100644 index 00000000..5bc77918 --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/result-union.hbs @@ -0,0 +1,11 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public sealed interface {{name}} + +{{#each entries}} +public data class {{className}}(val value: {{type}}) : {{../name}} + +{{/each}} diff --git a/packages/gql/codegen/templates/kotlin/union.hbs b/packages/gql/codegen/templates/kotlin/union.hbs new file mode 100644 index 00000000..7fd34c06 --- /dev/null +++ b/packages/gql/codegen/templates/kotlin/union.hbs @@ -0,0 +1,29 @@ +{{#if description}} +/** + * {{{description}}} + */ +{{/if}} +public sealed interface {{name}}{{implementations}} { + fun toJson(): Map + + companion object { + fun fromJson(json: Map): {{name}} { + return when (json["__typename"] as String?) { +{{#each concreteMembers}} +{{#if isNested}} + "{{typeName}}" -> {{wrapperName}}({{delegateTo}}.fromJson(json)) +{{else}} + "{{typeName}}" -> {{delegateTo}}.fromJson(json) +{{/if}} +{{/each}} + else -> throw IllegalArgumentException("Unknown __typename for {{name}}: ${json["__typename"]}") + } + } + } +{{#each nestedUnionWrappers}} + + data class {{wrapperName}}(val value: {{unionName}}) : {{parentUnionName}} { + override fun toJson() = value.toJson() + } +{{/each}} +} diff --git a/packages/gql/codegen/templates/swift/enum.hbs b/packages/gql/codegen/templates/swift/enum.hbs new file mode 100644 index 00000000..6dcad1dc --- /dev/null +++ b/packages/gql/codegen/templates/swift/enum.hbs @@ -0,0 +1,27 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public enum {{name}}: String, Codable, CaseIterable { +{{#each values}} +{{#if description}} + /// {{{description}}} +{{/if}} + case {{caseName}} = "{{rawValue}}" +{{/each}} +{{#if isErrorCode}} + + /// Custom initializer to handle both kebab-case and camelCase error codes + /// This ensures compatibility with react-native-iap and other libraries that may send camelCase + public init?(rawValue: String) { + // Try direct match first (kebab-case) + switch rawValue { +{{#each values}} + case "{{rawValue}}", "{{camelCaseName}}": + self = .{{caseName}} +{{/each}} + default: + return nil + } + } +{{/if}} +} diff --git a/packages/gql/codegen/templates/swift/header.hbs b/packages/gql/codegen/templates/swift/header.hbs new file mode 100644 index 00000000..34f16e55 --- /dev/null +++ b/packages/gql/codegen/templates/swift/header.hbs @@ -0,0 +1,6 @@ +// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run `bun run generate` after updating any *.graphql schema file. +// ============================================================================ + +import Foundation diff --git a/packages/gql/codegen/templates/swift/input.hbs b/packages/gql/codegen/templates/swift/input.hbs new file mode 100644 index 00000000..b6086278 --- /dev/null +++ b/packages/gql/codegen/templates/swift/input.hbs @@ -0,0 +1,25 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public struct {{name}}: Codable { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + public var {{propertyName}}: {{type}} +{{/each}} +{{#if hasFields}} + + public init( +{{#each fields}} + {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} +{{/each}} + ) { +{{#each fields}} + self.{{propertyName}} = {{propertyName}} +{{/each}} + } +{{else}} + public init() {} +{{/if}} +} diff --git a/packages/gql/codegen/templates/swift/interface.hbs b/packages/gql/codegen/templates/swift/interface.hbs new file mode 100644 index 00000000..5b2cf113 --- /dev/null +++ b/packages/gql/codegen/templates/swift/interface.hbs @@ -0,0 +1,11 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public protocol {{name}}: Codable { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + var {{propertyName}}: {{type}} { get } +{{/each}} +} diff --git a/packages/gql/codegen/templates/swift/object.hbs b/packages/gql/codegen/templates/swift/object.hbs new file mode 100644 index 00000000..13d37e5e --- /dev/null +++ b/packages/gql/codegen/templates/swift/object.hbs @@ -0,0 +1,14 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public struct {{name}}: {{conformances}} { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} + public var {{propertyName}}: {{type}}{{defaultValue}} +{{/each}} +{{#unless hasFields}} + public init() {} +{{/unless}} +} diff --git a/packages/gql/codegen/templates/swift/operation-helpers.hbs b/packages/gql/codegen/templates/swift/operation-helpers.hbs new file mode 100644 index 00000000..c68261d1 --- /dev/null +++ b/packages/gql/codegen/templates/swift/operation-helpers.hbs @@ -0,0 +1,25 @@ +// MARK: - {{name}} Helpers + +{{#each fields}} +{{#if hasArgs}} +public typealias {{aliasName}} = ({{paramsSignature}}) async throws -> {{returnType}} +{{else}} +public typealias {{aliasName}} = () async throws -> {{returnType}} +{{/if}} +{{/each}} + +public struct {{handlersName}} { +{{#each fields}} + public var {{escapedName}}: {{aliasName}}? +{{/each}} + + public init( +{{#each fields}} + {{escapedName}}: {{aliasName}}? = nil{{#unless isLast}},{{/unless}} +{{/each}} + ) { +{{#each fields}} + self.{{escapedName}} = {{escapedName}} +{{/each}} + } +} diff --git a/packages/gql/codegen/templates/swift/operation-protocol.hbs b/packages/gql/codegen/templates/swift/operation-protocol.hbs new file mode 100644 index 00000000..a959d6e5 --- /dev/null +++ b/packages/gql/codegen/templates/swift/operation-protocol.hbs @@ -0,0 +1,19 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public protocol {{protocolName}} { +{{#each fields}} +{{#if description}} + /// {{{description}}} +{{/if}} +{{#if hasArgs}} +{{#if hasSingleArg}} + func {{escapedName}}(_ {{argsSignature}}) async throws -> {{returnType}} +{{else}} + func {{escapedName}}({{argsSignature}}) async throws -> {{returnType}} +{{/if}} +{{else}} + func {{escapedName}}() async throws -> {{returnType}} +{{/if}} +{{/each}} +} diff --git a/packages/gql/codegen/templates/swift/result-union.hbs b/packages/gql/codegen/templates/swift/result-union.hbs new file mode 100644 index 00000000..5566e331 --- /dev/null +++ b/packages/gql/codegen/templates/swift/result-union.hbs @@ -0,0 +1,8 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public enum {{name}} { +{{#each entries}} + case {{caseName}}({{type}}) +{{/each}} +} diff --git a/packages/gql/codegen/templates/swift/union.hbs b/packages/gql/codegen/templates/swift/union.hbs new file mode 100644 index 00000000..d46c96d9 --- /dev/null +++ b/packages/gql/codegen/templates/swift/union.hbs @@ -0,0 +1,24 @@ +{{#if description}} +/// {{{description}}} +{{/if}} +public enum {{name}}{{conformances}} { +{{#each members}} + case {{caseName}}({{name}}) +{{/each}} +{{#if hasInterfaceFields}} +{{#each interfaceFields}} + +{{#if description}} + /// {{{description}}} +{{/if}} + public var {{propertyName}}: {{type}} { + switch self { +{{#each ../members}} + case let .{{caseName}}(value): + return value.{{../propertyName}} +{{/each}} + } + } +{{/each}} +{{/if}} +} diff --git a/packages/gql/package.json b/packages/gql/package.json index 9275525c..dd57508c 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -13,10 +13,10 @@ "scripts": { "generate": "bun run generate:ts && bun run generate:swift && bun run generate:kotlin && bun run generate:dart && bun run generate:gdscript && bun run sync", "generate:ts": "graphql-codegen --config codegen.ts && bun scripts/fix-generated-types.mjs", - "generate:swift": "bun scripts/generate-swift-types.mjs", - "generate:kotlin": "bun scripts/generate-kotlin-types.mjs", - "generate:dart": "bun scripts/generate-dart-types.mjs", - "generate:gdscript": "bun scripts/generate-gdscript-types.mjs", + "generate:swift": "bun codegen/index.ts swift", + "generate:kotlin": "bun codegen/index.ts kotlin", + "generate:dart": "bun codegen/index.ts dart", + "generate:gdscript": "bun codegen/index.ts gdscript", "sync": "bun scripts/sync-to-platforms.mjs" }, "keywords": [ @@ -33,6 +33,7 @@ "@graphql-codegen/cli": "^6.0.0", "@graphql-codegen/typescript": "^5.0.0", "graphql": "^16.11.0", + "handlebars": "^4.7.8", "ts-node": "^10.9.2", "typescript": "^5.9.2" }, diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs deleted file mode 100644 index 5d509f6e..00000000 --- a/packages/gql/scripts/generate-dart-types.mjs +++ /dev/null @@ -1,1105 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - GraphQLList, - GraphQLNonNull, - buildASTSchema, - isEnumType, - isInputObjectType, - isInterfaceType, - isObjectType, - isScalarType, - isUnionType, - parse, -} from 'graphql'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const schemaPaths = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', -].map((relativePath) => resolve(__dirname, relativePath)); - -const documentNode = { - kind: 'Document', - definitions: schemaPaths.flatMap((schemaPath) => { - const sdl = readFileSync(schemaPath, 'utf8'); - return parse(sdl).definitions; - }), -}; - -const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); -const typeMap = schema.getTypeMap(); -const typeNames = Object.keys(typeMap) - .filter((name) => !name.startsWith('__')) - .sort((a, b) => a.localeCompare(b)); - -const dartKeywords = new Set([ - 'abstract', 'as', 'assert', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', - 'continue', 'covariant', 'default', 'deferred', 'do', 'dynamic', 'else', 'enum', 'export', - 'extends', 'extension', 'external', 'factory', 'false', 'final', 'finally', 'for', 'Function', - 'get', 'hide', 'if', 'implements', 'import', 'in', 'interface', 'is', 'late', 'library', 'mixin', - 'new', 'null', 'on', 'operator', 'part', 'required', 'rethrow', 'return', 'set', 'show', 'static', - 'super', 'switch', 'sync', 'this', 'throw', 'true', 'try', 'typedef', 'var', 'void', 'while', 'with', - 'yield', -]); - -const dartKeywordOverrides = new Map([ - ['deferred', 'deferred'], -]); - -const escapeDartName = (name) => { - if (dartKeywordOverrides.has(name)) { - return dartKeywordOverrides.get(name); - } - return dartKeywords.has(name) ? `_${name}` : name; -}; - -const toCamelCase = (value, upper = false) => { - const tokens = value - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_\-\s]+/g, ' ') - .split(' ') - .filter(Boolean) - .map((token) => token.toLowerCase()); - if (tokens.length === 0) return value; - const normalized = tokens.map((token) => (token === 'ios' ? 'IOS' : token)); - const [first, ...rest] = normalized; - const formatFirst = () => { - if (first === 'IOS') { - return upper ? 'IOS' : 'ios'; - } - return upper ? first.charAt(0).toUpperCase() + first.slice(1) : first; - }; - const firstToken = formatFirst(); - const restTokens = rest.map((token) => (token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1))); - return [firstToken, ...restTokens].join(''); -}; - -const toPascalCase = (value) => toCamelCase(value, true); - -const toConstantCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toUpperCase(); - -const toKebabCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - -const scalarMap = new Map([ - ['ID', 'String'], - ['String', 'String'], - ['Boolean', 'bool'], - ['Int', 'int'], - ['Float', 'double'], -]); - -const addDocComment = (lines, description, indent = '') => { - if (!description) return; - for (const docLine of description.split(/\r?\n/)) { - lines.push(`${indent}/// ${docLine}`); - } -}; - -const unionMembership = new Map(); -const enums = []; -const interfaces = []; -const objects = []; -const inputs = []; -const unions = []; -const operationTypes = []; - -const unionWrapperNames = new Set(); -for (const schemaPath of schemaPaths) { - let expectTypeName = false; - for (const line of readFileSync(schemaPath, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectTypeName = true; - continue; - } - if (expectTypeName) { - if (trimmed.length === 0) { - continue; - } - if (trimmed.startsWith('#')) { - continue; - } - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - unionWrapperNames.add(typeMatch[1]); - } - expectTypeName = false; - } - } -} - -for (const name of typeNames) { - const type = typeMap[name]; - if (isScalarType(type)) { - if (scalarMap.has(type.name)) continue; - continue; - } - if (isEnumType(type)) { - enums.push(type); - continue; - } - if (isInterfaceType(type)) { - interfaces.push(type); - continue; - } - if (isUnionType(type)) { - unions.push(type); - for (const member of type.getTypes()) { - if (!unionMembership.has(member.name)) { - unionMembership.set(member.name, new Set()); - } - unionMembership.get(member.name).add(type.name); - } - continue; - } - if (isObjectType(type)) { - if (['Query', 'Mutation', 'Subscription'].includes(name)) { - operationTypes.push(type); - continue; - } - objects.push(type); - continue; - } -if (isInputObjectType(type)) { - inputs.push(type); - } -} - -const enumNames = new Set(enums.map((value) => value.name)); -const interfaceNames = new Set(interfaces.map((value) => value.name)); -const objectNames = new Set(objects.map((value) => value.name)); -const inputNames = new Set(inputs.map((value) => value.name)); -const unionNames = new Set(unions.map((value) => value.name)); - -const singleFieldObjects = new Map(); -for (const objectType of objects) { - const fields = Object.values(objectType.getFields()); - if (fields.length === 1 && objectType.name.endsWith('Args')) { - singleFieldObjects.set(objectType.name, fields[0].type); - } -} - -const getTypeMetadata = (graphqlType) => { - if (graphqlType instanceof GraphQLNonNull) { - const inner = getTypeMetadata(graphqlType.ofType); - return { ...inner, nullable: false }; - } - if (graphqlType instanceof GraphQLList) { - const inner = getTypeMetadata(graphqlType.ofType); - const innerType = inner.dartType + (inner.nullable ? '?' : ''); - return { - kind: 'list', - nullable: true, - elementType: inner, - dartType: `List<${innerType}>`, - }; - } - const typeName = graphqlType.name; - let kind = 'object'; - if (scalarMap.has(typeName)) { - kind = 'scalar'; - } else if (enumNames.has(typeName)) { - kind = 'enum'; - } else if (interfaceNames.has(typeName)) { - kind = 'interface'; - } else if (inputNames.has(typeName)) { - kind = 'input'; - } else if (unionNames.has(typeName)) { - kind = 'union'; - } else if (objectNames.has(typeName)) { - kind = 'object'; - } - const dartType = scalarMap.get(typeName) ?? typeName; - return { - kind, - name: typeName, - nullable: true, - dartType, - }; -}; - -const getDartType = (graphqlType) => { - const metadata = getTypeMetadata(graphqlType); - return { type: metadata.dartType, nullable: metadata.nullable, metadata }; -}; - -const isNullableGraphQLType = (graphqlType) => !(graphqlType instanceof GraphQLNonNull); - -const unwrapNonNull = (graphqlType) => { - let current = graphqlType; - while (current instanceof GraphQLNonNull) { - current = current.ofType; - } - return current; -}; - -const getNamedGraphQLType = (graphqlType) => { - const unwrapped = unwrapNonNull(graphqlType); - if (unwrapped instanceof GraphQLList) { - return null; - } - return unwrapped; -}; - -const getOperationReturnType = (graphqlType) => { - const base = getDartType(graphqlType); - if (base.metadata.kind === 'list') { - return base; - } - const namedType = getNamedGraphQLType(graphqlType); - if (namedType && namedType.name === 'VoidResult') { - return { - type: 'void', - nullable: isNullableGraphQLType(graphqlType), - metadata: base.metadata, - }; - } - if (!namedType) { - return base; - } - const singleFieldType = singleFieldObjects.get(namedType.name); - if (!singleFieldType) { - return base; - } - const fieldInfo = getDartType(singleFieldType); - const finalNullable = base.nullable || fieldInfo.nullable || isNullableGraphQLType(graphqlType); - return { - type: fieldInfo.type, - nullable: finalNullable, - metadata: fieldInfo.metadata, - }; -}; - -const resultUnionObjects = new Map(); -for (const objectType of objects) { - if (!unionWrapperNames.has(objectType.name)) continue; - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) continue; - const unionEntries = []; - let allOptional = true; - for (const field of fields) { - if (field.type instanceof GraphQLNonNull) { - allOptional = false; - break; - } - const { type, nullable, metadata } = getDartType(field.type); - unionEntries.push({ field, type, nullable, metadata }); - } - if (!allOptional) continue; - if (unionEntries.length === 0) continue; - resultUnionObjects.set(objectType.name, unionEntries); -} - -const buildFromJsonExpression = (metadata, sourceExpression) => { - if (metadata.kind === 'list') { - const listCast = `(${sourceExpression} as List${metadata.nullable ? '?' : ''})`; - const elementExpression = buildFromJsonExpression(metadata.elementType, 'e'); - const mapCall = (target) => `${target}.map((e) => ${elementExpression}).toList()`; - if (metadata.nullable) { - return `${listCast} == null ? null : ${mapCall(`${listCast}!`)}`; - } - return mapCall(listCast); - } - if (metadata.kind === 'scalar') { - switch (metadata.name) { - case 'Float': - return metadata.nullable - ? `(${sourceExpression} as num?)?.toDouble()` - : `(${sourceExpression} as num).toDouble()`; - case 'Int': - return metadata.nullable - ? `${sourceExpression} as int?` - : `${sourceExpression} as int`; - case 'Boolean': - return metadata.nullable - ? `${sourceExpression} as bool?` - : `${sourceExpression} as bool`; - case 'ID': - case 'String': - return metadata.nullable - ? `${sourceExpression} as String?` - : `${sourceExpression} as String`; - default: - return metadata.nullable ? `${sourceExpression}` : `${sourceExpression}`; - } - } - if (metadata.kind === 'enum') { - return metadata.nullable - ? `${sourceExpression} != null ? ${metadata.name}.fromJson(${sourceExpression} as String) : null` - : `${metadata.name}.fromJson(${sourceExpression} as String)`; - } - if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) { - return metadata.nullable - ? `${sourceExpression} != null ? ${metadata.dartType}.fromJson(${sourceExpression} as Map) : null` - : `${metadata.dartType}.fromJson(${sourceExpression} as Map)`; - } - return metadata.nullable ? `${sourceExpression}` : `${sourceExpression}`; -}; - -const buildToJsonExpression = (metadata, accessorExpression) => { - if (metadata.kind === 'list') { - const inner = buildToJsonExpression(metadata.elementType, 'e'); - // If element is scalar (no transformation needed), skip map entirely - if (inner === 'e') { - return accessorExpression; - } - if (metadata.nullable) { - return `${accessorExpression} == null ? null : ${accessorExpression}!.map((e) => ${inner}).toList()`; - } - return `${accessorExpression}.map((e) => ${inner}).toList()`; - } - if (metadata.kind === 'enum') { - return metadata.nullable - ? `${accessorExpression}?.toJson()` - : `${accessorExpression}.toJson()`; - } - if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) { - return metadata.nullable - ? `${accessorExpression}?.toJson()` - : `${accessorExpression}.toJson()`; - } - return accessorExpression; -}; - -const lines = []; -lines.push( - '// ============================================================================', - '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', - "// Run `npm run generate` after updating any *.graphql schema file.", - '// ============================================================================', - '', - '// ignore_for_file: unused_element, unused_field', - '', - "import 'dart:async';", - '', -); - -const printEnum = (enumType) => { - addDocComment(lines, enumType.description); - lines.push(`enum ${enumType.name} {`); - const values = enumType.getValues(); - values.forEach((value, index) => { - addDocComment(lines, value.description, ' '); - const name = escapeDartName(toPascalCase(value.name)); - const rawValue = toKebabCase(value.name); - const suffix = index === values.length - 1 ? ';' : ','; - lines.push(` ${name}('${rawValue}')${suffix}`); - }); - lines.push( - '', - ` const ${enumType.name}(this.value);`, - ' final String value;', - '', - ` factory ${enumType.name}.fromJson(String value) {`, - " final normalized = value.toLowerCase().replaceAll('_', '-');", - ' switch (normalized) {' - ); - values.forEach((value) => { - const name = escapeDartName(toPascalCase(value.name)); - const rawValue = toKebabCase(value.name); - lines.push(` case '${rawValue}':`); - lines.push(` return ${enumType.name}.${name};`); - }); - lines.push( - ' }', - ` throw ArgumentError('Unknown ${enumType.name} value: \$value');`, - ' }', - '', - ' String toJson() => value;', - '}', - '' - ); -}; - -const printInterface = (interfaceType) => { - addDocComment(lines, interfaceType.description); - lines.push(`abstract class ${interfaceType.name} {`); - const fields = Object.values(interfaceType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, nullable } = getDartType(field.type); - const fieldType = `${type}${nullable ? '?' : ''}`; - const fieldName = escapeDartName(field.name); - lines.push(` ${fieldType} get ${fieldName};`); - } - lines.push('}', ''); -}; - -const printObject = (objectType) => { - if (objectType.name === 'VoidResult') { - lines.push('typedef VoidResult = void;', ''); - return; - } - if (resultUnionObjects.has(objectType.name)) { - const unionEntries = resultUnionObjects.get(objectType.name); - addDocComment(lines, objectType.description); - lines.push(`abstract class ${objectType.name} {`); - lines.push(` const ${objectType.name}();`); - lines.push('}', ''); - unionEntries.forEach(({ field, type, nullable }) => { - const className = `${objectType.name}${toPascalCase(field.name)}`; - const valueType = `${type}${nullable ? '?' : ''}`; - lines.push(`class ${className} extends ${objectType.name} {`); - lines.push(` const ${className}(this.value);`); - lines.push(` final ${valueType} value;`); - lines.push('}', ''); - }); - return; - } - addDocComment(lines, objectType.description); - const interfacesForObject = objectType.getInterfaces().map((iface) => iface.name); - const unionsForObject = unionMembership.has(objectType.name) - ? Array.from(unionMembership.get(objectType.name)).sort() - : []; - const baseUnion = unionsForObject.shift() ?? null; - const extendsClause = baseUnion ? ` extends ${baseUnion}` : ''; - const implementsTargets = [...interfacesForObject, ...unionsForObject]; - const implementsClause = implementsTargets.length ? ` implements ${implementsTargets.join(', ')}` : ''; - lines.push(`class ${objectType.name}${extendsClause}${implementsClause} {`); - lines.push(` const ${objectType.name}({`); - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - - // Special handling for PurchaseAndroid and PurchaseIOS to add isAlternativeBilling if missing - const needsAlternativeBilling = (objectType.name === 'PurchaseAndroid' || objectType.name === 'PurchaseIOS') - && !fields.some(f => f.name === 'isAlternativeBilling'); - - const fieldInfos = fields.map((field) => { - const { type, nullable, metadata } = getDartType(field.type); - const fieldName = escapeDartName(field.name); - return { field, fieldName, type, nullable, metadata }; - }); - - fieldInfos.forEach(({ nullable, fieldName }) => { - // Don't add doc comments here - they're added on the field definition below - // Dart analyzer will associate field docs with constructor params automatically - const line = ` ${nullable ? '' : 'required '}this.${fieldName},`; - lines.push(line); - }); - - if (needsAlternativeBilling) { - lines.push(' this.isAlternativeBilling,'); - } - - lines.push(' });', ''); - fieldInfos.forEach(({ field, type, nullable, fieldName }) => { - addDocComment(lines, field.description, ' '); - const fieldType = `${type}${nullable ? '?' : ''}`; - lines.push(` final ${fieldType} ${fieldName};`); - }); - - if (needsAlternativeBilling) { - lines.push(' final bool? isAlternativeBilling;'); - } - - lines.push(''); - lines.push(` factory ${objectType.name}.fromJson(Map json) {`); - lines.push(` return ${objectType.name}(`); - fieldInfos.forEach(({ field, fieldName, metadata }) => { - const jsonExpression = buildFromJsonExpression(metadata, `json['${field.name}']`); - lines.push(` ${fieldName}: ${jsonExpression},`); - }); - if (needsAlternativeBilling) { - lines.push(` isAlternativeBilling: json['isAlternativeBilling'] as bool?,`); - } - lines.push(' );'); - lines.push(' }', ''); - if (baseUnion) { - lines.push(' @override'); - } - lines.push(' Map toJson() {'); - lines.push(' return {'); - lines.push(` '__typename': '${objectType.name}',`); - fieldInfos.forEach(({ field, fieldName, metadata }) => { - const toJsonExpression = buildToJsonExpression(metadata, fieldName); - lines.push(` '${field.name}': ${toJsonExpression},`); - }); - if (needsAlternativeBilling) { - lines.push(` 'isAlternativeBilling': isAlternativeBilling,`); - } - lines.push(' };'); - lines.push(' }'); - lines.push('}', ''); -}; - -const printInput = (inputType) => { - // Alias PurchaseInput to Purchase for cleaner API - if (inputType.name === 'PurchaseInput') { - lines.push('typedef PurchaseInput = Purchase;'); - lines.push(''); - return; - } - - // TypeScript-style discriminated union with compile-time safety - if (inputType.name === 'RequestPurchaseProps') { - addDocComment(lines, inputType.description); - - // Get field names from schema (prefer apple/google over deprecated ios/android) - const purchaseByPlatformsType = typeMap['RequestPurchasePropsByPlatforms']; - const subsByPlatformsType = typeMap['RequestSubscriptionPropsByPlatforms']; - const purchaseFields = purchaseByPlatformsType ? Object.values(purchaseByPlatformsType.getFields()) : []; - const subsFields = subsByPlatformsType ? Object.values(subsByPlatformsType.getFields()) : []; - - // Find apple/google fields (non-deprecated), fallback to ios/android - const findField = (fields, preferred, fallback) => { - const preferredField = fields.find((f) => f.name === preferred); - if (preferredField) return { name: preferred, type: getDartType(preferredField.type).type }; - const fallbackField = fields.find((f) => f.name === fallback); - if (fallbackField) return { name: fallback, type: getDartType(fallbackField.type).type }; - return null; - }; - - const appleField = findField(purchaseFields, 'apple', 'ios'); - const googleField = findField(purchaseFields, 'google', 'android'); - const appleSubsField = findField(subsFields, 'apple', 'ios'); - const googleSubsField = findField(subsFields, 'google', 'android'); - - // Use schema field names - const appleName = appleField?.name || 'apple'; - const googleName = googleField?.name || 'google'; - const appleType = appleField?.type || 'RequestPurchaseIosProps'; - const googleType = googleField?.type || 'RequestPurchaseAndroidProps'; - const appleSubsType = appleSubsField?.type || 'RequestSubscriptionIosProps'; - const googleSubsType = googleSubsField?.type || 'RequestSubscriptionAndroidProps'; - - // Sealed class for compile-time type safety - lines.push('sealed class RequestPurchaseProps {'); - lines.push(' const RequestPurchaseProps._();'); - lines.push(''); - lines.push(' const factory RequestPurchaseProps.inApp(({'); - lines.push(` ${appleType}? ${appleName},`); - lines.push(` ${googleType}? ${googleName},`); - lines.push(' bool? useAlternativeBilling,'); - lines.push(' }) props) = _InAppPurchase;'); - lines.push(''); - lines.push(' const factory RequestPurchaseProps.subs(({'); - lines.push(` ${appleSubsType}? ${appleName},`); - lines.push(` ${googleSubsType}? ${googleName},`); - lines.push(' bool? useAlternativeBilling,'); - lines.push(' }) props) = _SubsPurchase;'); - lines.push(''); - lines.push(' Map toJson();'); - lines.push('}'); - lines.push(''); - - // Purchase implementation - lines.push('class _InAppPurchase extends RequestPurchaseProps {'); - lines.push(' const _InAppPurchase(this.props) : super._();'); - lines.push(' final ({'); - lines.push(` ${appleType}? ${appleName},`); - lines.push(` ${googleType}? ${googleName},`); - lines.push(' bool? useAlternativeBilling,'); - lines.push(' }) props;'); - lines.push(''); - lines.push(' @override'); - lines.push(' Map toJson() {'); - lines.push(' return {'); - lines.push(" 'requestPurchase': {"); - lines.push(` if (props.${appleName} != null) 'ios': props.${appleName}!.toJson(),`); - lines.push(` if (props.${googleName} != null) 'android': props.${googleName}!.toJson(),`); - lines.push(' },'); - lines.push(" 'type': ProductQueryType.InApp.toJson(),"); - lines.push(" if (props.useAlternativeBilling != null) 'useAlternativeBilling': props.useAlternativeBilling,"); - lines.push(' };'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - - // Subscription implementation - lines.push('class _SubsPurchase extends RequestPurchaseProps {'); - lines.push(' const _SubsPurchase(this.props) : super._();'); - lines.push(' final ({'); - lines.push(` ${appleSubsType}? ${appleName},`); - lines.push(` ${googleSubsType}? ${googleName},`); - lines.push(' bool? useAlternativeBilling,'); - lines.push(' }) props;'); - lines.push(''); - lines.push(' @override'); - lines.push(' Map toJson() {'); - lines.push(' return {'); - lines.push(" 'requestSubscription': {"); - lines.push(` if (props.${appleName} != null) 'ios': props.${appleName}!.toJson(),`); - lines.push(` if (props.${googleName} != null) 'android': props.${googleName}!.toJson(),`); - lines.push(' },'); - lines.push(" 'type': ProductQueryType.Subs.toJson(),"); - lines.push(" if (props.useAlternativeBilling != null) 'useAlternativeBilling': props.useAlternativeBilling,"); - lines.push(' };'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - return; - } - - addDocComment(lines, inputType.description); - lines.push(`class ${inputType.name} {`); - lines.push(` const ${inputType.name}({`); - const fields = Object.values(inputType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - const fieldInfos = fields.map((field) => { - const { type, nullable, metadata } = getDartType(field.type); - const fieldName = escapeDartName(field.name); - return { field, fieldName, type, nullable, metadata }; - }); - fieldInfos.forEach(({ nullable, fieldName }) => { - // Don't add doc comments here - they're added on the field definition below - // Dart analyzer will associate field docs with constructor params automatically - const line = ` ${nullable ? '' : 'required '}this.${fieldName},`; - lines.push(line); - }); - lines.push(' });', ''); - fieldInfos.forEach(({ field, type, nullable, fieldName }) => { - addDocComment(lines, field.description, ' '); - const fieldType = `${type}${nullable ? '?' : ''}`; - lines.push(` final ${fieldType} ${fieldName};`); - }); - lines.push(''); - lines.push(` factory ${inputType.name}.fromJson(Map json) {`); - lines.push(` return ${inputType.name}(`); - fieldInfos.forEach(({ field, fieldName, metadata }) => { - const jsonExpression = buildFromJsonExpression(metadata, `json['${field.name}']`); - lines.push(` ${fieldName}: ${jsonExpression},`); - }); - lines.push(' );'); - lines.push(' }', ''); - lines.push(' Map toJson() {'); - lines.push(' return {'); - fieldInfos.forEach(({ field, fieldName, metadata }) => { - const toJsonExpression = buildToJsonExpression(metadata, fieldName); - lines.push(` '${field.name}': ${toJsonExpression},`); - }); - lines.push(' };'); - lines.push(' }'); - lines.push('}', ''); -}; - -const printUnion = (unionType) => { - addDocComment(lines, unionType.description); - const memberTypes = unionType.getTypes(); - const members = memberTypes.map((member) => member.name).sort(); - - let sharedInterfaceNames = []; - if (memberTypes.length > 0) { - const [firstMember, ...otherMembers] = memberTypes; - // Check if member is a union (unions don't have getInterfaces) - if (typeof firstMember.getInterfaces === 'function') { - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - let allMembersHaveInterfaces = true; - for (const member of otherMembers) { - if (typeof member.getInterfaces === 'function') { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); - } - } - } else { - // Member is a union, so no shared interfaces - allMembersHaveInterfaces = false; - break; - } - } - if (allMembersHaveInterfaces) { - sharedInterfaceNames = Array.from(firstInterfaces).sort(); - } - } - } - - const implementsClause = sharedInterfaceNames.length ? ` implements ${sharedInterfaceNames.join(', ')}` : ''; - - lines.push(`sealed class ${unionType.name}${implementsClause} {`); - lines.push(` const ${unionType.name}();`, ''); - lines.push(` factory ${unionType.name}.fromJson(Map json) {`); - lines.push(` final typeName = json['__typename'] as String?;`); - lines.push(' switch (typeName) {'); - - // Flatten nested unions: if a member is itself a union, include its concrete members - const concreteMembers = new Set(); - for (const memberType of memberTypes) { - if (isUnionType(memberType)) { - // Member is a union, get its concrete members - const nestedMembers = memberType.getTypes(); - for (const nestedMember of nestedMembers) { - concreteMembers.add(nestedMember.name); - } - } else { - // Member is a concrete type - concreteMembers.add(memberType.name); - } - } - - // Track nested unions that need wrapper classes - const nestedUnions = new Set(); - - // Generate case for each concrete member, wrapping nested unions - const sortedConcreteMembers = Array.from(concreteMembers).sort(); - sortedConcreteMembers.forEach((concreteMember) => { - // Find which direct member this concrete type belongs to - let delegateTo = concreteMember; - let isNestedUnion = false; - - for (const memberType of memberTypes) { - if (isUnionType(memberType)) { - const nestedMembers = memberType.getTypes().map(t => t.name); - if (nestedMembers.includes(concreteMember)) { - delegateTo = memberType.name; - isNestedUnion = true; - nestedUnions.add(memberType.name); - break; - } - } - } - - if (isNestedUnion) { - // Wrap nested union in a typed wrapper class - const wrapperName = `${unionType.name}${delegateTo}`; - lines.push(` case '${concreteMember}':`, ` return ${wrapperName}(${delegateTo}.fromJson(json));`); - } else { - // Direct member, no wrapping needed - lines.push(` case '${concreteMember}':`, ` return ${delegateTo}.fromJson(json);`); - } - }); - - lines.push(' }'); - lines.push(` throw ArgumentError('Unknown __typename for ${unionType.name}: $typeName');`); - lines.push(' }', ''); - - if (sharedInterfaceNames.length) { - const interfaceFieldMap = new Map(); - for (const interfaceName of sharedInterfaceNames) { - const interfaceType = schema.getType(interfaceName); - if (!interfaceType || !isInterfaceType(interfaceType)) continue; - const fields = Object.values(interfaceType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - if (interfaceFieldMap.has(field.name)) continue; - const { type, nullable } = getDartType(field.type); - interfaceFieldMap.set(field.name, { field, type, nullable }); - } - } - - const interfaceFields = Array.from(interfaceFieldMap.values()).sort((a, b) => a.field.name.localeCompare(b.field.name)); - interfaceFields.forEach(({ field, type, nullable }) => { - addDocComment(lines, field.description, ' '); - const fieldType = `${type}${nullable ? '?' : ''}`; - const fieldName = escapeDartName(field.name); - lines.push(' @override'); - lines.push(` ${fieldType} get ${fieldName};`); - }); - if (interfaceFields.length) { - lines.push(''); - } - } - - lines.push(' Map toJson();'); - lines.push('}', ''); - - // Generate wrapper classes for nested unions - for (const nestedUnionName of Array.from(nestedUnions).sort()) { - const wrapperName = `${unionType.name}${nestedUnionName}`; - lines.push(`class ${wrapperName} extends ${unionType.name} {`); - lines.push(` const ${wrapperName}(this.value);`); - lines.push(` final ${nestedUnionName} value;`); - lines.push(''); - lines.push(' @override'); - lines.push(' Map toJson() => value.toJson();'); - lines.push('}', ''); - } -}; - -const expandInputToParams = (inputTypeName) => { - const inputType = typeMap[inputTypeName]; - if (!inputType || !isInputObjectType(inputType)) return []; - - // Don't expand RequestPurchaseProps - it's now a sealed class union type - if (inputTypeName === 'RequestPurchaseProps') { - return []; - } - - const fields = Object.values(inputType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - return fields; -}; - -const printOperationInterface = (operationType) => { - const interfaceName = `${operationType.name}Resolver`; - addDocComment(lines, operationType.description ?? `GraphQL root ${operationType.name.toLowerCase()} operations.`); - lines.push(`abstract class ${interfaceName} {`); - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, nullable } = getOperationReturnType(field.type); - const returnType = `${type}${nullable ? '?' : ''}`; - if (field.args.length === 0) { - lines.push(` Future<${returnType}> ${escapeDartName(field.name)}();`); - continue; - } - - // Special handling for v5.x style APIs - expand input objects - // Don't expand 'purchase' - keep it as an object - const expandableParams = ['params', 'options', 'config', 'props']; - const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); - - if (expandableArg) { - const namedType = getNamedGraphQLType(expandableArg.type); - if (namedType && isInputObjectType(namedType)) { - const expandedFields = expandInputToParams(namedType.name); - const otherArgs = field.args.filter(arg => arg !== expandableArg); - - if (expandedFields.length > 0) { - // Always use named params when expanding - lines.push(` Future<${returnType}> ${escapeDartName(field.name)}({`); - - // Add expanded fields first - expandedFields.forEach((f) => { - // Handle synthetic fields (e.g., 'request' field in RequestPurchaseProps) - if (f.isSynthetic) { - lines.push(` required RequestPurchaseRequest ${escapeDartName(f.name)},`); - } else { - const { type: argType, nullable: argNullable } = getDartType(f.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${escapeDartName(f.name)},`); - } - }); - - // Add other args - otherArgs.forEach((arg) => { - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${escapeDartName(arg.name)},`); - }); - - lines.push(' });'); - continue; - } - } - } - - if (field.args.length === 1) { - const arg = field.args[0]; - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const argName = escapeDartName(arg.name); - const open = argNullable ? '[' : ''; - const close = argNullable ? ']' : ''; - lines.push(` Future<${returnType}> ${escapeDartName(field.name)}(${open}${finalType} ${argName}${close});`); - continue; - } - lines.push(' Future<' + returnType + `> ${escapeDartName(field.name)}({`); - field.args.forEach((arg) => { - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const argName = escapeDartName(arg.name); - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${argName},`); - }); - lines.push(' });'); - } - lines.push('}', ''); -}; - -const printOperationHelpers = (operationType) => { - const rootName = operationType.name; - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) return; - - lines.push(`// MARK: - ${rootName} Helpers`, ''); - - fields.forEach((field) => { - const pascalField = toPascalCase(field.name); - const aliasName = `${rootName}${pascalField}Handler`; - const { type, nullable } = getOperationReturnType(field.type); - const returnType = `${type}${nullable ? '?' : ''}`; - if (field.args.length === 0) { - lines.push(`typedef ${aliasName} = Future<${returnType}> Function();`); - return; - } - - // Special handling for v5.x style APIs - expand input objects - // Don't expand 'purchase' - keep it as an object - const expandableParams = ['params', 'options', 'config', 'props']; - const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); - - if (expandableArg) { - const namedType = getNamedGraphQLType(expandableArg.type); - if (namedType && isInputObjectType(namedType)) { - const expandedFields = expandInputToParams(namedType.name); - const otherArgs = field.args.filter(arg => arg !== expandableArg); - - if (expandedFields.length > 0) { - // Always use named params when expanding - lines.push(`typedef ${aliasName} = Future<${returnType}> Function({`); - - // Add expanded fields first - expandedFields.forEach((f) => { - // Handle synthetic fields (e.g., 'request' field in RequestPurchaseProps) - if (f.isSynthetic) { - lines.push(` required RequestPurchaseRequest ${escapeDartName(f.name)},`); - } else { - const { type: argType, nullable: argNullable } = getDartType(f.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${escapeDartName(f.name)},`); - } - }); - - // Add other args - otherArgs.forEach((arg) => { - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${escapeDartName(arg.name)},`); - }); - - lines.push('});'); - return; - } - } - } - - if (field.args.length === 1) { - const arg = field.args[0]; - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const argName = escapeDartName(arg.name); - if (argNullable) { - lines.push(`typedef ${aliasName} = Future<${returnType}> Function([${finalType} ${argName}]);`); - } else { - lines.push(`typedef ${aliasName} = Future<${returnType}> Function(${finalType} ${argName});`); - } - return; - } - lines.push(`typedef ${aliasName} = Future<${returnType}> Function({`); - field.args.forEach((arg) => { - const { type: argType, nullable: argNullable } = getDartType(arg.type); - const finalType = `${argType}${argNullable ? '?' : ''}`; - const prefix = argNullable ? '' : 'required '; - lines.push(` ${prefix}${finalType} ${escapeDartName(arg.name)},`); - }); - lines.push('});'); - }); - - const helperClass = `${rootName}Handlers`; - lines.push('', `class ${helperClass} {`); - lines.push(` const ${helperClass}({`); - fields.forEach((field) => { - lines.push(` this.${escapeDartName(field.name)},`); - }); - lines.push(' });', ''); - fields.forEach((field) => { - const pascalField = toPascalCase(field.name); - const aliasName = `${rootName}${pascalField}Handler`; - const propertyName = escapeDartName(field.name); - lines.push(` final ${aliasName}? ${propertyName};`); - }); - lines.push('}', ''); -}; - -if (enums.length) { - lines.push('// MARK: - Enums', ''); - enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum); -} - -if (interfaces.length) { - lines.push('// MARK: - Interfaces', ''); - interfaces.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInterface); -} - -if (objects.length) { - lines.push('// MARK: - Objects', ''); - objects.sort((a, b) => a.name.localeCompare(b.name)).forEach(printObject); -} - -if (inputs.length) { - lines.push('// MARK: - Input Objects', ''); - inputs.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInput); -} - -if (unions.length) { - lines.push('// MARK: - Unions', ''); - unions.sort((a, b) => a.name.localeCompare(b.name)).forEach(printUnion); -} - -if (operationTypes.length) { - lines.push('// MARK: - Root Operations', ''); - operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationInterface); -} - -if (operationTypes.length) { - lines.push('// MARK: - Root Operation Helpers', ''); - operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers); -} - -// Post-process: Convert platform and type fields to fixed default values for discriminated unions -// This enables Dart's pattern matching to properly narrow types based on platform and type -const productTypeMapping = { - ProductIOS: { platform: 'IapPlatform.ios', type: 'ProductType.inApp' }, - ProductAndroid: { platform: 'IapPlatform.android', type: 'ProductType.inApp' }, - ProductSubscriptionIOS: { platform: 'IapPlatform.ios', type: 'ProductType.subs' }, - ProductSubscriptionAndroid: { platform: 'IapPlatform.android', type: 'ProductType.subs' }, -}; - -for (const [typeName, literals] of Object.entries(productTypeMapping)) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Find class definition - if (line.includes(`class ${typeName} `)) { - // Look for platform and type fields within this class - for (let j = i; j < lines.length && !lines[j].includes(');'); j++) { - // Replace platform field with fixed default value (exact match only) - if (lines[j].match(/required this\.platform,?\s*$/)) { - lines[j] = lines[j].replace( - /required this\.platform(,?)/, - `this.platform = ${literals.platform}$1` - ); - } - // Replace type field with fixed default value (exact match only, not typeIOS) - if (lines[j].match(/required this\.type,?\s*$/)) { - lines[j] = lines[j].replace( - /required this\.type(,?)/, - `this.type = ${literals.type}$1` - ); - } - } - break; - } - } -} - -// All unions including nested ones are auto-generated with proper wrapper classes -let output = lines.join('\n'); - -// Fix enum default values - Dart uses PascalCase for enum values -output = output.replace(/IapPlatform\.ios/g, 'IapPlatform.IOS'); -output = output.replace(/IapPlatform\.android/g, 'IapPlatform.Android'); -output = output.replace(/ProductType\.inApp/g, 'ProductType.InApp'); -output = output.replace(/ProductType\.subs/g, 'ProductType.Subs'); - -const outputPath = resolve(__dirname, '../src/generated/types.dart'); -mkdirSync(dirname(outputPath), { recursive: true }); -writeFileSync(outputPath, output); - -// eslint-disable-next-line no-console -console.log('[generate-dart-types] wrote', outputPath); diff --git a/packages/gql/scripts/generate-gdscript-types.mjs b/packages/gql/scripts/generate-gdscript-types.mjs deleted file mode 100644 index 6cde2e77..00000000 --- a/packages/gql/scripts/generate-gdscript-types.mjs +++ /dev/null @@ -1,812 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - GraphQLList, - GraphQLNonNull, - buildASTSchema, - isEnumType, - isInputObjectType, - isInterfaceType, - isObjectType, - isScalarType, - isUnionType, - parse, -} from 'graphql'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const schemaPaths = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', -].map((relativePath) => resolve(__dirname, relativePath)); - -const documentNode = { - kind: 'Document', - definitions: schemaPaths.flatMap((schemaPath) => { - const sdl = readFileSync(schemaPath, 'utf8'); - return parse(sdl).definitions; - }), -}; - -const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); -const typeMap = schema.getTypeMap(); -const typeNames = Object.keys(typeMap) - .filter((name) => !name.startsWith('__')) - .sort((a, b) => a.localeCompare(b)); - -// GDScript reserved words -const gdscriptKeywords = new Set([ - 'if', 'elif', 'else', 'for', 'while', 'match', 'break', 'continue', - 'pass', 'return', 'class', 'class_name', 'extends', 'is', 'in', 'as', - 'self', 'signal', 'func', 'static', 'const', 'enum', 'var', 'onready', - 'export', 'setget', 'tool', 'await', 'yield', 'assert', 'preload', - 'true', 'false', 'null', 'not', 'and', 'or', -]); - -const escapeGdscriptName = (name) => gdscriptKeywords.has(name.toLowerCase()) ? `_${name}` : name; - -// Field name aliases for cleaner API (OpenIAP spec compliance) -// Maps "TypeName.fieldName" to shorter GDScript property names -// This allows different aliases per type -const fieldNameAliases = new Map([ - ['RequestPurchaseProps.requestPurchase', 'request'], - ['RequestSubscriptionProps.requestSubscription', 'request'], -]); - -const toSnakeCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toLowerCase(); - -const toPascalCase = (value) => { - const tokens = value - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_\-\s]+/g, ' ') - .split(' ') - .filter(Boolean) - .map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()); - return tokens.join(''); -}; - -const toConstantCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toUpperCase(); - -const toKebabCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - -// GDScript type mapping -const scalarMap = new Map([ - ['ID', 'String'], - ['String', 'String'], - ['Boolean', 'bool'], - ['Int', 'int'], - ['Float', 'float'], -]); - -const enumNames = new Set(); -const interfaceNames = new Set(); -const objectNames = new Set(); -const inputNames = new Set(); -const unionNames = new Set(); - -const addDocComment = (lines, description, indent = '') => { - if (!description) return; - // Convert multiline to single line for GDScript - const singleLine = description.replace(/\r?\n/g, ' ').trim(); - lines.push(`${indent}## ${singleLine}`); -}; - -const unionMembership = new Map(); -const unions = []; -const enums = []; -const interfaces = []; -const objects = []; -const inputs = []; -const operationTypes = []; - -// Process union wrappers -const unionWrapperNames = new Set(); -for (const schemaPath of schemaPaths) { - let expectTypeName = false; - for (const line of readFileSync(schemaPath, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectTypeName = true; - continue; - } - if (expectTypeName) { - if (trimmed.length === 0) continue; - if (trimmed.startsWith('#')) continue; - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - unionWrapperNames.add(typeMatch[1]); - } - expectTypeName = false; - } - } -} - -// Categorize types -for (const name of typeNames) { - const type = typeMap[name]; - if (isScalarType(type)) { - if (scalarMap.has(type.name)) continue; - continue; - } - if (isEnumType(type)) { - enums.push(type); - continue; - } - if (isInterfaceType(type)) { - interfaces.push(type); - continue; - } - if (isUnionType(type)) { - unions.push(type); - for (const member of type.getTypes()) { - if (!unionMembership.has(member.name)) { - unionMembership.set(member.name, new Set()); - } - unionMembership.get(member.name).add(type.name); - } - continue; - } - if (isObjectType(type)) { - if (['Query', 'Mutation', 'Subscription'].includes(name)) { - operationTypes.push(type); - continue; - } - objects.push(type); - continue; - } - if (isInputObjectType(type)) { - inputs.push(type); - } -} - -for (const enumType of enums) { - enumNames.add(enumType.name); -} -for (const interfaceType of interfaces) { - interfaceNames.add(interfaceType.name); -} -for (const objectType of objects) { - objectNames.add(objectType.name); -} -for (const inputType of inputs) { - inputNames.add(inputType.name); -} -for (const unionType of unions) { - unionNames.add(unionType.name); -} - -// Convert GraphQL type to GDScript type -const toGdscriptType = (graphqlType, nullable = true, seenUnions = new Set()) => { - if (graphqlType instanceof GraphQLNonNull) { - return toGdscriptType(graphqlType.ofType, false, seenUnions); - } - if (graphqlType instanceof GraphQLList) { - const innerType = toGdscriptType(graphqlType.ofType, true, seenUnions); - return `Array[${innerType}]`; - } - const typeName = graphqlType.name; - if (scalarMap.has(typeName)) { - return scalarMap.get(typeName); - } - if (enumNames.has(typeName)) { - return typeName; - } - if (unionNames.has(typeName)) { - // For unions, use Variant (can be any type) - return 'Variant'; - } - if (objectNames.has(typeName) || inputNames.has(typeName) || interfaceNames.has(typeName)) { - return typeName; - } - return 'Variant'; -}; - -// Get the base type info for from_dict deserialization -const getFieldTypeInfo = (graphqlType) => { - let isArray = false; - let baseType = graphqlType; - - // Unwrap NonNull - if (baseType instanceof GraphQLNonNull) { - baseType = baseType.ofType; - } - - // Check if it's an array - if (baseType instanceof GraphQLList) { - isArray = true; - baseType = baseType.ofType; - // Unwrap NonNull inside array - if (baseType instanceof GraphQLNonNull) { - baseType = baseType.ofType; - } - } - - const typeName = baseType.name; - const isObjectOrInput = objectNames.has(typeName) || inputNames.has(typeName); - const isEnum = enumNames.has(typeName); - - return { isArray, isObjectOrInput, isEnum, typeName }; -}; - -// Generate GDScript output -const outputLines = []; - -outputLines.push('# ============================================================================'); -outputLines.push('# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); -outputLines.push('# Generated from OpenIAP GraphQL schema (https://openiap.dev)'); -outputLines.push('# Run `npm run generate:gdscript` to regenerate this file.'); -outputLines.push('# ============================================================================'); -outputLines.push('# Usage: const Types = preload("types.gd")'); -outputLines.push('# var store: Types.IapStore = Types.IapStore.APPLE'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -// Generate enums -outputLines.push('# ============================================================================'); -outputLines.push('# Enums'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -for (const enumType of enums) { - addDocComment(outputLines, enumType.description); - outputLines.push(`enum ${enumType.name} {`); - const values = enumType.getValues(); - for (let i = 0; i < values.length; i++) { - const value = values[i]; - const enumValueName = toConstantCase(value.name); - if (value.description) { - const singleLineDesc = value.description.replace(/\r?\n/g, ' ').trim(); - outputLines.push(`\t## ${singleLineDesc}`); - } - outputLines.push(`\t${enumValueName} = ${i},`); - } - outputLines.push('}'); - outputLines.push(''); -} - -// Generate interface/object classes -outputLines.push('# ============================================================================'); -outputLines.push('# Types'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -// Get the GDScript property name for a GraphQL field -// typeName is optional - used for type-specific aliases -const getGdscriptFieldName = (fieldName, typeName = null) => { - // Check if there's a type-specific alias for this field - if (typeName) { - const typeSpecificKey = `${typeName}.${fieldName}`; - if (fieldNameAliases.has(typeSpecificKey)) { - const aliasedName = fieldNameAliases.get(typeSpecificKey); - return toSnakeCase(escapeGdscriptName(aliasedName)); - } - } - // Fall back to regular snake_case conversion - return toSnakeCase(escapeGdscriptName(fieldName)); -}; - -// Helper function to generate class fields -const generateClassFields = (type, lines, indent = '\t') => { - const fields = type.getFields(); - const typeName = type.name; - for (const fieldName of Object.keys(fields)) { - const field = fields[fieldName]; - const gdscriptType = toGdscriptType(field.type); - const snakeCaseName = getGdscriptFieldName(fieldName, typeName); - - if (field.description) { - lines.push(`${indent}## ${field.description.split('\n')[0]}`); - } - lines.push(`${indent}var ${snakeCaseName}: ${gdscriptType}`); - } -}; - -// Generate classes for objects -for (const objectType of objects) { - // Skip union wrapper types - if (unionWrapperNames.has(objectType.name)) continue; - - addDocComment(outputLines, objectType.description); - outputLines.push(`class ${objectType.name}:`); - - const fields = objectType.getFields(); - if (Object.keys(fields).length === 0) { - outputLines.push('\tpass'); - } else { - generateClassFields(objectType, outputLines); - - // Add from_dict method - outputLines.push(''); - outputLines.push('\tstatic func from_dict(data: Dictionary) -> ' + objectType.name + ':'); - outputLines.push('\t\tvar obj = ' + objectType.name + '.new()'); - for (const fieldName of Object.keys(fields)) { - const field = fields[fieldName]; - const snakeCaseName = getGdscriptFieldName(fieldName, objectType.name); - const typeInfo = getFieldTypeInfo(field.type); - - outputLines.push(`\t\tif data.has("${fieldName}") and data["${fieldName}"] != null:`); - - if (typeInfo.isObjectOrInput && typeInfo.isArray) { - // Array of objects - need to convert each element - outputLines.push(`\t\t\tvar arr = []`); - outputLines.push(`\t\t\tfor item in data["${fieldName}"]:`); - outputLines.push(`\t\t\t\tif item is Dictionary:`); - outputLines.push(`\t\t\t\t\tarr.append(${typeInfo.typeName}.from_dict(item))`); - outputLines.push(`\t\t\t\telse:`); - outputLines.push(`\t\t\t\t\tarr.append(item)`); - outputLines.push(`\t\t\tobj.${snakeCaseName} = arr`); - } else if (typeInfo.isObjectOrInput) { - // Single object - convert from dictionary - outputLines.push(`\t\t\tif data["${fieldName}"] is Dictionary:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = ${typeInfo.typeName}.from_dict(data["${fieldName}"])`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = data["${fieldName}"]`); - } else if (typeInfo.isEnum) { - // Enum - convert string to enum using reverse lookup - const enumReverseLookup = toConstantCase(typeInfo.typeName) + '_FROM_STRING'; - outputLines.push(`\t\t\tvar enum_str = data["${fieldName}"]`); - outputLines.push(`\t\t\tif enum_str is String and ${enumReverseLookup}.has(enum_str):`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = ${enumReverseLookup}[enum_str]`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = enum_str`); - } else { - // Scalar - direct assignment - outputLines.push(`\t\t\tobj.${snakeCaseName} = data["${fieldName}"]`); - } - } - outputLines.push('\t\treturn obj'); - - // Add to_dict method - outputLines.push(''); - outputLines.push('\tfunc to_dict() -> Dictionary:'); - outputLines.push('\t\tvar dict = {}'); - for (const fieldName of Object.keys(fields)) { - const field = fields[fieldName]; - const snakeCaseName = getGdscriptFieldName(fieldName, objectType.name); - const typeInfo = getFieldTypeInfo(field.type); - const enumConstName = toConstantCase(typeInfo.typeName) + '_VALUES'; - - if (typeInfo.isObjectOrInput && typeInfo.isArray) { - outputLines.push(`\t\tif ${snakeCaseName} != null:`); - outputLines.push(`\t\t\tvar arr = []`); - outputLines.push(`\t\t\tfor item in ${snakeCaseName}:`); - outputLines.push(`\t\t\t\tif item != null and item.has_method("to_dict"):`); - outputLines.push(`\t\t\t\t\tarr.append(item.to_dict())`); - outputLines.push(`\t\t\t\telse:`); - outputLines.push(`\t\t\t\t\tarr.append(item)`); - outputLines.push(`\t\t\tdict["${fieldName}"] = arr`); - outputLines.push(`\t\telse:`); - outputLines.push(`\t\t\tdict["${fieldName}"] = null`); - } else if (typeInfo.isObjectOrInput) { - outputLines.push(`\t\tif ${snakeCaseName} != null and ${snakeCaseName}.has_method("to_dict"):`); - outputLines.push(`\t\t\tdict["${fieldName}"] = ${snakeCaseName}.to_dict()`); - outputLines.push(`\t\telse:`); - outputLines.push(`\t\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } else if (typeInfo.isEnum) { - // Convert enum to string using the _VALUES constant - outputLines.push(`\t\tif ${enumConstName}.has(${snakeCaseName}):`); - outputLines.push(`\t\t\tdict["${fieldName}"] = ${enumConstName}[${snakeCaseName}]`); - outputLines.push(`\t\telse:`); - outputLines.push(`\t\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } else { - outputLines.push(`\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } - } - outputLines.push('\t\treturn dict'); - } - outputLines.push(''); -} - -// Generate classes for inputs -outputLines.push('# ============================================================================'); -outputLines.push('# Input Types'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -for (const inputType of inputs) { - addDocComment(outputLines, inputType.description); - outputLines.push(`class ${inputType.name}:`); - - const fields = inputType.getFields(); - if (Object.keys(fields).length === 0) { - outputLines.push('\tpass'); - } else { - generateClassFields(inputType, outputLines); - - // Add from_dict method for inputs - outputLines.push(''); - outputLines.push('\tstatic func from_dict(data: Dictionary) -> ' + inputType.name + ':'); - outputLines.push('\t\tvar obj = ' + inputType.name + '.new()'); - for (const fieldName of Object.keys(fields)) { - const field = fields[fieldName]; - const snakeCaseName = getGdscriptFieldName(fieldName, inputType.name); - const typeInfo = getFieldTypeInfo(field.type); - - outputLines.push(`\t\tif data.has("${fieldName}") and data["${fieldName}"] != null:`); - - if (typeInfo.isObjectOrInput && typeInfo.isArray) { - outputLines.push(`\t\t\tvar arr = []`); - outputLines.push(`\t\t\tfor item in data["${fieldName}"]:`); - outputLines.push(`\t\t\t\tif item is Dictionary:`); - outputLines.push(`\t\t\t\t\tarr.append(${typeInfo.typeName}.from_dict(item))`); - outputLines.push(`\t\t\t\telse:`); - outputLines.push(`\t\t\t\t\tarr.append(item)`); - outputLines.push(`\t\t\tobj.${snakeCaseName} = arr`); - } else if (typeInfo.isObjectOrInput) { - outputLines.push(`\t\t\tif data["${fieldName}"] is Dictionary:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = ${typeInfo.typeName}.from_dict(data["${fieldName}"])`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = data["${fieldName}"]`); - } else if (typeInfo.isEnum) { - // Enum - convert string to enum using reverse lookup - const enumReverseLookup = toConstantCase(typeInfo.typeName) + '_FROM_STRING'; - outputLines.push(`\t\t\tvar enum_str = data["${fieldName}"]`); - outputLines.push(`\t\t\tif enum_str is String and ${enumReverseLookup}.has(enum_str):`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = ${enumReverseLookup}[enum_str]`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tobj.${snakeCaseName} = enum_str`); - } else { - outputLines.push(`\t\t\tobj.${snakeCaseName} = data["${fieldName}"]`); - } - } - outputLines.push('\t\treturn obj'); - - // Add to_dict method for inputs - outputLines.push(''); - outputLines.push('\tfunc to_dict() -> Dictionary:'); - outputLines.push('\t\tvar dict = {}'); - for (const fieldName of Object.keys(fields)) { - const field = fields[fieldName]; - const snakeCaseName = getGdscriptFieldName(fieldName, inputType.name); - const typeInfo = getFieldTypeInfo(field.type); - const enumConstName = toConstantCase(typeInfo.typeName) + '_VALUES'; - - outputLines.push(`\t\tif ${snakeCaseName} != null:`); - - if (typeInfo.isObjectOrInput && typeInfo.isArray) { - outputLines.push(`\t\t\tvar arr = []`); - outputLines.push(`\t\t\tfor item in ${snakeCaseName}:`); - outputLines.push(`\t\t\t\tif item.has_method("to_dict"):`); - outputLines.push(`\t\t\t\t\tarr.append(item.to_dict())`); - outputLines.push(`\t\t\t\telse:`); - outputLines.push(`\t\t\t\t\tarr.append(item)`); - outputLines.push(`\t\t\tdict["${fieldName}"] = arr`); - } else if (typeInfo.isObjectOrInput) { - outputLines.push(`\t\t\tif ${snakeCaseName}.has_method("to_dict"):`); - outputLines.push(`\t\t\t\tdict["${fieldName}"] = ${snakeCaseName}.to_dict()`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } else if (typeInfo.isEnum) { - // Convert enum to string using the _VALUES constant - outputLines.push(`\t\t\tif ${enumConstName}.has(${snakeCaseName}):`); - outputLines.push(`\t\t\t\tdict["${fieldName}"] = ${enumConstName}[${snakeCaseName}]`); - outputLines.push(`\t\t\telse:`); - outputLines.push(`\t\t\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } else { - outputLines.push(`\t\t\tdict["${fieldName}"] = ${snakeCaseName}`); - } - } - outputLines.push('\t\treturn dict'); - } - outputLines.push(''); -} - -// Generate helper constants for enum string values (enum -> string) -outputLines.push('# ============================================================================'); -outputLines.push('# Enum String Helpers'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -for (const enumType of enums) { - outputLines.push(`const ${toConstantCase(enumType.name)}_VALUES = {`); - const values = enumType.getValues(); - for (let i = 0; i < values.length; i++) { - const value = values[i]; - const enumValueName = toConstantCase(value.name); - const rawValue = toKebabCase(value.name); - const comma = i < values.length - 1 ? ',' : ''; - outputLines.push(`\t${enumType.name}.${enumValueName}: "${rawValue}"${comma}`); - } - outputLines.push('}'); - outputLines.push(''); -} - -// Generate reverse lookup constants for enum deserialization (string -> enum) -outputLines.push('# ============================================================================'); -outputLines.push('# Enum Reverse Lookup (string -> enum for deserialization)'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -for (const enumType of enums) { - outputLines.push(`const ${toConstantCase(enumType.name)}_FROM_STRING = {`); - const values = enumType.getValues(); - for (let i = 0; i < values.length; i++) { - const value = values[i]; - const enumValueName = toConstantCase(value.name); - const rawValue = toKebabCase(value.name); - const comma = i < values.length - 1 ? ',' : ''; - outputLines.push(`\t"${rawValue}": ${enumType.name}.${enumValueName}${comma}`); - } - outputLines.push('}'); - outputLines.push(''); -} - -// ============================================================================ -// Generate API Operation Types (Query/Mutation fields) -// ============================================================================ -outputLines.push('# ============================================================================'); -outputLines.push('# Query Types'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -// Collect Query operations -const queryType = operationTypes.find(op => op.name === 'Query'); -const mutationType = operationTypes.find(op => op.name === 'Mutation'); - -// Generate Query class with typed methods -if (queryType) { - outputLines.push('class Query:'); - const queryFields = queryType.getFields(); - const fieldNames = Object.keys(queryFields); - - if (fieldNames.length === 0) { - outputLines.push('\tpass'); - } else { - for (const fieldName of fieldNames) { - const field = queryFields[fieldName]; - const returnType = toGdscriptType(field.type); - const snakeCaseName = toSnakeCase(fieldName); - const args = field.args || []; - - // Add description - if (field.description) { - outputLines.push(`\t## ${field.description.split('\n')[0]}`); - } - - // Generate field info class for each query - outputLines.push(`\tclass ${fieldName}Field:`); - outputLines.push(`\t\tconst name = "${fieldName}"`); - outputLines.push(`\t\tconst snake_name = "${snakeCaseName}"`); - - // Args type - if (args.length > 0) { - outputLines.push(`\t\tclass Args:`); - for (const arg of args) { - const argType = toGdscriptType(arg.type); - const argSnakeName = toSnakeCase(arg.name); - if (arg.description) { - outputLines.push(`\t\t\t## ${arg.description.split('\n')[0]}`); - } - outputLines.push(`\t\t\tvar ${argSnakeName}: ${argType}`); - } - outputLines.push(''); - outputLines.push(`\t\t\tstatic func from_dict(data: Dictionary) -> Args:`); - outputLines.push(`\t\t\t\tvar obj = Args.new()`); - for (const arg of args) { - const argSnakeName = toSnakeCase(arg.name); - outputLines.push(`\t\t\t\tif data.has("${arg.name}") and data["${arg.name}"] != null:`); - outputLines.push(`\t\t\t\t\tobj.${argSnakeName} = data["${arg.name}"]`); - } - outputLines.push(`\t\t\t\treturn obj`); - outputLines.push(''); - outputLines.push(`\t\t\tfunc to_dict() -> Dictionary:`); - outputLines.push(`\t\t\t\tvar dict = {}`); - for (const arg of args) { - const argSnakeName = toSnakeCase(arg.name); - outputLines.push(`\t\t\t\tdict["${arg.name}"] = ${argSnakeName}`); - } - outputLines.push(`\t\t\t\treturn dict`); - } else { - outputLines.push(`\t\tclass Args:`); - outputLines.push(`\t\t\tpass`); - } - - // Return type alias - const baseReturnType = getFieldTypeInfo(field.type); - outputLines.push(`\t\tconst return_type = "${baseReturnType.typeName}"`); - outputLines.push(`\t\tconst is_array = ${baseReturnType.isArray}`); - outputLines.push(''); - } - } - outputLines.push(''); -} - -// Generate Mutation class with typed methods -outputLines.push('# ============================================================================'); -outputLines.push('# Mutation Types'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -if (mutationType) { - outputLines.push('class Mutation:'); - const mutationFields = mutationType.getFields(); - const fieldNames = Object.keys(mutationFields); - - if (fieldNames.length === 0) { - outputLines.push('\tpass'); - } else { - for (const fieldName of fieldNames) { - const field = mutationFields[fieldName]; - const returnType = toGdscriptType(field.type); - const snakeCaseName = toSnakeCase(fieldName); - const args = field.args || []; - - // Add description - if (field.description) { - outputLines.push(`\t## ${field.description.split('\n')[0]}`); - } - - // Generate field info class for each mutation - outputLines.push(`\tclass ${fieldName}Field:`); - outputLines.push(`\t\tconst name = "${fieldName}"`); - outputLines.push(`\t\tconst snake_name = "${snakeCaseName}"`); - - // Args type - if (args.length > 0) { - outputLines.push(`\t\tclass Args:`); - for (const arg of args) { - const argType = toGdscriptType(arg.type); - const argSnakeName = toSnakeCase(arg.name); - if (arg.description) { - outputLines.push(`\t\t\t## ${arg.description.split('\n')[0]}`); - } - outputLines.push(`\t\t\tvar ${argSnakeName}: ${argType}`); - } - outputLines.push(''); - outputLines.push(`\t\t\tstatic func from_dict(data: Dictionary) -> Args:`); - outputLines.push(`\t\t\t\tvar obj = Args.new()`); - for (const arg of args) { - const argSnakeName = toSnakeCase(arg.name); - outputLines.push(`\t\t\t\tif data.has("${arg.name}") and data["${arg.name}"] != null:`); - outputLines.push(`\t\t\t\t\tobj.${argSnakeName} = data["${arg.name}"]`); - } - outputLines.push(`\t\t\t\treturn obj`); - outputLines.push(''); - outputLines.push(`\t\t\tfunc to_dict() -> Dictionary:`); - outputLines.push(`\t\t\t\tvar dict = {}`); - for (const arg of args) { - const argSnakeName = toSnakeCase(arg.name); - outputLines.push(`\t\t\t\tdict["${arg.name}"] = ${argSnakeName}`); - } - outputLines.push(`\t\t\t\treturn dict`); - } else { - outputLines.push(`\t\tclass Args:`); - outputLines.push(`\t\t\tpass`); - } - - // Return type alias - const baseReturnType = getFieldTypeInfo(field.type); - outputLines.push(`\t\tconst return_type = "${baseReturnType.typeName}"`); - outputLines.push(`\t\tconst is_array = ${baseReturnType.isArray}`); - outputLines.push(''); - } - } - outputLines.push(''); -} - -// ============================================================================ -// Generate API Wrapper Functions -// These provide typed wrappers that godot-iap can use directly -// ============================================================================ -outputLines.push('# ============================================================================'); -outputLines.push('# API Wrapper Functions'); -outputLines.push('# These typed functions can be used by godot-iap wrapper'); -outputLines.push('# ============================================================================'); -outputLines.push(''); - -// Helper to generate function signature -const generateApiFunction = (fieldName, field, operationType) => { - const snakeCaseName = toSnakeCase(fieldName); - const args = field.args || []; - const returnTypeInfo = getFieldTypeInfo(field.type); - let returnType = returnTypeInfo.typeName; - - // Map scalar types - if (returnType === 'Boolean') returnType = 'bool'; - else if (returnType === 'String') returnType = 'String'; - else if (returnType === 'Int') returnType = 'int'; - else if (returnType === 'Float') returnType = 'float'; - - // Build return type with array wrapper if needed - const fullReturnType = returnTypeInfo.isArray ? `Array[${returnType}]` : returnType; - - // Add description - if (field.description) { - outputLines.push(`## ${field.description.split('\n')[0]}`); - } - - // Build function parameters - const paramList = []; - for (const arg of args) { - const argType = toGdscriptType(arg.type); - const argSnakeName = toSnakeCase(arg.name); - paramList.push(`${argSnakeName}: ${argType}`); - } - - // Generate static helper function - const params = paramList.length > 0 ? paramList.join(', ') : ''; - outputLines.push(`static func ${snakeCaseName}_args(${params}) -> Dictionary:`); - - if (args.length > 0) { - outputLines.push('\tvar args = {}'); - for (const arg of args) { - const argSnakeName = toSnakeCase(arg.name); - const typeInfo = getFieldTypeInfo(arg.type); - - if (typeInfo.isObjectOrInput) { - outputLines.push(`\tif ${argSnakeName} != null:`); - outputLines.push(`\t\tif ${argSnakeName}.has_method("to_dict"):`); - outputLines.push(`\t\t\targs["${arg.name}"] = ${argSnakeName}.to_dict()`); - outputLines.push(`\t\telse:`); - outputLines.push(`\t\t\targs["${arg.name}"] = ${argSnakeName}`); - } else { - outputLines.push(`\targs["${arg.name}"] = ${argSnakeName}`); - } - } - outputLines.push('\treturn args'); - } else { - outputLines.push('\treturn {}'); - } - outputLines.push(''); -}; - -// Generate Query API functions -if (queryType) { - outputLines.push('# Query API helpers'); - outputLines.push(''); - const queryFields = queryType.getFields(); - for (const fieldName of Object.keys(queryFields)) { - if (fieldName === '_placeholder') continue; - generateApiFunction(fieldName, queryFields[fieldName], 'Query'); - } -} - -// Generate Mutation API functions -if (mutationType) { - outputLines.push('# Mutation API helpers'); - outputLines.push(''); - const mutationFields = mutationType.getFields(); - for (const fieldName of Object.keys(mutationFields)) { - if (fieldName === '_placeholder') continue; - generateApiFunction(fieldName, mutationFields[fieldName], 'Mutation'); - } -} - -// Write output file to src/generated (consistent with other generators) -const generatedDir = resolve(__dirname, '../src/generated'); -mkdirSync(generatedDir, { recursive: true }); - -const outputPath = resolve(generatedDir, 'types.gd'); -writeFileSync(outputPath, outputLines.join('\n'), 'utf8'); - -const queryCount = queryType ? Object.keys(queryType.getFields()).filter(f => f !== '_placeholder').length : 0; -const mutationCount = mutationType ? Object.keys(mutationType.getFields()).filter(f => f !== '_placeholder').length : 0; - -console.log(`✅ Generated GDScript types: ${outputPath}`); -console.log(` - ${enums.length} enums`); -console.log(` - ${objects.length} types`); -console.log(` - ${inputs.length} input types`); -console.log(` - ${queryCount} query operations`); -console.log(` - ${mutationCount} mutation operations`); diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs deleted file mode 100644 index 198638a1..00000000 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ /dev/null @@ -1,939 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - GraphQLList, - GraphQLNonNull, - buildASTSchema, - isEnumType, - isInputObjectType, - isInterfaceType, - isObjectType, - isScalarType, - isUnionType, - parse, -} from 'graphql'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const schemaPaths = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', -].map((relativePath) => resolve(__dirname, relativePath)); - -const documentNode = { - kind: 'Document', - definitions: schemaPaths.flatMap((schemaPath) => { - const sdl = readFileSync(schemaPath, 'utf8'); - return parse(sdl).definitions; - }), -}; - -const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); -const typeMap = schema.getTypeMap(); -const typeNames = Object.keys(typeMap) - .filter((name) => !name.startsWith('__')) - .sort((a, b) => a.localeCompare(b)); - -const kotlinKeywords = new Set([ - 'as', - 'break', - 'class', - 'continue', - 'do', - 'else', - 'false', - 'for', - 'fun', - 'if', - 'in', - 'interface', - 'is', - 'null', - 'object', - 'package', - 'return', - 'super', - 'this', - 'throw', - 'true', - 'try', - 'typealias', - 'val', - 'var', - 'when', - 'while', -]); - -const escapeKotlinName = (name) => (kotlinKeywords.has(name) ? `\`${name}\`` : name); - -const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1); - -const pascalCase = (value) => { - const tokens = value - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_\-\s]+/g, ' ') - .split(' ') - .filter(Boolean) - .map((token) => token.toLowerCase()); - if (tokens.length === 0) return value; - return tokens.map(capitalize).join(''); -}; - -const toConstantCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toUpperCase(); - -const toKebabCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - -const scalarMap = new Map([ - ['ID', 'String'], - ['String', 'String'], - ['Boolean', 'Boolean'], - ['Int', 'Int'], - ['Float', 'Double'], -]); - -const enumNames = new Set(); -const interfaceNames = new Set(); -const objectNames = new Set(); -const inputNames = new Set(); -const unionNames = new Set(); - -const addDocComment = (lines, description, indent = '') => { - if (!description) return; - lines.push(`${indent}/**`); - for (const docLine of description.split(/\r?\n/)) { - lines.push(`${indent} * ${docLine}`); - } - lines.push(`${indent} */`); -}; - -const unionMembership = new Map(); -const unions = []; -const enums = []; -const interfaces = []; -const objects = []; -const inputs = []; -const operationTypes = []; - -const unionWrapperNames = new Set(); -for (const schemaPath of schemaPaths) { - let expectTypeName = false; - for (const line of readFileSync(schemaPath, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectTypeName = true; - continue; - } - if (expectTypeName) { - if (trimmed.length === 0) { - continue; - } - if (trimmed.startsWith('#')) { - continue; - } - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - unionWrapperNames.add(typeMatch[1]); - } - expectTypeName = false; - } - } -} - -for (const name of typeNames) { - const type = typeMap[name]; - if (isScalarType(type)) { - if (scalarMap.has(type.name)) continue; - continue; - } - if (isEnumType(type)) { - enums.push(type); - continue; - } - if (isInterfaceType(type)) { - interfaces.push(type); - continue; - } - if (isUnionType(type)) { - unions.push(type); - for (const member of type.getTypes()) { - if (!unionMembership.has(member.name)) { - unionMembership.set(member.name, new Set()); - } - unionMembership.get(member.name).add(type.name); - } - continue; - } - if (isObjectType(type)) { - if (['Query', 'Mutation', 'Subscription'].includes(name)) { - operationTypes.push(type); - continue; - } - objects.push(type); - continue; - } - if (isInputObjectType(type)) { - inputs.push(type); - } -} - -for (const enumType of enums) { - enumNames.add(enumType.name); -} -for (const interfaceType of interfaces) { - interfaceNames.add(interfaceType.name); -} -for (const objectType of objects) { - objectNames.add(objectType.name); -} -// Track which input types have required (non-nullable) fields -// These will have nullable fromJson() methods -const inputsWithRequiredFields = new Set(); -for (const inputType of inputs) { - inputNames.add(inputType.name); - const fields = Object.values(inputType.getFields()); - const hasRequired = fields.some((field) => field.type instanceof GraphQLNonNull); - if (hasRequired) { - inputsWithRequiredFields.add(inputType.name); - } -} -for (const unionType of unions) { - unionNames.add(unionType.name); -} - -const singleFieldObjects = new Map(); -for (const objectType of objects) { - const fields = Object.values(objectType.getFields()); - if (fields.length === 1 && objectType.name.endsWith('Args')) { - singleFieldObjects.set(objectType.name, fields[0].type); - } -} - -const getTypeMetadata = (graphqlType) => { - if (graphqlType instanceof GraphQLNonNull) { - const inner = getTypeMetadata(graphqlType.ofType); - return { ...inner, nullable: false }; - } - if (graphqlType instanceof GraphQLList) { - const inner = getTypeMetadata(graphqlType.ofType); - const elementType = { ...inner }; - const elementKotlin = inner.kotlinType + (inner.nullable ? '?' : ''); - return { - kind: 'list', - nullable: true, - elementType, - kotlinType: `List<${elementKotlin}>`, - }; - } - const typeName = graphqlType.name; - let kind = 'object'; - if (scalarMap.has(typeName)) { - kind = 'scalar'; - } else if (enumNames.has(typeName)) { - kind = 'enum'; - } else if (interfaceNames.has(typeName)) { - kind = 'interface'; - } else if (inputNames.has(typeName)) { - kind = 'input'; - } else if (unionNames.has(typeName)) { - kind = 'union'; - } else if (objectNames.has(typeName)) { - kind = 'object'; - } - const kotlinType = scalarMap.get(typeName) ?? typeName; - return { - kind, - name: typeName, - nullable: true, - kotlinType, - }; -}; - -const getKotlinType = (graphqlType) => { - const metadata = getTypeMetadata(graphqlType); - return { type: metadata.kotlinType, nullable: metadata.nullable, metadata }; -}; - -const resultUnionObjects = new Map(); -for (const objectType of objects) { - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) continue; - if (!unionWrapperNames.has(objectType.name)) continue; - const unionEntries = []; - let allOptional = true; - for (const field of fields) { - if (field.type instanceof GraphQLNonNull) { - allOptional = false; - break; - } - const { type, nullable } = getKotlinType(field.type); - unionEntries.push({ field, type, nullable }); - } - if (!allOptional) continue; - if (unionEntries.length === 0) continue; - resultUnionObjects.set(objectType.name, unionEntries); -} - -const unwrapNonNull = (graphqlType) => { - let current = graphqlType; - while (current instanceof GraphQLNonNull) { - current = current.ofType; - } - return current; -}; - -const getNamedGraphQLType = (graphqlType) => { - const unwrapped = unwrapNonNull(graphqlType); - if (unwrapped instanceof GraphQLList) { - return null; - } - return unwrapped; -}; - -const isNullableGraphQLType = (graphqlType) => !(graphqlType instanceof GraphQLNonNull); - -const getOperationReturnType = (graphqlType) => { - const base = getKotlinType(graphqlType); - if (base.metadata.kind === 'list') { - return base; - } - const namedType = getNamedGraphQLType(graphqlType); - if (namedType && namedType.name === 'VoidResult') { - return { - type: 'Unit', - nullable: isNullableGraphQLType(graphqlType), - metadata: base.metadata, - }; - } - if (!namedType) { - return base; - } - const singleFieldType = singleFieldObjects.get(namedType.name); - if (!singleFieldType) { - return base; - } - const fieldInfo = getKotlinType(singleFieldType); - const finalNullable = base.nullable || fieldInfo.nullable || isNullableGraphQLType(graphqlType); - return { - type: fieldInfo.type, - nullable: finalNullable, - metadata: fieldInfo.metadata, - }; -}; - -const buildFromJsonExpression = (metadata, sourceExpression, isListElement = false, forNullableFromJson = false) => { - if (metadata.kind === 'list') { - const element = buildFromJsonExpression(metadata.elementType, 'it', true, forNullableFromJson); - // Use mapNotNull for non-nullable elements to filter out nulls and get List - // Use map for nullable elements to keep nulls and get List - const mapFn = metadata.elementType.nullable ? 'map' : 'mapNotNull'; - if (metadata.nullable || forNullableFromJson) { - return `(${sourceExpression} as? List<*>)?.${mapFn} { ${element} }`; - } - return `(${sourceExpression} as? List<*>)?.${mapFn} { ${element} } ?: emptyList()`; - } - if (metadata.kind === 'scalar') { - // When inside map/mapNotNull or nullable fromJson, return nullable expression for filtering - const useNullable = metadata.nullable || isListElement || forNullableFromJson; - switch (metadata.name) { - case 'Float': - return useNullable - ? `(${sourceExpression} as? Number)?.toDouble()` - : `(${sourceExpression} as? Number)?.toDouble() ?: 0.0`; - case 'Int': - return useNullable - ? `(${sourceExpression} as? Number)?.toInt()` - : `(${sourceExpression} as? Number)?.toInt() ?: 0`; - case 'Boolean': - return useNullable - ? `${sourceExpression} as? Boolean` - : `${sourceExpression} as? Boolean ?: false`; - case 'ID': - case 'String': - return useNullable - ? `${sourceExpression} as? String` - : `${sourceExpression} as? String ?: ""`; - default: - return useNullable ? `${sourceExpression}` : `${sourceExpression}`; - } - } - if (metadata.kind === 'enum') { - if (metadata.nullable) { - return `(${sourceExpression} as? String)?.let { ${metadata.name}.fromJson(it) }`; - } - // For non-nullable enums, use safe cast and fallback to .Empty if the enum has it - // This handles cases where the JSON might not have the value but the schema requires it - const enumType = typeMap[metadata.name]; - const hasEmptyValue = enumType && isEnumType(enumType) && - enumType.getValues().some(v => v.name.toLowerCase() === 'empty'); - - if (hasEmptyValue) { - return `(${sourceExpression} as? String)?.let { ${metadata.name}.fromJson(it) } ?: ${metadata.name}.Empty`; - } - // For enums without Empty, get first value as fallback - const firstValue = enumType && isEnumType(enumType) ? enumType.getValues()[0] : null; - const fallback = firstValue ? `${metadata.name}.${escapeKotlinName(pascalCase(firstValue.name))}` : `throw IllegalArgumentException("Missing required enum value for ${metadata.name}")`; - return `(${sourceExpression} as? String)?.let { ${metadata.name}.fromJson(it) } ?: ${fallback}`; - } - if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) { - const callTarget = metadata.name ?? metadata.kotlinType; - // Check if this input type has a nullable fromJson (has required fields) - const hasNullableFromJson = metadata.kind === 'input' && inputsWithRequiredFields.has(callTarget); - - if (metadata.nullable || forNullableFromJson) { - // Already nullable context - just return nullable - return `(${sourceExpression} as? Map)?.let { ${callTarget}.fromJson(it) }`; - } - if (hasNullableFromJson) { - // Input has required fields, fromJson returns nullable - // Use flatMap to handle the nullable return from fromJson - return `(${sourceExpression} as? Map)?.let { ${callTarget}.fromJson(it) } ?: throw IllegalArgumentException("Missing or invalid required object for ${callTarget}")`; - } - // For non-nullable objects with non-nullable fromJson - return `(${sourceExpression} as? Map)?.let { ${callTarget}.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ${callTarget}")`; - } - return metadata.nullable ? `${sourceExpression}` : `${sourceExpression}`; -}; - -const buildToJsonExpression = (metadata, accessorExpression) => { - if (metadata.kind === 'list') { - const inner = buildToJsonExpression(metadata.elementType, 'it'); - // If inner expression is just 'it', skip the map entirely (e.g., List) - if (inner === 'it') { - return accessorExpression; - } - return metadata.nullable - ? `${accessorExpression}?.map { ${inner} }` - : `${accessorExpression}.map { ${inner} }`; - } - if (metadata.kind === 'enum') { - return metadata.nullable ? `${accessorExpression}?.toJson()` : `${accessorExpression}.toJson()`; - } - if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) { - return metadata.nullable ? `${accessorExpression}?.toJson()` : `${accessorExpression}.toJson()`; - } - return accessorExpression; -}; - -const lines = []; -lines.push( - '// ============================================================================', - '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', - "// Run `npm run generate` after updating any *.graphql schema file.", - '// ============================================================================', - '', - '// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure', - '@file:Suppress("UNCHECKED_CAST")', - '' -); - -const printEnum = (enumType) => { - addDocComment(lines, enumType.description); - lines.push(`public enum class ${enumType.name}(val rawValue: String) {`); - const values = enumType.getValues(); - values.forEach((value, index) => { - addDocComment(lines, value.description, ' '); - const caseName = escapeKotlinName(pascalCase(value.name)); - const rawValue = toKebabCase(value.name); - const suffix = index === values.length - 1 ? '' : ','; - lines.push(` ${caseName}("${rawValue}")${suffix}`); - }); - lines.push('', ' companion object {'); - lines.push(` fun fromJson(value: String): ${enumType.name} = when (value) {`); - values.forEach((value) => { - const caseName = escapeKotlinName(pascalCase(value.name)); - const rawValue = toKebabCase(value.name); - const legacyValues = Array.from(new Set([toConstantCase(value.name), value.name])) - .filter((candidate) => candidate !== rawValue); - lines.push(` "${rawValue}" -> ${enumType.name}.${caseName}`); - legacyValues.forEach((legacy) => { - lines.push(` "${legacy}" -> ${enumType.name}.${caseName}`); - }); - }); - lines.push(` else -> throw IllegalArgumentException("Unknown ${enumType.name} value: $value")`, ' }'); - lines.push(' }', ''); - lines.push(' fun toJson(): String = rawValue'); - lines.push('}', ''); -}; - -const printInterface = (interfaceType) => { - addDocComment(lines, interfaceType.description); - lines.push(`public interface ${interfaceType.name} {`); - const fields = Object.values(interfaceType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, nullable } = getKotlinType(field.type); - const propertyType = type + (nullable ? '?' : ''); - const propertyName = escapeKotlinName(field.name); - lines.push(` val ${propertyName}: ${propertyType}`); - } - lines.push('}', ''); -}; - -const printDataClass = (objectType) => { - if (objectType.name === 'VoidResult') { - lines.push('public typealias VoidResult = Unit', ''); - return; - } - if (resultUnionObjects.has(objectType.name)) { - addDocComment(lines, objectType.description); - lines.push(`public sealed interface ${objectType.name}`, ''); - const unionEntries = resultUnionObjects.get(objectType.name); - unionEntries.forEach(({ field, type, nullable }) => { - const className = `${objectType.name}${capitalize(field.name)}`; - const propertyType = `${type}${nullable ? '?' : ''}`; - lines.push(`public data class ${className}(val value: ${propertyType}) : ${objectType.name}`, ''); - }); - return; - } - addDocComment(lines, objectType.description); - const interfacesForObject = objectType.getInterfaces().map((iface) => iface.name); - const unionInterfaces = unionMembership.has(objectType.name) - ? Array.from(unionMembership.get(objectType.name)).sort() - : []; - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) { - lines.push(`public class ${objectType.name}`); - lines.push(''); - return; - } - // Collect all fields from interfaces to determine which need 'override' - const interfaceFields = new Set(); - for (const iface of objectType.getInterfaces()) { - for (const fieldName of Object.keys(iface.getFields())) { - interfaceFields.add(fieldName); - } - } - const fieldInfos = fields.map((field) => { - const { type, nullable, metadata } = getKotlinType(field.type); - const propertyType = type + (nullable ? '?' : ''); - const propertyName = escapeKotlinName(field.name); - const defaultValue = nullable ? ' = null' : ''; - const needsOverride = interfaceFields.has(field.name); - return { field, propertyName, propertyType, defaultValue, metadata, needsOverride }; - }); - lines.push(`public data class ${objectType.name}(`); - fieldInfos.forEach(({ field, propertyName, propertyType, defaultValue, needsOverride }, index) => { - addDocComment(lines, field.description, ' '); - const suffix = index === fieldInfos.length - 1 ? '' : ','; - const overrideKeyword = needsOverride ? 'override ' : ''; - lines.push(` ${overrideKeyword}val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); - }); - const implementsList = [...interfacesForObject, ...unionInterfaces]; - if (implementsList.length > 0) { - lines.push(`) : ${implementsList.join(', ')} {`); - } else { - lines.push(') {'); - } - lines.push(''); - lines.push(' companion object {'); - lines.push(` fun fromJson(json: Map): ${objectType.name} {`); - lines.push(` return ${objectType.name}(`); - fieldInfos.forEach(({ field, propertyName, metadata }) => { - const expression = buildFromJsonExpression(metadata, `json["${field.name}"]`); - lines.push(` ${propertyName} = ${expression},`); - }); - lines.push(' )'); - lines.push(' }'); - lines.push(' }', ''); - const overrideKeyword = unionInterfaces.length > 0 ? 'override ' : ''; - lines.push(` ${overrideKeyword}fun toJson(): Map = mapOf(`); - lines.push(` "__typename" to "${objectType.name}",`); - fieldInfos.forEach(({ field, propertyName, metadata }) => { - const expression = buildToJsonExpression(metadata, propertyName); - lines.push(` "${field.name}" to ${expression},`); - }); - lines.push(' )'); - lines.push('}', ''); -}; - -const printInput = (inputType) => { - // Alias PurchaseInput to Purchase for cleaner API - if (inputType.name === 'PurchaseInput') { - lines.push('public typealias PurchaseInput = Purchase'); - lines.push(''); - return; - } - if (inputType.name === 'RequestPurchaseProps') { - addDocComment(lines, inputType.description); - lines.push('public data class RequestPurchaseProps('); - lines.push(' val request: Request,'); - lines.push(' val type: ProductQueryType,'); - lines.push(' val useAlternativeBilling: Boolean? = null'); - lines.push(') {'); - lines.push(' init {'); - lines.push(' when (request) {'); - lines.push(' is Request.Purchase -> require(type == ProductQueryType.InApp) { "type must be IN_APP when request is purchase" }'); - lines.push(' is Request.Subscription -> require(type == ProductQueryType.Subs) { "type must be SUBS when request is subscription" }'); - lines.push(' }'); - lines.push(' }'); - lines.push(''); - lines.push(' companion object {'); - lines.push(' fun fromJson(json: Map): RequestPurchaseProps {'); - lines.push(' val rawType = (json["type"] as String?)?.let { ProductQueryType.fromJson(it) }'); - lines.push(' val useAlternativeBilling = json["useAlternativeBilling"] as Boolean?'); - lines.push(' val purchaseJson = json["requestPurchase"] as Map?'); - lines.push(' if (purchaseJson != null) {'); - lines.push(' val request = Request.Purchase(RequestPurchasePropsByPlatforms.fromJson(purchaseJson))'); - lines.push(' val finalType = rawType ?: ProductQueryType.InApp'); - lines.push(' require(finalType == ProductQueryType.InApp) { "type must be IN_APP when requestPurchase is provided" }'); - lines.push(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); - lines.push(' }'); - lines.push(' val subscriptionJson = json["requestSubscription"] as Map?'); - lines.push(' if (subscriptionJson != null) {'); - lines.push(' val request = Request.Subscription(RequestSubscriptionPropsByPlatforms.fromJson(subscriptionJson))'); - lines.push(' val finalType = rawType ?: ProductQueryType.Subs'); - lines.push(' require(finalType == ProductQueryType.Subs) { "type must be SUBS when requestSubscription is provided" }'); - lines.push(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); - lines.push(' }'); - lines.push(' throw IllegalArgumentException("RequestPurchaseProps requires requestPurchase or requestSubscription")'); - lines.push(' }'); - lines.push(' }'); - lines.push(''); - lines.push(' fun toJson(): Map = when (request) {'); - lines.push(' is Request.Purchase -> mapOf('); - lines.push(' "requestPurchase" to request.value.toJson(),'); - lines.push(' "type" to type.toJson(),'); - lines.push(' "useAlternativeBilling" to useAlternativeBilling,'); - lines.push(' )'); - lines.push(' is Request.Subscription -> mapOf('); - lines.push(' "requestSubscription" to request.value.toJson(),'); - lines.push(' "type" to type.toJson(),'); - lines.push(' "useAlternativeBilling" to useAlternativeBilling,'); - lines.push(' )'); - lines.push(' }'); - lines.push(''); - lines.push(' sealed class Request {'); - lines.push(' data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request()'); - lines.push(' data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request()'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - return; - } - addDocComment(lines, inputType.description); - lines.push(`public data class ${inputType.name}(`); - const fields = Object.values(inputType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - const fieldInfos = fields.map((field) => { - const { type, nullable, metadata } = getKotlinType(field.type); - const propertyType = type + (nullable ? '?' : ''); - const propertyName = escapeKotlinName(field.name); - const defaultValue = nullable ? ' = null' : ''; - return { field, propertyName, propertyType, defaultValue, metadata }; - }); - fieldInfos.forEach(({ field, propertyName, propertyType, defaultValue }, index) => { - addDocComment(lines, field.description, ' '); - const suffix = index === fieldInfos.length - 1 ? '' : ','; - lines.push(` val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); - }); - lines.push(') {'); - lines.push(' companion object {'); - - // Check if input has any non-nullable fields - if so, fromJson should return nullable - const hasRequiredFields = fieldInfos.some(({ metadata }) => !metadata.nullable); - - if (hasRequiredFields) { - // Nullable fromJson pattern: returns null if any required field is missing - lines.push(` fun fromJson(json: Map): ${inputType.name}? {`); - // Parse all fields with nullable expressions - fieldInfos.forEach(({ field, propertyName, metadata }) => { - const expression = buildFromJsonExpression(metadata, `json["${field.name}"]`, false, true); - lines.push(` val ${propertyName} = ${expression}`); - }); - // Check required fields are not null - // Exclude non-nullable enums since they always have a fallback value (never null) - const requiredFields = fieldInfos.filter(({ metadata }) => !metadata.nullable && metadata.kind !== 'enum'); - if (requiredFields.length > 0) { - const nullChecks = requiredFields.map(({ propertyName }) => `${propertyName} == null`).join(' || '); - lines.push(` if (${nullChecks}) return null`); - } - lines.push(` return ${inputType.name}(`); - fieldInfos.forEach(({ propertyName }) => { - // Kotlin smart cast applies after null check, so no !! needed - lines.push(` ${propertyName} = ${propertyName},`); - }); - lines.push(' )'); - lines.push(' }'); - } else { - // All fields are nullable, use simple non-null fromJson - lines.push(` fun fromJson(json: Map): ${inputType.name} {`); - lines.push(` return ${inputType.name}(`); - fieldInfos.forEach(({ field, propertyName, metadata }) => { - const expression = buildFromJsonExpression(metadata, `json["${field.name}"]`); - lines.push(` ${propertyName} = ${expression},`); - }); - lines.push(' )'); - lines.push(' }'); - } - lines.push(' }', ''); - lines.push(' fun toJson(): Map = mapOf('); - fieldInfos.forEach(({ field, propertyName, metadata }) => { - const expression = buildToJsonExpression(metadata, propertyName); - lines.push(` "${field.name}" to ${expression},`); - }); - lines.push(' )'); - lines.push('}', ''); -}; - -const printUnion = (unionType) => { - addDocComment(lines, unionType.description); - const memberTypes = unionType.getTypes(); - const members = memberTypes.map((member) => member.name).sort(); - - let sharedInterfaceNames = []; - if (memberTypes.length > 0) { - const [firstMember, ...otherMembers] = memberTypes; - // Check if member is a union (unions don't have getInterfaces) - if (typeof firstMember.getInterfaces === 'function') { - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - let allMembersHaveInterfaces = true; - for (const member of otherMembers) { - if (typeof member.getInterfaces === 'function') { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); - } - } - } else { - // Member is a union, so no shared interfaces - allMembersHaveInterfaces = false; - break; - } - } - if (allMembersHaveInterfaces) { - sharedInterfaceNames = Array.from(firstInterfaces).sort(); - } - } - } - - const implementations = sharedInterfaceNames.length ? ` : ${sharedInterfaceNames.join(', ')}` : ''; - lines.push(`public sealed interface ${unionType.name}${implementations} {`); - lines.push(' fun toJson(): Map', ''); - lines.push(' companion object {'); - lines.push(` fun fromJson(json: Map): ${unionType.name} {`); - lines.push(' return when (json["__typename"] as String?) {'); - - // Flatten nested unions: if a member is itself a union, include its concrete members - const concreteMembers = new Set(); - for (const memberType of memberTypes) { - if (isUnionType(memberType)) { - // Member is a union, get its concrete members - const nestedMembers = memberType.getTypes(); - for (const nestedMember of nestedMembers) { - concreteMembers.add(nestedMember.name); - } - } else { - // Member is a concrete type - concreteMembers.add(memberType.name); - } - } - - // Track nested unions that need wrapper classes - const nestedUnions = new Set(); - - // Generate case for each concrete member, wrapping nested unions - const sortedConcreteMembers = Array.from(concreteMembers).sort(); - sortedConcreteMembers.forEach((concreteMember) => { - // Find which direct member this concrete type belongs to - let delegateTo = concreteMember; - let isNestedUnion = false; - - for (const memberType of memberTypes) { - if (isUnionType(memberType)) { - const nestedMembers = memberType.getTypes().map(t => t.name); - if (nestedMembers.includes(concreteMember)) { - delegateTo = memberType.name; - isNestedUnion = true; - nestedUnions.add(memberType.name); - break; - } - } - } - - if (isNestedUnion) { - // Wrap nested union in a typed wrapper class - const wrapperName = `${delegateTo}Item`; - lines.push(` "${concreteMember}" -> ${wrapperName}(${delegateTo}.fromJson(json))`); - } else { - // Direct member, no wrapping needed - lines.push(` "${concreteMember}" -> ${delegateTo}.fromJson(json)`); - } - }); - - lines.push(` else -> throw IllegalArgumentException("Unknown __typename for ${unionType.name}: ${'$'}{json["__typename"]}")`); - lines.push(' }'); - lines.push(' }'); - lines.push(' }'); - - // Generate wrapper data classes for nested unions (inside the sealed interface) - for (const nestedUnionName of Array.from(nestedUnions).sort()) { - const wrapperName = `${nestedUnionName}Item`; - lines.push(''); - lines.push(` data class ${wrapperName}(val value: ${nestedUnionName}) : ${unionType.name} {`); - lines.push(' override fun toJson() = value.toJson()'); - lines.push(' }'); - } - - lines.push('}', ''); -}; - -const printOperationInterface = (operationType) => { - const interfaceName = `${operationType.name}Resolver`; - addDocComment(lines, operationType.description ?? `GraphQL root ${operationType.name.toLowerCase()} operations.`); - lines.push(`public interface ${interfaceName} {`); - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, nullable } = getOperationReturnType(field.type); - const returnType = type + (nullable ? '?' : ''); - const args = field.args.map((arg) => { - const { type: argType, nullable: argNullable } = getKotlinType(arg.type); - const argumentType = argType + (argNullable ? '?' : ''); - const defaultValue = argNullable ? ' = null' : ''; - return `${escapeKotlinName(arg.name)}: ${argumentType}${defaultValue}`; - }); - const params = args.length > 0 ? args.join(', ') : ''; - const paramSegment = params ? `(${params})` : '()'; - lines.push(` suspend fun ${escapeKotlinName(field.name)}${paramSegment}: ${returnType}`); - } - lines.push('}', ''); -}; - -const printOperationHelpers = (operationType) => { - const rootName = operationType.name; - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) return; - - lines.push(`// MARK: - ${rootName} Helpers`, ''); - - fields.forEach((field) => { - const aliasName = `${rootName}${capitalize(field.name)}Handler`; - const { type, nullable } = getOperationReturnType(field.type); - const returnType = type + (nullable ? '?' : ''); - if (field.args.length === 0) { - lines.push(`public typealias ${aliasName} = suspend () -> ${returnType}`); - return; - } - const argsSignature = field.args.map((arg) => { - const { type: argType, nullable: argNullable } = getKotlinType(arg.type); - const argumentType = argType + (argNullable ? '?' : ''); - return `${escapeKotlinName(arg.name)}: ${argumentType}`; - }).join(', '); - lines.push(`public typealias ${aliasName} = suspend (${argsSignature}) -> ${returnType}`); - }); - - const helperClass = `${rootName}Handlers`; - lines.push('', `public data class ${helperClass}(`); - fields.forEach((field, index) => { - const aliasName = `${rootName}${capitalize(field.name)}Handler`; - const propertyName = escapeKotlinName(field.name); - const suffix = index === fields.length - 1 ? '' : ','; - lines.push(` val ${propertyName}: ${aliasName}? = null${suffix}`); - }); - lines.push(')', ''); -}; - -if (enums.length) { - lines.push('// MARK: - Enums', ''); - enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum); -} - -if (interfaces.length) { - lines.push('// MARK: - Interfaces', ''); - interfaces.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInterface); -} - -if (objects.length) { - lines.push('// MARK: - Objects', ''); - objects.sort((a, b) => a.name.localeCompare(b.name)).forEach(printDataClass); -} - -if (inputs.length) { - lines.push('// MARK: - Input Objects', ''); - inputs.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInput); -} - -if (unions.length) { - lines.push('// MARK: - Unions', ''); - unions.sort((a, b) => a.name.localeCompare(b.name)).forEach(printUnion); -} - -if (operationTypes.length) { - lines.push('// MARK: - Root Operations', ''); - operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationInterface); -} - -if (operationTypes.length) { - lines.push('// MARK: - Root Operation Helpers', ''); - operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers); -} - -// Post-process: Convert platform and type fields to fixed default values for discriminated unions -// This enables Kotlin's when expression to properly narrow types based on platform and type -const productTypeMapping = { - ProductIOS: { platform: 'IapPlatform.Ios', type: 'ProductType.InApp' }, - ProductAndroid: { platform: 'IapPlatform.Android', type: 'ProductType.InApp' }, - ProductSubscriptionIOS: { platform: 'IapPlatform.Ios', type: 'ProductType.Subs' }, - ProductSubscriptionAndroid: { platform: 'IapPlatform.Android', type: 'ProductType.Subs' }, -}; - -for (const [typeName, literals] of Object.entries(productTypeMapping)) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Find data class definition - if (line.includes(`data class ${typeName}(`)) { - // Look for platform and type fields within this data class - for (let j = i; j < lines.length && !lines[j].includes(') :') && !lines[j].trim().startsWith(') {'); j++) { - // Replace platform field with fixed default value - if (lines[j].includes('val platform: IapPlatform')) { - lines[j] = lines[j].replace( - /val platform: IapPlatform(,?)/, - `val platform: IapPlatform = ${literals.platform}$1` - ); - } - // Replace type field with fixed default value - if (lines[j].includes('val type: ProductType')) { - lines[j] = lines[j].replace( - /val type: ProductType(,?)/, - `val type: ProductType = ${literals.type}$1` - ); - } - } - break; - } - } -} - -// ProductOrSubscription union is now auto-generated from GraphQL schema -// FetchProductsResultAll is also auto-generated -let output = lines.join('\n'); - -const outputPath = resolve(__dirname, '../src/generated/Types.kt'); -mkdirSync(dirname(outputPath), { recursive: true }); -writeFileSync(outputPath, output); - -// eslint-disable-next-line no-console -console.log('[generate-kotlin-types] wrote', outputPath); diff --git a/packages/gql/scripts/generate-swift-types.mjs b/packages/gql/scripts/generate-swift-types.mjs deleted file mode 100644 index effea7a0..00000000 --- a/packages/gql/scripts/generate-swift-types.mjs +++ /dev/null @@ -1,831 +0,0 @@ -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - buildASTSchema, - GraphQLList, - GraphQLNonNull, - isEnumType, - isInputObjectType, - isInterfaceType, - isObjectType, - isScalarType, - isUnionType, - parse, -} from 'graphql'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const schemaPaths = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', -].map((relativePath) => resolve(__dirname, relativePath)); - -const documentNode = { - kind: 'Document', - definitions: schemaPaths.flatMap((schemaPath) => { - const sdl = readFileSync(schemaPath, 'utf8'); - return parse(sdl).definitions; - }), -}; - -const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); -const typeMap = schema.getTypeMap(); -const typeNames = Object.keys(typeMap) - .filter((name) => !name.startsWith('__')) - .sort((a, b) => a.localeCompare(b)); - -const swiftKeywords = new Set([ - 'associatedtype', - 'class', - 'deinit', - 'enum', - 'extension', - 'func', - 'import', - 'init', - 'inout', - 'internal', - 'let', - 'operator', - 'private', - 'protocol', - 'public', - 'static', - 'struct', - 'subscript', - 'typealias', - 'var', - 'break', - 'case', - 'continue', - 'default', - 'defer', - 'do', - 'else', - 'fallthrough', - 'for', - 'guard', - 'if', - 'in', - 'repeat', - 'return', - 'switch', - 'where', - 'while', - 'as', - 'catch', - 'false', - 'is', - 'nil', - 'rethrows', - 'super', - 'self', - 'Self', - 'throw', - 'throws', - 'true', - 'try', - 'Any', - 'Protocol', -]); - -const escapeSwiftName = (name) => (swiftKeywords.has(name) ? `\`${name}\`` : name); - -const lowerCamelCase = (value) => { - const parts = value - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_\-]+/g, ' ') - .split(/\s+/) - .filter(Boolean) - .map((segment) => segment.toLowerCase()); - if (parts.length === 0) return value; - return parts[0] + parts.slice(1).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(''); -}; - -const capitalize = (value) => (value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1)); - -const toKebabCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - -const scalarMap = new Map([ - ['ID', 'String'], - ['String', 'String'], - ['Boolean', 'Bool'], - ['Int', 'Int'], - ['Float', 'Double'], -]); - -const swiftTypeFor = (graphqlType) => { - if (graphqlType instanceof GraphQLNonNull) { - const inner = swiftTypeFor(graphqlType.ofType); - return { type: inner.type, optional: false }; - } - if (graphqlType instanceof GraphQLList) { - const inner = swiftTypeFor(graphqlType.ofType); - const element = inner.type + (inner.optional ? '?' : ''); - return { type: `[${element}]`, optional: true }; - } - const namedType = graphqlType.name; - const mapped = scalarMap.get(namedType) ?? namedType; - return { type: mapped, optional: true }; -}; - -const addDocComment = (lines, description, indent = '') => { - if (!description) return; - for (const docLine of description.split(/\r?\n/)) { - lines.push(`${indent}/// ${docLine}`); - } -}; - -const unwrapNonNull = (graphqlType) => { - let current = graphqlType; - while (current instanceof GraphQLNonNull) { - current = current.ofType; - } - return current; -}; - -const getNamedGraphQLType = (graphqlType) => { - const unwrapped = unwrapNonNull(graphqlType); - if (unwrapped instanceof GraphQLList) { - return null; - } - return unwrapped; -}; - -const getOperationReturnType = (graphqlType) => { - const base = swiftTypeFor(graphqlType); - const namedType = getNamedGraphQLType(graphqlType); - if (namedType && namedType.name === 'VoidResult') { - return { - type: 'Void', - optional: !(graphqlType instanceof GraphQLNonNull), - }; - } - if (!namedType) { - return base; - } - const singleFieldType = singleFieldObjects.get(namedType.name); - if (!singleFieldType) { - return base; - } - const fieldInfo = swiftTypeFor(singleFieldType); - const fieldOptional = !(graphqlType instanceof GraphQLNonNull); - return { - type: fieldInfo.type, - optional: fieldInfo.optional || fieldOptional, - }; -}; - -const lines = []; -lines.push( - '// ============================================================================', - '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', - "// Run `npm run generate` after updating any *.graphql schema file.", - '// ============================================================================', - '', - 'import Foundation', - '' -); - -const unionWrapperNames = new Set(); -for (const schemaPath of schemaPaths) { - let expectTypeName = false; - for (const line of readFileSync(schemaPath, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectTypeName = true; - continue; - } - if (expectTypeName) { - if (trimmed.length === 0) { - continue; - } - if (trimmed.startsWith('#')) { - continue; - } - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - unionWrapperNames.add(typeMatch[1]); - } - expectTypeName = false; - } - } -} - -const enums = []; -const interfaces = []; -const objects = []; -const inputs = []; -const unions = []; -const operations = []; - -for (const name of typeNames) { - const type = typeMap[name]; - if (isScalarType(type)) { - if (scalarMap.has(type.name)) continue; - // Custom scalars can be represented as typealias to String by default - const swiftType = scalarMap.get(type.name) ?? 'String'; - lines.push(`// TODO: Map custom scalar ${type.name} to an appropriate Swift type (defaulting to ${swiftType}).`); - continue; - } - if (isEnumType(type)) { - enums.push(type); - continue; - } - if (isInterfaceType(type)) { - interfaces.push(type); - continue; - } - if (isUnionType(type)) { - unions.push(type); - continue; - } - if (isObjectType(type)) { - if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { - operations.push(type); - continue; - } - objects.push(type); - continue; - } - if (isInputObjectType(type)) { - inputs.push(type); - } -} - -const singleFieldObjects = new Map(); -for (const objectType of objects) { - const fields = Object.values(objectType.getFields()); - if (fields.length === 1 && objectType.name.endsWith('Args')) { - singleFieldObjects.set(objectType.name, fields[0].type); - } -} - -const resultUnionObjects = new Map(); -for (const objectType of objects) { - if (!unionWrapperNames.has(objectType.name)) continue; - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) continue; - const unionEntries = []; - let allOptional = true; - for (const field of fields) { - if (field.type instanceof GraphQLNonNull) { - allOptional = false; - break; - } - const { type, optional } = swiftTypeFor(field.type); - unionEntries.push({ field, type, optional }); - } - if (!allOptional) continue; - if (unionEntries.length === 0) continue; - resultUnionObjects.set(objectType.name, unionEntries); -} - -const printEnum = (enumType) => { - addDocComment(lines, enumType.description); - lines.push(`public enum ${enumType.name}: String, Codable, CaseIterable {`); - const values = enumType.getValues(); - values.forEach((value, index) => { - addDocComment(lines, value.description, ' '); - const caseName = escapeSwiftName(lowerCamelCase(value.name)); - const rawValue = toKebabCase(value.name); - lines.push(` case ${caseName} = "${rawValue}"`); - if (index === values.length - 1) return; - }); - - // Add custom initializer for ErrorCode to handle both kebab-case and camelCase - if (enumType.name === 'ErrorCode') { - // Define legacy aliases: old error codes that map to new ones - const legacyAliases = { - 'receipt-failed': 'purchaseVerificationFailed', - 'ReceiptFailed': 'purchaseVerificationFailed', - }; - - lines.push(''); - lines.push(' /// Custom initializer to handle both kebab-case and camelCase error codes'); - lines.push(' /// This ensures compatibility with react-native-iap and other libraries that may send camelCase'); - lines.push(' public init?(rawValue: String) {'); - lines.push(' // Try direct match first (kebab-case)'); - lines.push(' switch rawValue {'); - values.forEach((value) => { - const caseName = escapeSwiftName(lowerCamelCase(value.name)); - const rawValue = toKebabCase(value.name); - const camelCaseName = value.name.charAt(0).toUpperCase() + value.name.slice(1); - // Check if this case has legacy aliases that should map to it - const aliasTarget = legacyAliases[rawValue] || legacyAliases[camelCaseName]; - if (aliasTarget && aliasTarget === caseName) { - // This case is a legacy alias target - already handled by the main case - lines.push(` case "${rawValue}", "${camelCaseName}":`); - lines.push(` self = .${caseName}`); - } else if (legacyAliases[rawValue] || legacyAliases[camelCaseName]) { - // This is a legacy alias - map to the new case - const targetCase = legacyAliases[rawValue] || legacyAliases[camelCaseName]; - lines.push(` case "${rawValue}", "${camelCaseName}":`); - lines.push(` self = .${targetCase} // Legacy alias`); - } else { - lines.push(` case "${rawValue}", "${camelCaseName}":`); - lines.push(` self = .${caseName}`); - } - }); - lines.push(' default:'); - lines.push(' return nil'); - lines.push(' }'); - lines.push(' }'); - } - - lines.push('}', ''); -}; - -const printInterface = (interfaceType) => { - addDocComment(lines, interfaceType.description); - lines.push(`public protocol ${interfaceType.name}: Codable {`); - const fields = Object.values(interfaceType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, optional } = swiftTypeFor(field.type); - const propertyType = type + (optional ? '?' : ''); - const propertyName = escapeSwiftName(field.name); - lines.push(` var ${propertyName}: ${propertyType} { get }`); - } - lines.push('}', ''); -}; - -const printObject = (objectType) => { - if (objectType.name === 'VoidResult') { - lines.push('public typealias VoidResult = Void', ''); - return; - } - if (resultUnionObjects.has(objectType.name)) { - addDocComment(lines, objectType.description); - lines.push(`public enum ${objectType.name} {`); - const unionEntries = resultUnionObjects.get(objectType.name); - unionEntries.forEach(({ field, type, optional }) => { - const caseName = escapeSwiftName(lowerCamelCase(field.name)); - const payloadType = type + (optional ? '?' : ''); - lines.push(` case ${caseName}(${payloadType})`); - }); - lines.push('}', ''); - return; - } - addDocComment(lines, objectType.description); - const interfacesForObject = objectType.getInterfaces(); - const conformances = ['Codable', ...interfacesForObject.map((iface) => iface.name)]; - lines.push(`public struct ${objectType.name}: ${conformances.join(', ')} {`); - const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) { - lines.push(' public init() {}'); - } else { - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, optional } = swiftTypeFor(field.type); - const propertyType = type + (optional ? '?' : ''); - const propertyName = escapeSwiftName(field.name); - lines.push(` public var ${propertyName}: ${propertyType}`); - } - } - lines.push('}', ''); -}; - -const printInput = (inputType) => { - // Alias PurchaseInput to Purchase for cleaner API - if (inputType.name === 'PurchaseInput') { - lines.push('public typealias PurchaseInput = Purchase'); - lines.push(''); - return; - } - // Custom Decodable for DiscountOfferInputIOS to handle String -> Double conversion - if (inputType.name === 'DiscountOfferInputIOS') { - addDocComment(lines, inputType.description); - lines.push('public struct DiscountOfferInputIOS: Codable {'); - lines.push(' public var identifier: String'); - lines.push(' public var keyIdentifier: String'); - lines.push(' public var nonce: String'); - lines.push(' public var signature: String'); - lines.push(' public var timestamp: Double'); - lines.push(''); - lines.push(' public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) {'); - lines.push(' self.identifier = identifier'); - lines.push(' self.keyIdentifier = keyIdentifier'); - lines.push(' self.nonce = nonce'); - lines.push(' self.signature = signature'); - lines.push(' self.timestamp = timestamp'); - lines.push(' }'); - lines.push(''); - lines.push(' private enum CodingKeys: String, CodingKey {'); - lines.push(' case identifier, keyIdentifier, nonce, signature, timestamp'); - lines.push(' }'); - lines.push(''); - lines.push(' public init(from decoder: Decoder) throws {'); - lines.push(' let container = try decoder.container(keyedBy: CodingKeys.self)'); - lines.push(' identifier = try container.decode(String.self, forKey: .identifier)'); - lines.push(' keyIdentifier = try container.decode(String.self, forKey: .keyIdentifier)'); - lines.push(' nonce = try container.decode(String.self, forKey: .nonce)'); - lines.push(' signature = try container.decode(String.self, forKey: .signature)'); - lines.push(''); - lines.push(' // Flexible timestamp decoding: accept Double or String'); - lines.push(' if let timestampDouble = try? container.decode(Double.self, forKey: .timestamp) {'); - lines.push(' timestamp = timestampDouble'); - lines.push(' } else if let timestampString = try? container.decode(String.self, forKey: .timestamp),'); - lines.push(' let timestampDouble = Double(timestampString) {'); - lines.push(' timestamp = timestampDouble'); - lines.push(' } else {'); - lines.push(' throw DecodingError.dataCorruptedError('); - lines.push(' forKey: .timestamp,'); - lines.push(' in: container,'); - lines.push(' debugDescription: "timestamp must be a number or numeric string"'); - lines.push(' )'); - lines.push(' }'); - lines.push(' }'); - lines.push(''); - lines.push(' public func encode(to encoder: Encoder) throws {'); - lines.push(' var container = encoder.container(keyedBy: CodingKeys.self)'); - lines.push(' try container.encode(identifier, forKey: .identifier)'); - lines.push(' try container.encode(keyIdentifier, forKey: .keyIdentifier)'); - lines.push(' try container.encode(nonce, forKey: .nonce)'); - lines.push(' try container.encode(signature, forKey: .signature)'); - lines.push(' try container.encode(timestamp, forKey: .timestamp)'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - return; - } - if (inputType.name === 'RequestPurchaseProps') { - addDocComment(lines, inputType.description); - lines.push('public struct RequestPurchaseProps: Codable {'); - lines.push(' public var request: Request'); - lines.push(' public var type: ProductQueryType'); - lines.push(' public var useAlternativeBilling: Bool?'); - lines.push(''); - lines.push(' public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) {'); - lines.push(' switch request {'); - lines.push(' case .purchase:'); - lines.push(' let resolved = type ?? .inApp'); - lines.push(' precondition(resolved == .inApp, "RequestPurchaseProps.type must be .inApp when request is purchase")'); - lines.push(' self.type = resolved'); - lines.push(' case .subscription:'); - lines.push(' let resolved = type ?? .subs'); - lines.push(' precondition(resolved == .subs, "RequestPurchaseProps.type must be .subs when request is subscription")'); - lines.push(' self.type = resolved'); - lines.push(' }'); - lines.push(' self.request = request'); - lines.push(' self.useAlternativeBilling = useAlternativeBilling'); - lines.push(' }'); - lines.push(''); - lines.push(' private enum CodingKeys: String, CodingKey {'); - lines.push(' case requestPurchase'); - lines.push(' case requestSubscription'); - lines.push(' case type'); - lines.push(' case useAlternativeBilling'); - lines.push(' }'); - lines.push(''); - lines.push(' public init(from decoder: Decoder) throws {'); - lines.push(' let container = try decoder.container(keyedBy: CodingKeys.self)'); - lines.push(' let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)'); - lines.push(' self.useAlternativeBilling = try container.decodeIfPresent(Bool.self, forKey: .useAlternativeBilling)'); - lines.push(' if let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase) {'); - lines.push(' let finalType = decodedType ?? .inApp'); - lines.push(' guard finalType == .inApp else {'); - lines.push(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be IN_APP when requestPurchase is provided")'); - lines.push(' }'); - lines.push(' self.request = .purchase(purchase)'); - lines.push(' self.type = finalType'); - lines.push(' return'); - lines.push(' }'); - lines.push(' if let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription) {'); - lines.push(' let finalType = decodedType ?? .subs'); - lines.push(' guard finalType == .subs else {'); - lines.push(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be SUBS when requestSubscription is provided")'); - lines.push(' }'); - lines.push(' self.request = .subscription(subscription)'); - lines.push(' self.type = finalType'); - lines.push(' return'); - lines.push(' }'); - lines.push(' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps requires requestPurchase or requestSubscription.")'); - lines.push(' }'); - lines.push(''); - lines.push(' public func encode(to encoder: Encoder) throws {'); - lines.push(' var container = encoder.container(keyedBy: CodingKeys.self)'); - lines.push(' switch request {'); - lines.push(' case let .purchase(value):'); - lines.push(' try container.encode(value, forKey: .requestPurchase)'); - lines.push(' case let .subscription(value):'); - lines.push(' try container.encode(value, forKey: .requestSubscription)'); - lines.push(' }'); - lines.push(' try container.encode(type, forKey: .type)'); - lines.push(' try container.encodeIfPresent(useAlternativeBilling, forKey: .useAlternativeBilling)'); - lines.push(' }'); - lines.push(''); - lines.push(' public enum Request {'); - lines.push(' case purchase(RequestPurchasePropsByPlatforms)'); - lines.push(' case subscription(RequestSubscriptionPropsByPlatforms)'); - lines.push(' }'); - lines.push('}'); - lines.push(''); - return; - } - addDocComment(lines, inputType.description); - lines.push(`public struct ${inputType.name}: Codable {`); - const fields = Object.values(inputType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) { - lines.push(' public init() {}'); - } else { - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, optional } = swiftTypeFor(field.type); - const propertyType = type + (optional ? '?' : ''); - const propertyName = escapeSwiftName(field.name); - lines.push(` public var ${propertyName}: ${propertyType}`); - } - // Generate public initializer - lines.push(''); - const initParams = fields.map((field) => { - const { type, optional } = swiftTypeFor(field.type); - const propertyType = type + (optional ? '?' : ''); - const propertyName = escapeSwiftName(field.name); - const defaultValue = optional ? ' = nil' : ''; - return ` ${propertyName}: ${propertyType}${defaultValue}`; - }).join(',\n'); - lines.push(' public init('); - lines.push(initParams); - lines.push(' ) {'); - for (const field of fields) { - const propertyName = escapeSwiftName(field.name); - lines.push(` self.${propertyName} = ${propertyName}`); - } - lines.push(' }'); - } - lines.push('}', ''); -}; - -const printUnion = (unionType) => { - addDocComment(lines, unionType.description); - const memberTypes = unionType.getTypes(); - const caseInfos = memberTypes.map((member) => ({ - typeName: member.name, - caseName: escapeSwiftName(lowerCamelCase(member.name)), - })); - - let sharedInterfaceNames = []; - if (memberTypes.length > 0) { - const [firstMember, ...otherMembers] = memberTypes; - // Check if member is a union (unions don't have getInterfaces) - if (typeof firstMember.getInterfaces === 'function') { - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - for (const member of otherMembers) { - if (typeof member.getInterfaces === 'function') { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); - } - } - } - } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); - } - } - - const conformances = ['Codable', ...sharedInterfaceNames]; - const conformanceClause = conformances.length ? `: ${conformances.join(', ')}` : ''; - - lines.push(`public enum ${unionType.name}${conformanceClause} {`); - caseInfos.forEach(({ typeName, caseName }) => { - lines.push(` case ${caseName}(${typeName})`); - }); - - if (sharedInterfaceNames.length) { - const interfaceFieldMap = new Map(); - for (const interfaceName of sharedInterfaceNames) { - const interfaceType = schema.getType(interfaceName); - if (!interfaceType || !isInterfaceType(interfaceType)) continue; - const fields = Object.values(interfaceType.getFields()).sort((a, b) => a.name.localeCompare(b.name)); - for (const field of fields) { - if (interfaceFieldMap.has(field.name)) continue; - const { type, optional } = swiftTypeFor(field.type); - interfaceFieldMap.set(field.name, { field, type, optional }); - } - } - - const interfaceFields = Array.from(interfaceFieldMap.values()).sort((a, b) => a.field.name.localeCompare(b.field.name)); - if (interfaceFields.length) { - lines.push(''); - } - interfaceFields.forEach(({ field, type, optional }, index) => { - addDocComment(lines, field.description, ' '); - const propertyType = type + (optional ? '?' : ''); - const propertyName = escapeSwiftName(field.name); - lines.push(` public var ${propertyName}: ${propertyType} {`); - lines.push(' switch self {'); - caseInfos.forEach(({ caseName }) => { - lines.push(` case let .${caseName}(value):`); - lines.push(` return value.${propertyName}`); - }); - lines.push(' }'); - lines.push(' }'); - if (index < interfaceFields.length - 1) { - lines.push(''); - } - }); - } - - lines.push('}', ''); -}; - -const printOperationProtocol = (operationType) => { - const protocolName = `${operationType.name}Resolver`; - addDocComment(lines, operationType.description ?? `GraphQL root ${operationType.name.toLowerCase()} operations.`); - lines.push(`public protocol ${protocolName} {`); - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) { - lines.push(' // No operations defined.'); - } - for (const field of fields) { - addDocComment(lines, field.description, ' '); - const { type, optional } = getOperationReturnType(field.type); - const returnType = type + (optional ? '?' : ''); - if (field.args.length === 0) { - lines.push(` func ${escapeSwiftName(field.name)}() async throws -> ${returnType}`); - continue; - } - if (field.args.length === 1) { - const arg = field.args[0]; - const { type: argType, optional: argOptional } = swiftTypeFor(arg.type); - const argName = escapeSwiftName(arg.name); - const finalType = argType + (argOptional ? '?' : ''); - lines.push(` func ${escapeSwiftName(field.name)}(_ ${argName}: ${finalType}) async throws -> ${returnType}`); - continue; - } - const params = field.args.map((arg) => { - const { type: argType, optional: argOptional } = swiftTypeFor(arg.type); - const argName = escapeSwiftName(arg.name); - const finalType = argType + (argOptional ? '?' : ''); - return `${argName}: ${finalType}`; - }).join(', '); - lines.push(` func ${escapeSwiftName(field.name)}(${params}) async throws -> ${returnType}`); - } - lines.push('}', ''); -}; - -const printOperationHelpers = (operationType) => { - const rootName = operationType.name; - const fields = Object.values(operationType.getFields()) - .filter((field) => field.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); - if (fields.length === 0) return; - - lines.push(`// MARK: - ${rootName} Helpers`, ''); - - fields.forEach((field) => { - const aliasName = `${rootName}${capitalize(field.name)}Handler`; - const { type, optional } = getOperationReturnType(field.type); - const returnType = type + (optional ? '?' : ''); - if (field.args.length === 0) { - lines.push(`public typealias ${aliasName} = () async throws -> ${returnType}`); - return; - } - const params = field.args.map((arg) => { - const { type: argType, optional: argOptional } = swiftTypeFor(arg.type); - const finalType = argType + (argOptional ? '?' : ''); - return `_ ${escapeSwiftName(arg.name)}: ${finalType}`; - }).join(', '); - lines.push(`public typealias ${aliasName} = (${params}) async throws -> ${returnType}`); - }); - - const structName = `${rootName}Handlers`; - lines.push('', `public struct ${structName} {`); - fields.forEach((field) => { - const aliasName = `${rootName}${capitalize(field.name)}Handler`; - lines.push(` public var ${escapeSwiftName(field.name)}: ${aliasName}?`); - }); - lines.push(''); - const initParams = fields.map((field) => { - const aliasName = `${rootName}${capitalize(field.name)}Handler`; - return `${escapeSwiftName(field.name)}: ${aliasName}? = nil`; - }).join(',\n '); - lines.push(' public init(' + (fields.length ? `\n ${initParams}\n ` : '') + ') {'); - fields.forEach((field) => { - const propertyName = escapeSwiftName(field.name); - lines.push(` self.${propertyName} = ${propertyName}`); - }); - lines.push(' }'); - lines.push('}', ''); -}; - -if (enums.length) { - lines.push('// MARK: - Enums', ''); - enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum); -} - -if (interfaces.length) { - lines.push('// MARK: - Interfaces', ''); - interfaces.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInterface); -} - -if (objects.length) { - lines.push('// MARK: - Objects', ''); - objects.sort((a, b) => a.name.localeCompare(b.name)).forEach(printObject); -} - -if (inputs.length) { - lines.push('// MARK: - Input Objects', ''); - inputs.sort((a, b) => a.name.localeCompare(b.name)).forEach(printInput); -} - -if (unions.length) { - lines.push('// MARK: - Unions', ''); - unions.sort((a, b) => a.name.localeCompare(b.name)).forEach(printUnion); -} - -if (operations.length) { - lines.push('// MARK: - Root Operations', ''); - operations.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationProtocol); -} - -if (operations.length) { - lines.push('// MARK: - Root Operation Helpers', ''); - operations.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers); -} - -// Post-process: Convert platform and type fields to fixed default values for discriminated unions -// This enables Swift's switch/pattern matching to properly narrow types based on platform and type -const productTypeMapping = { - ProductIOS: { platform: '.ios', type: '.inApp' }, - ProductAndroid: { platform: '.android', type: '.inApp' }, - ProductSubscriptionIOS: { platform: '.ios', type: '.subs' }, - ProductSubscriptionAndroid: { platform: '.android', type: '.subs' }, -}; - -for (const [typeName, literals] of Object.entries(productTypeMapping)) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Find struct definition - if (line.match(new RegExp(`public struct ${typeName}[:\\s]`))) { - let foundInit = false; - - // Look for the init method or property declarations - for (let j = i; j < lines.length && !lines[j].includes('}'); j++) { - // Handle property declarations - add default values - if (lines[j].match(/^\s+public var platform: IapPlatform$/)) { - lines[j] = lines[j].replace( - /public var platform: IapPlatform$/, - `public var platform: IapPlatform = ${literals.platform}` - ); - } - if (lines[j].match(/^\s+public var type: ProductType$/)) { - lines[j] = lines[j].replace( - /public var type: ProductType$/, - `public var type: ProductType = ${literals.type}` - ); - } - - // Handle init parameters - add default values - if (lines[j].includes('public init(')) { - foundInit = true; - } - if (foundInit) { - if (lines[j].match(/^\s+platform: IapPlatform[,)]/)) { - lines[j] = lines[j].replace( - /platform: IapPlatform([,)])/, - `platform: IapPlatform = ${literals.platform}$1` - ); - } - if (lines[j].match(/^\s+type: ProductType[,)]/)) { - lines[j] = lines[j].replace( - /type: ProductType([,)])/, - `type: ProductType = ${literals.type}$1` - ); - } - } - } - break; - } - } -} - -// ProductOrSubscription union is now auto-generated from GraphQL schema -// FetchProductsResultAll case is also auto-generated -let output = lines.join('\n'); - -const outputPath = resolve(__dirname, '../src/generated/Types.swift'); -mkdirSync(dirname(outputPath), { recursive: true }); -writeFileSync(outputPath, output); - -// eslint-disable-next-line no-console -console.log('[generate-swift-types] wrote', outputPath); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 04764fc0..910ec915 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Run `bun run generate` after updating any *.graphql schema file. // ============================================================================ // Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 0ce716da..a1ff1be1 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Run `bun run generate` after updating any *.graphql schema file. // ============================================================================ import Foundation diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 373737f3..1719a536 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Run `bun run generate` after updating any *.graphql schema file. // ============================================================================ // ignore_for_file: unused_element, unused_field diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 46e41e1e..f828af72 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -1,7 +1,7 @@ # ============================================================================ # AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY # Generated from OpenIAP GraphQL schema (https://openiap.dev) -# Run `npm run generate:gdscript` to regenerate this file. +# Run `bun run generate` to regenerate this file. # ============================================================================ # Usage: const Types = preload("types.gd") # var store: Types.IapStore = Types.IapStore.APPLE