From 2ad69d541523e883c3ace4f262630e88e0a6391f Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Tue, 20 Oct 2026 10:51:24 -0700 Subject: [PATCH 1/5] feat(core): emit an OIDC client's inferred provider names Without written type args, an oidc/client result now carries the provider names the type checker infers, as a string enum. AuthOIDC.createApi() types getClient() as OIDCClient>, which resolves to the configured names, so the spec gets e.g. [{ "type": "string", "enum": ["google", "github"] }]. Names that resolve to plain string or any emit nothing. Inference stays limited to oidc/client: an inferred realtime message type can be any or a primitive that native generators can't decode, and a declared binding's arity check would reject inferred args. Written type args are unchanged. --- .changeset/inferred-transferable-type-args.md | 16 ++++ .../core/src/scripts/extract-ts-types.test.ts | 75 +++++++++++++++++++ packages/core/src/scripts/extract-ts-types.ts | 30 +++++++- .../core/src/scripts/generate-spec.test.ts | 52 +++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .changeset/inferred-transferable-type-args.md 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/packages/core/src/scripts/extract-ts-types.test.ts b/packages/core/src/scripts/extract-ts-types.test.ts index 42252bf93..2373dfe86 100644 --- a/packages/core/src/scripts/extract-ts-types.test.ts +++ b/packages/core/src/scripts/extract-ts-types.test.ts @@ -512,6 +512,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 ceb1caac5..de637047d 100644 --- a/packages/core/src/scripts/extract-ts-types.ts +++ b/packages/core/src/scripts/extract-ts-types.ts @@ -558,6 +558,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, @@ -596,7 +600,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) { @@ -611,11 +616,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 }); + } + }); +}); From 26a9c761594cbf9e4bf255c0080fda6a97c4a820 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Tue, 20 Oct 2026 10:51:25 -0700 Subject: [PATCH 2/5] fix(kotlin, dart): ignore an oidc/client's provider type args The spec now fills an oidc/client's x-blocks-type-args with the app's provider names. Neither generator uses them yet, and turning them into an unused enum breaks codegen: Kotlin throws on a name such as login.gov, and Dart emits constants such as default or okta:corp that don't compile. Both now drop the type args, so the generated OidcClient result is unchanged. --- native/dart/packages/blocks_codegen/CHANGELOG.md | 4 ++++ .../packages/blocks_codegen/lib/src/builder.dart | 10 +++++++--- .../.changeset/kotlin-oidc-type-args-ignored.md | 8 ++++++++ .../com/aws/blocks/kotlin/parser/OpenRpcParser.kt | 13 +++++++++---- 4 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 native/kotlin/.changeset/kotlin-oidc-type-args-ignored.md 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) } From a534a3f31799ddb4b3db034cbb5d083a5ac2a6d1 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Tue, 20 Oct 2026 10:51:25 -0700 Subject: [PATCH 3/5] feat(swift): generate an OIDC provider enum for typed sign-in A direct or optional oidc/client result whose first type arg is a string enum generates a CaseIterable, Sendable Provider enum in the operation's enum (e.g. OidcAuthApi.GetClient.Provider) and a signIn(provider:relayTo:launcher:) overload on BlocksRuntime.OIDCClient that forwards its raw value, so signIn(provider: .google, ...) checks the name at compile time. getClient() still returns OIDCClient and the String overload stays. The enum is named ResultProvider, with a swift: warning, when the method's own types already use Provider. Case names are camelCased, made legal and backticked when they're keywords. A nested oidc/client keeps decoding as JSONValue with no enum. Fixture 37-oidc-provider-enum covers all three platforms. --- docs/native-clients/codegen-design.md | 2 +- .../schema-generation-guide-for-devs.md | 17 +- .../37-oidc-provider-enum/dart/client.dart | 49 +++++ .../37-oidc-provider-enum/kotlin/Auth.kt | 19 ++ .../kotlin/LegacyAuth.kt | 19 ++ .../37-oidc-provider-enum/kotlin/Servers.kt | 7 + .../37-oidc-provider-enum/spec.json | 27 +++ .../37-oidc-provider-enum/swift/Api.swift | 110 ++++++++++ .../37-oidc-provider-enum/swift/Models.swift | 0 .../.changeset/swift-oidc-provider-enum.md | 17 ++ native/swift/AGENTS.md | 2 +- native/swift/README.md | 1 + .../BlocksCodegen/CodegenModelBuilder.swift | 55 ++++- .../swift/Sources/BlocksCodegen/Helpers.swift | 2 +- .../SwiftCodeGenerator+OIDCProviders.swift | 91 ++++++++ .../BlocksCodegen/SwiftCodeGenerator.swift | 14 +- .../SwiftCodeGeneratorTests.swift | 201 ++++++++++++++++++ .../AppDeclaredTypes.swift | 2 + .../GeneratedEntryPointTests.swift | 23 +- .../BlocksGeneratedCodeTests/blocks.spec.json | 6 +- .../MainActorDefaultTests.swift | 29 +++ .../blocks.spec.json | 6 +- 22 files changed, 673 insertions(+), 26 deletions(-) create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/dart/client.dart create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/spec.json create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift create mode 100644 native/codegen-fixtures/37-oidc-provider-enum/swift/Models.swift create mode 100644 native/swift/.changeset/swift-oidc-provider-enum.md create mode 100644 native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift 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..3c01adc6f 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); `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 an `OIDCClient.signIn(provider:relayTo:launcher:)` overload that takes it. 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..4b8364c18 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt @@ -0,0 +1,19 @@ +package com.example.app + +import com.aws.blocks.kotlin.BlocksClient +import com.aws.blocks.kotlin.BlocksServer +import kotlin.Deprecated +import kotlin.DeprecationLevel +import kotlin.Nothing + +public class Auth( + private val server: BlocksServer = Servers.local, +) { + private val client: BlocksClient = BlocksClient(server) + + @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.") +} 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..9e7402674 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt @@ -0,0 +1,19 @@ +package com.example.app + +import com.aws.blocks.kotlin.BlocksClient +import com.aws.blocks.kotlin.BlocksServer +import kotlin.Deprecated +import kotlin.DeprecationLevel +import kotlin.Nothing + +public class LegacyAuth( + private val server: BlocksServer = Servers.local, +) { + private val client: BlocksClient = BlocksClient(server) + + @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.") +} 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..aec47932d --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt @@ -0,0 +1,7 @@ +package com.example.app + +import com.aws.blocks.kotlin.BlocksServer + +public object Servers { + public val local: BlocksServer = BlocksServer(name = "local", url = "http://localhost:3001") +} 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..39443b282 --- /dev/null +++ b/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift @@ -0,0 +1,110 @@ +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, Sendable { + case google + case github + case mySso = "my-sso" + case loginGov = "login.gov" + } + } +} + +extension BlocksRuntime.OIDCClient { + /// Signs in with one of the providers `auth.getClient` returns a client for. + public func signIn( + provider: Auth.GetClient.Provider, + relayTo: String, + launcher: any BlocksRuntime.BrowserLauncher + ) async throws -> BlocksRuntime.OIDCUser { + try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher) + } +} + +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/swift/.changeset/swift-oidc-provider-enum.md b/native/swift/.changeset/swift-oidc-provider-enum.md new file mode 100644 index 000000000..8a3de6259 --- /dev/null +++ b/native/swift/.changeset/swift-oidc-provider-enum.md @@ -0,0 +1,17 @@ +--- +"aws-blocks-swift": minor +--- + +feat(swift): generate an OIDC provider enum for typed sign-in + +A direct or optional `oidc/client` result whose `x-blocks-type-args[0]` is a +string enum now generates a `CaseIterable` `Provider` enum in the method's +namespace (e.g. `OidcAuthApi.GetClient.Provider`) and an +`OIDCClient.signIn(provider:relayTo:launcher:)` overload that takes it, so +`signIn(provider: .google, ...)` checks the provider name at compile time. When +the method's own types already use the name `Provider`, the enum is +`ResultProvider` and codegen prints a `swift:` warning that names it. +`getClient()` still returns `OIDCClient`, and the `String` overload stays for +untyped specs. 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 64122d40c..7cac294fa 100644 --- a/native/swift/AGENTS.md +++ b/native/swift/AGENTS.md @@ -64,7 +64,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 29 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..29387f9e8 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.** When the spec names an OIDC client's providers, the method's namespace gets a `CaseIterable` `Provider` enum, and `OIDCClient` gets a `signIn` overload that takes it: `let user = try await client.signIn(provider: .google, relayTo: "myapp://auth", launcher: launcher)`. The `String` overload stays. With two typed `getClient` methods whose providers share a name, `.google` is ambiguous; name the enum, such as `OidcAuthApi.GetClient.Provider.google`. 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..a98df1272 100644 --- a/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift +++ b/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift @@ -391,22 +391,55 @@ 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, declaredNames: declaredNames ) - } + : 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], + declaredNames: Set + ) -> [ResolvedType] { + guard operationLevel, case .unionLiteral(let values)? = typeArgs.first else { return [] } + // The operation enum's types, which a root union's variant structs join. With any of them, also the + // models they may name, which the enum would shadow. + let roots = inlineTypes.filter { $0.parentId == nil } + var taken = roots.isEmpty ? [] : declaredNames + for entry in roots { + taken.insert(entry.shortName) + if case .union(_, let variants, _) = entry.type { + taken.formUnion(variants.map(\.name)) + } + } + var enumName = "Provider" + while taken.contains(enumName) { + enumName = pascalCase(name) + enumName + } + return [.enum(name: enumName, values: values)] + } + // 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..40d292b69 --- /dev/null +++ b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift @@ -0,0 +1,91 @@ +// +// 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 a `signIn` overload on `OIDCClient` +// that takes it. `OIDCClient` itself stays untyped. + +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.") + lines.append("\(indent)public enum \(provider.name): String, CaseIterable, Codable, Sendable {") + 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)}") + } + + /// A `signIn` overload on `OIDCClient` that takes the operation's provider enum. Runtime names are qualified, + /// since a model or an app type may take them. + func emitOIDCProviderSignIn(_ operation: Operation, namespace: String, className: String, lines: inout [String]) { + guard let provider = oidcProviderEnum(operation) else { return } + let providerType = "\(className).\(pascalCase(operation.name)).\(provider.name)" + lines.append("extension BlocksRuntime.OIDCClient {") + lines.append(" /// Signs in with one of the providers `\(fullMethodName(operation, namespace: namespace))` returns a client for.") + lines.append(" public func signIn(") + lines.append(" provider: \(providerType),") + lines.append(" relayTo: String,") + lines.append(" launcher: any BlocksRuntime.BrowserLauncher") + lines.append(" ) async throws -> BlocksRuntime.OIDCUser {") + lines.append(" try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher)") + lines.append(" }") + lines.append("}") + lines.append("") + } + + /// 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..ff85fecd2 100644 --- a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift +++ b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift @@ -145,13 +145,20 @@ 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 { + emitOIDCProviderSignIn(operation, namespace: namespace.name, className: names.className, lines: &apiLines) + if let warning = oidcProviderRenameWarning(operation, namespace: namespace.name, className: names.className) { + warnings.append(warning) + } + } } emitBlocksEntryPoint(entryPoint.names, defaultServer: defaultServerName, lines: &apiLines) @@ -166,12 +173,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)}") } diff --git a/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift b/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift index d32619cd4..2d8eb718a 100644 --- a/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift +++ b/native/swift/Tests/BlocksCodegenTests/SwiftCodeGeneratorTests.swift @@ -780,6 +780,207 @@ final class SwiftCodeGeneratorTests: XCTestCase { ]) } + // MARK: - OIDC Provider Enums + + private func oidcSpec(_ methods: String) -> String { + """ + { + "openrpc": "1.3.2", + "info": { "title": "test", "version": "1.0.0" }, + "methods": [\(methods)] + } + """ + } + + func testTypedOIDCClientEmitsProviderEnumAndSignInOverload() 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 {"), "the client type is unchanged, got:\n\(output.api)") + XCTAssertTrue(output.api.contains(""" + public enum GetClient { + + /// The providers `auth.getClient` signs in with. + public enum Provider: String, CaseIterable, Codable, Sendable { + case google + case github + case mySso = "my-sso" + } + } + """), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains(""" + extension BlocksRuntime.OIDCClient { + /// Signs in with one of the providers `auth.getClient` returns a client for. + public func signIn( + provider: Auth.GetClient.Provider, + relayTo: String, + launcher: any BlocksRuntime.BrowserLauncher + ) async throws -> BlocksRuntime.OIDCUser { + try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher) + } + } + """), "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)") + XCTAssertFalse(output.api.contains("public enum GetClient"), "no nested types for \(typeArgs), got:\n\(output.api)") + XCTAssertFalse(output.api.contains("extension BlocksRuntime.OIDCClient"), "no overload 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("provider: Auth.FindClient.Provider,"), "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)") + XCTAssertFalse(output.api.contains("extension BlocksRuntime.OIDCClient"), "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, Sendable { + 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, Sendable {"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("provider: Auth.GetClient.ResultProvider,"), "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, Sendable {"), "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, Sendable {"), "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("provider: Auth.GetClient.Provider,"), "got:\n\(unshadowed.api)") + XCTAssertTrue(unshadowed.models.contains("public nonisolated struct Provider: Codable {"), "got:\n\(unshadowed.models)") + XCTAssertEqual(unshadowed.warnings, []) + } + + 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 testDefaultNamespaceProviderEnumIsQualifiedByItsClass() 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("provider: _Default.GetClient.Provider,"), "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/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 89c24c9e2..20c0c3432 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""# } @@ -133,6 +133,20 @@ final class GeneratedEntryPointTests: XCTestCase { XCTAssertIdentical(blocks.client.tokenStore as AnyObject, store) } + func testTypedSignInSendsTheProvidersWireName() async throws { + let blocks = Blocks(server: Servers.local, session: mockSession()) + let auth = 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() @@ -195,3 +209,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..0d3f323a6 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" } } }, From 87fd8dff36a2bc5cb12c18514975cd1df5445c21 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Thu, 22 Oct 2026 10:18:45 -0700 Subject: [PATCH 4/5] feat(swift): type OIDCClient by the spec's provider names OIDCClient is now generic over the providers signIn takes: OIDCClient, with Provider phantom. A typed spec's getClient() returns and hydrates OIDCClient, so a client accepts only its own providers and another namespace's enum no longer compiles. String conforms to OIDCProviderName, so an untyped spec returns OIDCClient and still signs in with a string. The generated signIn extension is gone. Breaking: a bare OIDCClient in a type position no longer compiles; write OIDCClient, or the typed form, including for its static members. Also from review: the Provider enum is renamed only for names the operation's own types declare or reference, not every model in the spec, and the rename numbers the name when there's no prefix, so it terminates. --- .../schema-generation-guide-for-devs.md | 4 +- .../37-oidc-provider-enum/swift/Api.swift | 21 +--- .../.changeset/swift-oidc-provider-enum.md | 28 ++--- native/swift/README.md | 2 +- .../BlocksCodegen/CodegenModelBuilder.swift | 51 +++++++-- .../SwiftCodeGenerator+OIDCProviders.swift | 25 +---- .../BlocksCodegen/SwiftCodeGenerator.swift | 7 +- .../BlocksCodegen/TransferableEmission.swift | 6 +- .../Sources/BlocksRuntime/BlocksClient.swift | 2 +- .../BlocksRuntime/OIDC/OIDCClient.swift | 26 ++++- .../SwiftCodeGeneratorTests.swift | 101 ++++++++++++------ .../Tests/BlocksE2ETests/OIDCE2ETests.swift | 2 +- .../GeneratedEntryPointTests.swift | 3 +- .../MainActorDefaultTests.swift | 2 +- .../BlocksClientLifecycleTests.swift | 2 +- .../BlocksRuntimeTests/OIDCClientTests.swift | 20 ++-- 16 files changed, 188 insertions(+), 114 deletions(-) diff --git a/docs/native-clients/schema-generation-guide-for-devs.md b/docs/native-clients/schema-generation-guide-for-devs.md index 3c01adc6f..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); `x-blocks-type-args[0]`, when the emitter infers it, is a string enum of provider names | `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. @@ -431,7 +431,7 @@ The one exception is an `oidc/client`, which carries the provider names the type } ``` -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 an `OIDCClient.signIn(provider:relayTo:launcher:)` overload that takes it. Kotlin and Dart ignore it for now. +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/swift/Api.swift b/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift index 39443b282..20c19b862 100644 --- a/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift +++ b/native/codegen-fixtures/37-oidc-provider-enum/swift/Api.swift @@ -14,20 +14,20 @@ public final nonisolated class Auth: Sendable { } /// Calls `auth.getClient`. - public func getClient() async throws -> OIDCClient { + 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) + 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, Sendable { + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { case google case github case mySso = "my-sso" @@ -36,17 +36,6 @@ public final nonisolated class Auth: Sendable { } } -extension BlocksRuntime.OIDCClient { - /// Signs in with one of the providers `auth.getClient` returns a client for. - public func signIn( - provider: Auth.GetClient.Provider, - relayTo: String, - launcher: any BlocksRuntime.BrowserLauncher - ) async throws -> BlocksRuntime.OIDCUser { - try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher) - } -} - public final nonisolated class LegacyAuth: Sendable { private let client: BlocksClient @@ -60,14 +49,14 @@ public final nonisolated class LegacyAuth: Sendable { } /// Calls `legacyAuth.getClient`. - public func getClient() async throws -> OIDCClient { + 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) + return try OIDCClient.fromJSON(descriptor, baseUrl: self.client.baseUrl, client: self.client) } } diff --git a/native/swift/.changeset/swift-oidc-provider-enum.md b/native/swift/.changeset/swift-oidc-provider-enum.md index 8a3de6259..f5660bebf 100644 --- a/native/swift/.changeset/swift-oidc-provider-enum.md +++ b/native/swift/.changeset/swift-oidc-provider-enum.md @@ -2,16 +2,20 @@ "aws-blocks-swift": minor --- -feat(swift): generate an OIDC provider enum for typed sign-in +feat(swift): type `OIDCClient` by the spec's provider names -A direct or optional `oidc/client` result whose `x-blocks-type-args[0]` is a -string enum now generates a `CaseIterable` `Provider` enum in the method's -namespace (e.g. `OidcAuthApi.GetClient.Provider`) and an -`OIDCClient.signIn(provider:relayTo:launcher:)` overload that takes it, so -`signIn(provider: .google, ...)` checks the provider name at compile time. When -the method's own types already use the name `Provider`, the enum is -`ResultProvider` and codegen prints a `swift:` warning that names it. -`getClient()` still returns `OIDCClient`, and the `String` overload stays for -untyped specs. 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. +**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/README.md b/native/swift/README.md index 29387f9e8..38efe2a7d 100644 --- a/native/swift/README.md +++ b/native/swift/README.md @@ -73,7 +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.** When the spec names an OIDC client's providers, the method's namespace gets a `CaseIterable` `Provider` enum, and `OIDCClient` gets a `signIn` overload that takes it: `let user = try await client.signIn(provider: .google, relayTo: "myapp://auth", launcher: launcher)`. The `String` overload stays. With two typed `getClient` methods whose providers share a name, `.google` is ambiguous; name the enum, such as `OidcAuthApi.GetClient.Provider.google`. When the method's own types already use the name `Provider`, the enum is `ResultProvider` and codegen prints a `swift:` warning that names it. +- **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 a98df1272..330af7762 100644 --- a/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift +++ b/native/swift/Sources/BlocksCodegen/CodegenModelBuilder.swift @@ -395,7 +395,7 @@ public struct CodegenModelBuilder { let resolvedArgs = blocksType == "oidc/client" ? oidcProviderArgs( typeArgs, name: name, operationLevel: parentSchemaName == nil && currentParentId == nil, - inlineTypes: inlineTypes, declaredNames: declaredNames + inlineTypes: inlineTypes ) : typeArgs.map { arg in resolveType( @@ -419,27 +419,60 @@ public struct CodegenModelBuilder { _ typeArgs: [TypeRef], name: String, operationLevel: Bool, - inlineTypes: [InlineTypeEntry], - declaredNames: Set + 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. With any of them, also the - // models they may name, which the enum would shadow. - let roots = inlineTypes.filter { $0.parentId == nil } - var taken = roots.isEmpty ? [] : declaredNames - for entry in roots { + // 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) { - enumName = pascalCase(name) + 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/SwiftCodeGenerator+OIDCProviders.swift b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift index 40d292b69..765dd7303 100644 --- a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift +++ b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator+OIDCProviders.swift @@ -10,8 +10,8 @@ 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 a `signIn` overload on `OIDCClient` -// that takes it. `OIDCClient` itself stays untyped. +// 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. @@ -26,7 +26,8 @@ extension SwiftCodeGenerator { guard let provider = oidcProviderEnum(operation) else { return } lines.append("") lines.append("\(indent)/// The providers `\(fullMethodName(operation, namespace: namespace))` signs in with.") - lines.append("\(indent)public enum \(provider.name): String, CaseIterable, Codable, Sendable {") + 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 @@ -36,24 +37,6 @@ extension SwiftCodeGenerator { lines.append("\(indent)}") } - /// A `signIn` overload on `OIDCClient` that takes the operation's provider enum. Runtime names are qualified, - /// since a model or an app type may take them. - func emitOIDCProviderSignIn(_ operation: Operation, namespace: String, className: String, lines: inout [String]) { - guard let provider = oidcProviderEnum(operation) else { return } - let providerType = "\(className).\(pascalCase(operation.name)).\(provider.name)" - lines.append("extension BlocksRuntime.OIDCClient {") - lines.append(" /// Signs in with one of the providers `\(fullMethodName(operation, namespace: namespace))` returns a client for.") - lines.append(" public func signIn(") - lines.append(" provider: \(providerType),") - lines.append(" relayTo: String,") - lines.append(" launcher: any BlocksRuntime.BrowserLauncher") - lines.append(" ) async throws -> BlocksRuntime.OIDCUser {") - lines.append(" try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher)") - lines.append(" }") - lines.append("}") - lines.append("") - } - /// 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 } diff --git a/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift b/native/swift/Sources/BlocksCodegen/SwiftCodeGenerator.swift index ff85fecd2..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 } } @@ -154,7 +157,6 @@ public struct SwiftCodeGenerator { apiLines.append("") for operation in namespace.operations { - emitOIDCProviderSignIn(operation, namespace: namespace.name, className: names.className, lines: &apiLines) if let warning = oidcProviderRenameWarning(operation, namespace: namespace.name, className: names.className) { warnings.append(warning) } @@ -1351,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 d415710d9..f37cad42a 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 e4fc85f3b..8325c6d18 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 @@ -112,10 +130,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) } @@ -444,7 +463,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 2d8eb718a..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,7 +774,7 @@ 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." ]) @@ -792,37 +792,30 @@ final class SwiftCodeGeneratorTests: XCTestCase { """ } - func testTypedOIDCClientEmitsProviderEnumAndSignInOverload() throws { + 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 {"), "the client type is unchanged, got:\n\(output.api)") + 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, Sendable { + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { case google case github case mySso = "my-sso" } } """), "got:\n\(output.api)") - XCTAssertTrue(output.api.contains(""" - extension BlocksRuntime.OIDCClient { - /// Signs in with one of the providers `auth.getClient` returns a client for. - public func signIn( - provider: Auth.GetClient.Provider, - relayTo: String, - launcher: any BlocksRuntime.BrowserLauncher - ) async throws -> BlocksRuntime.OIDCUser { - try await signIn(provider: provider.rawValue, relayTo: relayTo, launcher: launcher) - } - } - """), "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, []) } @@ -837,9 +830,9 @@ final class SwiftCodeGeneratorTests: XCTestCase { 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 {"), "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)") - XCTAssertFalse(output.api.contains("extension BlocksRuntime.OIDCClient"), "no overload for \(typeArgs), got:\n\(output.api)") } } @@ -850,8 +843,8 @@ final class SwiftCodeGeneratorTests: XCTestCase { { "type": "null" } ]}}} """)) - XCTAssertTrue(output.api.contains("-> OIDCClient? {"), "got:\n\(output.api)") - XCTAssertTrue(output.api.contains("provider: Auth.FindClient.Provider,"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("-> OIDCClient? {"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("OIDCClient.fromJSON(descriptor"), "got:\n\(output.api)") } func testNestedTypedOIDCClientHasNoProviderEnum() throws { @@ -865,7 +858,6 @@ final class SwiftCodeGeneratorTests: XCTestCase { """)) 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)") - XCTAssertFalse(output.api.contains("extension BlocksRuntime.OIDCClient"), "got:\n\(output.api)") } func testProviderCaseNamesAreLegalSwift() throws { @@ -876,7 +868,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { }}} """)) XCTAssertTrue(output.api.contains(""" - public enum Provider: String, CaseIterable, Codable, Sendable { + public enum Provider: String, CaseIterable, Codable, BlocksRuntime.OIDCProviderName { case `default` case _1password = "1password" case google = "Google" @@ -896,9 +888,11 @@ final class SwiftCodeGeneratorTests: XCTestCase { "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, Sendable {"), "got:\n\(output.api)") - XCTAssertTrue(output.api.contains("provider: Auth.GetClient.ResultProvider,"), "got:\n\(output.api)") + 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." ]) @@ -918,7 +912,10 @@ final class SwiftCodeGeneratorTests: XCTestCase { ], "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, Sendable {"), "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: """ { @@ -932,7 +929,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { "components": { "schemas": { "Provider": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } } } """) - XCTAssertTrue(model.api.contains("public enum ResultProvider: String, CaseIterable, Codable, Sendable {"), "got:\n\(model.api)") + 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: """ @@ -948,11 +945,51 @@ final class SwiftCodeGeneratorTests: XCTestCase { "components": { "schemas": { "Provider": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } } } """) - XCTAssertTrue(unshadowed.api.contains("provider: Auth.GetClient.Provider,"), "got:\n\(unshadowed.api)") + 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: """ { @@ -970,7 +1007,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { XCTAssertFalse(output.api.contains("Provider"), "got:\n\(output.api)") } - func testDefaultNamespaceProviderEnumIsQualifiedByItsClass() throws { + func testDefaultNamespaceClientIsTypedByItsProviderEnum() throws { let output = try generate(from: oidcSpec(""" { "name": "getClient", "params": [], "result": { "name": "R", "schema": { "x-blocks-transferable": "oidc/client", @@ -978,7 +1015,7 @@ final class SwiftCodeGeneratorTests: XCTestCase { }}} """)) XCTAssertTrue(output.api.contains("/// The providers `getClient` signs in with."), "got:\n\(output.api)") - XCTAssertTrue(output.api.contains("provider: _Default.GetClient.Provider,"), "got:\n\(output.api)") + XCTAssertTrue(output.api.contains("public func getClient() async throws -> OIDCClient {"), "got:\n\(output.api)") } func testUnboundTransferableDiagnosticQualifiesDefaultNamespace() throws { 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/GeneratedEntryPointTests.swift b/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift index 20c0c3432..579ad94bb 100644 --- a/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift +++ b/native/swift/Tests/BlocksGeneratedCodeTests/GeneratedEntryPointTests.swift @@ -135,7 +135,8 @@ final class GeneratedEntryPointTests: XCTestCase { func testTypedSignInSendsTheProvidersWireName() async throws { let blocks = Blocks(server: Servers.local, session: mockSession()) - let auth = try await blocks.auth.getClient() + // 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 { diff --git a/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift b/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift index 0d3f323a6..9239e3973 100644 --- a/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift +++ b/native/swift/Tests/BlocksGeneratedMainActorTests/MainActorDefaultTests.swift @@ -63,7 +63,7 @@ final nonisolated class MainActorDefaultTests: XCTestCase { 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( + let oidc = OIDCClient( exchangePath: "/auth/exchange", refreshPath: "/auth/exchange/refresh", signOutPath: "/auth/signout", providers: ["github"], providerConfigs: [:], baseUrl: blocks.client.baseUrl, client: blocks.client ) 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 5453618ce..1574e117e 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,11 +173,11 @@ 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)) } func testRandomValuesAreFullLengthNonZeroAndDistinct() throws { - let values = (0 ..< 8).map { _ in OIDCClient.generateVerifier() } + [OIDCClient.generateNonce()] + let values = (0 ..< 8).map { _ in OIDCClient.generateVerifier() } + [OIDCClient.generateNonce()] for value in values { // 32 bytes → 43 base64url chars (no padding) XCTAssertEqual(value.count, 43) @@ -552,7 +552,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 @@ -562,7 +562,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")) } From 3bb2db7b90f3312c3168a419eba78f1c77390a14 Mon Sep 17 00:00:00 2001 From: Abhash Kumar Singh Date: Thu, 22 Oct 2026 11:47:46 -0700 Subject: [PATCH 5/5] test(native): regenerate fixture 37's Kotlin goldens for the Blocks entry point main's Kotlin Blocks entry point (#704) changed generated namespace and Servers files, so fixture 37's Kotlin goldens are regenerated. The typed and untyped OIDC clients still generate the same Kotlin. --- .../37-oidc-provider-enum/kotlin/Auth.kt | 9 +++++---- .../37-oidc-provider-enum/kotlin/LegacyAuth.kt | 9 +++++---- .../37-oidc-provider-enum/kotlin/Servers.kt | 6 +++++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt index 4b8364c18..d79f53ece 100644 --- a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Auth.kt @@ -1,19 +1,20 @@ package com.example.app +import com.aws.blocks.kotlin.Blocks import com.aws.blocks.kotlin.BlocksClient -import com.aws.blocks.kotlin.BlocksServer import kotlin.Deprecated import kotlin.DeprecationLevel import kotlin.Nothing public class Auth( - private val server: BlocksServer = Servers.local, + private val client: BlocksClient, ) { - private val client: BlocksClient = BlocksClient(server) - @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 index 9e7402674..d879dfe9a 100644 --- a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/LegacyAuth.kt @@ -1,19 +1,20 @@ package com.example.app +import com.aws.blocks.kotlin.Blocks import com.aws.blocks.kotlin.BlocksClient -import com.aws.blocks.kotlin.BlocksServer import kotlin.Deprecated import kotlin.DeprecationLevel import kotlin.Nothing public class LegacyAuth( - private val server: BlocksServer = Servers.local, + private val client: BlocksClient, ) { - private val client: BlocksClient = BlocksClient(server) - @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 index aec47932d..e4b313012 100644 --- a/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt +++ b/native/codegen-fixtures/37-oidc-provider-enum/kotlin/Servers.kt @@ -1,7 +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") + public val local: BlocksServer = + BlocksServer(name = "local", url = "http://localhost:3001/aws-blocks/api") } + +public operator fun Blocks.Companion.invoke(): Blocks = Blocks(Servers.local)