From 364f7d5828c542a8cc1aa687cdd7af2c2cf34581 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Fri, 14 Aug 2026 15:19:56 -0700 Subject: [PATCH] feat(generator): add Rust SDK backend --- README.md | 4 +- packages/sdk-generator/README.md | 6 +- .../__tests__/backends/rust.test.ts | 112 ++++ packages/sdk-generator/package.json | 3 +- .../src/backends/contract-tests/index.ts | 5 +- .../backends/contract-tests/rust-emitter.ts | 238 ++++++++ .../src/backends/rust/identifiers.ts | 41 ++ .../sdk-generator/src/backends/rust/index.ts | 559 ++++++++++++++++++ packages/sdk-generator/src/index.ts | 24 +- 9 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 packages/sdk-generator/__tests__/backends/rust.test.ts create mode 100644 packages/sdk-generator/src/backends/contract-tests/rust-emitter.ts create mode 100644 packages/sdk-generator/src/backends/rust/identifiers.ts create mode 100644 packages/sdk-generator/src/backends/rust/index.ts diff --git a/README.md b/README.md index 7efdef5..31dab56 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OpenAPI tooling for the ArchAstro platform. Two tools live here: | Package | What it does | Install / run | | --- | --- | --- | -| [`@archastro/sdk-generator`](./packages/sdk-generator) | Reads an OpenAPI spec and emits typed TypeScript / Python / Swift / Go SDKs plus cross-language contract tests. | `npx @archastro/sdk-generator` / `sdk-generator` | +| [`@archastro/sdk-generator`](./packages/sdk-generator) | Reads an OpenAPI spec and emits typed TypeScript / Python / Swift / Go / Elixir / Rust SDKs plus cross-language contract tests. | `npx @archastro/sdk-generator` / `sdk-generator` | | [`@archastro/channel-harness`](./packages/channel-harness) | Runtime contract-testing harness for Phoenix `x-channels` declared in the spec. Exposes a WebSocket + HTTP control API so TS, Python, (or any other) test suites can drive the same server. | `npx @archastro/channel-harness` / `channel-harness` | --- @@ -33,11 +33,13 @@ Supported `--lang` values: - `go` — emit a typed Go SDK (structs with JSON tags, context-taking resource methods, channel helpers) - `elixir` — emit a typed Elixir SDK under `lib/archastro/generated` +- `rust` — emit typed Rust models, async/blocking resources, SSE, auth, and channel facades - `contract-tests-ts` — emit TS contract tests that drive the channel harness - `contract-tests-py` — emit Python contract tests (pytest + prism mock server) - `contract-tests-swift` — emit swift-testing contract tests - `contract-tests-go` — emit Go contract tests (`go test`, prism + harness) - `contract-tests-elixir` — emit Elixir contract tests (ExUnit + prism + harness) +- `contract-tests-rust` — emit Rust integration contracts (cargo test + Prism + harness) Other flags: diff --git a/packages/sdk-generator/README.md b/packages/sdk-generator/README.md index cf21ba6..a85b526 100644 --- a/packages/sdk-generator/README.md +++ b/packages/sdk-generator/README.md @@ -1,6 +1,6 @@ # @archastro/sdk-generator -Generate typed TypeScript, Python, Swift, Go, and Elixir SDKs — plus cross-language +Generate typed TypeScript, Python, Swift, Go, Elixir, and Rust SDKs — plus cross-language contract tests — from an OpenAPI spec produced by the ArchAstro API DSL. ## Install @@ -18,7 +18,7 @@ sdk-generator --spec ./openapi.json --lang python --out ./sdk ``` sdk-generator --spec \ - [--lang typescript|python|swift|go|elixir|contract-tests-ts|contract-tests-py|contract-tests-swift|contract-tests-go|contract-tests-elixir] \ + [--lang typescript|python|swift|go|elixir|rust|contract-tests-ts|contract-tests-py|contract-tests-swift|contract-tests-go|contract-tests-elixir|contract-tests-rust] \ [--out ] \ [--config ] \ [--ast-only] @@ -33,11 +33,13 @@ Targets: | `swift` | Swift SDK: Codable models, resources, channels, async client | | `go` | Go SDK: JSON-tagged structs, context-taking resources, channels, client | | `elixir` | Elixir SDK: structs, resource modules, auth, and Phoenix Channel facades | +| `rust` | Rust SDK: serde models, async/blocking resources, typed SSE, auth, and channel facades | | `contract-tests-ts` | TS contract tests that drive `@archastro/channel-harness` | | `contract-tests-py` | Python contract tests (pytest + Prism mock server) | | `contract-tests-swift` | Swift contract tests (swift-testing + Prism + harness) | | `contract-tests-go` | Go contract tests (`go test` + Prism + harness) | | `contract-tests-elixir` | Elixir contract tests (ExUnit + Prism + harness) | +| `contract-tests-rust` | Rust integration contracts (`cargo test` + Prism + harness) | ### Go target configuration diff --git a/packages/sdk-generator/__tests__/backends/rust.test.ts b/packages/sdk-generator/__tests__/backends/rust.test.ts new file mode 100644 index 0000000..e35daf3 --- /dev/null +++ b/packages/sdk-generator/__tests__/backends/rust.test.ts @@ -0,0 +1,112 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { emitRustContractTests } from "../../src/backends/contract-tests/rust-emitter.js"; +import { rustIdent, rustTypeName, uniqueRustNames } from "../../src/backends/rust/identifiers.js"; +import { generateRust, rustType } from "../../src/backends/rust/index.js"; +import { parseOpenApiSpec } from "../../src/frontend/index.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse(readFileSync(resolve(here, "../fixtures/sample-spec.json"), "utf8")); + +function ast() { return parseOpenApiSpec(fixture, { apiBase: "/api", defaultVersion: "v1" }); } + +describe("rust identifiers", () => { + it("uses idiomatic snake and Pascal case while escaping keywords", () => { + expect(rustIdent("createdAt")).toBe("created_at"); + expect(rustIdent("type")).toBe("type_"); + expect(rustTypeName("api_chat_channel")).toBe("ApiChatChannel"); + expect(uniqueRustNames(["type", "type", "type_2"])).toEqual(["type_", "type__2", "type_2"]); + }); +}); + +describe("rust type mapping", () => { + it("maps primitives, collections, nullability, and schema refs", () => { + expect(rustType({ kind: "primitive", type: "datetime" })).toBe("chrono::DateTime"); + expect(rustType({ kind: "array", items: { kind: "ref", schema: "Team" } })).toBe("Vec"); + expect(rustType({ kind: "nullable", inner: { kind: "primitive", type: "string" } })).toBe("Option"); + expect(rustType({ kind: "unknown" })).toBe("Value"); + }); +}); + +describe("rust backend", () => { + it("generates models, auth, channels, version resources, and module wiring", () => { + const files = generateRust(ast(), { outDir: "sdk" }); + expect(Object.keys(files)).toEqual(expect.arrayContaining([ + "sdk/src/generated/types.rs", + "sdk/src/generated/auth.rs", + "sdk/src/generated/channels.rs", + "sdk/src/generated/v1.rs", + "sdk/src/generated/mod.rs", + ])); + const output = Object.values(files).join("\n"); + expect(output).toContain("pub struct V1"); + expect(output).toContain("pub async fn"); + expect(output).toContain("_blocking("); + expect(output).toContain("SseDecode, SseStream"); + expect(output).toContain("ChannelEventStream<"); + }); + + it("lifts synthetic api/version wrappers out of the public namespace", () => { + const files = generateRust(ast(), { outDir: "sdk" }); + const version = files["sdk/src/generated/v1.rs"]!; + expect(version).not.toContain("pub fn api(&self)"); + expect(version).not.toContain("pub fn v1(&self)"); + }); + + it("emits typed enums and unions while boxing only inline recursive references", () => { + const spec = ast(); + spec.schemas.push( + { + name: "Leaf", + fields: [{ name: "value", type: { kind: "primitive", type: "string" }, required: true }], + }, + { + name: "RecursiveNode", + fields: [ + { name: "leaf", type: { kind: "ref", schema: "Leaf" }, required: true }, + { name: "next", type: { kind: "optional", inner: { kind: "ref", schema: "RecursiveNode" } }, required: false }, + { name: "children", type: { kind: "array", items: { kind: "ref", schema: "RecursiveNode" } }, required: true }, + { name: "status", type: { kind: "enum", values: ["in-progress", "in_progress"] }, required: true }, + { + name: "choice", + type: { kind: "union", variants: [ + { kind: "primitive", type: "string" }, + { kind: "primitive", type: "integer" }, + ] }, + required: true, + }, + ], + }, + ); + + const types = generateRust(spec, { outDir: "sdk" })["sdk/src/generated/types.rs"]!; + expect(types).toContain("pub leaf: Leaf,"); + expect(types).toContain("pub next: Option>,"); + expect(types).toContain("pub children: Vec,"); + expect(types).toContain("pub enum RecursiveNodeStatus"); + expect(types).toContain("#[serde(rename = \"in-progress\")]"); + expect(types).toContain("InProgress2,"); + expect(types).toContain("pub enum RecursiveNodeChoice"); + expect(types).toContain("Variant1(String)"); + expect(types).toContain("Variant2(i64)"); + }); +}); + +describe("rust contract emitter", () => { + it("emits REST happy/error cases and SSE harness cases", () => { + const spec = ast(); + const operation = spec.versions[0]!.resources[0]!.children[0]!.operations[0]!; + operation.streaming = { style: "sse", events: [{ event: "updated", dataType: { kind: "unknown" } }] }; + const files = emitRustContractTests(spec, "sdk"); + const rest = files["sdk/tests/generated_rest_contract.rs"]!; + const streams = files["sdk/tests/generated_stream_contract.rs"]!; + expect(rest).toContain("#[tokio::test]"); + expect(rest).toContain("support::assert_api_error"); + expect(rest).toContain("serde_json::from_str"); + expect(streams).toContain("register_stream"); + expect(streams).toContain("StreamExt"); + }); +}); diff --git a/packages/sdk-generator/package.json b/packages/sdk-generator/package.json index c431a89..bd6596c 100644 --- a/packages/sdk-generator/package.json +++ b/packages/sdk-generator/package.json @@ -1,7 +1,7 @@ { "name": "@archastro/sdk-generator", "version": "0.11.0", - "description": "Generate typed TypeScript, Python, Swift, Go, and Elixir SDKs (plus contract tests) from an OpenAPI spec.", + "description": "Generate typed TypeScript, Python, Swift, Go, Elixir, and Rust SDKs (plus contract tests) from an OpenAPI spec.", "keywords": [ "openapi", "sdk", @@ -11,6 +11,7 @@ "swift", "golang", "elixir", + "rust", "archastro" ], "license": "MIT", diff --git a/packages/sdk-generator/src/backends/contract-tests/index.ts b/packages/sdk-generator/src/backends/contract-tests/index.ts index 40d90f8..e8e8fc9 100644 --- a/packages/sdk-generator/src/backends/contract-tests/index.ts +++ b/packages/sdk-generator/src/backends/contract-tests/index.ts @@ -4,12 +4,13 @@ import { emitPythonContractTests } from "./python-emitter.js"; import { emitSwiftContractTests } from "./swift-emitter.js"; import { emitGoContractTests } from "./go-emitter.js"; import { emitElixirContractTests } from "./elixir-emitter.js"; +import { emitRustContractTests } from "./rust-emitter.js"; export type GeneratedFiles = Record; export interface ContractTestOptions { outDir: string; - lang: "typescript" | "python" | "swift" | "go" | "elixir"; + lang: "typescript" | "python" | "swift" | "go" | "elixir" | "rust"; /** Go only: import path of the generated SDK package. */ goImportPath?: string; /** Go only: package alias the generated tests reference the SDK through. */ @@ -39,6 +40,8 @@ export function generateContractTests( }); } else if (options.lang === "elixir") { return emitElixirContractTests(spec, options.outDir); + } else if (options.lang === "rust") { + return emitRustContractTests(spec, options.outDir); } else { return emitPythonContractTests(spec, options); } diff --git a/packages/sdk-generator/src/backends/contract-tests/rust-emitter.ts b/packages/sdk-generator/src/backends/contract-tests/rust-emitter.ts new file mode 100644 index 0000000..efc0ca6 --- /dev/null +++ b/packages/sdk-generator/src/backends/contract-tests/rust-emitter.ts @@ -0,0 +1,238 @@ +import { join } from "node:path"; +import type { BodyDef, ChannelDef, ChannelJoinDef, FieldDef, OperationDef, ParamDef, ResourceDef, SchemaDef, SdkSpec, TypeRef } from "../../ast/types.js"; +import { generatedHeader } from "../../utils/codegen.js"; +import { rustIdent, rustString, rustTypeName } from "../rust/identifiers.js"; + +export const RUST_TESTS_DIR = "tests"; +type GeneratedFiles = Record; + +interface TestCase { + operation: OperationDef; + chain: string; + scopes: ParamDef[]; +} + +export function emitRustContractTests(spec: SdkSpec, outDir: string): GeneratedFiles { + const cases: TestCase[] = []; + for (const version of spec.versions) { + const prefix = `client.${rustIdent(version.sdkName ?? version.version)}()`; + const surface = versionSurface(version.resources, version.version, version.apiPrefix); + for (const operation of surface.operations) cases.push({ operation, chain: prefix, scopes: [] }); + for (const resource of surface.resources) collect(resource, prefix, [], [], cases); + } + for (const operation of spec.authOperations) cases.push({ operation, chain: "client.auth()", scopes: [] }); + + const rest = cases.filter((item) => !item.operation.streaming); + const streams = cases.filter((item) => item.operation.streaming); + return { + [join(outDir, RUST_TESTS_DIR, "generated_rest_contract.rs")]: renderRest(rest, spec.schemas), + [join(outDir, RUST_TESTS_DIR, "generated_stream_contract.rs")]: renderStreams(streams, spec.schemas), + [join(outDir, RUST_TESTS_DIR, "generated_channel_contract.rs")]: renderChannels(spec.channels, spec.schemas), + }; +} + +function collect( + resource: ResourceDef, + parentChain: string, + ancestry: string[], + parentScopes: ParamDef[], + output: TestCase[] +): void { + const introduced = resource.scopeParams.filter((param) => !parentScopes.some((parent) => parent.name === param.name)); + const args = introduced.map((param) => paramValue(param.type, param.name)).join(", "); + const chain = `${parentChain}.${rustIdent(resource.name)}(${args})`; + for (const operation of resource.operations) output.push({ operation, chain, scopes: resource.scopeParams }); + for (const child of resource.children) collect(child, chain, [...ancestry, resource.name], resource.scopeParams, output); +} + +function renderRest(cases: TestCase[], schemas: SchemaDef[]): string { + const lines = [generatedHeader(), "//! Generated REST contract tests.", "mod support;", "use archastro::generated::*;", "", "#[test]", "fn generated_support_is_linked() { support::mark_all_used(); }", ""]; + for (const item of cases) { + const name = rustIdent(item.operation.operationId); + lines.push("#[tokio::test]", "#[ignore = \"requires Prism contract server\"]", `async fn ${name}_success() {`, " let client = support::rest_client(None).await;", ...indentLines(callSetup(item, schemas), 4)); + lines.push(` let result = ${operationCall(item)}.await;`, ` assert!(result.is_ok(), "{}: {:?}", ${rustString(`${item.operation.method} ${item.operation.path}`)}, result.err());`, "}", ""); + for (const status of [...new Set(item.operation.errors.map((error) => error.status))]) { + lines.push("#[tokio::test]", "#[ignore = \"requires Prism contract server\"]", `async fn ${name}_error_${status}() {`, ` let client = support::rest_client(Some(${status})).await;`, ...indentLines(callSetup(item, schemas), 4)); + lines.push(` let error = ${operationCall(item)}.await.expect_err("expected API error");`, ` support::assert_api_error(error, ${status});`, "}", ""); + } + } + return `${lines.join("\n")}\n`; +} + +function renderStreams(cases: TestCase[], schemas: SchemaDef[]): string { + const lines = [generatedHeader(), "//! Generated SSE contract tests.", "mod support;", "use archastro::generated::*;", "use futures_util::StreamExt;", "", "#[test]", "fn generated_support_is_linked() { support::mark_all_used(); }", ""]; + for (const item of cases) { + const name = rustIdent(item.operation.operationId); + const events = item.operation.streaming!.events.map((event) => rustString(event.event)).join(", "); + lines.push("#[tokio::test]", "#[serial_test::serial]", "#[ignore = \"requires channel harness\"]", `async fn ${name}_events() {`, ` let harness = support::harness().await;`, ` harness.register_stream(${rustString(`${item.operation.method} ${item.operation.path}`)}, &[${events}]).await;`, " let client = harness.client();", ...indentLines(callSetup(item, schemas), 4)); + lines.push(` let mut stream = ${operationCall(item)}.await.expect("open stream");`, " let mut count = 0usize;", ` while let Some(event) = stream.next().await { event.expect("decode event"); count += 1; if count == ${item.operation.streaming!.events.length} { break; } }`, ` assert_eq!(count, ${item.operation.streaming!.events.length});`, "}", ""); + } + return `${lines.join("\n")}\n`; +} + +function renderChannels(channels: ChannelDef[], schemas: SchemaDef[]): string { + const lines = [generatedHeader(), "//! Generated Phoenix channel contract tests.", "mod support;", "use archastro::generated::*;", "use futures_util::StreamExt;", "", "#[test]", "fn generated_support_is_linked() { support::mark_all_used(); }", ""]; + for (const channel of channels) { + const facade = rustTypeName(channel.sdkName ?? channel.className); + channel.joins.forEach((joinDef, joinIndex) => { + const testName = rustIdent(`${channel.name}_${joinDef.name ?? `join_${joinIndex + 1}`}`); + const setup = channelJoinSetup(channel, joinDef, joinIndex, schemas); + lines.push("#[tokio::test]", "#[serial_test::serial]", "#[ignore = \"requires channel harness\"]", `async fn ${testName}_join() {`, " let harness = support::harness().await;", ...indentLines(setup.lines, 4), ` harness.register_channel(${setup.topicVar}, &[], &[]).await;`, " let socket = harness.socket().await;", ` let channel = ${facade}::${setup.method}(&socket${setup.args.length ? `, ${setup.args.join(", ")}` : ""}).await.expect("join channel");`, " channel.leave().await.expect(\"leave channel\");", "}", ""); + }); + + const first = channel.joins[0]; + if (first && (channel.messages.length > 0 || channel.pushes.length > 0)) { + const setup = channelJoinSetup(channel, first, 0, schemas); + const testName = rustIdent(`${channel.name}_messages_and_pushes`); + const messages = channel.messages.map((message) => rustString(message.event)).join(", "); + const pushes = channel.pushes.map((push) => rustString(push.event)).join(", "); + lines.push("#[tokio::test]", "#[serial_test::serial]", "#[ignore = \"requires channel harness\"]", `async fn ${testName}() {`, " let harness = support::harness().await;", ...indentLines(setup.lines, 4), ` harness.register_channel(${setup.topicVar}, &[${messages}], &[${pushes}]).await;`, " let socket = harness.socket().await;", ` let channel = ${facade}::${setup.method}(&socket${setup.args.length ? `, ${setup.args.join(", ")}` : ""}).await.expect("join channel");`); + channel.messages.forEach((message, index) => { + if (message.params.length > 0) { + const type = `${facade}${rustTypeName(message.sdkTypeName ?? message.event)}Input`; + const value = fieldsJson(message.params, schemas, new Set()); + lines.push(` let message_${index}: ${type} = serde_json::from_str(${rawString(JSON.stringify(value))}).expect("valid message input");`); + } + lines.push(` channel.${rustIdent(message.event)}(${message.params.length > 0 ? `&message_${index}` : ""}).await.expect("channel push");`); + }); + channel.pushes.forEach((push, index) => { + lines.push(` let mut push_${index} = channel.subscribe_${rustIdent(push.event)}();`, ` push_${index}.next().await.expect("server push").expect("decode server push");`); + }); + lines.push(" channel.leave().await.expect(\"leave channel\");", "}", ""); + } + } + return `${lines.join("\n")}\n`; +} + +function channelJoinSetup(channel: ChannelDef, joinDef: ChannelJoinDef, joinIndex: number, schemas: SchemaDef[]): { lines: string[]; args: string[]; method: string; topicVar: string } { + const facade = rustTypeName(channel.sdkName ?? channel.className); + const placeholders = [...joinDef.topicPattern.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]!); + const args = placeholders.map((placeholder) => rustString(stringValue(placeholder))); + let topic = joinDef.topicPattern; + placeholders.forEach((placeholder) => { topic = topic.replace(`{${placeholder}}`, stringValue(placeholder)); }); + const lines = [`let topic = ${rustString(topic)};`]; + const payload = joinDef.params.filter((param) => !placeholders.includes(param.wireName ?? param.name)); + if (payload.length > 0) { + const type = `${facade}${rustTypeName(joinDef.sdkTypeName ?? joinDef.name ?? `Join${joinIndex + 1}`)}Params`; + lines.push(`let join_params: ${type} = serde_json::from_str(${rawString(JSON.stringify(fieldsJson(payload, schemas, new Set())))}).expect("valid join params");`); + args.push("&join_params"); + } + return { + lines, + args, + method: rustIdent(joinDef.name ?? (channel.joins.length > 1 ? `join_${joinIndex + 1}` : "join")), + topicVar: "topic", + }; +} + +function callSetup(item: TestCase, schemas: SchemaDef[]): string[] { + const op = item.operation; + const lines: string[] = []; + if (op.body) { + const type = op.body.fields ? `${rustTypeName(op.operationId)}Input` : rustTypeName(op.body.schema); + const value = bodyJson(op.body, schemas); + lines.push(`let body: ${type} = serde_json::from_str(${rawString(JSON.stringify(value))}).expect("valid generated body");`); + } + if (op.queryParams.length > 0) { + const type = `${rustTypeName(op.operationId)}Params`; + const value = fieldsJson(op.queryParams, schemas, new Set()); + lines.push(`let params: ${type} = serde_json::from_str(${rawString(JSON.stringify(value))}).expect("valid generated params");`); + } + return lines; +} + +function operationCall(item: TestCase): string { + const op = item.operation; + const args = op.pathParams.filter((param) => !item.scopes.some((scope) => scope.name === param.name)).map((param) => paramValue(param.type, param.name)); + if (op.body) args.push("&body"); + if (op.queryParams.length > 0) args.push(op.queryParams.some((param) => param.required) ? "¶ms" : "Some(¶ms)"); + return `${item.chain}.${rustIdent(op.sdkName ?? op.name)}(${args.join(", ")})`; +} + +function bodyJson(body: BodyDef, schemas: SchemaDef[]): unknown { + if (body.fields) return fieldsJson(body.fields, schemas, new Set()); + const schema = schemas.find((candidate) => candidate.name === body.schema); + return schema ? schemaJson(schema, schemas, new Set()) : {}; +} + +function schemaJson(schema: SchemaDef, schemas: SchemaDef[], seen: Set): unknown { + if (seen.has(schema.name)) return {}; + const next = new Set(seen).add(schema.name); + if (schema.unionType) return typeJson(schema.unionType.variants[0]!, schema.name, schemas, next); + return fieldsJson(schema.fields, schemas, next); +} + +function fieldsJson(fields: readonly FieldDef[], schemas: SchemaDef[], seen: Set): Record { + return Object.fromEntries(fields.filter((field) => field.required).map((field) => [field.wireName ?? field.name, field.example ?? typeJson(field.type, field.name, schemas, seen)])); +} + +function typeJson(type: TypeRef, fieldName: string, schemas: SchemaDef[], seen: Set): unknown { + switch (type.kind) { + case "primitive": + if (type.type === "integer") return 1; + if (type.type === "float") return 1.0; + if (type.type === "boolean") return true; + if (type.type === "datetime") return "2024-01-01T00:00:00Z"; + return stringValue(fieldName); + case "array": return [typeJson(type.items, "item", schemas, seen)]; + case "object": return fieldsJson(type.fields, schemas, seen); + case "ref": { + const schema = schemas.find((candidate) => candidate.name === type.schema); + return schema ? schemaJson(schema, schemas, seen) : {}; + } + case "enum": return type.values[0] ?? "unknown"; + case "union": return type.variants.length ? typeJson(type.variants[0]!, fieldName, schemas, seen) : null; + case "optional": case "nullable": return typeJson(type.inner, fieldName, schemas, seen); + case "map": case "unknown": return {}; + case "void": return null; + } +} + +function paramValue(type: TypeRef, name: string): string { + if (type.kind === "primitive") { + if (type.type === "string") return rustString(stringValue(name)); + if (type.type === "integer") return "1"; + if (type.type === "float") return "1.0"; + if (type.type === "boolean") return "true"; + if (type.type === "datetime") return "chrono::DateTime::parse_from_rfc3339(\"2024-01-01T00:00:00Z\").unwrap().with_timezone(&chrono::Utc)"; + } + if (type.kind === "enum") return `${rustString(type.values[0] ?? "unknown")}.to_owned()`; + return "Default::default()"; +} + +function stringValue(name: string): string { + const lower = name.toLowerCase(); + if (lower.includes("email")) return "test@example.com"; + if (lower.includes("password")) return "Password1234!"; + if (lower.includes("url") || lower.includes("uri")) return "https://example.com"; + if (lower.includes("key")) return "test-key"; + if (lower.includes("id") || lower === "agent" || lower === "team" || lower === "user") return "test-id"; + if (lower === "role") return "user"; + if (lower === "status") return "active"; + if (lower === "timezone") return "UTC"; + return "test-value"; +} + +function rawString(value: string): string { return `r#"${value}"#`; } + +function versionSurface(resourcesInput: ResourceDef[], version: string, apiPrefix: string): { resources: ResourceDef[]; operations: OperationDef[] } { + const resources: ResourceDef[] = []; + const operations: OperationDef[] = []; + for (const root of resourcesInput) { + if (root.name === "api") { + const versionRoot = root.children.find((child) => child.name === version || child.path === apiPrefix); + if (versionRoot) { + resources.push(...versionRoot.children); + operations.push(...versionRoot.operations); + continue; + } + } + resources.push(root); + } + return { resources, operations }; +} + +function indentLines(lines: string[], spaces: number): string[] { + const prefix = " ".repeat(spaces); + return lines.map((line) => `${prefix}${line}`); +} diff --git a/packages/sdk-generator/src/backends/rust/identifiers.ts b/packages/sdk-generator/src/backends/rust/identifiers.ts new file mode 100644 index 0000000..19b22d0 --- /dev/null +++ b/packages/sdk-generator/src/backends/rust/identifiers.ts @@ -0,0 +1,41 @@ +import { pascalCase, snakeCase } from "../../utils/naming.js"; + +const KEYWORDS = new Set([ + "as", "break", "const", "continue", "crate", "else", "enum", "extern", + "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", + "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", + "struct", "super", "trait", "true", "type", "unsafe", "use", "where", + "while", "async", "await", "dyn", "abstract", "become", "box", "do", + "final", "macro", "override", "priv", "typeof", "unsized", "virtual", + "yield", "try", "gen", +]); + +export function rustIdent(value: string): string { + let result = snakeCase(value).replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); + if (!result) result = "value"; + if (/^[0-9]/.test(result)) result = `_${result}`; + return KEYWORDS.has(result) ? `${result}_` : result; +} + +export function rustTypeName(value: string): string { + let result = pascalCase(value.replace(/[^a-zA-Z0-9_]/g, "_")); + if (!result) result = "Value"; + if (/^[0-9]/.test(result)) result = `Value${result}`; + return KEYWORDS.has(result) ? `${result}Type` : result; +} + +export function rustString(value: string): string { + return JSON.stringify(value); +} + +export function uniqueRustNames(values: string[], reserved: string[] = []): string[] { + const used = new Set(reserved); + return values.map((value) => { + const base = rustIdent(value); + let candidate = base; + let suffix = 2; + while (used.has(candidate)) candidate = `${base}_${suffix++}`; + used.add(candidate); + return candidate; + }); +} diff --git a/packages/sdk-generator/src/backends/rust/index.ts b/packages/sdk-generator/src/backends/rust/index.ts new file mode 100644 index 0000000..b43059d --- /dev/null +++ b/packages/sdk-generator/src/backends/rust/index.ts @@ -0,0 +1,559 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + ChannelDef, + FieldDef, + OperationDef, + ParamDef, + ResourceDef, + SchemaDef, + SdkSpec, + TypeRef, + VersionedResourceSet, +} from "../../ast/types.js"; +import { addContentHash, cleanStaleFiles, generatedHeader } from "../../utils/codegen.js"; +import { hoistInlineObjects } from "../python/inline-object-hoist.js"; +import { rustIdent, rustString, rustTypeName, uniqueRustNames } from "./identifiers.js"; + +export const RUST_GENERATED_DIR = "src/generated"; +export type GeneratedFiles = Record; + +export interface RustBackendOptions { outDir: string } + +export function generateRust(spec: SdkSpec, options: RustBackendOptions): GeneratedFiles { + const files: GeneratedFiles = {}; + const root = join(options.outDir, RUST_GENERATED_DIR); + files[join(root, "types.rs")] = emitTypes(spec); + files[join(root, "auth.rs")] = emitAuth(spec); + files[join(root, "channels.rs")] = emitChannels(spec); + for (const version of spec.versions) { + files[join(root, `${rustIdent(version.sdkName ?? version.version)}.rs`)] = emitVersion(version); + } + files[join(root, "mod.rs")] = emitMod(spec); + return files; +} + +export function writeRustFiles(files: GeneratedFiles, cleanDirs: string[]): void { + cleanStaleFiles(files, cleanDirs, [".rs"], true); + for (const [path, content] of Object.entries(files)) { + mkdirSync(path.substring(0, path.lastIndexOf("/")), { recursive: true }); + writeFileSync(path, addContentHash(content, "//"), "utf8"); + } +} + +function header(): string { return generatedHeader(); } + +function emitMod(spec: SdkSpec): string { + const lines = [header(), "/// Generated authentication operations.", "pub mod auth;", "/// Generated Phoenix channel facades.", "pub mod channels;", "/// Generated API models.", "pub mod types;"]; + for (const version of spec.versions) lines.push(`/// Generated ${version.version} API resources.`, `pub mod ${rustIdent(version.sdkName ?? version.version)};`); + lines.push("", "pub use auth::*;", "pub use channels::*;", "pub use types::*;"); + for (const version of spec.versions) lines.push(`pub use ${rustIdent(version.sdkName ?? version.version)}::*;`); + return `${lines.join("\n")}\n`; +} + +function emitTypes(spec: SdkSpec): string { + const lines = [ + header(), + "#![allow(clippy::large_enum_variant)]", + "use serde::{Deserialize, Serialize};", + "use serde_json::Value;", + "", + ]; + for (const scalar of spec.types) { + lines.push(doc(scalar.description), `pub type ${rustTypeName(scalar.name)} = ${primitiveType(scalar.baseType)};`, ""); + } + for (const schema of spec.schemas) { + lines.push(emitSchema(schema, spec.schemas), ""); + } + return `${lines.filter((line) => line !== undefined).join("\n")}\n`; +} + +function emitSchema(schema: SchemaDef, schemas: readonly SchemaDef[]): string { + const name = rustTypeName(schema.name); + if (schema.unionType) { + const definitions: string[] = []; + const variantNames = uniqueRustTypeNames(schema.unionType.variants.map((variant, index) => + variant.kind === "ref" ? variant.schema : `Variant${index + 1}`)); + const variants = schema.unionType.variants.map((variant, index) => { + const variantName = variantNames[index]!; + const type = namedRustType(variant, `${name}${variantName}`, definitions, schema.name, schemas); + return ` /// ${variantName} union variant.\n ${variantName}(${type}),`; + }); + return [...definitions, + doc(schema.description), + "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]", + "#[serde(untagged)]", + `pub enum ${name} {`, + ...variants, + "}", + ].join("\n"); + } + return emitStruct(name, schema.fields, schema.description, false, schema.name, schemas); +} + +function emitStruct(name: string, fields: readonly FieldDef[], description?: string, boxRefs = false, schemaName?: string, schemas: readonly SchemaDef[] = []): string { + const names = uniqueRustNames(fields.map((field) => field.name)); + const definitions: string[] = []; + const types = fields.map((field) => namedRustType( + field.type, + `${name}${rustTypeName(field.name)}`, + definitions, + schemaName, + schemas, + boxRefs, + )); + const lines = [ + doc(description), + "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]", + `pub struct ${name} {`, + ]; + fields.forEach((field, index) => { + const ident = names[index]!; + const wire = field.wireName ?? field.name; + const optional = !field.required || field.type.kind === "optional"; + if (ident !== wire) lines.push(` #[serde(rename = ${rustString(wire)})]`); + if (optional) lines.push(" #[serde(default, skip_serializing_if = \"Option::is_none\")]" ); + lines.push(indentDoc(field.description, 4)); + const type = optionalRustType(types[index]!, optional); + lines.push(` pub ${ident}: ${type},`); + }); + lines.push("}"); + return [...definitions, lines.filter(Boolean).join("\n")].join("\n\n"); +} + +function namedRustType( + ref: TypeRef, + name: string, + definitions: string[], + schemaName?: string, + schemas: readonly SchemaDef[] = [], + boxRefs = false, + inlineStorage = true, +): string { + switch (ref.kind) { + case "enum": { + const variants = uniqueRustTypeNames(ref.values); + definitions.push([ + `/// Contract-defined values for ${name}.`, + "#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]", + `pub enum ${name} {`, + ...ref.values.flatMap((value, index) => [ + ` /// The ${value.replace(/[\r\n]/g, " ")} wire value.`, + ` #[serde(rename = ${rustString(value)})]`, + ` ${variants[index]},`, + ]), + "}", + ].join("\n")); + return name; + } + case "union": { + const variants = uniqueRustTypeNames(ref.variants.map((variant, index) => + variant.kind === "ref" ? variant.schema : `Variant${index + 1}`)); + const types = ref.variants.map((variant, index) => + namedRustType(variant, `${name}${variants[index]}`, definitions, schemaName, schemas, boxRefs, inlineStorage)); + definitions.push([ + `/// Contract-defined alternatives for ${name}.`, + "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]", + "#[serde(untagged)]", + `pub enum ${name} {`, + ...types.flatMap((type, index) => [ + ` /// ${variants[index]} union variant.`, + ` ${variants[index]}(${type}),`, + ]), + "}", + ].join("\n")); + return name; + } + case "optional": return `Option<${namedRustType(ref.inner, name, definitions, schemaName, schemas, boxRefs, inlineStorage)}>`; + case "nullable": return `Option<${namedRustType(ref.inner, name, definitions, schemaName, schemas, boxRefs, inlineStorage)}>`; + case "array": return `Vec<${namedRustType(ref.items, `${name}Item`, definitions, schemaName, schemas, boxRefs, false)}>`; + case "map": return `std::collections::BTreeMap`; + case "ref": { + const shouldBox = boxRefs || Boolean(schemaName && inlineStorage && reachesInline(ref.schema, schemaName, schemas, new Set())); + return shouldBox ? `Box<${rustTypeName(ref.schema)}>` : rustTypeName(ref.schema); + } + default: return rustType(ref, boxRefs); + } +} + +function uniqueRustTypeNames(values: readonly string[]): string[] { + const used = new Set(); + return values.map((value) => { + const base = rustTypeName(value); + let candidate = base; + let suffix = 2; + while (used.has(candidate)) candidate = `${base}${suffix++}`; + used.add(candidate); + return candidate; + }); +} + +function primitiveType(type: string): string { + switch (type) { + case "string": return "String"; + case "integer": return "i64"; + case "float": return "f64"; + case "boolean": return "bool"; + case "datetime": return "chrono::DateTime"; + default: return "Value"; + } +} + +export function rustType(ref: TypeRef, boxRefs = false): string { + switch (ref.kind) { + case "primitive": return primitiveType(ref.type); + case "array": return `Vec<${rustType(ref.items, boxRefs)}>`; + case "object": return "Value"; + case "ref": return boxRefs ? `Box<${rustTypeName(ref.schema)}>` : rustTypeName(ref.schema); + case "enum": return "String"; + case "union": return "Value"; + case "optional": return `Option<${rustType(ref.inner, boxRefs)}>`; + case "nullable": return `Option<${rustType(ref.inner, boxRefs)}>`; + case "map": return `std::collections::BTreeMap`; + case "unknown": return "Value"; + case "void": return "()"; + } +} + +function optionalRustType(input: string, optional: boolean): string { + let type = input; + if (optional && !type.startsWith("Option<")) type = `Option<${type}>`; + return type; +} + +function reachesInline(from: string, target: string, schemas: readonly SchemaDef[], seen: Set): boolean { + if (from === target) return true; + if (seen.has(from)) return false; + seen.add(from); + const schema = schemas.find((candidate) => candidate.name === from); + if (!schema) return false; + const refs = schema.unionType + ? inlineRefs(schema.unionType) + : schema.fields.flatMap((field) => inlineRefs(field.type)); + return refs.some((next) => reachesInline(next, target, schemas, seen)); +} + +function inlineRefs(ref: TypeRef): string[] { + switch (ref.kind) { + case "ref": return [ref.schema]; + case "optional": case "nullable": return inlineRefs(ref.inner); + case "union": return ref.variants.flatMap(inlineRefs); + default: return []; + } +} + +function emitVersion(version: VersionedResourceSet): string { + const lines = [ + header(), + "use reqwest::Method;", + "use serde::{Deserialize, Serialize};", + "use serde_json::Value;", + "use crate::{Client, Result};", + "use crate::generated::types::*;", + "use crate::sse::{SseDecode, SseStream};", + "", + ]; + const surface = versionSurface(version); + const top = surface.resources; + const versionType = rustTypeName(version.sdkName ?? version.version); + const allOperations = [ + ...surface.operations, + ...top.flatMap((resource) => collectResourceOperations(resource)), + ]; + for (const op of allOperations) { + const definitions = emitOperation(op, []).definitions; + if (definitions) lines.push(definitions, ""); + } + lines.push(`/// ${version.version} API namespace.`, "#[derive(Clone)]", `pub struct ${versionType} { pub(crate) client: Client }`, ""); + lines.push(`impl ${versionType} {`); + for (const resource of top) { + const info = resourceInfo(resource, []); + lines.push(` /// Access the ${resource.name} resource.`, ` pub fn ${rustIdent(resource.name)}(&self) -> ${info.typeName} { ${info.typeName} { client: self.client.clone()${scopeInitializers(resource.scopeParams)} } }`); + } + for (const op of surface.operations) lines.push(indent(emitOperation(op, []).method, 4)); + lines.push("}", ""); + for (const resource of top) emitResource(lines, resource, []); + return `${lines.join("\n")}\n`; +} + +interface ResourceInfo { typeName: string; ancestry: string[] } + +function resourceInfo(resource: ResourceDef, ancestry: string[]): ResourceInfo { + const chain = [...ancestry, resource.name]; + return { typeName: `${chain.map(rustTypeName).join("")}Resource`, ancestry: chain }; +} + +function emitResource(lines: string[], resource: ResourceDef, ancestry: string[]): void { + const info = resourceInfo(resource, ancestry); + lines.push(doc(resource.description ?? `${resource.name} API resource.`), "#[derive(Clone)]", `pub struct ${info.typeName} {`, " client: Client,"); + for (const param of resource.scopeParams) lines.push(` /// Bound ${param.name} scope.`, ` ${rustIdent(param.name)}: ${ownedParamType(param.type)},`); + lines.push("}", "", `impl ${info.typeName} {`); + for (const child of resource.children) { + const childInfo = resourceInfo(child, info.ancestry); + const inherited = new Map(resource.scopeParams.map((p) => [p.name, p])); + const introduced = child.scopeParams.filter((p) => !inherited.has(p.name)); + const args = introduced.map((p) => `${rustIdent(p.name)}: ${paramType(p.type)}`).join(", "); + const initializers = child.scopeParams.map((p) => { + const id = rustIdent(p.name); + return inherited.has(p.name) ? `${id}: self.${id}.clone()` : `${id}: ${ownedExpr(p.type, id)}`; + }); + lines.push(` /// Access the nested ${child.name} resource.`, ` pub fn ${rustIdent(child.name)}(&self${args ? `, ${args}` : ""}) -> ${childInfo.typeName} {`); + lines.push(` ${childInfo.typeName} { client: self.client.clone()${initializers.length ? `, ${initializers.join(", ")}` : ""} }`); + lines.push(" }"); + } + for (const op of resource.operations) lines.push(indent(emitOperation(op, resource.scopeParams).method, 4)); + lines.push("}", ""); + for (const child of resource.children) emitResource(lines, child, info.ancestry); +} + +interface EmittedOperation { definitions: string; method: string } + +function emitOperation(op: OperationDef, scopes: ParamDef[]): EmittedOperation { + const method = rustIdent(op.sdkName ?? op.name); + const pathParams = op.pathParams.filter((p) => !scopes.some((s) => s.name === p.name)); + const args: string[] = pathParams.map((p) => `${rustIdent(p.name)}: ${paramType(p.type)}`); + const definitions: string[] = []; + let bodyArg: string | undefined; + if (op.body) { + const typeName = op.body.fields ? `${rustTypeName(op.operationId)}Input` : rustTypeName(op.body.schema); + if (op.body.fields) definitions.push(emitInlineTypes(typeName, op.body.fields, op.description)); + args.push(`body: &${typeName}`); + bodyArg = "body"; + } + let queryArg: string | undefined; + let queryRequired = false; + if (op.queryParams.length > 0) { + const typeName = `${rustTypeName(op.operationId)}Params`; + definitions.push(emitStruct(typeName, op.queryParams, `Query parameters for ${op.operationId}.`)); + queryRequired = op.queryParams.some((param) => param.required); + args.push(queryRequired ? `params: &${typeName}` : `params: Option<&${typeName}>`); + queryArg = "params"; + } + let returnType = operationReturnType(op); + if (op.returnType.kind === "object" && op.returnType.fields.length > 0 && !op.streaming) { + const typeName = `${rustTypeName(op.operationId)}Response`; + definitions.push(emitInlineTypes(typeName, op.returnType.fields, op.returnDescription)); + returnType = typeName; + } + if (op.streaming) { + const enumName = `${rustTypeName(op.operationId)}Event`; + definitions.push(emitStreamEnum(enumName, op)); + returnType = `SseStream<${enumName}>`; + } + const lines: string[] = []; + lines.push(doc(op.summary ?? op.description)); + if (op.deprecated) lines.push("#[deprecated]"); + lines.push(`pub async fn ${method}(&self${args.length ? `, ${args.join(", ")}` : ""}) -> Result<${returnType}> {`); + const hasPathReplacements = scopes.length > 0 || pathParams.length > 0; + lines.push(` let ${hasPathReplacements ? "mut " : ""}path = ${rustString(op.path)}.to_owned();`); + for (const scope of scopes) lines.push(` path = path.replace(${rustString(`{${scope.wireName ?? scope.name}}`)}, &crate::encode_path(${pathValueExpr(scope.type, `self.${rustIdent(scope.name)}`, true)}));`); + for (const param of pathParams) lines.push(` path = path.replace(${rustString(`{${param.wireName ?? param.name}}`)}, &crate::encode_path(${pathValueExpr(param.type, rustIdent(param.name), false)}));`); + lines.push(` let request = self.client.request(Method::${op.method}, &path);`); + if (queryArg) lines.push(queryRequired ? ` let request = request.query(${queryArg})?;` : ` let request = match ${queryArg} { Some(value) => request.query(value)?, None => request };`); + if (bodyArg) lines.push(` let request = request.json(${bodyArg})?;`); + if (op.streaming) lines.push(" request.stream().await"); + else if (op.rawResponse) lines.push(" request.send_raw().await"); + else if (op.returnType.kind === "void") lines.push(" request.send_empty().await"); + else lines.push(" request.send().await"); + lines.push("}"); + if (!op.streaming) { + lines.push(`/// Blocking variant of [Self::${method}].`, "#[cfg(feature = \"blocking\")]", `pub fn ${method}_blocking(&self${args.length ? `, ${args.join(", ")}` : ""}) -> Result<${returnType}> {`); + lines.push(` crate::blocking::block_on(self.${method}(${callArgs(pathParams, bodyArg, queryArg)}))`); + lines.push("}"); + } + return { + definitions: definitions.filter(Boolean).join("\n\n"), + method: lines.filter(Boolean).join("\n"), + }; +} + +function callArgs(pathParams: ParamDef[], body?: string, query?: string): string { + const args = pathParams.map((p) => rustIdent(p.name)); + if (body) args.push(body); + if (query) args.push(query); + return args.join(", "); +} + +function emitInlineTypes(name: string, fields: readonly FieldDef[], description?: string): string { + const hoist = hoistInlineObjects([...fields], name, "typeddict"); + const parts = hoist.hoisted.map((item) => emitStruct(rustTypeName(item.name), item.fields, item.description)); + parts.push(emitStruct(name, hoist.fields, description)); + return parts.join("\n\n"); +} + +function emitStreamEnum(name: string, op: OperationDef): string { + const variants = op.streaming!.events.map((event) => ` ${rustTypeName(event.sdkTypeName ?? event.event)}(${rustType(event.dataType)}),`); + const arms = op.streaming!.events.map((event) => ` ${rustString(event.event)} => Ok(Self::${rustTypeName(event.sdkTypeName ?? event.event)}(serde_json::from_str(data)?)),`); + return [ + `/// Typed events emitted by ${op.operationId}.`, "#[derive(Debug, Clone, PartialEq)]", `pub enum ${name} {`, ...variants.flatMap((variant) => [` /// Contract-defined stream event.`, variant]), "}", + `impl SseDecode for ${name} {`, + " fn decode(event: &str, data: &str) -> Result {", " match event {", ...arms, + " other => Err(crate::Error::UnknownSseEvent(other.to_owned())),", " }", " }", "}", + ].join("\n"); +} + +function operationReturnType(op: OperationDef): string { + if (op.rawResponse) return "crate::RawResponse"; + return rustType(op.returnType); +} + +function emitAuth(spec: SdkSpec): string { + const lines = [header(), "use reqwest::Method;", "use serde::{Deserialize, Serialize};", "use crate::{Client, Result};", "use crate::generated::types::*;", ""]; + for (const op of spec.authOperations) { + const definitions = emitOperation(op, []).definitions; + if (definitions) lines.push(definitions, ""); + } + lines.push("/// Authentication API resource.", "#[derive(Clone)]", "pub struct Auth { pub(crate) client: Client }", "", "impl Auth {"); + for (const op of spec.authOperations) lines.push(indent(emitOperation(op, []).method, 4)); + lines.push("}"); + const login = spec.authOperations.find((op) => op.path.endsWith("/auth/login") && op.method === "POST"); + const refresh = spec.authOperations.find((op) => op.path.endsWith("/auth/refresh") && op.method === "POST"); + if (login?.body?.fields && refresh) { + const loginType = `${rustTypeName(login.operationId)}Input`; + const loginMethod = rustIdent(login.sdkName ?? login.name); + lines.push("", "impl Client {", " /// Authenticate with email/password and enable generation-fenced automatic refresh."); + lines.push(" pub async fn with_credentials(api_key: impl Into, email: impl Into, password: impl Into) -> Result {"); + lines.push(" let client = Self::builder().publishable_key(api_key).build()?;"); + lines.push(` let tokens = client.auth().${loginMethod}(&${loginType} { email: email.into(), password: password.into() }).await?;`); + const tokenSchemaName = login.returnType.kind === "ref" ? login.returnType.schema : "AuthTokens"; + const tokenSchema = spec.schemas.find((schema) => schema.name === tokenSchemaName); + const accessField = tokenSchema?.fields.find((field) => field.sdkRole === "access_token" || field.name === "access_token" || field.name === "token"); + const refreshField = tokenSchema?.fields.find((field) => field.sdkRole === "refresh_token" || field.name === "refresh_token"); + const accessExpr = `tokens.${rustIdent(accessField?.name ?? "access_token")}.clone()`; + const refreshBase = `tokens.${rustIdent(refreshField?.name ?? "refresh_token")}.clone()`; + const refreshExpr = refreshField && (refreshField.required && refreshField.type.kind !== "optional" && refreshField.type.kind !== "nullable") ? `Some(${refreshBase})` : refreshBase; + lines.push(` client.install_session(${accessExpr}, ${refreshExpr}, ${rustString(refresh.path)}).await;`); + lines.push(" Ok(client)", " }", "}"); + } + return `${lines.join("\n")}\n`; +} + +function emitChannels(spec: SdkSpec): string { + const lines = [header(), "use serde::{Deserialize, Serialize};", "use serde_json::Value;", "use crate::{Channel, ChannelEventStream, Result, Socket};", ""]; + for (const channel of spec.channels) lines.push(emitChannel(channel), ""); + return `${lines.join("\n")}\n`; +} + +function emitChannel(channel: ChannelDef): string { + const name = rustTypeName(channel.sdkName ?? channel.className); + const definitions: string[] = []; + for (const message of channel.messages) { + if (message.params.length > 0) definitions.push(emitInlineTypes(`${name}${rustTypeName(message.sdkTypeName ?? message.event)}Input`, message.params, message.description), ""); + } + for (const push of channel.pushes) { + if (push.payloadType.kind === "object" && push.payloadType.fields.length > 0) definitions.push(emitInlineTypes(`${name}${rustTypeName(push.sdkTypeName ?? push.event)}Payload`, push.payloadType.fields, push.description), ""); + } + for (const [index, join] of channel.joins.entries()) { + const segment = rustTypeName(join.sdkTypeName ?? join.name ?? `Join${index + 1}`); + const payload = join.params.filter((p) => ![...join.topicPattern.matchAll(/\{([^}]+)\}/g)].some((match) => match[1] === (p.wireName ?? p.name))); + if (payload.length > 0) definitions.push(emitInlineTypes(`${name}${segment}Params`, payload, join.description), ""); + if (join.returnType.kind === "object" && join.returnType.fields.length > 0) definitions.push(emitInlineTypes(`${name}${segment}Response`, join.returnType.fields, join.description), ""); + } + const lines: string[] = [...definitions]; + lines.push(doc(channel.description), "#[derive(Clone)]", `pub struct ${name} {`, " /// Underlying joined Phoenix channel.", " pub channel: Channel,", " /// Typed payload returned by the join.", " pub join_response: R,", "}", `impl ${name} {`); + channel.joins.forEach((join, index) => { + const method = rustIdent(join.name ?? (channel.joins.length > 1 ? `join_${index + 1}` : "join")); + const segment = rustTypeName(join.sdkTypeName ?? join.name ?? `Join${index + 1}`); + const placeholders = [...join.topicPattern.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]!); + const params = uniqueRustNames(placeholders); + const payload = join.params.filter((p) => !placeholders.includes(p.wireName ?? p.name)); + const args = params.map((p) => `${p}: &str`); + if (payload.length > 0) { + const inputName = `${name}${segment}Params`; + args.push(`params: &${inputName}`); + } + const responseType = join.returnType.kind === "object" && join.returnType.fields.length > 0 + ? `${name}${segment}Response` + : rustType(join.returnType); + let topic = rustString(join.topicPattern); + placeholders.forEach((placeholder, i) => { topic = `${topic}.replace(${rustString(`{${placeholder}}`)}, ${params[i]})`; }); + lines.push(` /// ${join.description ?? `Join ${join.topicPattern}.`}`, ` pub async fn ${method}(socket: &Socket${args.length ? `, ${args.join(", ")}` : ""}) -> Result<${name}<${responseType}>> {`); + lines.push(` let topic = ${topic};`); + lines.push(` let channel = socket.channel(topic);`); + lines.push(` let value = channel.join(${payload.length > 0 ? "serde_json::to_value(params)?" : "serde_json::json!({})"}).await?;`); + lines.push(responseType === "Value" ? " let join_response = value;" : ` let join_response = serde_json::from_value(value)?;`); + lines.push(` Ok(${name} { channel, join_response })`, " }"); + }); + lines.push("}", `impl ${name} {`); + lines.push(" /// Leave the underlying Phoenix channel.", " pub async fn leave(&self) -> Result<()> { self.channel.leave().await }"); + for (const message of channel.messages) { + const method = rustIdent(message.event); + const inputName = `${name}${rustTypeName(message.sdkTypeName ?? message.event)}Input`; + const args = message.params.length > 0 ? `, input: &${inputName}` : ""; + const payload = message.params.length > 0 ? "serde_json::to_value(input)?" : "serde_json::json!({})"; + lines.push(` /// ${message.description ?? `Push the ${message.event} event.`}`, ` pub async fn ${method}(&self${args}) -> Result<${rustType(message.returnType)}> {`); + lines.push(` let value = self.channel.push(${rustString(message.event)}, ${payload}).await?;`); + if (message.returnType.kind === "void") lines.push(" Ok(())"); + else lines.push(" Ok(serde_json::from_value(value)?)"); + lines.push(" }"); + } + for (const push of channel.pushes) { + const type = push.payloadType.kind === "object" && push.payloadType.fields.length > 0 + ? `${name}${rustTypeName(push.sdkTypeName ?? push.event)}Payload` + : rustType(push.payloadType); + lines.push(` /// ${push.description ?? `Subscribe to ${push.event} pushes.`}`, ` pub fn subscribe_${rustIdent(push.event)}(&self) -> ChannelEventStream<${type}> { self.channel.subscribe(${rustString(push.event)}) }`); + } + lines.push("}"); + return lines.filter(Boolean).join("\n"); +} + +function versionSurface(version: VersionedResourceSet): { resources: ResourceDef[]; operations: OperationDef[] } { + const resources: ResourceDef[] = []; + const operations: OperationDef[] = []; + for (const root of version.resources) { + if (root.name === "api") { + const versionRoot = root.children.find((child) => child.name === version.version || child.path === version.apiPrefix); + if (versionRoot) { + operations.push(...versionRoot.operations); + resources.push(...versionRoot.children); + continue; + } + } + resources.push(root); + } + return { resources, operations }; +} + +function collectResourceOperations(resource: ResourceDef): OperationDef[] { + return [...resource.operations, ...resource.children.flatMap((child) => collectResourceOperations(child))]; +} + +function ownedParamType(ref: TypeRef): string { + if (ref.kind === "primitive" && ref.type === "string") return "String"; + return rustType(ref); +} + +function paramType(ref: TypeRef): string { + if (ref.kind === "primitive" && ref.type === "string") return "&str"; + return rustType(ref); +} + +function ownedExpr(ref: TypeRef, ident: string): string { + return ref.kind === "primitive" && ref.type === "string" ? `${ident}.to_owned()` : ident; +} + +function pathValueExpr(ref: TypeRef, ident: string, owned: boolean): string { + if (ref.kind === "primitive" && ref.type === "string") { + return owned ? `${ident}.as_str()` : ident; + } + return `&${ident}.to_string()`; +} + +function scopeInitializers(params: ParamDef[]): string { + return params.length ? `, ${params.map((p) => `${rustIdent(p.name)}: Default::default()`).join(", ")}` : ""; +} + +function doc(value?: string): string { + if (!value) return "/// Generated from the ArchAstro OpenAPI contract."; + return value.split("\n").map((line) => `/// ${line.replace(/\r/g, "")}`).join("\n"); +} + +function indentDoc(value: string | undefined, spaces: number): string { + if (!value) return indent("/// API field.", spaces); + return indent(doc(value), spaces); +} + +function indent(value: string, spaces: number): string { + const prefix = " ".repeat(spaces); + return value.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n"); +} diff --git a/packages/sdk-generator/src/index.ts b/packages/sdk-generator/src/index.ts index b2b19c1..448321a 100644 --- a/packages/sdk-generator/src/index.ts +++ b/packages/sdk-generator/src/index.ts @@ -34,6 +34,12 @@ import { ELIXIR_GENERATED_DIR, } from "./backends/elixir/index.js"; import { ELIXIR_TESTS_DIR } from "./backends/contract-tests/elixir-emitter.js"; +import { + generateRust, + writeRustFiles, + RUST_GENERATED_DIR, +} from "./backends/rust/index.js"; +import { RUST_TESTS_DIR } from "./backends/contract-tests/rust-emitter.js"; import { generateContractTests } from "./backends/contract-tests/index.js"; import { generateTypeScriptSamples } from "./backends/typescript/sample-emitter.js"; import { generatePythonSamples } from "./backends/python/sample-emitter.js"; @@ -52,7 +58,9 @@ export { generatePython, writePythonFiles } from "./backends/python/index.js"; export { generateSwift, writeSwiftFiles, prepareSwiftSpec } from "./backends/swift/index.js"; export { generateGo, writeGoFiles, prepareGoSpec } from "./backends/go/index.js"; export { generateElixir, writeElixirFiles } from "./backends/elixir/index.js"; +export { generateRust, writeRustFiles } from "./backends/rust/index.js"; export { generateContractTests } from "./backends/contract-tests/index.js"; +export { emitRustContractTests } from "./backends/contract-tests/rust-emitter.js"; export { emitSwiftContractTests } from "./backends/contract-tests/swift-emitter.js"; export { emitGoContractTests } from "./backends/contract-tests/go-emitter.js"; export { generateTypeScriptSamples } from "./backends/typescript/sample-emitter.js"; @@ -99,7 +107,7 @@ function main() { const astOnly = args.includes("--ast-only"); if (!specPath) { - console.error("Usage: sdk-generator --spec [--lang typescript|python|swift|go|elixir|contract-tests-ts|contract-tests-py|contract-tests-swift|contract-tests-go|contract-tests-elixir] [--out ] [--config ] [--mode sdk|samples] [--ast-only]"); + console.error("Usage: sdk-generator --spec [--lang typescript|python|swift|go|elixir|rust|contract-tests-ts|contract-tests-py|contract-tests-swift|contract-tests-go|contract-tests-elixir|contract-tests-rust] [--out ] [--config ] [--mode sdk|samples] [--ast-only]"); process.exit(1); } @@ -232,6 +240,12 @@ function main() { console.log(`Elixir SDK generated at ${resolvedOut} (${Object.keys(files).length} files)`); break; } + case "rust": { + const files = generateRust(ast, { outDir: resolvedOut }); + writeRustFiles(files, [resolve(resolvedOut, RUST_GENERATED_DIR)]); + console.log(`Rust SDK generated at ${resolvedOut} (${Object.keys(files).length} files)`); + break; + } case "contract-tests-go": { const files = generateContractTests(ast, { outDir: resolvedOut, @@ -249,6 +263,12 @@ function main() { console.log(`Elixir contract tests generated at ${resolvedOut} (${Object.keys(files).length} files)`); break; } + case "contract-tests-rust": { + const files = generateContractTests(ast, { outDir: resolvedOut, lang: "rust" }); + writeRustFiles(files, [resolve(resolvedOut, RUST_TESTS_DIR)]); + console.log(`Rust contract tests generated at ${resolvedOut} (${Object.keys(files).length} files)`); + break; + } case "contract-tests-swift": { const files = generateContractTests(ast, { outDir: resolvedOut, lang: "swift" }); const tests = resolve(resolvedOut, SWIFT_TESTS_DIR); @@ -261,7 +281,7 @@ function main() { break; } default: - console.error(`Unknown language: ${lang}. Supported: typescript, python, swift, go, elixir, contract-tests-ts, contract-tests-py, contract-tests-swift, contract-tests-go, contract-tests-elixir`); + console.error(`Unknown language: ${lang}. Supported: typescript, python, swift, go, elixir, rust, contract-tests-ts, contract-tests-py, contract-tests-swift, contract-tests-go, contract-tests-elixir, contract-tests-rust`); process.exit(1); } }