From 6999346504f05906f2af316c5c0a8233a5bbddf6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 04:17:53 +0900 Subject: [PATCH 1/6] feat(gql): add discriminated union type support - ProductOrSubscription Add ProductOrSubscription union type to GraphQL schema and implement discriminated union type narrowing across all platforms (TypeScript, Swift, Kotlin, Dart). Changes: - Add `union ProductOrSubscription = Product | ProductSubscription` to GraphQL schema - Update type generators to handle union of unions pattern - Optimize platform-specific type extraction with nested pattern matching - Fix TypeScript FetchProductsResult to support mixed arrays - Add ProductOrSubscription interface implementation to Product and ProductSubscription TypeScript: - Extend FetchProductsResult type to include `(Product | ProductSubscription)[]` - Fix Query.fetchProducts to use FetchProductsResult type alias Swift: - Use nested pattern matching for direct type extraction - Change `.productSubscription(let sub), case .productSubscriptionIos(let val)` to `.productSubscription(.productSubscriptionIos(let val))` Kotlin: - Add ProductOrSubscription interface implementation with override modifiers - Optimize type extraction to filter platform-specific types directly - Change from wrapper types to direct sealed interface casting Dart: - Add implements clause for Product and ProductSubscription - Handle union of unions in type generation Generator scripts: - Add getInterfaces() type check for union members in all generators - Remove manual ProductOrSubscription post-processing (now auto-generated) - Add post-processing to make unions implement ProductOrSubscription Benefits: - Enables type-safe discriminated union narrowing - Eliminates unnecessary intermediate object creation - Platform-specific code only handles its own types - Better IDE autocomplete and compile-time type safety --- .../Sources/Models/OpenIapSerialization.swift | 3 +- packages/apple/Sources/Models/Types.swift | 40 ++-------- .../apple/Sources/OpenIapModule+ObjC.swift | 3 +- packages/apple/Sources/OpenIapStore.swift | 4 +- .../src/main/java/dev/hyo/openiap/Types.kt | 31 +++++--- .../dev/hyo/openiap/store/OpenIapStore.kt | 11 ++- packages/gql/scripts/fix-generated-types.mjs | 31 +++++--- packages/gql/scripts/generate-dart-types.mjs | 67 ++++++----------- .../gql/scripts/generate-kotlin-types.mjs | 66 ++++++++--------- packages/gql/scripts/generate-swift-types.mjs | 74 ++++--------------- packages/gql/src/generated/Types.kt | 31 +++++--- packages/gql/src/generated/Types.swift | 40 ++-------- packages/gql/src/generated/types.dart | 47 ++++++------ packages/gql/src/generated/types.ts | 6 +- packages/gql/src/type.graphql | 4 + 15 files changed, 185 insertions(+), 273 deletions(-) diff --git a/packages/apple/Sources/Models/OpenIapSerialization.swift b/packages/apple/Sources/Models/OpenIapSerialization.swift index 7ea6b640..534cebc1 100644 --- a/packages/apple/Sources/Models/OpenIapSerialization.swift +++ b/packages/apple/Sources/Models/OpenIapSerialization.swift @@ -213,8 +213,7 @@ public enum OpenIapSerialization { return value } let iosSubscriptions = allItems.compactMap { item -> ProductSubscriptionIOS? in - guard case .subscription(let subscription) = item, - case .productSubscriptionIos(let value) = subscription + guard case .productSubscription(.productSubscriptionIos(let value)) = item else { return nil } return value } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 153275ea..a3446a45 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -245,43 +245,10 @@ public struct ExternalPurchaseNoticeResultIOS: Codable { public var result: ExternalPurchaseNoticeAction } - -// Union type for FetchProductsResult.all -public enum ProductOrSubscription: Codable { - case product(Product) - case subscription(ProductSubscription) - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let product = try? container.decode(Product.self) { - self = .product(product) - return - } - if let subscription = try? container.decode(ProductSubscription.self) { - self = .subscription(subscription) - return - } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Cannot decode ProductOrSubscription" - ) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .product(let product): - try container.encode(product) - case .subscription(let subscription): - try container.encode(subscription) - } - } -} - public enum FetchProductsResult { + case all([ProductOrSubscription]?) case products([Product]?) case subscriptions([ProductSubscription]?) - case all([ProductOrSubscription]?) } public struct PricingPhaseAndroid: Codable { @@ -1032,6 +999,11 @@ public enum Product: Codable, ProductCommon { } } +public enum ProductOrSubscription: Codable { + case product(Product) + case productSubscription(ProductSubscription) +} + public enum ProductSubscription: Codable, ProductCommon { case productSubscriptionAndroid(ProductSubscriptionAndroid) case productSubscriptionIos(ProductSubscriptionIOS) diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index 4288aa42..2aa5a11b 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -74,8 +74,7 @@ import StoreKit return value } let subscriptionIOS = allItems.compactMap { item -> ProductSubscriptionIOS? in - guard case .subscription(let subscription) = item, - case .productSubscriptionIos(let value) = subscription + guard case .productSubscription(.productSubscriptionIos(let value)) = item else { return nil } return value } diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index f710cd5e..7a9e4952 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -193,8 +193,8 @@ public final class OpenIapStore: ObservableObject { return nil } subscriptions = allItems.compactMap { item in - if case .subscription(let subscription) = item { - return subscription + if case .productSubscription(.productSubscriptionIos(let subscription)) = item { + return .productSubscriptionIos(subscription) } return nil } 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 66d93787..c5a4158c 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 @@ -712,21 +712,14 @@ public data class ExternalPurchaseNoticeResultIOS( ) } - -// Union type for FetchProductsResult.all -public sealed interface ProductOrSubscription { - data class ProductItem(val value: Product) : ProductOrSubscription - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription -} - public sealed interface FetchProductsResult +public data class FetchProductsResultAll(val value: List?) : FetchProductsResult + public data class FetchProductsResultProducts(val value: List?) : FetchProductsResult public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult -public data class FetchProductsResultAll(val value: List?) : FetchProductsResult - public data class PricingPhaseAndroid( val billingCycleCount: Int, val billingPeriod: String, @@ -2156,8 +2149,8 @@ public data class RequestSubscriptionPropsByPlatforms( // MARK: - Unions -public sealed interface Product : ProductCommon { - fun toJson(): Map +public sealed interface Product : ProductCommon, ProductOrSubscription { + override fun toJson(): Map companion object { fun fromJson(json: Map): Product { @@ -2170,9 +2163,23 @@ public sealed interface Product : ProductCommon { } } -public sealed interface ProductSubscription : ProductCommon { +public sealed interface ProductOrSubscription { fun toJson(): Map + companion object { + fun fromJson(json: Map): ProductOrSubscription { + return when (json["__typename"] as String?) { + "Product" -> Product.fromJson(json) + "ProductSubscription" -> ProductSubscription.fromJson(json) + else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") + } + } + } +} + +public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription { + override fun toJson(): Map + companion object { fun fromJson(json: Map): ProductSubscription { return when (json["__typename"] as String?) { diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index 89311551..6c6076af 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -283,12 +283,17 @@ class OpenIapStore(private val module: OpenIapProtocol) { // The result.value is List? containing union of Product and ProductSubscription val items = result.value ?: emptyList() - // Extract products and subscriptions from ProductOrSubscription union + // Extract Android-specific products and subscriptions directly + // ProductOrSubscription is a sealed interface with Product and ProductSubscription as implementations val allProducts = items.mapNotNull { - (it as? ProductOrSubscription.ProductItem)?.value + (it as? Product)?.let { product -> + if (product is ProductAndroid) product else null + } } val allSubs = items.mapNotNull { - (it as? ProductOrSubscription.SubscriptionItem)?.value + (it as? ProductSubscription)?.let { subscription -> + if (subscription is ProductSubscriptionAndroid) subscription else null + } } // Merge products diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index 6569824f..3d079bda 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -310,6 +310,18 @@ for (const file of schemaDefinitionFiles) { } } +// Extend FetchProductsResult to support mixed arrays for 'all' type +// MUST be done BEFORE interface parsing to ensure optionalUnionInterfaces map has the correct union +// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays +// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing +const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/; +if (fetchProductsResultPattern.test(content)) { + content = content.replace( + fetchProductsResultPattern, + 'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;' + ); +} + const singleFieldInterfaceTypes = new Map(); const optionalUnionInterfaces = new Map(); const interfacePattern = /export interface (\w+) \{\n([\s\S]*?)\n\}\n/g; @@ -489,17 +501,6 @@ for (const [name, unionType] of optionalUnionInterfaces) { content = content.replace(pattern, `export type ${name} = ${unionType};\n\n`); } -// Extend FetchProductsResult to support mixed arrays for 'all' type -// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays -// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing -const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/; -if (fetchProductsResultPattern.test(content)) { - content = content.replace( - fetchProductsResultPattern, - 'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;' - ); -} - const futureFields = new Set(); for (const file of schemaFiles) { let previousWasMarker = false; @@ -548,6 +549,14 @@ for (const [name, unionType] of optionalUnionInterfaces) { } } +// Fix Query interface to use FetchProductsResult type alias instead of inline union +// This ensures the Query['fetchProducts'] return type matches our implementation +// Must be done AFTER singleFieldInterfaceTypes replacement expands the type +content = content.replace( + /fetchProducts: Promise<\(Product\[\] \| ProductSubscription\[\] \| \(Product \| ProductSubscription\)\[\] \| null\)>/g, + 'fetchProducts: Promise' +); + content = content.replace(/^\s*_placeholder\??: [^;]+;\n/gm, ''); const ROOT_DEFINITIONS = ['Query', 'Mutation', 'Subscription']; diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs index 70bfa0e3..1511a2a8 100644 --- a/packages/gql/scripts/generate-dart-types.mjs +++ b/packages/gql/scripts/generate-dart-types.mjs @@ -668,16 +668,21 @@ const printUnion = (unionType) => { let sharedInterfaceNames = []; if (memberTypes.length > 0) { const [firstMember, ...otherMembers] = memberTypes; - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - for (const member of otherMembers) { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); + // 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(); } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); } const implementsClause = sharedInterfaceNames.length ? ` implements ${sharedInterfaceNames.join(', ')}` : ''; @@ -989,44 +994,20 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { } } -// Post-process: Add ProductOrSubscription union class -// This allows FetchProductsResult.all to contain heterogeneous lists -const productOrSubscriptionUnion = ` -// Union type for FetchProductsResult.all -abstract class ProductOrSubscription { - const ProductOrSubscription(); -} - -class ProductOrSubscriptionProduct extends ProductOrSubscription { - const ProductOrSubscriptionProduct(this.value); - final Product value; -} - -class ProductOrSubscriptionSubscription extends ProductOrSubscription { - const ProductOrSubscriptionSubscription(this.value); - final ProductSubscription value; -} -`; - +// ProductOrSubscription union is now auto-generated from GraphQL schema +// FetchProductsResultAll is also auto-generated let output = lines.join('\n'); -// Insert ProductOrSubscription before FetchProductsResult -const fetchProductsResultAbstractPattern = /abstract class FetchProductsResult \{/; -if (fetchProductsResultAbstractPattern.test(output)) { - output = output.replace( - fetchProductsResultAbstractPattern, - productOrSubscriptionUnion + '\nabstract class FetchProductsResult {' - ); -} - -// Add the 'all' case to FetchProductsResult -const fetchProductsResultPattern = /(class FetchProductsResultSubscriptions extends FetchProductsResult \{\n const FetchProductsResultSubscriptions\(this\.value\);\n final List\? value;\n\})/; -if (fetchProductsResultPattern.test(output)) { - output = output.replace( - fetchProductsResultPattern, - '$1\n\nclass FetchProductsResultAll extends FetchProductsResult {\n const FetchProductsResultAll(this.value);\n final List? value;\n}' - ); -} +// Fix ProductOrSubscription union - Product and ProductSubscription must implement it +// Since they are also unions (sealed classes), we need to make them implement ProductOrSubscription +output = output.replace( + /sealed class Product implements ProductCommon \{/, + 'sealed class Product implements ProductCommon, ProductOrSubscription {' +); +output = output.replace( + /sealed class ProductSubscription implements ProductCommon \{/, + 'sealed class ProductSubscription implements ProductCommon, ProductOrSubscription {' +); // Fix enum default values - Dart uses PascalCase for enum values output = output.replace(/IapPlatform\.ios/g, 'IapPlatform.IOS'); diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs index 7cd1be2a..9acd1c6b 100644 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ b/packages/gql/scripts/generate-kotlin-types.mjs @@ -633,16 +633,21 @@ const printUnion = (unionType) => { let sharedInterfaceNames = []; if (memberTypes.length > 0) { const [firstMember, ...otherMembers] = memberTypes; - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - for (const member of otherMembers) { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); + // 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(); } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); } const implementations = sharedInterfaceNames.length ? ` : ${sharedInterfaceNames.join(', ')}` : ''; @@ -793,35 +798,30 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { } } -// Post-process: Add ProductOrSubscription sealed interface for union type -// This allows FetchProductsResult.all to contain heterogeneous lists -const productOrSubscriptionUnion = ` -// Union type for FetchProductsResult.all -public sealed interface ProductOrSubscription { - data class ProductItem(val value: Product) : ProductOrSubscription - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription -} -`; - +// ProductOrSubscription union is now auto-generated from GraphQL schema +// FetchProductsResultAll is also auto-generated let output = lines.join('\n'); -// Insert ProductOrSubscription before FetchProductsResult -const fetchProductsResultInterfacePattern = /public sealed interface FetchProductsResult/; -if (fetchProductsResultInterfacePattern.test(output)) { - output = output.replace( - fetchProductsResultInterfacePattern, - productOrSubscriptionUnion + '\npublic sealed interface FetchProductsResult' - ); -} +// Fix ProductOrSubscription union - Product and ProductSubscription must implement it +// Since they are also unions (sealed interfaces), we need to make them implement ProductOrSubscription +output = output.replace( + /public sealed interface Product : ProductCommon \{/, + 'public sealed interface Product : ProductCommon, ProductOrSubscription {' +); +output = output.replace( + /public sealed interface ProductSubscription : ProductCommon \{/, + 'public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription {' +); -// Add the 'all' case to FetchProductsResult -const fetchProductsResultPattern = /(public data class FetchProductsResultSubscriptions\(val value: List\?\) : FetchProductsResult)/; -if (fetchProductsResultPattern.test(output)) { - output = output.replace( - fetchProductsResultPattern, - '$1\n\npublic data class FetchProductsResultAll(val value: List?) : FetchProductsResult' - ); -} +// Add override modifier to toJson methods since they're defined in ProductOrSubscription +output = output.replace( + /(public sealed interface Product : ProductCommon, ProductOrSubscription \{[\s\S]*?)\n fun toJson\(\)/, + '$1\n override fun toJson()' +); +output = output.replace( + /(public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription \{[\s\S]*?)\n fun toJson\(\)/, + '$1\n override fun toJson()' +); const outputPath = resolve(__dirname, '../src/generated/Types.kt'); mkdirSync(dirname(outputPath), { recursive: true }); diff --git a/packages/gql/scripts/generate-swift-types.mjs b/packages/gql/scripts/generate-swift-types.mjs index 12698595..5a87facc 100644 --- a/packages/gql/scripts/generate-swift-types.mjs +++ b/packages/gql/scripts/generate-swift-types.mjs @@ -541,16 +541,21 @@ const printUnion = (unionType) => { let sharedInterfaceNames = []; if (memberTypes.length > 0) { const [firstMember, ...otherMembers] = memberTypes; - const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name)); - for (const member of otherMembers) { - const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name)); - for (const ifaceName of Array.from(firstInterfaces)) { - if (!memberInterfaces.has(ifaceName)) { - firstInterfaces.delete(ifaceName); + // 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(); } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); } const conformances = ['Codable', ...sharedInterfaceNames]; @@ -773,60 +778,9 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { } } -// Post-process: Add ProductOrSubscription union enum -// This allows FetchProductsResult.all to contain heterogeneous arrays -const productOrSubscriptionEnum = ` -// Union type for FetchProductsResult.all -public enum ProductOrSubscription: Codable { - case product(Product) - case subscription(ProductSubscription) - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let product = try? container.decode(Product.self) { - self = .product(product) - return - } - if let subscription = try? container.decode(ProductSubscription.self) { - self = .subscription(subscription) - return - } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Cannot decode ProductOrSubscription" - ) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .product(let product): - try container.encode(product) - case .subscription(let subscription): - try container.encode(subscription) - } - } -} -`; - -// Add ProductOrSubscription before FetchProductsResult +// ProductOrSubscription union is now auto-generated from GraphQL schema +// FetchProductsResultAll case is also auto-generated let output = lines.join('\n'); -const fetchProductsResultPattern = /public enum FetchProductsResult \{/; -if (fetchProductsResultPattern.test(output)) { - output = output.replace( - fetchProductsResultPattern, - productOrSubscriptionEnum + '\npublic enum FetchProductsResult {' - ); -} - -// Add the 'all' case to FetchProductsResult -const fetchProductsResultEnumPattern = /public enum FetchProductsResult \{([\s\S]*?)\n\}/; -if (fetchProductsResultEnumPattern.test(output)) { - output = output.replace( - fetchProductsResultEnumPattern, - 'public enum FetchProductsResult {$1\n case all([ProductOrSubscription]?)\n}' - ); -} const outputPath = resolve(__dirname, '../src/generated/Types.swift'); mkdirSync(dirname(outputPath), { recursive: true }); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index c68b4df0..1cd114fc 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -774,21 +774,14 @@ public data class ExternalPurchaseNoticeResultIOS( ) } - -// Union type for FetchProductsResult.all -public sealed interface ProductOrSubscription { - data class ProductItem(val value: Product) : ProductOrSubscription - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription -} - public sealed interface FetchProductsResult +public data class FetchProductsResultAll(val value: List?) : FetchProductsResult + public data class FetchProductsResultProducts(val value: List?) : FetchProductsResult public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult -public data class FetchProductsResultAll(val value: List?) : FetchProductsResult - public data class PricingPhaseAndroid( val billingCycleCount: Int, val billingPeriod: String, @@ -2218,8 +2211,8 @@ public data class RequestSubscriptionPropsByPlatforms( // MARK: - Unions -public sealed interface Product : ProductCommon { - fun toJson(): Map +public sealed interface Product : ProductCommon, ProductOrSubscription { + override fun toJson(): Map companion object { fun fromJson(json: Map): Product { @@ -2232,9 +2225,23 @@ public sealed interface Product : ProductCommon { } } -public sealed interface ProductSubscription : ProductCommon { +public sealed interface ProductOrSubscription { fun toJson(): Map + companion object { + fun fromJson(json: Map): ProductOrSubscription { + return when (json["__typename"] as String?) { + "Product" -> Product.fromJson(json) + "ProductSubscription" -> ProductSubscription.fromJson(json) + else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") + } + } + } +} + +public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription { + override fun toJson(): Map + companion object { fun fromJson(json: Map): ProductSubscription { return when (json["__typename"] as String?) { diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 153275ea..a3446a45 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -245,43 +245,10 @@ public struct ExternalPurchaseNoticeResultIOS: Codable { public var result: ExternalPurchaseNoticeAction } - -// Union type for FetchProductsResult.all -public enum ProductOrSubscription: Codable { - case product(Product) - case subscription(ProductSubscription) - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let product = try? container.decode(Product.self) { - self = .product(product) - return - } - if let subscription = try? container.decode(ProductSubscription.self) { - self = .subscription(subscription) - return - } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Cannot decode ProductOrSubscription" - ) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .product(let product): - try container.encode(product) - case .subscription(let subscription): - try container.encode(subscription) - } - } -} - public enum FetchProductsResult { + case all([ProductOrSubscription]?) case products([Product]?) case subscriptions([ProductSubscription]?) - case all([ProductOrSubscription]?) } public struct PricingPhaseAndroid: Codable { @@ -1032,6 +999,11 @@ public enum Product: Codable, ProductCommon { } } +public enum ProductOrSubscription: Codable { + case product(Product) + case productSubscription(ProductSubscription) +} + public enum ProductSubscription: Codable, ProductCommon { case productSubscriptionAndroid(ProductSubscriptionAndroid) case productSubscriptionIos(ProductSubscriptionIOS) diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 3531075c..32b52da7 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -914,26 +914,15 @@ class ExternalPurchaseNoticeResultIOS { } } - -// Union type for FetchProductsResult.all -abstract class ProductOrSubscription { - const ProductOrSubscription(); -} - -class ProductOrSubscriptionProduct extends ProductOrSubscription { - const ProductOrSubscriptionProduct(this.value); - final Product value; -} - -class ProductOrSubscriptionSubscription extends ProductOrSubscription { - const ProductOrSubscriptionSubscription(this.value); - final ProductSubscription value; -} - abstract class FetchProductsResult { const FetchProductsResult(); } +class FetchProductsResultAll extends FetchProductsResult { + const FetchProductsResultAll(this.value); + final List? value; +} + class FetchProductsResultProducts extends FetchProductsResult { const FetchProductsResultProducts(this.value); final List? value; @@ -944,11 +933,6 @@ class FetchProductsResultSubscriptions extends FetchProductsResult { final List? value; } -class FetchProductsResultAll extends FetchProductsResult { - const FetchProductsResultAll(this.value); - final List? value; -} - class PricingPhaseAndroid { const PricingPhaseAndroid({ required this.billingCycleCount, @@ -2681,7 +2665,7 @@ class RequestSubscriptionPropsByPlatforms { // MARK: - Unions -sealed class Product implements ProductCommon { +sealed class Product implements ProductCommon, ProductOrSubscription { const Product(); factory Product.fromJson(Map json) { @@ -2719,7 +2703,24 @@ sealed class Product implements ProductCommon { Map toJson(); } -sealed class ProductSubscription implements ProductCommon { +sealed class ProductOrSubscription { + const ProductOrSubscription(); + + factory ProductOrSubscription.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'Product': + return Product.fromJson(json); + case 'ProductSubscription': + return ProductSubscription.fromJson(json); + } + throw ArgumentError('Unknown __typename for ProductOrSubscription: $typeName'); + } + + Map toJson(); +} + +sealed class ProductSubscription implements ProductCommon, ProductOrSubscription { const ProductSubscription(); factory ProductSubscription.fromJson(Map json) { diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index a3c624ab..5d0698e9 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -166,7 +166,7 @@ export interface ExternalPurchaseNoticeResultIOS { result: ExternalPurchaseNoticeAction; } -export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null; +export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android'; @@ -354,6 +354,8 @@ export interface ProductIOS extends ProductCommon { typeIOS: ProductTypeIOS; } +export type ProductOrSubscription = Product | ProductSubscription; + export type ProductQueryType = 'in-app' | 'subs' | 'all'; export interface ProductRequest { @@ -526,7 +528,7 @@ export interface Query { /** Get current StoreKit 2 entitlements (iOS 15+) */ currentEntitlementIOS?: Promise<(PurchaseIOS | null)>; /** Retrieve products or subscriptions from the store */ - fetchProducts: Promise<(Product[] | ProductSubscription[] | null)>; + fetchProducts: Promise<(ProductOrSubscription[] | Product[] | ProductSubscription[] | null)>; /** Get active subscriptions (filters by subscriptionIds when provided) */ getActiveSubscriptions: Promise; /** Fetch the current app transaction (iOS 16+) */ diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index d2883e59..6bf83c60 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -77,11 +77,15 @@ type VoidResult { success: Boolean! } +# Product or Subscription union for 'all' type +union ProductOrSubscription = Product | ProductSubscription + # => Union # Product fetch responses can return products, subscriptions, or both type FetchProductsResult { products: [Product!] subscriptions: [ProductSubscription!] + all: [ProductOrSubscription!] } # => Union From b5a1b2a573029da2a64fbdab806dc41215fe2767 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 04:19:41 +0900 Subject: [PATCH 2/6] chore: gql to 1.2.4 --- openiap-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openiap-versions.json b/openiap-versions.json index 9ee47441..6913a182 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { - "gql": "1.2.5", + "gql": "1.2.4", "docs": "1.2.5", "google": "1.3.5", "apple": "1.2.32" From 9338fdefdccb3d00b0e01e3e4fe652bc1b5d54f5 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 04:41:29 +0900 Subject: [PATCH 3/6] fix: code review --- .../src/main/java/dev/hyo/openiap/Types.kt | 6 ++-- packages/gql/scripts/generate-dart-types.mjs | 34 +++++++++++++++++-- .../gql/scripts/generate-kotlin-types.mjs | 34 +++++++++++++++++-- packages/gql/src/generated/Types.kt | 6 ++-- packages/gql/src/generated/types.dart | 8 +++-- 5 files changed, 78 insertions(+), 10 deletions(-) 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 c5a4158c..77d5a987 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 @@ -2169,8 +2169,10 @@ public sealed interface ProductOrSubscription { companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "Product" -> Product.fromJson(json) - "ProductSubscription" -> ProductSubscription.fromJson(json) + "ProductAndroid" -> Product.fromJson(json) + "ProductIOS" -> Product.fromJson(json) + "ProductSubscriptionAndroid" -> ProductSubscription.fromJson(json) + "ProductSubscriptionIOS" -> ProductSubscription.fromJson(json) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs index 1511a2a8..c497437f 100644 --- a/packages/gql/scripts/generate-dart-types.mjs +++ b/packages/gql/scripts/generate-dart-types.mjs @@ -692,9 +692,39 @@ const printUnion = (unionType) => { lines.push(` factory ${unionType.name}.fromJson(Map json) {`); lines.push(` final typeName = json['__typename'] as String?;`); lines.push(' switch (typeName) {'); - members.forEach((member) => { - lines.push(` case '${member}':`, ` return ${member}.fromJson(json);`); + + // 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); + } + } + + // Generate case for each concrete member, delegating to parent union if needed + const sortedConcreteMembers = Array.from(concreteMembers).sort(); + sortedConcreteMembers.forEach((concreteMember) => { + // Find which direct member this concrete type belongs to + let delegateTo = concreteMember; + for (const memberType of memberTypes) { + if (isUnionType(memberType)) { + const nestedMembers = memberType.getTypes().map(t => t.name); + if (nestedMembers.includes(concreteMember)) { + delegateTo = memberType.name; + break; + } + } + } + lines.push(` case '${concreteMember}':`, ` return ${delegateTo}.fromJson(json);`); }); + lines.push(' }'); lines.push(` throw ArgumentError('Unknown __typename for ${unionType.name}: $typeName');`); lines.push(' }', ''); diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs index 9acd1c6b..6b8bde68 100644 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ b/packages/gql/scripts/generate-kotlin-types.mjs @@ -656,9 +656,39 @@ const printUnion = (unionType) => { lines.push(' companion object {'); lines.push(` fun fromJson(json: Map): ${unionType.name} {`); lines.push(' return when (json["__typename"] as String?) {'); - members.forEach((member) => { - lines.push(` "${member}" -> ${member}.fromJson(json)`); + + // 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); + } + } + + // Generate case for each concrete member, delegating to parent union if needed + const sortedConcreteMembers = Array.from(concreteMembers).sort(); + sortedConcreteMembers.forEach((concreteMember) => { + // Find which direct member this concrete type belongs to + let delegateTo = concreteMember; + for (const memberType of memberTypes) { + if (isUnionType(memberType)) { + const nestedMembers = memberType.getTypes().map(t => t.name); + if (nestedMembers.includes(concreteMember)) { + delegateTo = memberType.name; + break; + } + } + } + lines.push(` "${concreteMember}" -> ${delegateTo}.fromJson(json)`); }); + lines.push(` else -> throw IllegalArgumentException("Unknown __typename for ${unionType.name}: ${'$'}{json["__typename"]}")`); lines.push(' }'); lines.push(' }'); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 1cd114fc..483ee96d 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2231,8 +2231,10 @@ public sealed interface ProductOrSubscription { companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "Product" -> Product.fromJson(json) - "ProductSubscription" -> ProductSubscription.fromJson(json) + "ProductAndroid" -> Product.fromJson(json) + "ProductIOS" -> Product.fromJson(json) + "ProductSubscriptionAndroid" -> ProductSubscription.fromJson(json) + "ProductSubscriptionIOS" -> ProductSubscription.fromJson(json) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 32b52da7..adbb8840 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2709,9 +2709,13 @@ sealed class ProductOrSubscription { factory ProductOrSubscription.fromJson(Map json) { final typeName = json['__typename'] as String?; switch (typeName) { - case 'Product': + case 'ProductAndroid': + return Product.fromJson(json); + case 'ProductIOS': return Product.fromJson(json); - case 'ProductSubscription': + case 'ProductSubscriptionAndroid': + return ProductSubscription.fromJson(json); + case 'ProductSubscriptionIOS': return ProductSubscription.fromJson(json); } throw ArgumentError('Unknown __typename for ProductOrSubscription: $typeName'); From 46d94c0980d5ce1b54928d700721932b349d96ea Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 04:55:59 +0900 Subject: [PATCH 4/6] fix: code review --- .../src/main/java/dev/hyo/openiap/Types.kt | 22 +++++--- packages/gql/scripts/generate-dart-types.mjs | 54 ++++++++++++++----- .../gql/scripts/generate-kotlin-types.mjs | 46 +++++++++------- packages/gql/src/generated/Types.kt | 22 +++++--- packages/gql/src/generated/types.dart | 26 ++++++--- 5 files changed, 117 insertions(+), 53 deletions(-) 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 77d5a987..7141a0bf 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 @@ -2149,8 +2149,8 @@ public data class RequestSubscriptionPropsByPlatforms( // MARK: - Unions -public sealed interface Product : ProductCommon, ProductOrSubscription { - override fun toJson(): Map +public sealed interface Product : ProductCommon { + fun toJson(): Map companion object { fun fromJson(json: Map): Product { @@ -2166,21 +2166,27 @@ public sealed interface Product : ProductCommon, ProductOrSubscription { public sealed interface ProductOrSubscription { fun toJson(): Map + data class ProductItem(val value: Product) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + + data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "ProductAndroid" -> Product.fromJson(json) - "ProductIOS" -> Product.fromJson(json) - "ProductSubscriptionAndroid" -> ProductSubscription.fromJson(json) - "ProductSubscriptionIOS" -> ProductSubscription.fromJson(json) + "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) + "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } } } -public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription { - override fun toJson(): Map +public sealed interface ProductSubscription : ProductCommon { + fun toJson(): Map companion object { fun fromJson(json: Map): ProductSubscription { diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs index c497437f..9531527e 100644 --- a/packages/gql/scripts/generate-dart-types.mjs +++ b/packages/gql/scripts/generate-dart-types.mjs @@ -1024,20 +1024,50 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { } } -// ProductOrSubscription union is now auto-generated from GraphQL schema -// FetchProductsResultAll is also auto-generated +// ProductOrSubscription and FetchProductsResult are auto-generated from GraphQL schema let output = lines.join('\n'); -// Fix ProductOrSubscription union - Product and ProductSubscription must implement it -// Since they are also unions (sealed classes), we need to make them implement ProductOrSubscription -output = output.replace( - /sealed class Product implements ProductCommon \{/, - 'sealed class Product implements ProductCommon, ProductOrSubscription {' -); -output = output.replace( - /sealed class ProductSubscription implements ProductCommon \{/, - 'sealed class ProductSubscription implements ProductCommon, ProductOrSubscription {' -); +// Fix ProductOrSubscription - it should be an abstract wrapper, not a direct union +// Replace the auto-generated sealed class with proper wrapper pattern +const productOrSubscriptionPattern = /sealed class ProductOrSubscription \{[\s\S]*?factory ProductOrSubscription\.fromJson\(Map json\) \{[\s\S]*?\n \}\n\n Map toJson\(\);\n\}/; +if (productOrSubscriptionPattern.test(output)) { + const replacement = `sealed class ProductOrSubscription { + const ProductOrSubscription(); + + factory ProductOrSubscription.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'ProductAndroid': + case 'ProductIOS': + return ProductOrSubscriptionProduct(Product.fromJson(json)); + case 'ProductSubscriptionAndroid': + case 'ProductSubscriptionIOS': + return ProductOrSubscriptionSubscription(ProductSubscription.fromJson(json)); + } + throw ArgumentError('Unknown __typename for ProductOrSubscription: \$typeName'); + } + + Map toJson(); +} + +class ProductOrSubscriptionProduct extends ProductOrSubscription { + const ProductOrSubscriptionProduct(this.value); + final Product value; + + @override + Map toJson() => value.toJson(); +} + +class ProductOrSubscriptionSubscription extends ProductOrSubscription { + const ProductOrSubscriptionSubscription(this.value); + final ProductSubscription value; + + @override + Map toJson() => value.toJson(); +}`; + + output = output.replace(productOrSubscriptionPattern, replacement); +} // Fix enum default values - Dart uses PascalCase for enum values output = output.replace(/IapPlatform\.ios/g, 'IapPlatform.IOS'); diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs index 6b8bde68..9e77e0d5 100644 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ b/packages/gql/scripts/generate-kotlin-types.mjs @@ -832,26 +832,34 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { // FetchProductsResultAll is also auto-generated let output = lines.join('\n'); -// Fix ProductOrSubscription union - Product and ProductSubscription must implement it -// Since they are also unions (sealed interfaces), we need to make them implement ProductOrSubscription -output = output.replace( - /public sealed interface Product : ProductCommon \{/, - 'public sealed interface Product : ProductCommon, ProductOrSubscription {' -); -output = output.replace( - /public sealed interface ProductSubscription : ProductCommon \{/, - 'public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription {' -); +// Fix ProductOrSubscription - it should be a wrapper sealed interface +// Replace the auto-generated sealed interface with proper wrapper pattern +const productOrSubscriptionPattern = /public sealed interface ProductOrSubscription \{[\s\S]*?companion object \{[\s\S]*?fun fromJson\(json: Map\): ProductOrSubscription \{[\s\S]*?\n \}\n \}\n\}/; +if (productOrSubscriptionPattern.test(output)) { + const replacement = `public sealed interface ProductOrSubscription { + fun toJson(): Map + + data class ProductItem(val value: Product) : ProductOrSubscription { + override fun toJson() = value.toJson() + } -// Add override modifier to toJson methods since they're defined in ProductOrSubscription -output = output.replace( - /(public sealed interface Product : ProductCommon, ProductOrSubscription \{[\s\S]*?)\n fun toJson\(\)/, - '$1\n override fun toJson()' -); -output = output.replace( - /(public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription \{[\s\S]*?)\n fun toJson\(\)/, - '$1\n override fun toJson()' -); + data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + + companion object { + fun fromJson(json: Map): ProductOrSubscription { + return when (json["__typename"] as String?) { + "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) + "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) + else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: \${json["__typename"]}") + } + } + } +}`; + + output = output.replace(productOrSubscriptionPattern, replacement); +} const outputPath = resolve(__dirname, '../src/generated/Types.kt'); mkdirSync(dirname(outputPath), { recursive: true }); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 483ee96d..ef98554c 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2211,8 +2211,8 @@ public data class RequestSubscriptionPropsByPlatforms( // MARK: - Unions -public sealed interface Product : ProductCommon, ProductOrSubscription { - override fun toJson(): Map +public sealed interface Product : ProductCommon { + fun toJson(): Map companion object { fun fromJson(json: Map): Product { @@ -2228,21 +2228,27 @@ public sealed interface Product : ProductCommon, ProductOrSubscription { public sealed interface ProductOrSubscription { fun toJson(): Map + data class ProductItem(val value: Product) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + + data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "ProductAndroid" -> Product.fromJson(json) - "ProductIOS" -> Product.fromJson(json) - "ProductSubscriptionAndroid" -> ProductSubscription.fromJson(json) - "ProductSubscriptionIOS" -> ProductSubscription.fromJson(json) + "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) + "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } } } -public sealed interface ProductSubscription : ProductCommon, ProductOrSubscription { - override fun toJson(): Map +public sealed interface ProductSubscription : ProductCommon { + fun toJson(): Map companion object { fun fromJson(json: Map): ProductSubscription { diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index adbb8840..d5e6a156 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2665,7 +2665,7 @@ class RequestSubscriptionPropsByPlatforms { // MARK: - Unions -sealed class Product implements ProductCommon, ProductOrSubscription { +sealed class Product implements ProductCommon { const Product(); factory Product.fromJson(Map json) { @@ -2710,13 +2710,11 @@ sealed class ProductOrSubscription { final typeName = json['__typename'] as String?; switch (typeName) { case 'ProductAndroid': - return Product.fromJson(json); case 'ProductIOS': - return Product.fromJson(json); + return ProductOrSubscriptionProduct(Product.fromJson(json)); case 'ProductSubscriptionAndroid': - return ProductSubscription.fromJson(json); case 'ProductSubscriptionIOS': - return ProductSubscription.fromJson(json); + return ProductOrSubscriptionSubscription(ProductSubscription.fromJson(json)); } throw ArgumentError('Unknown __typename for ProductOrSubscription: $typeName'); } @@ -2724,7 +2722,23 @@ sealed class ProductOrSubscription { Map toJson(); } -sealed class ProductSubscription implements ProductCommon, ProductOrSubscription { +class ProductOrSubscriptionProduct extends ProductOrSubscription { + const ProductOrSubscriptionProduct(this.value); + final Product value; + + @override + Map toJson() => value.toJson(); +} + +class ProductOrSubscriptionSubscription extends ProductOrSubscription { + const ProductOrSubscriptionSubscription(this.value); + final ProductSubscription value; + + @override + Map toJson() => value.toJson(); +} + +sealed class ProductSubscription implements ProductCommon { const ProductSubscription(); factory ProductSubscription.fromJson(Map json) { From 1d1a7d48882300fc0e3e3dfbc67befa0aad31ff6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 05:21:11 +0900 Subject: [PATCH 5/6] fix: code review --- .../src/main/java/dev/hyo/openiap/Types.kt | 22 +++--- .../dev/hyo/openiap/store/OpenIapStore.kt | 9 +-- packages/gql/scripts/generate-dart-types.mjs | 75 ++++++++----------- .../gql/scripts/generate-kotlin-types.mjs | 58 +++++++------- packages/gql/src/generated/Types.kt | 22 +++--- packages/gql/src/generated/types.dart | 8 +- 6 files changed, 90 insertions(+), 104 deletions(-) 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 7141a0bf..109c28a1 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 @@ -2166,23 +2166,25 @@ public sealed interface Product : ProductCommon { public sealed interface ProductOrSubscription { fun toJson(): Map - data class ProductItem(val value: Product) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) - "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) + "ProductAndroid" -> ProductItem(Product.fromJson(json)) + "ProductIOS" -> ProductItem(Product.fromJson(json)) + "ProductSubscriptionAndroid" -> ProductSubscriptionItem(ProductSubscription.fromJson(json)) + "ProductSubscriptionIOS" -> ProductSubscriptionItem(ProductSubscription.fromJson(json)) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } } + + data class ProductItem(val value: Product) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + + data class ProductSubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { + override fun toJson() = value.toJson() + } } public sealed interface ProductSubscription : ProductCommon { diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index 6c6076af..7632fe10 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -280,18 +280,17 @@ class OpenIapStore(private val module: OpenIapProtocol) { } is FetchProductsResultAll -> { // Handle the all case - merge both products and subscriptions - // The result.value is List? containing union of Product and ProductSubscription + // The result.value is List? containing union wrappers val items = result.value ?: emptyList() - // Extract Android-specific products and subscriptions directly - // ProductOrSubscription is a sealed interface with Product and ProductSubscription as implementations + // Extract Android-specific products and subscriptions from wrapper classes val allProducts = items.mapNotNull { - (it as? Product)?.let { product -> + (it as? ProductOrSubscription.ProductItem)?.value?.let { product -> if (product is ProductAndroid) product else null } } val allSubs = items.mapNotNull { - (it as? ProductSubscription)?.let { subscription -> + (it as? ProductOrSubscription.ProductSubscriptionItem)?.value?.let { subscription -> if (subscription is ProductSubscriptionAndroid) subscription else null } } diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs index 9531527e..b3a779f1 100644 --- a/packages/gql/scripts/generate-dart-types.mjs +++ b/packages/gql/scripts/generate-dart-types.mjs @@ -708,21 +708,36 @@ const printUnion = (unionType) => { } } - // Generate case for each concrete member, delegating to parent union if needed + // 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; } } } - lines.push(` case '${concreteMember}':`, ` return ${delegateTo}.fromJson(json);`); + + 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(' }'); @@ -757,6 +772,18 @@ const printUnion = (unionType) => { 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) => { @@ -1024,51 +1051,9 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { } } -// ProductOrSubscription and FetchProductsResult are auto-generated from GraphQL schema +// All unions including nested ones are auto-generated with proper wrapper classes let output = lines.join('\n'); -// Fix ProductOrSubscription - it should be an abstract wrapper, not a direct union -// Replace the auto-generated sealed class with proper wrapper pattern -const productOrSubscriptionPattern = /sealed class ProductOrSubscription \{[\s\S]*?factory ProductOrSubscription\.fromJson\(Map json\) \{[\s\S]*?\n \}\n\n Map toJson\(\);\n\}/; -if (productOrSubscriptionPattern.test(output)) { - const replacement = `sealed class ProductOrSubscription { - const ProductOrSubscription(); - - factory ProductOrSubscription.fromJson(Map json) { - final typeName = json['__typename'] as String?; - switch (typeName) { - case 'ProductAndroid': - case 'ProductIOS': - return ProductOrSubscriptionProduct(Product.fromJson(json)); - case 'ProductSubscriptionAndroid': - case 'ProductSubscriptionIOS': - return ProductOrSubscriptionSubscription(ProductSubscription.fromJson(json)); - } - throw ArgumentError('Unknown __typename for ProductOrSubscription: \$typeName'); - } - - Map toJson(); -} - -class ProductOrSubscriptionProduct extends ProductOrSubscription { - const ProductOrSubscriptionProduct(this.value); - final Product value; - - @override - Map toJson() => value.toJson(); -} - -class ProductOrSubscriptionSubscription extends ProductOrSubscription { - const ProductOrSubscriptionSubscription(this.value); - final ProductSubscription value; - - @override - Map toJson() => value.toJson(); -}`; - - output = output.replace(productOrSubscriptionPattern, replacement); -} - // 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'); diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs index 9e77e0d5..b51a83ad 100644 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ b/packages/gql/scripts/generate-kotlin-types.mjs @@ -672,27 +672,52 @@ const printUnion = (unionType) => { } } - // Generate case for each concrete member, delegating to parent union if needed + // 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; } } } - lines.push(` "${concreteMember}" -> ${delegateTo}.fromJson(json)`); + + 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('}', ''); }; @@ -832,35 +857,6 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { // FetchProductsResultAll is also auto-generated let output = lines.join('\n'); -// Fix ProductOrSubscription - it should be a wrapper sealed interface -// Replace the auto-generated sealed interface with proper wrapper pattern -const productOrSubscriptionPattern = /public sealed interface ProductOrSubscription \{[\s\S]*?companion object \{[\s\S]*?fun fromJson\(json: Map\): ProductOrSubscription \{[\s\S]*?\n \}\n \}\n\}/; -if (productOrSubscriptionPattern.test(output)) { - const replacement = `public sealed interface ProductOrSubscription { - fun toJson(): Map - - data class ProductItem(val value: Product) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - - companion object { - fun fromJson(json: Map): ProductOrSubscription { - return when (json["__typename"] as String?) { - "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) - "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) - else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: \${json["__typename"]}") - } - } - } -}`; - - output = output.replace(productOrSubscriptionPattern, replacement); -} - const outputPath = resolve(__dirname, '../src/generated/Types.kt'); mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, output); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index ef98554c..d11cf9f6 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2228,23 +2228,25 @@ public sealed interface Product : ProductCommon { public sealed interface ProductOrSubscription { fun toJson(): Map - data class ProductItem(val value: Product) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - - data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { - override fun toJson() = value.toJson() - } - companion object { fun fromJson(json: Map): ProductOrSubscription { return when (json["__typename"] as String?) { - "ProductAndroid", "ProductIOS" -> ProductItem(Product.fromJson(json)) - "ProductSubscriptionAndroid", "ProductSubscriptionIOS" -> SubscriptionItem(ProductSubscription.fromJson(json)) + "ProductAndroid" -> ProductItem(Product.fromJson(json)) + "ProductIOS" -> ProductItem(Product.fromJson(json)) + "ProductSubscriptionAndroid" -> ProductSubscriptionItem(ProductSubscription.fromJson(json)) + "ProductSubscriptionIOS" -> ProductSubscriptionItem(ProductSubscription.fromJson(json)) else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}") } } } + + data class ProductItem(val value: Product) : ProductOrSubscription { + override fun toJson() = value.toJson() + } + + data class ProductSubscriptionItem(val value: ProductSubscription) : ProductOrSubscription { + override fun toJson() = value.toJson() + } } public sealed interface ProductSubscription : ProductCommon { diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index d5e6a156..bca38f9b 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2710,11 +2710,13 @@ sealed class ProductOrSubscription { final typeName = json['__typename'] as String?; switch (typeName) { case 'ProductAndroid': + return ProductOrSubscriptionProduct(Product.fromJson(json)); case 'ProductIOS': return ProductOrSubscriptionProduct(Product.fromJson(json)); case 'ProductSubscriptionAndroid': + return ProductOrSubscriptionProductSubscription(ProductSubscription.fromJson(json)); case 'ProductSubscriptionIOS': - return ProductOrSubscriptionSubscription(ProductSubscription.fromJson(json)); + return ProductOrSubscriptionProductSubscription(ProductSubscription.fromJson(json)); } throw ArgumentError('Unknown __typename for ProductOrSubscription: $typeName'); } @@ -2730,8 +2732,8 @@ class ProductOrSubscriptionProduct extends ProductOrSubscription { Map toJson() => value.toJson(); } -class ProductOrSubscriptionSubscription extends ProductOrSubscription { - const ProductOrSubscriptionSubscription(this.value); +class ProductOrSubscriptionProductSubscription extends ProductOrSubscription { + const ProductOrSubscriptionProductSubscription(this.value); final ProductSubscription value; @override From be89da64732e481f46ea411a0702d0ba2c988fcc Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 5 Nov 2025 05:29:19 +0900 Subject: [PATCH 6/6] fix: code review --- packages/gql/scripts/generate-dart-types.mjs | 9 ++++++++- packages/gql/scripts/generate-kotlin-types.mjs | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/gql/scripts/generate-dart-types.mjs b/packages/gql/scripts/generate-dart-types.mjs index b3a779f1..dc7a1015 100644 --- a/packages/gql/scripts/generate-dart-types.mjs +++ b/packages/gql/scripts/generate-dart-types.mjs @@ -671,6 +671,7 @@ const printUnion = (unionType) => { // 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)); @@ -679,9 +680,15 @@ const printUnion = (unionType) => { firstInterfaces.delete(ifaceName); } } + } else { + // Member is a union, so no shared interfaces + allMembersHaveInterfaces = false; + break; } } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); + if (allMembersHaveInterfaces) { + sharedInterfaceNames = Array.from(firstInterfaces).sort(); + } } } diff --git a/packages/gql/scripts/generate-kotlin-types.mjs b/packages/gql/scripts/generate-kotlin-types.mjs index b51a83ad..3b5df2d3 100644 --- a/packages/gql/scripts/generate-kotlin-types.mjs +++ b/packages/gql/scripts/generate-kotlin-types.mjs @@ -636,6 +636,7 @@ const printUnion = (unionType) => { // 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)); @@ -644,9 +645,15 @@ const printUnion = (unionType) => { firstInterfaces.delete(ifaceName); } } + } else { + // Member is a union, so no shared interfaces + allMembersHaveInterfaces = false; + break; } } - sharedInterfaceNames = Array.from(firstInterfaces).sort(); + if (allMembersHaveInterfaces) { + sharedInterfaceNames = Array.from(firstInterfaces).sort(); + } } }