diff --git a/.changeset/inferred-transferable-type-args.md b/.changeset/inferred-transferable-type-args.md new file mode 100644 index 000000000..50e009f5b --- /dev/null +++ b/.changeset/inferred-transferable-type-args.md @@ -0,0 +1,16 @@ +--- +"@aws-blocks/core": minor +"@aws-blocks/blocks": minor +--- + +feat(core): emit an OIDC client's inferred provider names + +`blocks-generate-spec` now writes `x-blocks-type-args` for an `oidc/client` result +from the provider names the type checker infers, including for a namespace built +in another module. `AuthOIDC.createApi()`'s `getClient()`, typed +`OIDCClient>`, now carries the configured provider names as a +string enum, for example `[{ "type": "string", "enum": ["google", "github"] }]`, +so native clients can generate a provider type. Provider names that resolve to +plain `string` or `any` emit no type args. Other transferables are unchanged. + +`@aws-blocks/blocks` gets the same bump because it re-exports `@aws-blocks/core/scripts`. diff --git a/docs/native-clients/codegen-design.md b/docs/native-clients/codegen-design.md index 03c7659de..0f8533540 100644 --- a/docs/native-clients/codegen-design.md +++ b/docs/native-clients/codegen-design.md @@ -152,7 +152,7 @@ The OpenRPC document is emitted as a single self-contained file (`blocks.spec.js ### Blocks-specific extensions -Two `x-blocks-*` extension fields cover the gaps where standard OpenRPC doesn't capture Blocks semantics: `x-blocks-transferable` and `x-blocks-type-args` (on result schemas for Realtime / FileBucket handles). +Two `x-blocks-*` extension fields cover the gaps where standard OpenRPC doesn't capture Blocks semantics: `x-blocks-transferable` and `x-blocks-type-args` (on result schemas for Realtime / FileBucket / OIDC handles). The full reference, including the tag table for native codegen authors, lives in [`schema-generation-guide-for-devs.md` § Spec extensions reference](./schema-generation-guide-for-devs.md#spec-extensions-reference). diff --git a/docs/native-clients/schema-generation-guide-for-devs.md b/docs/native-clients/schema-generation-guide-for-devs.md index 167dff06b..177f921c2 100644 --- a/docs/native-clients/schema-generation-guide-for-devs.md +++ b/docs/native-clients/schema-generation-guide-for-devs.md @@ -399,7 +399,7 @@ Tags currently emitted: | `realtime/channel` | `RealtimeChannel` (where `T` comes from `x-blocks-type-args[0]`) | `bb-realtime` | | `file-bucket/download` | `FileDownloadHandle` | `bb-file-bucket` | | `file-bucket/upload` | `FileUploadHandle` | `bb-file-bucket` | -| `oidc/client` | `OidcClient` (`OIDCClient` on Swift) | `bb-auth-oidc` | +| `oidc/client` | `OidcClient` (`OIDCClient

` on Swift, where `P` is the provider enum from `x-blocks-type-args[0]`, or `String`); `x-blocks-type-args[0]`, when the emitter infers it, is a string enum of provider names | `bb-auth-oidc` | **Codegen contract.** When `x-blocks-transferable` is present on a result schema, ignore any other properties on the schema and emit the SDK type for that tag. The platform SDK is responsible for hydrating the descriptor returned over the wire into a live handle. @@ -418,7 +418,20 @@ Carries the JSON Schema for each generic type argument of the SDK type, in decla } ``` -Each entry is a valid JSON Schema and may use `$ref` against `components.schemas`. For non-generic transferables (`file-bucket/download`, `file-bucket/upload`, `oidc/client` today), this field is omitted. +Each entry is a valid JSON Schema and may use `$ref` against `components.schemas`. The emitter writes the field when a method's return type spells out the arguments, on a namespace the entry file exports directly (`export const api = new ApiNamespace(...)`). It's omitted for non-generic transferables (`file-bucket/download`, `file-bucket/upload`), for a `RealtimeChannel` whose message type isn't written in the return type, and for every method of a namespace built elsewhere, such as a block's `createApi()` or a factory function. + +The one exception is an `oidc/client`, which carries the provider names the type checker infers, as a string enum, on either path. `AuthOIDC.createApi()` types `getClient()` as `OIDCClient>`, which the checker resolves from the configured providers. Provider names that resolve to plain `string` (or `any`) emit no type args: + +```json +{ + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [ + { "type": "string", "enum": ["google", "github"] } + ] +} +``` + +Swift turns it into a `Provider` enum in the method's namespace (`OidcAuthApi.GetClient.Provider`, or `ResultProvider` with a `swift:` warning when the method's own types already use that name) and returns `OIDCClient`, whose `signIn(provider:relayTo:launcher:)` takes only those providers. Without the field, the client is `OIDCClient`. Kotlin and Dart ignore it for now. ### `x-blocks-native-packages` (document root) diff --git a/native/codegen-fixtures/37-oidc-provider-enum/dart/client.dart b/native/codegen-fixtures/37-oidc-provider-enum/dart/client.dart new file mode 100644 index 000000000..8ee7fd323 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/dart/client.dart @@ -0,0 +1,49 @@ +// GENERATED CODE — DO NOT MODIFY BY HAND +// Generator: blocks-codegen +// Source: test v1.0.0 +// ignore_for_file: constant_identifier_names + +import 'package:blocks_runtime/blocks_runtime.dart'; +export 'package:blocks_runtime/blocks_runtime.dart' show BlocksClient, BlocksRpcException, SessionStore, InMemorySessionStore; +export 'package:blocks_runtime/blocks_runtime.dart' show RealtimeChannel, FileDownloadHandle, FileUploadHandle; +export 'package:blocks_runtime/blocks_runtime.dart' show OidcClient, OidcAuthState, OidcSignedIn, OidcSignedOut, OidcLoading, OidcUser, TokenStore, InMemoryTokenStore, AuthProvider, BrowserLauncher, ProviderConfig; + +// --- Models --- + +// --- API Namespaces --- + +class AuthApi { + final BlocksClient _client; + AuthApi(this._client); + + Future getClient() async { + final result = await _client.call('auth.getClient', {}); + return OidcClient.fromJson(result as Map, baseUrl: _client.baseUrl, tokenStore: _client.tokenStore, sessionStore: _client.sessionStore); + } +} + + +class LegacyAuthApi { + final BlocksClient _client; + LegacyAuthApi(this._client); + + Future getClient() async { + final result = await _client.call('legacyAuth.getClient', {}); + return OidcClient.fromJson(result as Map, baseUrl: _client.baseUrl, tokenStore: _client.tokenStore, sessionStore: _client.sessionStore); + } +} + + +// --- Blocks Client --- + +class Blocks { + late final AuthApi auth; + late final LegacyAuthApi legacyAuth; + + Blocks({required String baseUrl, SessionStore? sessionStore}) { + final client = BlocksClient(baseUrl: baseUrl, sessionStore: sessionStore); + auth = AuthApi(client); + legacyAuth = LegacyAuthApi(client); + } +} + diff --git a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt new file mode 100644 index 000000000..d79f53ece --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt @@ -0,0 +1,20 @@ +package com.example.app + +import com.aws.blocks.kotlin.Blocks +import com.aws.blocks.kotlin.BlocksClient +import kotlin.Deprecated +import kotlin.DeprecationLevel +import kotlin.Nothing + +public class Auth( + private val client: BlocksClient, +) { + @Deprecated( + message = "OIDC is not configured. Add oidc { relayTo = \"...\" } to your awsBlocks block to enable this method.", + level = DeprecationLevel.ERROR, + ) + public suspend fun getClient(): Nothing = throw NotImplementedError("OIDC is not configured. Add oidc { relayTo = \"...\" } to your awsBlocks block to enable this method.") +} + +public val Blocks.auth: Auth + get() = Auth(client()) diff --git a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt new file mode 100644 index 000000000..d879dfe9a --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt @@ -0,0 +1,20 @@ +package com.example.app + +import com.aws.blocks.kotlin.Blocks +import com.aws.blocks.kotlin.BlocksClient +import kotlin.Deprecated +import kotlin.DeprecationLevel +import kotlin.Nothing + +public class LegacyAuth( + private val client: BlocksClient, +) { + @Deprecated( + message = "OIDC is not configured. Add oidc { relayTo = \"...\" } to your awsBlocks block to enable this method.", + level = DeprecationLevel.ERROR, + ) + public suspend fun getClient(): Nothing = throw NotImplementedError("OIDC is not configured. Add oidc { relayTo = \"...\" } to your awsBlocks block to enable this method.") +} + +public val Blocks.legacyAuth: LegacyAuth + get() = LegacyAuth(client()) diff --git a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt new file mode 100644 index 000000000..e4b313012 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt @@ -0,0 +1,11 @@ +package com.example.app + +import com.aws.blocks.kotlin.Blocks +import com.aws.blocks.kotlin.BlocksServer + +public object Servers { + public val local: BlocksServer = + BlocksServer(name = "local", url = "http://localhost:3001/aws-blocks/api") +} + +public operator fun Blocks.Companion.invoke(): Blocks = Blocks(Servers.local) diff --git a/native/codegen-fixtures/37-oidc-provider-enum/spec.json b/native/codegen-fixtures/37-oidc-provider-enum/spec.json new file mode 100644 index 000000000..9e3a46a34 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/spec.json @@ -0,0 +1,27 @@ +{ + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [ + { + "name": "auth.getClient", + "params": [], + "result": { + "name": "GetClientResult", + "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [ + { "type": "string", "enum": ["google", "github", "my-sso", "login.gov"] } + ] + } + } + }, + { + "name": "legacyAuth.getClient", + "params": [], + "result": { + "name": "GetClientResult", + "schema": { "x-blocks-transferable": "oidc/client" } + } + } + ] +} diff --git a/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift b/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift new file mode 100644 index 000000000..20c19b862 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift @@ -0,0 +1,99 @@ +import Foundation +import BlocksRuntime + +public final nonisolated class Auth: Sendable { + private let client: BlocksClient + + public init(client: BlocksClient) { + self.client = client + } + + @available(*, deprecated, message: "Use blocks.auth from one Blocks() or Blocks(server:) you hold") + public convenience init(server: BlocksServer = Servers.local) { + self.init(client: BlocksClient(server: server)) + } + + /// Calls `auth.getClient`. + public func getClient() async throws -> OIDCClient { + let request = BlocksRequest(method: "auth.getClient", params: [], id: BlocksRequest.nextId()) + let result = try await client.execute(request) + guard let result else { throw RPCError(message: "Unexpected null result for auth.getClient") } + guard let descriptor = try JSONSerialization.jsonObject(with: result) as? [String: Any] else { + throw RPCError(message: "Invalid OIDC client descriptor for auth.getClient") + } + return try OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client) + } + + public enum GetClient { + + /// The providers `auth.getClient` signs in with. + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { + case google + case github + case mySso = "my-sso" + case loginGov = "login.gov" + } + } +} + +public final nonisolated class LegacyAuth: Sendable { + private let client: BlocksClient + + public init(client: BlocksClient) { + self.client = client + } + + @available(*, deprecated, message: "Use blocks.legacyAuth from one Blocks() or Blocks(server:) you hold") + public convenience init(server: BlocksServer = Servers.local) { + self.init(client: BlocksClient(server: server)) + } + + /// Calls `legacyAuth.getClient`. + public func getClient() async throws -> OIDCClient { + let request = BlocksRequest(method: "legacyAuth.getClient", params: [], id: BlocksRequest.nextId()) + let result = try await client.execute(request) + guard let result else { throw RPCError(message: "Unexpected null result for legacyAuth.getClient") } + guard let descriptor = try JSONSerialization.jsonObject(with: result) as? [String: Any] else { + throw RPCError(message: "Invalid OIDC client descriptor for legacyAuth.getClient") + } + return try OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client) + } +} + + +// MARK: - Blocks + +/// One BlocksClient, so one URLSession (RPC and OIDC calls) and one token store, for every namespace in this spec; +/// file transfers and realtime use the shared runtime session. Hold one while the backend is in use: `let blocks = Blocks()`. +public final nonisolated class Blocks: Sendable { + /// The server every API calls. + public let server: BlocksRuntime.BlocksServer + /// The client every API runs on. + public let client: BlocksRuntime.BlocksClient + + public let auth: Auth + public let legacyAuth: LegacyAuth + + /// Defaults to the spec's first server; a `session` you pass stays yours. + public init( + server: BlocksRuntime.BlocksServer = Servers.local, + session: URLSession? = nil, + tokenStore: any BlocksRuntime.TokenStore = BlocksRuntime.InMemoryTokenStore() + ) { + self.server = server + self.client = BlocksRuntime.BlocksClient(server: server, session: session, tokenStore: tokenStore) + self.auth = .init(client: client) + self.legacyAuth = .init(client: client) + } + + /// Later calls throw ClientClosedError; calls in flight finish, then the session this instance created is released. + public func close() { + client.close() + } +} + +// MARK: - Servers + +public nonisolated enum Servers { + public static let local = BlocksServer(name: "local", url: "http://localhost:3001/aws-blocks/api") +} \ No newline at end of file diff --git a/native/codegen-fixtures/37-oidc-provider-enum/swift/Models.swift b/native/codegen-fixtures/37-oidc-provider-enum/swift/Models.swift new file mode 100644 index 000000000..e69de29bb diff --git a/native/dart/packages/blocks_codegen/CHANGELOG.md b/native/dart/packages/blocks_codegen/CHANGELOG.md index f1b71caa3..25de99b85 100644 --- a/native/dart/packages/blocks_codegen/CHANGELOG.md +++ b/native/dart/packages/blocks_codegen/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Ignore an `oidc/client` result's `x-blocks-type-args`, which + `blocks-generate-spec` now fills with the app's provider names, so the + generated `OidcClient` result is unchanged. Turning those names into an unused + enum emits constants such as `default` or `okta:corp` that don't compile. - Resolve a direct result's transferable tag through the spec's `x-blocks-native-packages` and `x-blocks-native-bindings` catalogs, emitting the declared import, its export in upper camel case, and diff --git a/native/dart/packages/blocks_codegen/lib/src/builder.dart b/native/dart/packages/blocks_codegen/lib/src/builder.dart index 6500a4565..45a86ee2d 100644 --- a/native/dart/packages/blocks_codegen/lib/src/builder.dart +++ b/native/dart/packages/blocks_codegen/lib/src/builder.dart @@ -534,9 +534,13 @@ class CodegenModelBuilder { TransferableRef(blocksType: final kt, typeArgs: final args) => TransferableType( blocksType: kt, - typeArgs: args - .map((a) => _resolveTypeArgWithDedup(a, hint, path)) - .toList(), + // An oidc/client's type argument names its providers, which the + // generated client doesn't use yet. + typeArgs: kt == 'oidc/client' + ? const [] + : args + .map((a) => _resolveTypeArgWithDedup(a, hint, path)) + .toList(), binding: _bindings[kt], ), MapRef(valueType: final vt) => MapType( diff --git a/native/kotlin/.changeset/kotlin-oidc-type-args-ignored.md b/native/kotlin/.changeset/kotlin-oidc-type-args-ignored.md new file mode 100644 index 000000000..190466d39 --- /dev/null +++ b/native/kotlin/.changeset/kotlin-oidc-type-args-ignored.md @@ -0,0 +1,8 @@ +--- +"aws-blocks-kotlin": patch +--- + +Ignore an `oidc/client` result's `x-blocks-type-args`, which `blocks-generate-spec` +now fills with the app's provider names, so the generated `OidcClient` result is +unchanged. Turning those names into an unused enum fails code generation for a +provider name such as `login.gov`. diff --git a/native/kotlin/codegen/src/main/java/com/aws/blocks/kotlin/parser/OpenRpcParser.kt b/native/kotlin/codegen/src/main/java/com/aws/blocks/kotlin/parser/OpenRpcParser.kt index d773ba1ae..640a7f546 100644 --- a/native/kotlin/codegen/src/main/java/com/aws/blocks/kotlin/parser/OpenRpcParser.kt +++ b/native/kotlin/codegen/src/main/java/com/aws/blocks/kotlin/parser/OpenRpcParser.kt @@ -308,10 +308,15 @@ object OpenRpcParser { // Handle x-blocks-transferable schema["x-blocks-transferable"]?.let { transferableEl -> val transferableName = transferableEl.jsonPrimitive.content - val typeArgs = schema["x-blocks-type-args"]?.jsonArray?.map { argEl -> - val argObj = argEl.jsonObject - resolveSchema(argObj, components) - } ?: emptyList() + // An oidc/client's type argument names its providers, which the generated client doesn't use yet. + val typeArgs = if (transferableName == "oidc/client") { + emptyList() + } else { + schema["x-blocks-type-args"]?.jsonArray?.map { argEl -> + val argObj = argEl.jsonObject + resolveSchema(argObj, components) + } ?: emptyList() + } return TypeRef.Transferable(transferableName, typeArgs) } diff --git a/native/swift/.changeset/swift-oidc-provider-enum.md b/native/swift/.changeset/swift-oidc-provider-enum.md new file mode 100644 index 000000000..f5660bebf --- /dev/null +++ b/native/swift/.changeset/swift-oidc-provider-enum.md @@ -0,0 +1,21 @@ +--- +"aws-blocks-swift": minor +--- + +feat(swift): type `OIDCClient` by the spec's provider names + +**Breaking:** `OIDCClient` is now generic over the providers +`signIn(provider:relayTo:launcher:)` takes. A direct or optional `oidc/client` +result whose `x-blocks-type-args[0]` is a string enum generates a `CaseIterable` +`Provider` enum in the method's namespace and returns +`OIDCClient`, so `signIn(provider: .google, ...)` +accepts only that client's own providers and a typo doesn't compile. An untyped +spec returns `OIDCClient`, whose `signIn` still takes a `String`. Write +`OIDCClient` (or the generated enum) where code names `OIDCClient`, +including for its static members, as in `OIDCClient.generateVerifier()`. +The new `OIDCProviderName` protocol is what a provider type conforms to. + +When the method's own types already use the name `Provider`, the enum is +`ResultProvider` and codegen prints a `swift:` warning that names it. A nested +`oidc/client` still decodes as `JSONValue` and gets no enum. A namespace that +would generate a class named `CaseIterable` now takes an `Api` suffix. diff --git a/native/swift/AGENTS.md b/native/swift/AGENTS.md index 5a8d41734..5bb9db756 100644 --- a/native/swift/AGENTS.md +++ b/native/swift/AGENTS.md @@ -62,7 +62,7 @@ A Swift Package that generates type-safe Swift client code from an OpenRPC speci | Entry-point naming and emission | `Tests/BlocksCodegenTests/BlocksEntryPointGeneratorTests.swift` | | Declared native binding resolution | `Tests/BlocksCodegenTests/DeclaredBindingParserTests.swift` | | Declared transferable emission | `Tests/BlocksCodegenTests/DeclaredTransferableBindingTests.swift` | -| Golden files for the 27 shared fixtures | `Tests/BlocksCodegenTests/GoldenFileTests.swift` | +| Golden files for the 28 shared fixtures | `Tests/BlocksCodegenTests/GoldenFileTests.swift` | | Hybrid-arm fixture spec | `../codegen-fixtures/18-hybrid-arm/spec.json` | | Runtime tests | `Tests/BlocksRuntimeTests/*` | | Build-plugin output compiled in Swift 6 language mode with `.treatAllWarnings(as: .error)`; any new compiler warning in generated code, such as one a runner Xcode update brings, fails CI | `Tests/BlocksGeneratedCodeTests/` (its `blocks.spec.json` is un-ignored in `.gitignore`) | diff --git a/native/swift/README.md b/native/swift/README.md index 778fe02ca..38efe2a7d 100644 --- a/native/swift/README.md +++ b/native/swift/README.md @@ -73,6 +73,7 @@ Create one `Blocks` in app-scoped state, such as the Demo's `AppState`, not in a - **Schema-ref reuse.** A `oneOf` variant referencing a component schema reuses that named type instead of inventing a duplicate struct. - **Unbound transferables.** A direct result with an unbound transferable tag returns `UnknownTransferable`, a carrier for the raw `tag` and `descriptor`, instead of a raw `Data?` that did not type-check. Only bare direct results are covered. - **Declared native packages.** When `x-blocks-native-bindings` binds the tag and its package has a `swift` field, generated source imports the declared module and returns that type via `.fromBlocksDescriptor`. See [the schema generation guide](../../docs/native-clients/schema-generation-guide-for-devs.md#x-blocks-native-bindings-document-root). +- **Typed OIDC providers.** `OIDCClient` is generic over the providers it signs in with. When the spec names an OIDC client's providers, the method's namespace gets a `CaseIterable` `Provider` enum and `getClient()` returns `OIDCClient`, so `let user = try await client.signIn(provider: .google, relayTo: "myapp://auth", launcher: launcher)` accepts only that client's own providers. An untyped spec returns `OIDCClient`, whose `signIn` takes a `String`. When the method's own types already use the name `Provider`, the enum is `ResultProvider` and codegen prints a `swift:` warning that names it. ## Configuration diff --git a/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift b/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift index a850233c0..330af7762 100644 --- a/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift +++ b/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift @@ -391,22 +391,88 @@ public struct CodegenModelBuilder { return .typeReference(name: refName) case .transferable(let blocksType, let typeArgs): - let resolvedArgs = typeArgs.map { arg in - resolveType( - arg, - name: "\(name)Message", - parentSchemaName: parentSchemaName, - componentSchemas: componentSchemas, - typeDefinitions: &typeDefinitions, - declaredNames: &declaredNames, - inlineTypes: &inlineTypes, - currentParentId: currentParentId + // An oidc/client's argument is its provider enum, which the generator emits, not an inline type. + let resolvedArgs = blocksType == "oidc/client" + ? oidcProviderArgs( + typeArgs, name: name, operationLevel: parentSchemaName == nil && currentParentId == nil, + inlineTypes: inlineTypes ) - } + : typeArgs.map { arg in + resolveType( + arg, + name: "\(name)Message", + parentSchemaName: parentSchemaName, + componentSchemas: componentSchemas, + typeDefinitions: &typeDefinitions, + declaredNames: &declaredNames, + inlineTypes: &inlineTypes, + currentParentId: currentParentId + ) + } return .transferable(blocksType: blocksType, typeArgs: resolvedArgs) } } + /// A string-enum type argument names the providers an `oidc/client` signs in with; any other + /// argument is dropped. Only an operation's own client hydrates, so a nested one keeps none. + private func oidcProviderArgs( + _ typeArgs: [TypeRef], + name: String, + operationLevel: Bool, + inlineTypes: [InlineTypeEntry] + ) -> [ResolvedType] { + guard operationLevel, case .unionLiteral(let values)? = typeArgs.first else { return [] } + // The operation enum's types, which a root union's variant structs join, and the models its types name, + // which the enum would shadow. + var taken: Set = [] + for entry in inlineTypes { + taken.formUnion(referencedTypeNames(entry.type)) + guard entry.parentId == nil else { continue } + taken.insert(entry.shortName) + if case .union(_, let variants, _) = entry.type { + taken.formUnion(variants.map(\.name)) + } + } + var enumName = "Provider" + var attempt = 1 + while taken.contains(enumName) { + attempt += 1 + let prefix = pascalCase(name) + enumName = prefix.isEmpty ? "Provider\(attempt)" : prefix + enumName + } + return [.enum(name: enumName, values: values)] + } + + /// The names a type reaches by name: the models it references and its union payloads. + private func referencedTypeNames(_ type: ResolvedType) -> Set { + switch type { + case .typeReference(let name): + return [name] + case .record(_, let fields, let additionalPropertiesType, let embeddedUnion): + return referencedTypeNames(in: fields.map(\.type) + [additionalPropertiesType, embeddedUnion].compactMap { $0 }) + case .union(_, let variants, _): + return variants.reduce(into: Set()) { names, variant in + names.formUnion(variant.payloadTypeName.map { [$0] } ?? []) + let types = variant.fields.map(\.type) + [variant.additionalPropertiesType, variant.embeddedUnion].compactMap { $0 } + names.formUnion(referencedTypeNames(in: types)) + } + case .list(let elementType, _): + return referencedTypeNames(elementType) + case .map(let valueType): + return referencedTypeNames(valueType) + case .nullable(let inner): + return referencedTypeNames(inner) + case .transferable(_, let typeArgs): + return referencedTypeNames(in: typeArgs) + case .primitive, .formattedType, .enum: + return [] + } + } + + private func referencedTypeNames(in types: [ResolvedType]) -> Set { + types.reduce(into: Set()) { $0.formUnion(referencedTypeNames($1)) } + } + // Overload for component-schema resolution (Step 1) which doesn't track inline nesting private func resolveType( _ typeRef: TypeRef, diff --git a/native/swift/Sources/BlocksCodegen/Helpers.swift b/native/swift/Sources/BlocksCodegen/Helpers.swift index ebc61d1cf..5d2112e66 100644 --- a/native/swift/Sources/BlocksCodegen/Helpers.swift +++ b/native/swift/Sources/BlocksCodegen/Helpers.swift @@ -175,7 +175,7 @@ let reservedNamespaceClassNames: Set = [ "Blocks", "Servers", "BlocksRuntime", "Foundation", "BlocksClient", "BlocksServer", "BlocksRequest", "RPCError", "CodegenError", "JSONValue", "RealtimeChannel", "FileDownloadHandle", "FileUploadHandle", "OIDCClient", - "Sendable", "URLSession", + "Sendable", "CaseIterable", "URLSession", "String", "Int", "Double", "Bool", "Date", "UUID", "URL", "Void", "Set", "Error", "Codable", "Encodable", "Decoder", "Encoder", "CodingKey", "DecodingError", "JSONDecoder", "JSONSerialization", "Self", "Any" diff --git a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift new file mode 100644 index 000000000..765dd7303 --- /dev/null +++ b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift @@ -0,0 +1,74 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - OIDC Providers +// +// An operation's `oidc/client` result with a string-enum type argument gets a +// provider enum in its operation enum, and the operation returns an `OIDCClient` +// typed by it, whose `signIn` then takes only those providers. + +extension SwiftCodeGenerator { + /// The provider enum of an operation whose direct or optional result is an `oidc/client` with a string-enum type argument. + func oidcProviderEnum(_ operation: Operation) -> (name: String, values: [String])? { + guard let transferable = transferableResult(operation.result.type), + case .transferable("oidc/client", let typeArgs) = transferable.type, + case .enum(let name, let values)? = typeArgs.first else { return nil } + return (name, values) + } + + func emitOIDCProviderEnum(_ operation: Operation, namespace: String, indent: String, lines: inout [String]) { + guard let provider = oidcProviderEnum(operation) else { return } + lines.append("") + lines.append("\(indent)/// The providers `\(fullMethodName(operation, namespace: namespace))` signs in with.") + let conformances = "String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName" + emitDeclaration(provider.name, line: "\(indent)public enum \(provider.name): \(conformances) {", lines: &lines) + for (caseName, value) in providerCases(provider.values) { + let unescaped = caseName.trimmingCharacters(in: CharacterSet(charactersIn: "`")) + lines.append(unescaped == value + ? "\(indent) case \(caseName)" + : "\(indent) case \(caseName) = \(swiftStringLiteral(value))") + } + lines.append("\(indent)}") + } + + /// Names the enum when it isn't `Provider`, since the operation already declares or uses a type by that name. + func oidcProviderRenameWarning(_ operation: Operation, namespace: String, className: String) -> String? { + guard let provider = oidcProviderEnum(operation), provider.name != "Provider" else { return nil } + let providerType = "\(className).\(pascalCase(operation.name)).\(provider.name)" + return "swift: \(fullMethodName(operation, namespace: namespace)) names its providers \(providerType)," + + " since its operation already declares or uses a type named Provider." + } + + /// Case names and raw values: camelCased, made legal and backticked if a keyword; a repeated + /// value is dropped and a name another value already took is numbered. + func providerCases(_ values: [String]) -> [(name: String, value: String)] { + var seenValues: Set = [] + var takenNames: Set = [] + var cases: [(name: String, value: String)] = [] + for value in values where seenValues.insert(value).inserted { + let camel = camelCase(value) + let legal = camel.first?.isNumber == true ? "_" + camel : camel + let sanitized = escapedSwiftName(legal).trimmingCharacters(in: CharacterSet(charactersIn: "`")) + // A lone symbol sanitizes to `_`, which can't name a case. + let base = sanitized == "_" ? "_unnamed" : sanitized + var name = base + var suffix = 2 + while !takenNames.insert(name).inserted { + name = "\(base)\(suffix)" + suffix += 1 + } + cases.append((escapedSwiftName(name), value)) + } + return cases + } + + private func fullMethodName(_ operation: Operation, namespace: String) -> String { + namespace == "_default" ? operation.name : "\(namespace).\(operation.name)" + } +} diff --git a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift index 268a66d00..c4c87d49f 100644 --- a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift +++ b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift @@ -104,6 +104,9 @@ public struct SwiftCodeGenerator { } } walkNodes(operation.nestedTypes, parentPath: opName) + if let provider = oidcProviderEnum(operation) { + nameMap[provider.name] = "\(opName).\(provider.name)" + } operationQualifiedNames[opKey] = nameMap } } @@ -145,13 +148,19 @@ public struct SwiftCodeGenerator { } // Emit nested types as operation enums inside the class - for operation in namespace.operations where !operation.nestedTypes.isEmpty { + for operation in namespace.operations where !operation.nestedTypes.isEmpty || oidcProviderEnum(operation) != nil { apiLines.append("") - emitOperationEnum(operation: operation, indent: " ", lines: &apiLines) + emitOperationEnum(operation: operation, namespace: namespace.name, indent: " ", lines: &apiLines) } apiLines.append("}") apiLines.append("") + + for operation in namespace.operations { + if let warning = oidcProviderRenameWarning(operation, namespace: namespace.name, className: names.className) { + warnings.append(warning) + } + } } emitBlocksEntryPoint(entryPoint.names, defaultServer: defaultServerName, lines: &apiLines) @@ -166,12 +175,13 @@ public struct SwiftCodeGenerator { // MARK: - Nested Type Emission - private func emitOperationEnum(operation: Operation, indent: String, lines: inout [String]) { + private func emitOperationEnum(operation: Operation, namespace: String, indent: String, lines: inout [String]) { let opName = pascalCase(operation.name) emitDeclaration(opName, line: "\(indent)public enum \(opName) {", lines: &lines) for node in operation.nestedTypes { emitNestedTypeNode(node, indent: indent + " ", lines: &lines) } + emitOIDCProviderEnum(operation, namespace: namespace, indent: indent + " ", lines: &lines) lines.append("\(indent)}") } @@ -1343,8 +1353,9 @@ public struct SwiftCodeGenerator { returnLines = [" return try \(runtimePrefix)FileUploadHandle.fromJSON(descriptor)"] case "oidc/client": descriptorNoun = "OIDC client" + let provider = typeArgs.first.map { qualifiedSwiftTypeName($0, qualifiedNames: qualifiedNames) } ?? "String" returnLines = [ - " return try \(runtimePrefix)OIDCClient" + " return try \(runtimePrefix)OIDCClient<\(provider)>" + ".fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client)" ] default: diff --git a/native/swift/Sources/BlocksCodegen/TransferableEmission.swift b/native/swift/Sources/BlocksCodegen/TransferableEmission.swift index 4836835b5..41f98137d 100644 --- a/native/swift/Sources/BlocksCodegen/TransferableEmission.swift +++ b/native/swift/Sources/BlocksCodegen/TransferableEmission.swift @@ -50,7 +50,11 @@ extension SwiftCodeGenerator { ) -> String { guard let binding = knownTransferableBindings[blocksType] else { return "\(runtimePrefix)JSONValue" } // Only a direct result hydrates an OIDCClient; below the top level it decodes as its descriptor. - if blocksType == "oidc/client", !direct { return "\(runtimePrefix)JSONValue" } + // A direct one is typed by its provider enum, or `String` for an untyped spec. + if blocksType == "oidc/client" { + guard direct else { return "\(runtimePrefix)JSONValue" } + return "\(runtimePrefix)\(binding.base)<\(typeArgs.first.map(renderArg) ?? "String")>" + } guard binding.isGeneric else { return "\(runtimePrefix)\(binding.base)" } let argType = typeArgs.first.map(renderArg) ?? "\(runtimePrefix)JSONValue" return "\(runtimePrefix)\(binding.base)<\(argType)>" diff --git a/native/swift/Sources/BlocksRuntime/BlocksClient.swift b/native/swift/Sources/BlocksRuntime/BlocksClient.swift index c4b1d501c..5c68443fb 100644 --- a/native/swift/Sources/BlocksRuntime/BlocksClient.swift +++ b/native/swift/Sources/BlocksRuntime/BlocksClient.swift @@ -34,7 +34,7 @@ public final class BlocksClient: Sendable { private static let baseHostLock = OSAllocatedUnfairLock(initialState: "localhost") /// Base URL the client was constructed with — exposed for transferable - /// hydration (e.g. `OIDCClient.fromJSON(descriptor, baseUrl: client.baseUrl, ...)`). + /// hydration (e.g. `OIDCClient.fromJSON(descriptor, baseUrl: client.baseUrl, ...)`). public var baseUrl: String { url } /// Token store used to hydrate `OIDCClient` transferables returned by this client. diff --git a/native/swift/Sources/BlocksRuntime/OIDC/OIDCClient.swift b/native/swift/Sources/BlocksRuntime/OIDC/OIDCClient.swift index b11cb21cb..064a9f410 100644 --- a/native/swift/Sources/BlocksRuntime/OIDC/OIDCClient.swift +++ b/native/swift/Sources/BlocksRuntime/OIDC/OIDCClient.swift @@ -8,13 +8,31 @@ import Foundation import CryptoKit +/// A provider an `OIDCClient` signs in with: the enum generated from the spec's provider names, or +/// `String` when the spec doesn't name them. +public protocol OIDCProviderName: Sendable { + /// The name the server knows the provider by. + var providerName: String { get } +} + +public extension OIDCProviderName where Self: RawRepresentable, RawValue == String { + var providerName: String { rawValue } +} + +extension String: OIDCProviderName { + public var providerName: String { self } +} + /// Server-relay OIDC client. Delegates networking to `BlocksClient` so /// cookies are shared automatically. Implements the relay sign-in flow: /// fetches signed authorize params from the server, opens the IdP in a /// browser, and exchanges the authorization code for tokens. /// +/// `Provider` is the type `signIn` takes: the generated provider enum, so a +/// client only accepts its own providers, or `String` for an untyped spec. +/// /// All mutable state is protected by actor isolation. -public actor OIDCClient: AuthProvider { +public actor OIDCClient: AuthProvider { public nonisolated let exchangePath: String public nonisolated let refreshPath: String @@ -107,10 +125,11 @@ public actor OIDCClient: AuthProvider { /// The server signs the state envelope and the IdP redirects to the /// server's callback, which then 302s to the app's `relayTo` scheme. public func signIn( - provider: String, + provider: Provider, relayTo: String, launcher: BrowserLauncher ) async throws -> OIDCUser { + let provider = provider.providerName guard providers.contains(provider) else { throw OIDCError.unknownProvider(provider) } @@ -439,7 +458,8 @@ public actor OIDCClient: AuthProvider { } } - private static let tokenNames = ["access_token", "refresh_token", "expires_at"] + // Computed: a generic type can't store a static property. + private static var tokenNames: [String] { ["access_token", "refresh_token", "expires_at"] } /// Scoped by exchange path, so OIDC blocks sharing one `TokenStore` keep separate sessions. private func tokenKey(_ name: String) -> String { diff --git a/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift b/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift index d32619cd4..a8955ac49 100644 --- a/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift +++ b/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift @@ -265,7 +265,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { """) XCTAssertTrue(output.api.contains("-> OIDCClient")) - XCTAssertTrue(output.api.contains("OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client)")) + XCTAssertTrue(output.api.contains("OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client)")) } // MARK: - Multiple Namespaces @@ -611,7 +611,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { "realtime/channel": "RealtimeChannel<", "file-bucket/download": "FileDownloadHandle.fromJSON(descriptor)", "file-bucket/upload": "FileUploadHandle.fromJSON(descriptor)", - "oidc/client": "OIDCClient.fromJSON(descriptor" + "oidc/client": "OIDCClient.fromJSON(descriptor" ] for tag in knownTransferableTags { guard let concreteReturn = expectedReturn[tag], let concreteBody = expectedBody[tag] else { @@ -774,12 +774,250 @@ final class SwiftCodeGeneratorTests: XCTestCase { } """) XCTAssertTrue(output.api.contains("public let client: JSONValue"), "a nested oidc/client decodes as its descriptor, got:\n\(output.api)") - XCTAssertTrue(output.api.contains("-> OIDCClient {"), "a direct oidc/client still hydrates, got:\n\(output.api)") + XCTAssertTrue(output.api.contains("-> OIDCClient {"), "a direct oidc/client still hydrates, got:\n\(output.api)") XCTAssertEqual(output.warnings.filter { $0.contains("oidc/client") }, [ "swift: auth.getSetup returns an oidc/client below the top level, which decodes as JSONValue; return it directly to get an OIDCClient." ]) } + // MARK: - OIDC Provider Enums + + private func oidcSpec(_ methods: String) -> String { + """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [\(methods)] + } + """ + } + + func testTypedOIDCClientIsTypedByItsProviderEnum() throws { + let output = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google", "github", "my-sso"] }] + }}} + """)) + XCTAssertTrue(output.api.contains("public func getClient() async throws -> OIDCClient {"), "got:\n\(output.api)") + XCTAssertTrue( + output.api.contains("return try OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client)"), + "got:\n\(output.api)" + ) + XCTAssertTrue(output.api.contains(""" + public enum GetClient { + + /// The providers `auth.getClient` signs in with. + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { + case google + case github + case mySso = "my-sso" + } + } + """), "got:\n\(output.api)") + XCTAssertFalse(output.api.contains("extension"), "the runtime's signIn takes the enum, got:\n\(output.api)") + XCTAssertFalse(output.api.contains("ResultMessage"), "the type argument is not a channel message, got:\n\(output.api)") + XCTAssertEqual(output.warnings, []) + } + + func testOIDCClientWithoutAStringEnumTypeArgStaysUntyped() throws { + let untypedArgs = [ + "", + #", "x-blocks-type-args": [{ "type": "string" }]"#, + #", "x-blocks-type-args": [{ "type": "object", "properties": { "x": { "type": "number" } } }]"# + ] + for typeArgs in untypedArgs { + let output = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client"\(typeArgs) } } } + """)) + XCTAssertTrue(output.api.contains("-> OIDCClient {"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("OIDCClient.fromJSON(descriptor"), "got:\n\(output.api)") + XCTAssertFalse(output.api.contains("public enum GetClient"), "no nested types for \(typeArgs), got:\n\(output.api)") + } + } + + func testOptionalTypedOIDCClientKeepsItsProviderEnum() throws { + let output = try generate(from: oidcSpec(""" + { "name": "auth.findClient", "params": [], "result": { "name": "R", "schema": { "oneOf": [ + { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] }, + { "type": "null" } + ]}}} + """)) + XCTAssertTrue(output.api.contains("-> OIDCClient? {"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("OIDCClient.fromJSON(descriptor"), "got:\n\(output.api)") + } + + func testNestedTypedOIDCClientHasNoProviderEnum() throws { + let output = try generate(from: oidcSpec(""" + { "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { + "type": "object", "properties": { "client": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }}, "required": ["client"] + }}} + """)) + XCTAssertTrue(output.api.contains("public let client: JSONValue"), "got:\n\(output.api)") + XCTAssertFalse(output.api.contains("Provider"), "a client that decodes as JSONValue gets no provider enum, got:\n\(output.api)") + } + + func testProviderCaseNamesAreLegalSwift() throws { + let output = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["default", "1password", "Google", "google", "my-sso", "my-sso", "+"] }] + }}} + """)) + XCTAssertTrue(output.api.contains(""" + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { + case `default` + case _1password = "1password" + case google = "Google" + case google2 = "google" + case mySso = "my-sso" + case _unnamed = "+" + } + """), "got:\n\(output.api)") + } + + func testProviderEnumNameAvoidsAParameterType() throws { + let output = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [ + { "name": "provider", "required": true, "schema": { "type": "string", "enum": ["a", "b"] } } + ], "result": { "name": "R", "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }}} + """)) + XCTAssertTrue( + output.api.contains("public func getClient(provider: GetClient.Provider) async throws -> OIDCClient {"), + "got:\n\(output.api)" + ) + XCTAssertTrue(output.api.contains("public enum ResultProvider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName {"), "got:\n\(output.api)") + XCTAssertEqual(output.warnings, [ + "swift: auth.getClient names its providers Auth.GetClient.ResultProvider, since its operation already declares or uses a type named Provider." + ]) + } + + func testProviderEnumNameAvoidsVariantStructsAndModels() throws { + let typedClient = #"{ "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] }"# + // A root union's variant structs share the operation enum with the provider enum. + let variants = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [ + { "name": "via", "required": true, "schema": { "oneOf": [ + { "type": "object", "required": ["kind", "name"], + "properties": { "kind": { "type": "string", "enum": ["provider"] }, "name": { "type": "string" } } }, + { "type": "object", "required": ["kind", "pw"], + "properties": { "kind": { "type": "string", "enum": ["password"] }, "pw": { "type": "string" } } } + ]}} + ], "result": { "name": "R", "schema": \(typedClient) } } + """)) + XCTAssertTrue(variants.api.contains("public struct Provider: Codable {"), "got:\n\(variants.api)") + XCTAssertTrue( + variants.api.contains("public enum ResultProvider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName {"), + "got:\n\(variants.api)" + ) + // A nested type naming the `Provider` model must not resolve to the provider enum. + let model = try generate(from: """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [{ "name": "auth.getClient", "params": [ + { "name": "options", "required": true, "schema": { + "type": "object", "properties": { "via": { "$ref": "#/components/schemas/Provider" } }, "required": ["via"] + }} + ], "result": { "name": "R", "schema": \(typedClient) } }], + "components": { "schemas": { "Provider": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } } + } + """) + XCTAssertTrue(model.api.contains("public enum ResultProvider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName {"), "got:\n\(model.api)") + XCTAssertFalse(model.api.contains("public enum Provider: String"), "got:\n\(model.api)") + // With no other types in the operation enum, nothing there can name the model, so the enum keeps its name. + let unshadowed = try generate(from: """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [ + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": \(typedClient) } }, + { "name": "api.listProviders", "params": [], "result": { "name": "R", "schema": { + "type": "array", "items": { "$ref": "#/components/schemas/Provider" } + }}} + ], + "components": { "schemas": { "Provider": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } } + } + """) + XCTAssertTrue(unshadowed.api.contains("-> OIDCClient {"), "got:\n\(unshadowed.api)") + XCTAssertTrue(unshadowed.models.contains("public nonisolated struct Provider: Codable {"), "got:\n\(unshadowed.models)") + XCTAssertEqual(unshadowed.warnings, []) + } + + func testUnrelatedProviderModelDoesNotRenameTheEnum() throws { + // The operation has an inline type, but only another method names the `Provider` model. + let output = try generate(from: """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [ + { "name": "auth.getClient", "params": [ + { "name": "opts", "required": true, "schema": { + "type": "object", "properties": { "force": { "type": "boolean" } }, "required": ["force"] + }} + ], "result": { "name": "R", "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }}}, + { "name": "catalog.listProviders", "params": [], "result": { "name": "R", "schema": { + "type": "array", "items": { "$ref": "#/components/schemas/Provider" } + }}} + ], + "components": { "schemas": { "Provider": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } } + } + """) + XCTAssertTrue(output.api.contains("-> OIDCClient {"), "got:\n\(output.api)") + XCTAssertEqual(output.warnings, []) + } + + func testProviderRenameEndsForAParameterWithNoNamePart() throws { + // A param named only by separators PascalCases to "", which can't prefix a clashing name. + let output = try generate(from: oidcSpec(""" + { "name": "auth.getClient", "params": [ + { "name": "provider", "required": true, "schema": { "type": "string", "enum": ["a", "b"] } }, + { "name": ".", "required": true, "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }} + ], "result": { "name": "R", "schema": { "type": "string" } } } + """)) + XCTAssertTrue(output.api.contains("public func getClient("), "got:\n\(output.api)") + } + + func testTypedOIDCClientInAModelHasNoProviderEnum() throws { + let output = try generate(from: """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [{ "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { "$ref": "#/components/schemas/Setup" } } }], + "components": { "schemas": { "Setup": { "type": "object", "properties": { "client": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }}, "required": ["client"] } } } + } + """) + XCTAssertTrue(output.models.contains("public let client: JSONValue"), "got:\n\(output.models)") + XCTAssertFalse(output.models.contains("enum"), "a client in a model gets no provider enum, got:\n\(output.models)") + XCTAssertFalse(output.api.contains("Provider"), "got:\n\(output.api)") + } + + func testDefaultNamespaceClientIsTypedByItsProviderEnum() throws { + let output = try generate(from: oidcSpec(""" + { "name": "getClient", "params": [], "result": { "name": "R", "schema": { + "x-blocks-transferable": "oidc/client", + "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] + }}} + """)) + XCTAssertTrue(output.api.contains("/// The providers `getClient` signs in with."), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("public func getClient() async throws -> OIDCClient {"), "got:\n\(output.api)") + } + func testUnboundTransferableDiagnosticQualifiesDefaultNamespace() throws { // A non-namespaced method nests under `_Default`; the diagnostic must // name that class, or the printed type is unusable from app scope. diff --git a/native/swift/Tests/BlocksE2ETests/OIDCE2ETests.swift b/native/swift/Tests/BlocksE2ETests/OIDCE2ETests.swift index abca063a8..04262c8ea 100644 --- a/native/swift/Tests/BlocksE2ETests/OIDCE2ETests.swift +++ b/native/swift/Tests/BlocksE2ETests/OIDCE2ETests.swift @@ -84,7 +84,7 @@ final class OIDCE2ETests: BlocksE2ETestCase { super.invokeTest() } - private func getOidcClient() async throws -> OIDCClient { + private func getOidcClient() async throws -> OIDCClient { try await blocks.oidcAuthApi.getClient() } diff --git a/native/swift/Tests/BlocksGeneratedCodeTests/AppDeclaredTypes.swift b/native/swift/Tests/BlocksGeneratedCodeTests/AppDeclaredTypes.swift index c44a09f7f..a133a3a61 100644 --- a/native/swift/Tests/BlocksGeneratedCodeTests/AppDeclaredTypes.swift +++ b/native/swift/Tests/BlocksGeneratedCodeTests/AppDeclaredTypes.swift @@ -8,3 +8,5 @@ // An app may declare types named like runtime ones; the generated `Blocks` must still compile. struct TokenStore {} struct InMemoryTokenStore {} +struct OIDCUser {} +struct BrowserLauncher {} diff --git a/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift b/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift index 5ab64401f..ca71a9e1f 100644 --- a/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift +++ b/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift @@ -35,7 +35,7 @@ private final class RecordingURLProtocol: URLProtocol { Self.lock.withLock { Self.recorded.append((method, request.url)) } let result = switch method { case "users.list": #"["ada"]"# - case "auth.getClient": #"{"exchangePath":"/auth/exchange","signOutPath":"/auth/signout"}"# + case "auth.getClient": #"{"exchangePath":"/auth/exchange","signOutPath":"/auth/signout","providers":["google","github","my-sso"]}"# case "admin.getClient": #"{"exchangePath":"/admin/exchange","signOutPath":"/auth/signout"}"# default: #""pong""# } @@ -130,6 +130,21 @@ final class GeneratedEntryPointTests: XCTestCase { XCTAssertIdentical(blocks.client.tokenStore as AnyObject, store) } + func testTypedSignInSendsTheProvidersWireName() async throws { + let blocks = Blocks(server: Servers.local, session: mockSession()) + // Typed by its own providers, so `auth.signIn(provider: Admin.GetClient.Provider.okta, ...)` doesn't compile. + let auth: OIDCClient = try await blocks.auth.getClient() + XCTAssertEqual(Auth.GetClient.Provider.allCases.map(\.rawValue), ["google", "github", "my-sso"]) + XCTAssertEqual(Admin.GetClient.Provider.allCases, [.okta]) + do { + _ = try await auth.signIn(provider: .mySso, relayTo: "app://auth", launcher: UnusedLauncher()) + XCTFail("expected the stubbed authorize-params response to fail sign-in") + } catch OIDCError.callbackError { + // The recorder answers authorize-params with an RPC body, so sign-in stops after that request. + } + XCTAssertTrue(RecordingURLProtocol.urls.contains(URL(string: "http://localhost:3001/auth/authorize-params/my-sso")), "\(RecordingURLProtocol.urls)") + } + func testCloseFailsLaterCallsFromEveryNamespace() async { let blocks = Blocks(session: mockSession()) blocks.close() @@ -192,3 +207,10 @@ final class GeneratedEntryPointTests: XCTestCase { private func requireSendable(_: T.Type) {} } + +private struct UnusedLauncher: BlocksRuntime.BrowserLauncher { + func launch(authorizeURL: URL, callbackScheme: String) async throws -> URL { + XCTFail("sign-in should stop before the browser opens") + return authorizeURL + } +} diff --git a/native/swift/Tests/BlocksGeneratedCodeTests/blocks.spec.json b/native/swift/Tests/BlocksGeneratedCodeTests/blocks.spec.json index 25e74b256..d308fe341 100644 --- a/native/swift/Tests/BlocksGeneratedCodeTests/blocks.spec.json +++ b/native/swift/Tests/BlocksGeneratedCodeTests/blocks.spec.json @@ -8,9 +8,9 @@ "methods": [ { "name": "users.list", "params": [], "result": { "name": "R", "schema": { "type": "array", "items": { "type": "string" } } } }, { "name": "client.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, - { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client" } } }, - { "name": "admin.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client" } } }, - { "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { "type": "object", "properties": { "client": { "x-blocks-transferable": "oidc/client" } }, "required": ["client"] } } }, + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google", "github", "my-sso"] }] } } }, + { "name": "admin.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["okta"] }] } } }, + { "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { "type": "object", "properties": { "client": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] } }, "required": ["client"] } } }, { "name": "class.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, { "name": "type.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, { "name": "self.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, diff --git a/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift b/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift index e3cb93f59..9239e3973 100644 --- a/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift +++ b/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift @@ -13,6 +13,18 @@ private final class AppModel { let blocks = Blocks() } +/// Main-actor-isolated by default, like a sign-in screen's model. +private final class SignInModel { + var provider: Auth.GetClient.Provider = .github +} + +private nonisolated struct RefusedLauncher: BrowserLauncher { + func launch(authorizeURL: URL, callbackScheme: String) async throws -> URL { + XCTFail("sign-in should fail before the browser opens") + return authorizeURL + } +} + /// Creates and closes its own `Blocks` off the main actor. private actor Worker { let blocks = Blocks() @@ -47,6 +59,23 @@ final nonisolated class MainActorDefaultTests: XCTestCase { } } + @MainActor + func testMainActorCodeSignsInWithAProviderEnum() async { + // Port 9 refuses the connection, so sign-in fails on its first request. + let blocks = Blocks(server: BlocksServer(name: "refused", url: "http://127.0.0.1:9/aws-blocks/api")) + let oidc = OIDCClient( + exchangePath: "/auth/exchange", refreshPath: "/auth/exchange/refresh", signOutPath: "/auth/signout", + providers: ["github"], providerConfigs: [:], baseUrl: blocks.client.baseUrl, client: blocks.client + ) + let model = SignInModel() + do { + _ = try await oidc.signIn(provider: model.provider, relayTo: "app://auth", launcher: RefusedLauncher()) + XCTFail("expected a connection error") + } catch { + XCTAssertTrue(error is URLError, "\(error)") + } + } + func testNonisolatedCodeBuildsBlocksAndNamespaces() async { let blocks = Blocks(server: Servers.default, session: nil, tokenStore: InMemoryTokenStore()) blocks.close() diff --git a/native/swift/Tests/BlocksGeneratedMainActorTests/blocks.spec.json b/native/swift/Tests/BlocksGeneratedMainActorTests/blocks.spec.json index 25e74b256..d308fe341 100644 --- a/native/swift/Tests/BlocksGeneratedMainActorTests/blocks.spec.json +++ b/native/swift/Tests/BlocksGeneratedMainActorTests/blocks.spec.json @@ -8,9 +8,9 @@ "methods": [ { "name": "users.list", "params": [], "result": { "name": "R", "schema": { "type": "array", "items": { "type": "string" } } } }, { "name": "client.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, - { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client" } } }, - { "name": "admin.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client" } } }, - { "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { "type": "object", "properties": { "client": { "x-blocks-transferable": "oidc/client" } }, "required": ["client"] } } }, + { "name": "auth.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google", "github", "my-sso"] }] } } }, + { "name": "admin.getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["okta"] }] } } }, + { "name": "auth.getSetup", "params": [], "result": { "name": "R", "schema": { "type": "object", "properties": { "client": { "x-blocks-transferable": "oidc/client", "x-blocks-type-args": [{ "type": "string", "enum": ["google"] }] } }, "required": ["client"] } } }, { "name": "class.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, { "name": "type.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, { "name": "self.ping", "params": [], "result": { "name": "R", "schema": { "type": "string" } } }, diff --git a/native/swift/Tests/BlocksRuntimeTests/BlocksClientLifecycleTests.swift b/native/swift/Tests/BlocksRuntimeTests/BlocksClientLifecycleTests.swift index 8b62d4a08..e8c2ea0c7 100644 --- a/native/swift/Tests/BlocksRuntimeTests/BlocksClientLifecycleTests.swift +++ b/native/swift/Tests/BlocksRuntimeTests/BlocksClientLifecycleTests.swift @@ -190,7 +190,7 @@ final class BlocksClientLifecycleTests: XCTestCase { await store.set("/auth/exchange#refresh_token", value: "rt") await store.set("/auth/exchange#expires_at", value: "0") let client = BlocksClient(url: url, tokenStore: store) - let oidc = OIDCClient( + let oidc = OIDCClient( exchangePath: "/auth/exchange", refreshPath: "/auth/refresh", signOutPath: "/auth/signout", providers: ["google"], providerConfigs: [:], baseUrl: "http://127.0.0.1:9", client: client ) diff --git a/native/swift/Tests/BlocksRuntimeTests/OIDCClientTests.swift b/native/swift/Tests/BlocksRuntimeTests/OIDCClientTests.swift index 9f87cd34c..49211893e 100644 --- a/native/swift/Tests/BlocksRuntimeTests/OIDCClientTests.swift +++ b/native/swift/Tests/BlocksRuntimeTests/OIDCClientTests.swift @@ -128,9 +128,9 @@ private actor RendezvousTokenStore: TokenStore { final class OIDCClientTests: XCTestCase { - private func makeClient(session: URLSession) -> OIDCClient { + private func makeClient(session: URLSession) -> OIDCClient { let blocksClient = BlocksClient(url: "https://api.example.com/aws-blocks/api", session: session) - return OIDCClient( + return OIDCClient( exchangePath: "/auth/exchange", refreshPath: "/auth/refresh", signOutPath: "/auth/signout", @@ -148,8 +148,8 @@ final class OIDCClientTests: XCTestCase { ) } - private func makeClient(named namespace: String, on blocksClient: BlocksClient) -> OIDCClient { - OIDCClient( + private func makeClient(named namespace: String, on blocksClient: BlocksClient) -> OIDCClient { + OIDCClient( exchangePath: "/\(namespace)/exchange", refreshPath: "/\(namespace)/refresh", signOutPath: "/auth/signout", providers: ["google"], providerConfigs: [:], baseUrl: "https://api.example.com", client: blocksClient ) @@ -163,8 +163,8 @@ final class OIDCClientTests: XCTestCase { // MARK: - PKCE func testPKCEGeneration() { - let verifier = OIDCClient.generateVerifier() - let challenge = OIDCClient.generateChallenge(verifier) + let verifier = OIDCClient.generateVerifier() + let challenge = OIDCClient.generateChallenge(verifier) XCTAssertFalse(verifier.isEmpty) XCTAssertFalse(challenge.isEmpty) // base64URL: no '+', '/', or '=' @@ -173,7 +173,7 @@ final class OIDCClientTests: XCTestCase { XCTAssertFalse(verifier.contains("=")) XCTAssertFalse(challenge.contains("=")) // Same verifier → same challenge (S256 deterministic) - XCTAssertEqual(challenge, OIDCClient.generateChallenge(verifier)) + XCTAssertEqual(challenge, OIDCClient.generateChallenge(verifier)) } // MARK: - Exchange @@ -540,7 +540,7 @@ final class OIDCClientTests: XCTestCase { ] ] let blocksClient = BlocksClient(url: "https://api.example.com/aws-blocks/api") - let client = try OIDCClient.fromJSON(descriptor, baseUrl: "https://api.example.com", client: blocksClient) + let client = try OIDCClient.fromJSON(descriptor, baseUrl: "https://api.example.com", client: blocksClient) XCTAssertEqual(client.providers, ["google"]) XCTAssertEqual(client.exchangePath, "/auth/exchange") // Defaulted refreshPath @@ -550,7 +550,7 @@ final class OIDCClientTests: XCTestCase { func testFromJSONThrowsOnMissingExchangePath() { let descriptor: [String: Any] = ["signOutPath": "/auth/signout"] - XCTAssertThrowsError(try OIDCClient.fromJSON(descriptor, baseUrl: "x", client: BlocksClient(url: "x"))) { error in + XCTAssertThrowsError(try OIDCClient.fromJSON(descriptor, baseUrl: "x", client: BlocksClient(url: "x"))) { error in guard let oidc = error as? OIDCError else { return XCTFail("Expected OIDCError") } XCTAssertEqual(oidc, .malformedDescriptor("exchangePath")) } diff --git a/packages/core/src/scripts/extract-ts-types.test.ts b/packages/core/src/scripts/extract-ts-types.test.ts index c7ffdd7d1..a1a6c8629 100644 --- a/packages/core/src/scripts/extract-ts-types.test.ts +++ b/packages/core/src/scripts/extract-ts-types.test.ts @@ -455,6 +455,81 @@ describe('extractMethodTypes — transferable detection', () => { } }); + it('keeps an inferred channel message type out of the spec', () => { + // Native generators can't decode every inferred message type (an `any`, a primitive), so a channel needs written args. + const dir = createTempProject({ + 'tsconfig.json': TRANSFERABLE_TSCONFIG, + 'index.ts': ` + ${TRANSFERABLE_PRELUDE} + interface CursorPosition { userId: string; x: number; y: number; } + declare function channelOf(name: string): RealtimeChannel; + export const api = new ApiNamespace(null, 'api', () => ({ + async inferred() { return channelOf('cursors'); }, + async inferredAny() { return channelOf('cursors'); }, + async inferredNames() { return channelOf<'online' | 'away'>('status'); }, + })); + `, + }); + try { + const types = extractMethodTypes(join(dir, 'index.ts')); + assert.strictEqual(types.get('api.inferred')?.transferable?.blocksTag, 'realtime/channel'); + assert.deepStrictEqual(types.get('api.inferred')?.transferable?.typeArgs, []); + assert.deepStrictEqual(types.get('api.inferredAny')?.transferable?.typeArgs, []); + // String literals reach the spec only for an oidc/client's provider names. + assert.deepStrictEqual(types.get('api.inferredNames')?.transferable?.typeArgs, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves a helper-built client generic to a string-literal enum, as AuthOIDC.createApi() does', () => { + const dir = createTempProject({ + 'tsconfig.json': TRANSFERABLE_TSCONFIG, + 'oidc.ts': ` + ${API_NS_MOCK} + interface ProviderConfig { readonly name: string; } + type ProviderName

= P[number]['name']; + interface OIDCClientDescriptor { __blocks: 'oidc/client'; providers: string[]; } + export interface OIDCClient { + signIn(provider: Provider): Promise; + toJSON(): OIDCClientDescriptor; + } + export function provider(name: N): { readonly name: N } { return { name }; } + export class AuthOIDC

{ + constructor(readonly options: { providers: P }) {} + createApi() { + return new ApiNamespace(null, 'auth', () => ({ + getClient: async (): Promise>> => null as any, + })); + } + } + `, + 'index.ts': ` + import { AuthOIDC, provider } from './oidc.js'; + const typed = new AuthOIDC({ providers: [provider('google'), provider('github')] }); + const loose = new AuthOIDC({ providers: [] as { readonly name: string }[] }); + const parsed = new AuthOIDC({ providers: JSON.parse('[]') }); + export const oidcAuthApi = typed.createApi(); + export const looseAuthApi = loose.createApi(); + export const parsedAuthApi = parsed.createApi(); + `, + }); + try { + const types = extractMethodTypes(join(dir, 'index.ts')); + const typed = types.get('oidcAuthApi.getClient')?.transferable; + assert.strictEqual(typed?.blocksTag, 'oidc/client'); + assert.deepStrictEqual(typed.typeArgs, [{ type: 'string', enum: ['google', 'github'] }]); + // Provider names that resolve to plain `string` or `any` name no providers, so the client stays untyped. + for (const name of ['looseAuthApi.getClient', 'parsedAuthApi.getClient']) { + const untyped = types.get(name)?.transferable; + assert.strictEqual(untyped?.blocksTag, 'oidc/client', name); + assert.deepStrictEqual(untyped.typeArgs, [], name); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('returns no transferable info when toJSON is missing or __blocks is not a literal', () => { const dir = createTempProject({ 'tsconfig.json': TRANSFERABLE_TSCONFIG, diff --git a/packages/core/src/scripts/extract-ts-types.ts b/packages/core/src/scripts/extract-ts-types.ts index c92d39dad..a4148dbf7 100644 --- a/packages/core/src/scripts/extract-ts-types.ts +++ b/packages/core/src/scripts/extract-ts-types.ts @@ -543,6 +543,10 @@ function extractMethodsFromResolvedType( * (`RealtimeChannel`, where `T = unknown` is filled in by the checker) and * an explicit `RealtimeChannel`. The two look identical in the * resolved `ts.Type`, but only the explicit form should emit a `[{}]` slot. + * + * An `oidc/client` also emits the arguments the checker infers, when they + * resolve to string literals: `AuthOIDC.createApi()` types `getClient()` as + * `OIDCClient>`, which resolves to `OIDCClient<'google' | 'github'>`. */ function detectTransferable( type: ts.Type, @@ -581,7 +585,8 @@ function detectTransferable( // Step 10-11: For generic type arguments — only emit them when the // customer actually wrote `<...>` in the source. If `syntacticNode` has // no `typeArguments`, treat the type as non-generic (defaults filled in - // by the checker shouldn't appear in the spec). + // by the checker shouldn't appear in the spec), except for the inferred + // provider names of an `oidc/client`. const typeArgs: JsonSchema[] = []; const wroteTypeArgs = syntacticNode?.typeArguments && syntacticNode.typeArguments.length > 0; if (wroteTypeArgs) { @@ -596,11 +601,34 @@ function detectTransferable( typeArgs.push(tsTypeToJsonSchema(arg, checker)); } } + } else if (blocksTag === 'oidc/client') { + typeArgs.push(...inferredProviderNames(type, checker)); } return { blocksTag, typeArgs }; } +/** + * An `oidc/client`'s inferred type argument as a string enum of provider + * names, which native generators turn into a provider type. Empty unless it + * resolves to string literals: the `string` default, `any`, or an unresolved + * type parameter names no providers. Other tags emit only written arguments, + * since an inferred realtime message type can be `any` or a shape a native + * generator can't decode. + */ +function inferredProviderNames(type: ts.Type, checker: ts.TypeChecker): JsonSchema[] { + if (!(type.getFlags() & ts.TypeFlags.Object)) return []; + if (!((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference)) return []; + const typeRef = type as ts.TypeReference; + // Arguments for the enclosing scope's type parameters come first, and a `this` argument may follow. + const outer = typeRef.target.outerTypeParameters?.length ?? 0; + const local = typeRef.target.localTypeParameters?.length ?? 0; + const args = checker.getTypeArguments(typeRef).slice(outer, outer + local); + const schemas = args.map((arg) => tsTypeToJsonSchema(arg, checker)); + const namesProviders = (schema: JsonSchema) => schema.type === 'string' && Array.isArray(schema.enum); + return schemas.every(namesProviders) ? schemas : []; +} + // ── ts.Type → JSON Schema conversion ──────────────────────────────────────── /** Recursion guard to prevent infinite loops on recursive types. */ diff --git a/packages/core/src/scripts/generate-spec.test.ts b/packages/core/src/scripts/generate-spec.test.ts index e23e9aa0c..ae727e4b0 100644 --- a/packages/core/src/scripts/generate-spec.test.ts +++ b/packages/core/src/scripts/generate-spec.test.ts @@ -198,3 +198,55 @@ describe('generateSpec — namespace returned by a factory, then destructured (r } }); }); + +describe('generateSpec — inferred transferable type args', () => { + it('emits a client generic resolved to a string-literal union as an inline enum type arg', async () => { + const dir = join(tmpdir(), `blocks-spec-test-${Date.now()}-type-args`); + mkdirSync(dir, { recursive: true }); + writeTsconfig(dir); + + // The client generic is inferred through a helper in another module, the + // way AuthOIDC.createApi() types getClient() as OIDCClient>. + writeFileSync(join(dir, 'oidc.ts'), ` + interface ApiNamespaceConstructor { new (scope: any, name: string, handler: (ctx: any) => T): T; } + const ApiNamespace: ApiNamespaceConstructor = class {} as any; + interface OIDCClientDescriptor { __blocks: 'oidc/client'; providers: string[]; } + export interface OIDCClient { + signIn(provider: Provider): Promise; + toJSON(): OIDCClientDescriptor; + } + export function createOidcApi(providers: readonly N[]) { + return new ApiNamespace(null, 'oidc', () => ({ + getClient: async (): Promise> => null as any, + })); + } + `); + writeFileSync(join(dir, 'index.ts'), ` + import { createOidcApi } from './oidc.js'; + export const oidcAuthApi = createOidcApi(['google', 'github']); + export const looseAuthApi = createOidcApi([] as string[]); + `); + + const client = { toJSON: () => ({ __blocks: 'oidc/client', providers: [] }) }; + const oidcAuthApi = new ApiNamespace(null as any, 'oidc', () => ({ async getClient() { return client; } })); + const looseAuthApi = new ApiNamespace(null as any, 'oidc', () => ({ async getClient() { return client; } })); + const loader = async () => ({ oidcAuthApi, looseAuthApi }) as Record; + + try { + const doc = await generateSpec(join(dir, 'index.ts'), loader); + const byName = new Map(doc.methods.map((m) => [m.name, m])); + assert.deepStrictEqual(byName.get('oidcAuthApi.getClient')?.result?.schema, { + 'x-blocks-transferable': 'oidc/client', + 'x-blocks-type-args': [{ type: 'string', enum: ['google', 'github'] }], + }); + // No component schema is hoisted for an inline enum. + assert.deepStrictEqual(Object.keys(doc.components?.schemas ?? {}), []); + // A plain `string` provider is the default, so no type args are emitted. + assert.deepStrictEqual(byName.get('looseAuthApi.getClient')?.result?.schema, { + 'x-blocks-transferable': 'oidc/client', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});