From 19e703b86ab05dff245d9576c9b1b33ddafaa992 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 16:58:22 +1200 Subject: [PATCH 01/16] chore: 0.17.2-alpha.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix reqwest-middleware feature so it implies reqwest (was a compile error to enable reqwest-middleware alone — reported by an internal consumer). - Add MIGRATION.md covering the transport-shape refactor: Rust try_new returns Result, ReqwestClient::new does not exist, and the current TS DOM-collision workaround. TS DOM collision (#143) and codegen typecheck-blocks-emit (#144) filed as issues; not in this alpha. --- Cargo.lock | 8 +- MIGRATION.md | 80 +++++++++++++++++++ reflectapi-cli/Cargo.toml | 4 +- reflectapi-derive/Cargo.toml | 4 +- reflectapi-python-runtime/pyproject.toml | 2 +- .../src/reflectapi_runtime/__init__.py | 2 +- reflectapi-schema/Cargo.toml | 2 +- reflectapi/Cargo.toml | 8 +- 8 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 MIGRATION.md diff --git a/Cargo.lock b/Cargo.lock index a846c0fa..ec654870 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "reflectapi" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" dependencies = [ "anyhow", "axum", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "reflectapi-cli" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" dependencies = [ "anyhow", "clap", @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "reflectapi-derive" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" dependencies = [ "proc-macro-error", "proc-macro2", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "reflectapi-schema" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" dependencies = [ "glob", "serde", diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..b1075a47 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,80 @@ +# Migration Guide + +## 0.17.2 (transport-shape refactor) + +The Rust, TypeScript, and Python clients now share a single `Request` DTO shape: +`{ path, headers, body }`. The base URL lives on the transport, not on the +generated `Interface`. + +### Rust + +The trait gained a `base_url` method and `Request::url` was renamed to `Request::path`: + +```rust +pub trait Client { + type Error; + fn base_url(&self) -> &Url; + fn request(&self, request: Request) -> impl Future>; +} + +pub struct Request { + pub path: String, // was: url: Url + pub headers: HeaderMap, + pub body: Bytes, +} +``` + +`reqwest::Client` no longer implements `Client` directly — it doesn't carry a +base URL. Use the bundled wrapper: + +```rust +// before +let api = MyClient::try_new(reqwest::Client::new(), base_url)?; + +// after — convenience constructor (recommended) +let api = MyClient::try_new(reqwest::Client::new(), base_url)?; + +// after — explicit, when composing a custom transport +let api = MyClient::new( + reflectapi::rt::ReqwestClient::try_new(reqwest::Client::new(), base_url)? +); +``` + +`ReqwestClient::try_new` returns `Result` because it +validates that `base_url` is a valid HTTP base. There is no infallible +`ReqwestClient::new` constructor. + +The generated `Interface::try_new(reqwest::Client, base_url)` convenience +constructor is available when the generated crate enables its own `reqwest` +feature, which should re-export `reflectapi/reqwest`. For +`reqwest_middleware::ClientWithMiddleware`, compose manually using +`ReqwestMiddlewareClient` (a type alias for +`ReqwestClient`). + +#### `reqwest-middleware` feature implies `reqwest` + +In 0.17.2-alpha.3 enabling only `reqwest-middleware` failed to compile +because the wrapper struct lives behind the `reqwest` feature. Fixed in +0.17.2-alpha.4: `reqwest-middleware` now implies `reqwest`. Consumers that +already enable both don't need to change anything. + +### TypeScript + +The transport DTOs are exported as bare `Request` / `Response` / `Headers` +from `generated.ts`. These names shadow the DOM globals of the same name +in any consumer module that also uses `fetch` types. Workarounds while a +fix lands (tracking in #143): + +```ts +import { Request as ApiRequest, Response as ApiResponse } from './generated'; +// or +import * as api from './generated'; +// then api.Request, api.Response +``` + +### Python + +`Request` / `Response` / `Client` / `AsyncClient` are exported from the +`reflectapi_runtime.transport` module. They are also re-exported from the +top-level `reflectapi_runtime` package for convenience. No collision with +standard-library names. diff --git a/reflectapi-cli/Cargo.toml b/reflectapi-cli/Cargo.toml index e231d6f4..834b9405 100644 --- a/reflectapi-cli/Cargo.toml +++ b/reflectapi-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflectapi-cli" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" edition = "2021" default-run = "reflectapi" @@ -23,7 +23,7 @@ doc = false workspace = true [dependencies] -reflectapi = { path = "../reflectapi", version = "0.17.2-alpha.3", features = ["codegen"] } +reflectapi = { path = "../reflectapi", version = "0.17.2-alpha.4", features = ["codegen"] } rouille = "3" clap = { version = "4.5.3", features = ["derive"] } diff --git a/reflectapi-derive/Cargo.toml b/reflectapi-derive/Cargo.toml index 03135bab..fe9faaed 100644 --- a/reflectapi-derive/Cargo.toml +++ b/reflectapi-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflectapi-derive" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" edition = "2021" license = "Apache-2.0" @@ -22,7 +22,7 @@ workspace = true proc-macro = true [dependencies] -reflectapi-schema = { path = '../reflectapi-schema', version = "0.17.2-alpha.3" } +reflectapi-schema = { path = '../reflectapi-schema', version = "0.17.2-alpha.4" } proc-macro2 = "1.0" quote = "1.0" diff --git a/reflectapi-python-runtime/pyproject.toml b/reflectapi-python-runtime/pyproject.toml index 97ea6496..04821f8c 100644 --- a/reflectapi-python-runtime/pyproject.toml +++ b/reflectapi-python-runtime/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "reflectapi-runtime" -version = "0.17.2a3" +version = "0.17.2a4" description = "Runtime library for ReflectAPI Python clients" readme = "README.md" requires-python = ">=3.12" diff --git a/reflectapi-python-runtime/src/reflectapi_runtime/__init__.py b/reflectapi-python-runtime/src/reflectapi_runtime/__init__.py index f7f65d7a..debd2a93 100644 --- a/reflectapi-python-runtime/src/reflectapi_runtime/__init__.py +++ b/reflectapi-python-runtime/src/reflectapi_runtime/__init__.py @@ -56,7 +56,7 @@ ) from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible -__version__ = "0.17.2a3" +__version__ = "0.17.2a4" __all__ = [ # Authentication diff --git a/reflectapi-schema/Cargo.toml b/reflectapi-schema/Cargo.toml index 76dc16f3..bd5b6395 100644 --- a/reflectapi-schema/Cargo.toml +++ b/reflectapi-schema/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflectapi-schema" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" edition = "2021" license = "Apache-2.0" diff --git a/reflectapi/Cargo.toml b/reflectapi/Cargo.toml index ca148c5e..af000907 100644 --- a/reflectapi/Cargo.toml +++ b/reflectapi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflectapi" -version = "0.17.2-alpha.3" +version = "0.17.2-alpha.4" edition = "2021" license = "Apache-2.0" @@ -20,8 +20,8 @@ workspace = true [dependencies] # workspace dependencies -reflectapi-derive = { path = "../reflectapi-derive", version = "0.17.2-alpha.3" } -reflectapi-schema = { path = "../reflectapi-schema", version = "0.17.2-alpha.3" } +reflectapi-derive = { path = "../reflectapi-derive", version = "0.17.2-alpha.4" } +reflectapi-schema = { path = "../reflectapi-schema", version = "0.17.2-alpha.4" } # mandatory 3rd party dependencies serde = { version = "1.0.197", features = ["derive"] } @@ -91,5 +91,5 @@ glob = ["reflectapi-schema/glob"] json = ["dep:serde_json"] indexmap = ["dep:indexmap"] reqwest = ["dep:reqwest"] -reqwest-middleware = ["dep:reqwest-middleware"] +reqwest-middleware = ["dep:reqwest-middleware", "reqwest"] rt-sse = ["dep:sseer", "rt"] From e7fc6daeb087ca5f0f78e55035f557303e44bbed Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 17:18:31 +1200 Subject: [PATCH 02/16] fix(codegen/ts): split transport DTOs into ./generated.transport (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated TypeScript now emits two files: generated.ts (the API surface) and generated.transport.ts (the transport contract). Bare Request/Response/Headers/Client live in the transport submodule, qualified by the import path, so they no longer shadow the DOM globals of the same name when imported from generated.ts. Internally, lib.ts imports Response under an alias (ClientResponse) so the unprefixed `new Response(...)` call inside __read_response_body reaches the platform global directly — no more `(globalThis as any)` cast. API change: typescript::generate now returns BTreeMap rather than String. CLI and demo callers updated; in-place snapshot tests strip boilerplate so they still match. ClientInstance carries a single TS structural cast (Uint8Array -> BodyInit) to work around a TS 5 lib.dom typing of BufferSource that flags otherwise-valid fetch calls. Behaviour is unchanged. --- MIGRATION.md | 22 ++++-- reflectapi-cli/src/main.rs | 21 ++--- .../clients/typescript/generated.transport.ts | 49 ++++++++++++ .../clients/typescript/generated.ts | 61 ++++---------- reflectapi-demo/src/tests/assert.rs | 25 +++--- reflectapi-demo/src/tests/mod.rs | 14 ++-- reflectapi/src/codegen/lib.ts | 61 ++++---------- reflectapi/src/codegen/lib_transport.ts | 49 ++++++++++++ reflectapi/src/codegen/typescript.rs | 79 ++++++++++++------- 9 files changed, 221 insertions(+), 160 deletions(-) create mode 100644 reflectapi-demo/clients/typescript/generated.transport.ts create mode 100644 reflectapi/src/codegen/lib_transport.ts diff --git a/MIGRATION.md b/MIGRATION.md index b1075a47..f2758595 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -60,18 +60,24 @@ already enable both don't need to change anything. ### TypeScript -The transport DTOs are exported as bare `Request` / `Response` / `Headers` -from `generated.ts`. These names shadow the DOM globals of the same name -in any consumer module that also uses `fetch` types. Workarounds while a -fix lands (tracking in #143): +Codegen now emits two files: `generated.ts` (the API surface) and +`generated.transport.ts` (the transport contract). Most consumers only +need `generated.ts`. Custom transports import from the transport +submodule: ```ts -import { Request as ApiRequest, Response as ApiResponse } from './generated'; -// or -import * as api from './generated'; -// then api.Request, api.Response +import type { Client, Request, Response } from './generated.transport'; ``` +The bare `Request` / `Response` / `Headers` names live behind the +`./generated.transport` import path, so they no longer shadow the DOM +globals of the same name when imported from `generated.ts`. See #143 +for the rationale. + +If your build tooling assumed a single generated file, update it to +include the new sibling. Typical pnpm/npm workflows pick it up +automatically (the file sits in the same directory). + ### Python `Request` / `Response` / `Client` / `AsyncClient` are exported from the diff --git a/reflectapi-cli/src/main.rs b/reflectapi-cli/src/main.rs index ef9acb53..230a8834 100644 --- a/reflectapi-cli/src/main.rs +++ b/reflectapi-cli/src/main.rs @@ -154,19 +154,14 @@ fn main() -> anyhow::Result<()> { .context("Failed to parse schema file as JSON into reflectapi::Schema object")?; let files: std::collections::BTreeMap = match language { - Language::Typescript => { - let content = reflectapi::codegen::typescript::generate( - schema, - reflectapi::codegen::typescript::Config::default() - .format(format) - .typecheck(typecheck) - .include_tags(include_tags) - .exclude_tags(exclude_tags), - )?; - let mut files = std::collections::BTreeMap::new(); - files.insert("generated.ts".to_string(), content); - files - } + Language::Typescript => reflectapi::codegen::typescript::generate( + schema, + reflectapi::codegen::typescript::Config::default() + .format(format) + .typecheck(typecheck) + .include_tags(include_tags) + .exclude_tags(exclude_tags), + )?, Language::Rust => { let content = reflectapi::codegen::rust::generate( schema, diff --git a/reflectapi-demo/clients/typescript/generated.transport.ts b/reflectapi-demo/clients/typescript/generated.transport.ts new file mode 100644 index 00000000..2b8587da --- /dev/null +++ b/reflectapi-demo/clients/typescript/generated.transport.ts @@ -0,0 +1,49 @@ +// Transport contract. Lives in its own module so the bare names +// `Request` / `Response` / `Headers` don't shadow the DOM globals of +// the same name inside the main generated module — consumers import +// them only when they need to write a custom transport. +// +// Method is intentionally absent: every reflectapi endpoint is POST by +// design, so transports hardcode it; if that ever changes it's a +// wire-protocol break and clients regenerate. + +export interface RequestOptions { + signal?: AbortSignal; +} + +export interface Request { + path: string; + headers: Record; + body: Uint8Array; + signal?: AbortSignal; +} + +export interface Headers { + get(name: string): string | null; +} + +export interface Response { + status: number; + headers: Headers; + body: ReadableStream | null; +} + +export interface Client { + request(request: Request): Promise; +} + +export class ClientInstance { + constructor(private base: string) {} + + public request(request: Request): Promise { + return fetch(`${this.base}${request.path}`, { + method: "POST", + headers: request.headers, + // BodyInit accepts BufferSource but TS 5's generic ArrayBufferLike + // typing on Uint8Array trips a structural check; the runtime + // behaviour is correct. + body: request.body as unknown as BodyInit, + signal: request.signal, + }); + } +} diff --git a/reflectapi-demo/clients/typescript/generated.ts b/reflectapi-demo/clients/typescript/generated.ts index f1fda355..ba2c265a 100644 --- a/reflectapi-demo/clients/typescript/generated.ts +++ b/reflectapi-demo/clients/typescript/generated.ts @@ -8,33 +8,19 @@ export function client(base: string | Client): __definition.Interface { return __implementation.__client(base); } /* <----- */ -export interface RequestOptions { - signal?: AbortSignal; -} - -// Transport DTOs. Method is intentionally absent: every reflectapi -// endpoint is POST by design, so transports hardcode it; if that ever -// changes it's a wire-protocol break and clients regenerate. -export interface Request { - path: string; - headers: Record; - body: Uint8Array; - signal?: AbortSignal; -} - -export interface Headers { - get(name: string): string | null; -} - -export interface Response { - status: number; - headers: Headers; - body: ReadableStream | null; -} - -export interface Client { - request(request: Request): Promise; -} +// Transport contract lives in `./generated.transport` so the bare +// names Request/Response/Headers don't shadow the DOM globals here +// or in any consumer module that imports from this file. We pull +// them in under aliases so lib.ts itself can keep using DOM types. +import type { + Client, + RequestOptions, + Response as ClientResponse, +} from "./generated.transport"; +import { ClientInstance } from "./generated.transport"; + +export { ClientInstance }; +export type { Client, RequestOptions }; type IsAny = 0 extends 1 & T ? true : false; export type NullToEmptyObject = @@ -316,7 +302,7 @@ export async function __stream_request( } async function* __sse_to_async_iterable( - response: Response, + response: ClientResponse, options?: RequestOptions, ): AsyncIterable { const body = response.body; @@ -354,24 +340,11 @@ async function* __sse_to_async_iterable( } } -async function __read_response_body(response: Response): Promise { +async function __read_response_body(response: ClientResponse): Promise { if (!response.body) return ""; - // Hand decoding to the platform; new (global) Response wraps any + // Hand decoding to the platform; the global Response wraps any // ReadableStream and exposes the well-tested .text() path. - return await new (globalThis as any).Response(response.body).text(); -} - -class ClientInstance { - constructor(private base: string) {} - - public request(request: Request): Promise { - return (globalThis as any).fetch(`${this.base}${request.path}`, { - method: "POST", - headers: request.headers, - body: request.body, - signal: request.signal, - }); - } + return await new Response(response.body).text(); } type UnionToIntersection = ( diff --git a/reflectapi-demo/src/tests/assert.rs b/reflectapi-demo/src/tests/assert.rs index 0256bf8f..b135702b 100644 --- a/reflectapi-demo/src/tests/assert.rs +++ b/reflectapi-demo/src/tests/assert.rs @@ -60,15 +60,15 @@ fn codegen_rust(schema: reflectapi::Schema) -> String { } fn codegen_typescript(schema: reflectapi::Schema) -> String { - reflectapi::codegen::strip_boilerplate( - &reflectapi::codegen::typescript::generate( - schema, - reflectapi::codegen::typescript::Config::default() - .format(true) - .typecheck(std::env::var("CI").is_ok()), - ) - .unwrap(), + let files = reflectapi::codegen::typescript::generate( + schema, + reflectapi::codegen::typescript::Config::default() + .format(true) + .typecheck(std::env::var("CI").is_ok()), ) + .unwrap(); + // Snapshot the main file only; the transport file is schema-invariant. + reflectapi::codegen::strip_boilerplate(&files["generated.ts"]) } fn codegen_python(schema: reflectapi::Schema) -> String { @@ -204,15 +204,16 @@ macro_rules! assert_builder_snapshot { ) .unwrap(), ); - let typescript = reflectapi::codegen::strip_boilerplate( - &reflectapi::codegen::typescript::generate( + let typescript = { + let files = reflectapi::codegen::typescript::generate( schema.clone(), &reflectapi::codegen::typescript::Config::default() .format(true) .typecheck(true), ) - .unwrap(), - ); + .unwrap(); + reflectapi::codegen::strip_boilerplate(&files["generated.ts"]) + }; insta::assert_json_snapshot!(schema); insta::assert_snapshot!(typescript); insta::assert_snapshot!(rust); diff --git a/reflectapi-demo/src/tests/mod.rs b/reflectapi-demo/src/tests/mod.rs index 3cbb3b5a..bcf68ab2 100644 --- a/reflectapi-demo/src/tests/mod.rs +++ b/reflectapi-demo/src/tests/mod.rs @@ -62,7 +62,7 @@ fn write_rust_client() { #[test] fn write_typescript_client() { let (schema, _) = crate::builder().build().unwrap(); - let src = reflectapi::codegen::typescript::generate( + let files = reflectapi::codegen::typescript::generate( schema, reflectapi::codegen::typescript::Config::default() .format(true) @@ -70,14 +70,10 @@ fn write_typescript_client() { ) .unwrap(); - std::fs::write( - format!( - "{}/clients/typescript/generated.ts", - env!("CARGO_MANIFEST_DIR"), - ), - src, - ) - .unwrap(); + let dir = format!("{}/clients/typescript", env!("CARGO_MANIFEST_DIR")); + for (filename, content) in files { + std::fs::write(format!("{dir}/{filename}"), content).unwrap(); + } } #[test] diff --git a/reflectapi/src/codegen/lib.ts b/reflectapi/src/codegen/lib.ts index a564381e..862e3375 100644 --- a/reflectapi/src/codegen/lib.ts +++ b/reflectapi/src/codegen/lib.ts @@ -1,30 +1,16 @@ -export interface RequestOptions { - signal?: AbortSignal; -} - -// Transport DTOs. Method is intentionally absent: every reflectapi -// endpoint is POST by design, so transports hardcode it; if that ever -// changes it's a wire-protocol break and clients regenerate. -export interface Request { - path: string; - headers: Record; - body: Uint8Array; - signal?: AbortSignal; -} - -export interface Headers { - get(name: string): string | null; -} - -export interface Response { - status: number; - headers: Headers; - body: ReadableStream | null; -} - -export interface Client { - request(request: Request): Promise; -} +// Transport contract lives in `./generated.transport` so the bare +// names Request/Response/Headers don't shadow the DOM globals here +// or in any consumer module that imports from this file. We pull +// them in under aliases so lib.ts itself can keep using DOM types. +import type { + Client, + RequestOptions, + Response as ClientResponse, +} from "./generated.transport"; +import { ClientInstance } from "./generated.transport"; + +export { ClientInstance }; +export type { Client, RequestOptions }; type IsAny = 0 extends (1 & T) ? true : false; export type NullToEmptyObject = IsAny extends true @@ -305,7 +291,7 @@ export async function __stream_request( } async function* __sse_to_async_iterable( - response: Response, + response: ClientResponse, options?: RequestOptions, ): AsyncIterable { const body = response.body; @@ -343,24 +329,11 @@ async function* __sse_to_async_iterable( } } -async function __read_response_body(response: Response): Promise { +async function __read_response_body(response: ClientResponse): Promise { if (!response.body) return ""; - // Hand decoding to the platform; new (global) Response wraps any + // Hand decoding to the platform; the global Response wraps any // ReadableStream and exposes the well-tested .text() path. - return await new (globalThis as any).Response(response.body).text(); -} - -class ClientInstance { - constructor(private base: string) { } - - public request(request: Request): Promise { - return (globalThis as any).fetch(`${this.base}${request.path}`, { - method: "POST", - headers: request.headers, - body: request.body, - signal: request.signal, - }); - } + return await new Response(response.body).text(); } type UnionToIntersection = (U extends any ? (k: U) => unknown : never) extends ( diff --git a/reflectapi/src/codegen/lib_transport.ts b/reflectapi/src/codegen/lib_transport.ts new file mode 100644 index 00000000..16160001 --- /dev/null +++ b/reflectapi/src/codegen/lib_transport.ts @@ -0,0 +1,49 @@ +// Transport contract. Lives in its own module so the bare names +// `Request` / `Response` / `Headers` don't shadow the DOM globals of +// the same name inside the main generated module — consumers import +// them only when they need to write a custom transport. +// +// Method is intentionally absent: every reflectapi endpoint is POST by +// design, so transports hardcode it; if that ever changes it's a +// wire-protocol break and clients regenerate. + +export interface RequestOptions { + signal?: AbortSignal; +} + +export interface Request { + path: string; + headers: Record; + body: Uint8Array; + signal?: AbortSignal; +} + +export interface Headers { + get(name: string): string | null; +} + +export interface Response { + status: number; + headers: Headers; + body: ReadableStream | null; +} + +export interface Client { + request(request: Request): Promise; +} + +export class ClientInstance { + constructor(private base: string) { } + + public request(request: Request): Promise { + return fetch(`${this.base}${request.path}`, { + method: "POST", + headers: request.headers, + // BodyInit accepts BufferSource but TS 5's generic ArrayBufferLike + // typing on Uint8Array trips a structural check; the runtime + // behaviour is correct. + body: request.body as unknown as BodyInit, + signal: request.signal, + }); + } +} diff --git a/reflectapi/src/codegen/typescript.rs b/reflectapi/src/codegen/typescript.rs index f18ce3c1..679bf00b 100644 --- a/reflectapi/src/codegen/typescript.rs +++ b/reflectapi/src/codegen/typescript.rs @@ -1,6 +1,6 @@ use std::{ borrow::Cow, - collections::{BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap}, process::{Command, Stdio}, }; @@ -43,7 +43,10 @@ impl Config { } } -pub fn generate(mut schema: crate::Schema, config: &Config) -> anyhow::Result { +pub fn generate( + mut schema: crate::Schema, + config: &Config, +) -> anyhow::Result> { let implemented_types = build_implemented_types(); let mut rendered_types = HashMap::new(); @@ -121,39 +124,54 @@ pub fn generate(mut schema: crate::Schema, config: &Config) -> anyhow::Result anyhow::Result<()> { - let path = super::tmp_path(src).with_extension("ts"); - std::fs::write(&path, src)?; +fn format_ts(src: String) -> anyhow::Result { + // NOTE: When updating the biome version, also update .github/workflows/ci.yaml + let biome_package = "@biomejs/biome@1.8.3"; + Ok(format_with( + // In descending order of speed. The output should be the same. + [ + Command::new("biome").args(["format", "--stdin-file-path", "dummy.ts"]), + Command::new("npx").arg("-y").arg(biome_package).args([ + "format", + "--stdin-file-path", + "dummy.ts", + ]), + Command::new("prettier").args(["--parser", "typescript"]), + Command::new("npx") + .arg("-y") + .arg("prettier") + .args(["--parser", "typescript"]), + ], + src, + )?) +} + +fn typecheck(main: &str, transport: &str) -> anyhow::Result<()> { + // tsc resolves `import ... from './generated.transport'` against + // the filesystem, so both files must live in the same directory. + let dir = super::tmp_path(main).with_extension("dir"); + std::fs::create_dir_all(&dir)?; + let main_path = dir.join("generated.ts"); + let transport_path = dir.join("generated.transport.ts"); + std::fs::write(&main_path, main)?; + std::fs::write(&transport_path, transport)?; for cmd in [ &mut Command::new("tsc"), @@ -168,7 +186,8 @@ fn typecheck(src: &str) -> anyhow::Result<()> { .arg("--skipLibCheck") .arg("--strict") .args(["--lib", "esnext,DOM"]) - .arg(&path) + .arg(&main_path) + .arg(&transport_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -190,8 +209,8 @@ fn typecheck(src: &str) -> anyhow::Result<()> { )); } - // Remove only after success check to keep file around for debugging - std::fs::remove_file(&path)?; + // Remove only after success check to keep files around for debugging + let _ = std::fs::remove_dir_all(&dir); return Ok(()); } From 6f17dbff617237afe4426413c2d951382f02e7ce Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 17:32:13 +1200 Subject: [PATCH 03/16] docs: fold MIGRATION.md into CHANGELOG.md and fix Rust migration snippets - Drop the misleading before/after pair where the convenience-constructor snippets were identical. Lead with "if your generated crate has a reqwest feature, Interface::try_new keeps working unchanged" and introduce the explicit form below. - Add a worked example for the reqwest_middleware::ClientWithMiddleware path (the common case with otel/retry layers and no feature-flagged generated crate). Matches what consumers wrote during the migration. --- MIGRATION.md => CHANGELOG.md | 49 ++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 16 deletions(-) rename MIGRATION.md => CHANGELOG.md (66%) diff --git a/MIGRATION.md b/CHANGELOG.md similarity index 66% rename from MIGRATION.md rename to CHANGELOG.md index f2758595..7e4786e2 100644 --- a/MIGRATION.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -# Migration Guide +# Changelog -## 0.17.2 (transport-shape refactor) +## 0.17.2 — transport-shape refactor The Rust, TypeScript, and Python clients now share a single `Request` DTO shape: `{ path, headers, body }`. The base URL lives on the transport, not on the @@ -25,16 +25,40 @@ pub struct Request { ``` `reqwest::Client` no longer implements `Client` directly — it doesn't carry a -base URL. Use the bundled wrapper: +base URL. Pick the path that matches your setup: + +**Plain `reqwest::Client`, generated crate has a `reqwest` feature.** The +generated `Interface::try_new(reqwest::Client, base_url)` convenience +constructor keeps working unchanged: ```rust -// before let api = MyClient::try_new(reqwest::Client::new(), base_url)?; +``` -// after — convenience constructor (recommended) -let api = MyClient::try_new(reqwest::Client::new(), base_url)?; +The constructor is gated on the generated crate enabling its own `reqwest` +feature (which should re-export `reflectapi/reqwest`). If you don't see +`try_new`, this is why. -// after — explicit, when composing a custom transport +**`reqwest_middleware::ClientWithMiddleware` (the common path with +otel/retry/etc.).** Compose the transport explicitly — there's no +convenience constructor for this case because of method-resolution +ambiguity: + +```rust +let transport = reflectapi::rt::ReqwestMiddlewareClient::try_new( + mw_client, + base_url, +)?; +let api = MyClient::new(transport); +``` + +`ReqwestMiddlewareClient` is a type alias for +`ReqwestClient`. + +**Custom transport.** Wrap with `ReqwestClient::try_new(...)` (or implement +the `Client` trait yourself) and pass the result to `MyClient::new`: + +```rust let api = MyClient::new( reflectapi::rt::ReqwestClient::try_new(reqwest::Client::new(), base_url)? ); @@ -44,21 +68,14 @@ let api = MyClient::new( validates that `base_url` is a valid HTTP base. There is no infallible `ReqwestClient::new` constructor. -The generated `Interface::try_new(reqwest::Client, base_url)` convenience -constructor is available when the generated crate enables its own `reqwest` -feature, which should re-export `reflectapi/reqwest`. For -`reqwest_middleware::ClientWithMiddleware`, compose manually using -`ReqwestMiddlewareClient` (a type alias for -`ReqwestClient`). - -#### `reqwest-middleware` feature implies `reqwest` +#### `reqwest-middleware` feature implies `reqwest` (alpha.4) In 0.17.2-alpha.3 enabling only `reqwest-middleware` failed to compile because the wrapper struct lives behind the `reqwest` feature. Fixed in 0.17.2-alpha.4: `reqwest-middleware` now implies `reqwest`. Consumers that already enable both don't need to change anything. -### TypeScript +### TypeScript (alpha.4) Codegen now emits two files: `generated.ts` (the API surface) and `generated.transport.ts` (the transport contract). Most consumers only From 8e2e8ee17d4e029bc4d630cc85c9bcad5cd6f25d Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 17:53:46 +1200 Subject: [PATCH 04/16] fix(cli): preserve --output .ts paths for multi-file TS codegen Previously, passing `--output some/dir/generated.ts` for TypeScript created a directory at that path containing generated.ts/generated.ts and generated.ts/generated.transport.ts because the multi-file branch treated the output path as a directory unconditionally. Now: if the output path looks like a file (not an existing directory, no trailing slash) and matches one of the codegen filenames, write that file at the requested path and place siblings in the parent directory. Backward-compatible with scripts that wrote --output .../generated.ts. If the file-looking path doesn't match any codegen filename (e.g. a custom name), error out with a migration hint suggesting --output rather than silently creating a directory there. Also update docs/src/clients/README.md to describe TS as two-file output. --- docs/src/clients/README.md | 12 +++++- reflectapi-cli/src/main.rs | 82 ++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/docs/src/clients/README.md b/docs/src/clients/README.md index bb581d9d..e3efbb28 100644 --- a/docs/src/clients/README.md +++ b/docs/src/clients/README.md @@ -6,7 +6,7 @@ | Output | Status | Notes | |--------|--------|-------| -| TypeScript | Stable | Single generated file | +| TypeScript | Stable | Two generated files: API surface + transport contract | | Rust | Stable | Single generated file | | Python | Experimental | Package-style output with `__init__.py` and `generated.py` | @@ -27,6 +27,7 @@ The CLI defaults to `reflectapi.json` if `--schema` is omitted. The demo project mkdir -p clients/typescript clients/python clients/rust # Generate TypeScript client -> clients/typescript/generated.ts +# and clients/typescript/generated.transport.ts cargo run --bin reflectapi -- codegen \ --language typescript \ --schema reflectapi.json \ @@ -54,7 +55,7 @@ The generators do not all emit the same file layout: | Output | Files written by the generator | |--------|--------------------------------| -| TypeScript | `generated.ts` | +| TypeScript | `generated.ts`, `generated.transport.ts` | | Rust | `generated.rs` | | Python | `__init__.py`, `generated.py` | @@ -64,6 +65,13 @@ The demo repository includes extra project scaffolding around some generated cli ### TypeScript +- Emits two files alongside each other: `generated.ts` (the API + surface — types, functions, the `client(base)` factory) and + `generated.transport.ts` (the transport contract — `Request`, + `Response`, `Headers`, `Client`, `RequestOptions`, `ClientInstance`). + The split keeps the bare DTO names from shadowing the DOM globals of + the same name when imported from `generated.ts`. Custom transports + import from `./generated.transport`. - Uses generated TypeScript types and function wrappers. - Uses a `fetch`-based default client implementation. - Parses JSON responses, but does not generate runtime schema validators today. diff --git a/reflectapi-cli/src/main.rs b/reflectapi-cli/src/main.rs index 230a8834..05b016c1 100644 --- a/reflectapi-cli/src/main.rs +++ b/reflectapi-cli/src/main.rs @@ -93,7 +93,7 @@ enum DocSubcommand { }, } -#[derive(ValueEnum, Clone, PartialEq)] +#[derive(ValueEnum, Clone, Debug, PartialEq)] enum Language { Typescript, Rust, @@ -213,36 +213,68 @@ fn main() -> anyhow::Result<()> { let output_path = output.unwrap_or_else(|| std::path::PathBuf::from("./")); - // For single-file languages, write directly to the specified path if it's a file, - // or to default filename in the directory if it's a directory - // For multi-file languages (like Python), create directory and write multiple files - if files.len() == 1 { - let (filename, content) = files.iter().next().unwrap(); - let final_path = - if output_path.is_dir() || output_path.to_string_lossy().ends_with('/') { - output_path.join(filename) - } else { - output_path - }; - let mut file = std::fs::File::create(&final_path) - .context(format!("Failed to create file: {final_path:?}"))?; - file.write_all(content.as_bytes()) - .context(format!("Failed to write to file: {final_path:?}"))?; - } else { - // Multi-file: create directory and write all files + // Resolve where each generated file lands. + // + // - If the output path looks like a directory (existing dir, or + // ends with a separator), every file is placed inside it + // under its codegen-assigned filename. + // - If the output path looks like a file, the codegen file + // whose name matches goes there and any siblings land in the + // same parent directory under their codegen-assigned names. + // This preserves backward compat with existing scripts that + // pass `--output .../generated.ts`. + // - If the output path looks like a file but no codegen file + // matches its name, we error rather than create a directory + // at that path (which would surprise existing users). + let looks_like_dir = + output_path.is_dir() || output_path.to_string_lossy().ends_with('/'); + + if looks_like_dir { std::fs::create_dir_all(&output_path).context(format!( "Failed to create output directory: {output_path:?}" ))?; - - for (filename, content) in files { - let file_path = output_path.join(&filename); - let mut file = std::fs::File::create(&file_path) - .context(format!("Failed to create file: {file_path:?}"))?; - file.write_all(content.as_bytes()) - .context(format!("Failed to write to file: {file_path:?}"))?; + for (filename, content) in &files { + write_file(&output_path.join(filename), content)?; + } + } else { + let primary_name = output_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if !files.contains_key(primary_name) { + let expected: Vec<&str> = files.keys().map(String::as_str).collect(); + anyhow::bail!( + "output path {output_path:?} looks like a file, but {language:?} \ + codegen now emits multiple files: {expected:?}. \ + Pass a directory path (e.g. --output {:?}) instead.", + output_path.parent().unwrap_or(std::path::Path::new(".")), + ); + } + let parent = output_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + std::fs::create_dir_all(&parent) + .context(format!("Failed to create output directory: {parent:?}"))?; + for (filename, content) in &files { + let dest = if filename == primary_name { + output_path.clone() + } else { + parent.join(filename) + }; + write_file(&dest, content)?; } } Ok(()) } } } + +fn write_file(path: &std::path::Path, content: &str) -> anyhow::Result<()> { + let mut file = + std::fs::File::create(path).context(format!("Failed to create file: {path:?}"))?; + file.write_all(content.as_bytes()) + .context(format!("Failed to write to file: {path:?}"))?; + Ok(()) +} From 0f0c60fd575aa3fbd480afea474ea3835b3d8b21 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 19:06:13 +1200 Subject: [PATCH 05/16] fix(cli): use parent_or_dot helper for both write and error paths When --output is a bare filename like `mystery.foo`, Path::parent() returns Some("") rather than None, so the previous unwrap_or fallback never fired and the error message read `Pass a directory path (e.g. --output "")`. Useless suggestion. Extract parent_or_dot() (the same .filter(empty).unwrap_or(".") that the write path already used) and apply it in both places. Now the error suggests `--output "."` for bare-filename inputs. --- reflectapi-cli/src/main.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/reflectapi-cli/src/main.rs b/reflectapi-cli/src/main.rs index 05b016c1..83ae4134 100644 --- a/reflectapi-cli/src/main.rs +++ b/reflectapi-cli/src/main.rs @@ -241,20 +241,15 @@ fn main() -> anyhow::Result<()> { .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(); + let parent = parent_or_dot(&output_path); if !files.contains_key(primary_name) { let expected: Vec<&str> = files.keys().map(String::as_str).collect(); anyhow::bail!( "output path {output_path:?} looks like a file, but {language:?} \ codegen now emits multiple files: {expected:?}. \ - Pass a directory path (e.g. --output {:?}) instead.", - output_path.parent().unwrap_or(std::path::Path::new(".")), + Pass a directory path (e.g. --output {parent:?}) instead.", ); } - let parent = output_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map(std::path::Path::to_path_buf) - .unwrap_or_else(|| std::path::PathBuf::from(".")); std::fs::create_dir_all(&parent) .context(format!("Failed to create output directory: {parent:?}"))?; for (filename, content) in &files { @@ -271,6 +266,13 @@ fn main() -> anyhow::Result<()> { } } +fn parent_or_dot(path: &std::path::Path) -> std::path::PathBuf { + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| std::path::PathBuf::from(".")) +} + fn write_file(path: &std::path::Path, content: &str) -> anyhow::Result<()> { let mut file = std::fs::File::create(path).context(format!("Failed to create file: {path:?}"))?; From cbf159bc90e6e6b0d2855bfa1bbcd5e263321bc8 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 19:24:39 +1200 Subject: [PATCH 06/16] docs(changelog): note typescript::generate signature change --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e4786e2..9ebb5a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,25 @@ If your build tooling assumed a single generated file, update it to include the new sibling. Typical pnpm/npm workflows pick it up automatically (the file sits in the same directory). +#### Library API: `typescript::generate` returns multiple files + +For consumers calling the codegen library directly rather than +through the `reflectapi` CLI, the signature changed: + +```rust +// before +pub fn generate(schema: Schema, config: &Config) -> Result; + +// after +pub fn generate(schema: Schema, config: &Config) -> Result>; +``` + +The map is keyed by filename (`"generated.ts"`, +`"generated.transport.ts"`). The CLI handles `--output .ts` paths +by writing the matching file at the requested path and the sibling in +the parent directory; downstream callers wrapping the library should +do the same. + ### Python `Request` / `Response` / `Client` / `AsyncClient` are exported from the From c49bc4588c73bf0ad76479dfab62383cb386de1e Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 19:26:25 +1200 Subject: [PATCH 07/16] docs(changelog): reflow to one paragraph per line --- CHANGELOG.md | 63 +++++++++++++--------------------------------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ebb5a84..733344ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ ## 0.17.2 — transport-shape refactor -The Rust, TypeScript, and Python clients now share a single `Request` DTO shape: -`{ path, headers, body }`. The base URL lives on the transport, not on the -generated `Interface`. +The Rust, TypeScript, and Python clients now share a single `Request` DTO shape: `{ path, headers, body }`. The base URL lives on the transport, not on the generated `Interface`. ### Rust @@ -24,25 +22,17 @@ pub struct Request { } ``` -`reqwest::Client` no longer implements `Client` directly — it doesn't carry a -base URL. Pick the path that matches your setup: +`reqwest::Client` no longer implements `Client` directly — it doesn't carry a base URL. Pick the path that matches your setup: -**Plain `reqwest::Client`, generated crate has a `reqwest` feature.** The -generated `Interface::try_new(reqwest::Client, base_url)` convenience -constructor keeps working unchanged: +**Plain `reqwest::Client`, generated crate has a `reqwest` feature.** The generated `Interface::try_new(reqwest::Client, base_url)` convenience constructor keeps working unchanged: ```rust let api = MyClient::try_new(reqwest::Client::new(), base_url)?; ``` -The constructor is gated on the generated crate enabling its own `reqwest` -feature (which should re-export `reflectapi/reqwest`). If you don't see -`try_new`, this is why. +The constructor is gated on the generated crate enabling its own `reqwest` feature (which should re-export `reflectapi/reqwest`). If you don't see `try_new`, this is why. -**`reqwest_middleware::ClientWithMiddleware` (the common path with -otel/retry/etc.).** Compose the transport explicitly — there's no -convenience constructor for this case because of method-resolution -ambiguity: +**`reqwest_middleware::ClientWithMiddleware` (the common path with otel/retry/etc.).** Compose the transport explicitly — there's no convenience constructor for this case because of method-resolution ambiguity: ```rust let transport = reflectapi::rt::ReqwestMiddlewareClient::try_new( @@ -52,11 +42,9 @@ let transport = reflectapi::rt::ReqwestMiddlewareClient::try_new( let api = MyClient::new(transport); ``` -`ReqwestMiddlewareClient` is a type alias for -`ReqwestClient`. +`ReqwestMiddlewareClient` is a type alias for `ReqwestClient`. -**Custom transport.** Wrap with `ReqwestClient::try_new(...)` (or implement -the `Client` trait yourself) and pass the result to `MyClient::new`: +**Custom transport.** Wrap with `ReqwestClient::try_new(...)` (or implement the `Client` trait yourself) and pass the result to `MyClient::new`: ```rust let api = MyClient::new( @@ -64,41 +52,27 @@ let api = MyClient::new( ); ``` -`ReqwestClient::try_new` returns `Result` because it -validates that `base_url` is a valid HTTP base. There is no infallible -`ReqwestClient::new` constructor. +`ReqwestClient::try_new` returns `Result` because it validates that `base_url` is a valid HTTP base. There is no infallible `ReqwestClient::new` constructor. #### `reqwest-middleware` feature implies `reqwest` (alpha.4) -In 0.17.2-alpha.3 enabling only `reqwest-middleware` failed to compile -because the wrapper struct lives behind the `reqwest` feature. Fixed in -0.17.2-alpha.4: `reqwest-middleware` now implies `reqwest`. Consumers that -already enable both don't need to change anything. +In 0.17.2-alpha.3 enabling only `reqwest-middleware` failed to compile because the wrapper struct lives behind the `reqwest` feature. Fixed in 0.17.2-alpha.4: `reqwest-middleware` now implies `reqwest`. Consumers that already enable both don't need to change anything. ### TypeScript (alpha.4) -Codegen now emits two files: `generated.ts` (the API surface) and -`generated.transport.ts` (the transport contract). Most consumers only -need `generated.ts`. Custom transports import from the transport -submodule: +Codegen now emits two files: `generated.ts` (the API surface) and `generated.transport.ts` (the transport contract). Most consumers only need `generated.ts`. Custom transports import from the transport submodule: ```ts import type { Client, Request, Response } from './generated.transport'; ``` -The bare `Request` / `Response` / `Headers` names live behind the -`./generated.transport` import path, so they no longer shadow the DOM -globals of the same name when imported from `generated.ts`. See #143 -for the rationale. +The bare `Request` / `Response` / `Headers` names live behind the `./generated.transport` import path, so they no longer shadow the DOM globals of the same name when imported from `generated.ts`. See #143 for the rationale. -If your build tooling assumed a single generated file, update it to -include the new sibling. Typical pnpm/npm workflows pick it up -automatically (the file sits in the same directory). +If your build tooling assumed a single generated file, update it to include the new sibling. Typical pnpm/npm workflows pick it up automatically (the file sits in the same directory). #### Library API: `typescript::generate` returns multiple files -For consumers calling the codegen library directly rather than -through the `reflectapi` CLI, the signature changed: +For consumers calling the codegen library directly rather than through the `reflectapi` CLI, the signature changed: ```rust // before @@ -108,15 +82,8 @@ pub fn generate(schema: Schema, config: &Config) -> Result; pub fn generate(schema: Schema, config: &Config) -> Result>; ``` -The map is keyed by filename (`"generated.ts"`, -`"generated.transport.ts"`). The CLI handles `--output .ts` paths -by writing the matching file at the requested path and the sibling in -the parent directory; downstream callers wrapping the library should -do the same. +The map is keyed by filename (`"generated.ts"`, `"generated.transport.ts"`). The CLI handles `--output .ts` paths by writing the matching file at the requested path and the sibling in the parent directory; downstream callers wrapping the library should do the same. ### Python -`Request` / `Response` / `Client` / `AsyncClient` are exported from the -`reflectapi_runtime.transport` module. They are also re-exported from the -top-level `reflectapi_runtime` package for convenience. No collision with -standard-library names. +`Request` / `Response` / `Client` / `AsyncClient` are exported from the `reflectapi_runtime.transport` module. They are also re-exported from the top-level `reflectapi_runtime` package for convenience. No collision with standard-library names. From c993ac31121aad1bbdeb7db0398a928a381a7a67 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Fri, 8 May 2026 19:35:55 +1200 Subject: [PATCH 08/16] docs(changelog): tighten and correct prose - Fix Rust trait snippet: Response is generic over Self::Error. - Replace loose "valid HTTP base" with the actual cannot_be_a_base check; correct re-export path (reflectapi::rt::UrlParseError). - Drop the defensive "No collision with standard-library names" Python line; reframe the Python section around what custom middleware / transport authors should target. --- CHANGELOG.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733344ed..17ea7c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,10 @@ The trait gained a `base_url` method and `Request::url` was renamed to `Request: pub trait Client { type Error; fn base_url(&self) -> &Url; - fn request(&self, request: Request) -> impl Future>; + fn request( + &self, + request: Request, + ) -> impl Future, Self::Error>>; } pub struct Request { @@ -52,7 +55,7 @@ let api = MyClient::new( ); ``` -`ReqwestClient::try_new` returns `Result` because it validates that `base_url` is a valid HTTP base. There is no infallible `ReqwestClient::new` constructor. +`ReqwestClient::try_new` returns `Result` (a re-export of `url::ParseError`). It rejects URLs that can't have a base (`data:`, `mailto:`, etc.) via `Url::cannot_be_a_base`. There is no infallible `ReqwestClient::new` constructor. #### `reqwest-middleware` feature implies `reqwest` (alpha.4) @@ -86,4 +89,4 @@ The map is keyed by filename (`"generated.ts"`, `"generated.transport.ts"`). The ### Python -`Request` / `Response` / `Client` / `AsyncClient` are exported from the `reflectapi_runtime.transport` module. They are also re-exported from the top-level `reflectapi_runtime` package for convenience. No collision with standard-library names. +End-user generated clients are unchanged. Authors of custom middleware or transports should target the transport-agnostic types in `reflectapi_runtime.transport` (`Request`, `Response`, `Client`, `AsyncClient`) rather than reaching for `httpx` types directly. They are also re-exported from the top-level `reflectapi_runtime` package. From 59ffe36eafaae6cf7108070b43c7345491f2bc04 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 15:34:15 +1200 Subject: [PATCH 09/16] fix(codegen/py): monomorphize structs that flatten a generic param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pydantic can't represent serde(flatten) over a TypeVar at runtime — the inner T's wire fields were silently dropped at class-definition time, and ConfigDict(extra="ignore") then discarded them on parse. Silent data loss for IdentityData, UpdateOrElse, InsertManyOrElse, and the like. Fix: walk the schema, find every concrete (struct, args) instantiation of a marked struct, and emit a specialized non-generic class. The existing flatten-of-concrete-type rendering then handles them correctly. Mangled names follow OriginalStruct_Arg1_Arg2 — short enough to avoid the existing 80-char hash-truncation path so all references (class def, namespace alias, method signatures, model_rebuild) stay consistent. Defence-in-depth: collect_flattened_fields' silent-skip arm now splits primitive (legitimate empty flatten for unit types) vs unresolved (codegen bug — bail). Any future regression that reintroduces the field-dropping behaviour will fail loudly instead of corrupting wire format. Tests: - Reproducer: TestUpdateOrElse - Multiple distinct instantiations of the same generic - Optional flatten (serde unwraps Option in flatten position) - End-to-end Pydantic round-trip via `uv run python` against the generated module — confirms the wire format actually parses Rust and TypeScript outputs are unaffected. Two pre-existing flatten tests had snapshots blessing the bug (empty class bodies for flatten-of-typevar); those snapshots are corrected. --- CHANGELOG.md | 8 + Cargo.lock | 1 + reflectapi-demo/Cargo.toml | 1 + reflectapi-demo/src/tests/serde.rs | 195 +++++++++ ...s__serde__flatten_internally_tagged-5.snap | 21 +- ...pi_demo__tests__serde__flatten_unit-5.snap | 44 +- ..._generic_flatten_drops_inner_fields-2.snap | 88 ++++ ..._generic_flatten_drops_inner_fields-3.snap | 87 ++++ ..._generic_flatten_drops_inner_fields-4.snap | 134 ++++++ ..._generic_flatten_drops_inner_fields-5.snap | 173 ++++++++ ...e__generic_flatten_drops_inner_fields.snap | 300 +++++++++++++ ...ts__serde__generic_flatten_optional-2.snap | 77 ++++ ...ts__serde__generic_flatten_optional-3.snap | 96 +++++ ...ts__serde__generic_flatten_optional-4.snap | 122 ++++++ ...ts__serde__generic_flatten_optional-5.snap | 185 ++++++++ ...ests__serde__generic_flatten_optional.snap | 257 +++++++++++ ..._generic_flatten_two_instantiations-2.snap | 85 ++++ ..._generic_flatten_two_instantiations-3.snap | 98 +++++ ..._generic_flatten_two_instantiations-4.snap | 166 ++++++++ ..._generic_flatten_two_instantiations-5.snap | 193 +++++++++ ...e__generic_flatten_two_instantiations.snap | 400 ++++++++++++++++++ reflectapi/src/codegen/python.rs | 335 ++++++++++++++- 22 files changed, 3017 insertions(+), 49 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ea7c4f..c9555391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,3 +90,11 @@ The map is keyed by filename (`"generated.ts"`, `"generated.transport.ts"`). The ### Python End-user generated clients are unchanged. Authors of custom middleware or transports should target the transport-agnostic types in `reflectapi_runtime.transport` (`Request`, `Response`, `Client`, `AsyncClient`) rather than reaching for `httpx` types directly. They are also re-exported from the top-level `reflectapi_runtime` package. + +#### Generic flatten correctness (alpha.4) + +Previously, a Rust struct that used `serde(flatten)` over a generic parameter (e.g. `IdentityData`, `UpdateOrElse`, `InsertManyOrElse`) generated a Pydantic model that silently dropped the inner type's wire fields, because the Python codegen couldn't resolve a TypeVar to a concrete struct at class-definition time. With `extra="ignore"` on the model, those fields were also discarded on parse — silent data loss for any endpoint using these patterns. + +Fix: the Python codegen now monomorphizes — for each concrete `(struct, args)` instantiation it emits a specialized class with the flatten resolved against the concrete type. The mangled name is `OriginalStruct_Arg1_Arg2…`, e.g. `UpdateOrElse_Pet_Conflict`. Method signatures, namespace classes, and `model_rebuild` lists all use the mangled name consistently. Rust and TypeScript clients are unaffected — they handled this case correctly already (serde at runtime; intersection types at compile time). + +The codegen also now hard-fails (rather than silently dropping fields) if it encounters an unresolved flatten target — a defence-in-depth check so any future regression is loud. diff --git a/Cargo.lock b/Cargo.lock index ec654870..4c460a96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1751,6 +1751,7 @@ dependencies = [ "rmp-serde", "serde", "serde_json", + "tempfile", "tokio", "tower 0.4.13", "tower-http", diff --git a/reflectapi-demo/Cargo.toml b/reflectapi-demo/Cargo.toml index f810ec89..4531e0a2 100644 --- a/reflectapi-demo/Cargo.toml +++ b/reflectapi-demo/Cargo.toml @@ -47,6 +47,7 @@ tower = "0.4.0" http-rest-file = "0.5.1" datatest-stable = "0.2.9" rmp-serde = "1.3.0" +tempfile = "3" [[test]] name = "run" diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index c0cbd3e5..a85f264f 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1194,3 +1194,198 @@ fn test_empty_enum() { enum Never {} assert_snapshot!(Never); } + +// Reproducer: a generic wrapper that flattens its generic parameter. +// Pattern used in real consumer code (e.g. UpdateOrElse). +// +// Wire format with serde(flatten) on `inner: T` is: T's fields ⊕ if_field +// at the top level. Python codegen previously dropped T's fields entirely +// because schema.get_type("T") returns None for a TypeVar. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestFlattenInner { + inner_a: u32, + inner_b: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestFlattenInnerAlt { + alt_x: bool, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestFlattenIfElse { + code: u16, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "T: serde::Serialize, C: serde::Serialize", + deserialize = "T: serde::de::DeserializeOwned, C: serde::de::DeserializeOwned", +))] +struct TestUpdateOrElse { + #[serde(flatten)] + inner: T, + if_else: Option, +} + +// Two endpoints sharing the same generic wrapper but with different +// instantiations. Each must produce its own monomorphized class. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestTwoInstantiations { + a: TestUpdateOrElse, + b: TestUpdateOrElse, +} + +// Optional-wrapped flatten of a generic param: serde unwraps Option in +// flatten position, so wire shape = T's fields ⊕ if_else (but T may +// be missing entirely). Verify codegen still expands T's fields. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "T: serde::Serialize", + deserialize = "T: serde::de::DeserializeOwned", +))] +struct TestOptionalFlatten { + #[serde(flatten, default = "Option::default")] + inner: Option, + code: u16, +} + +#[test] +fn test_generic_flatten_drops_inner_fields() { + assert_snapshot!(TestUpdateOrElse); +} + +#[test] +fn test_generic_flatten_two_instantiations() { + assert_snapshot!(TestTwoInstantiations); +} + +#[test] +fn test_generic_flatten_optional() { + assert_snapshot!(TestOptionalFlatten); +} + +/// End-to-end Pydantic round-trip: confirms the generated class +/// actually parses serde's wire format. Skipped if `uv` (or a Python +/// with pydantic) isn't available locally — CI uses uv. +#[test] +fn test_generic_flatten_pydantic_roundtrip() { + use std::io::Write; + + let py_source = + super::into_python_code::>(); + + // Wire format that serde would actually produce / accept: T's + // fields ⊕ if_else, all at top level. + let wire_payload = + serde_json::to_string(&TestUpdateOrElse:: { + inner: TestFlattenInner { + inner_a: 7, + inner_b: "hello".into(), + }, + if_else: Some(TestFlattenIfElse { code: 409 }), + }) + .unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let module_path = tmp.path().join("generated.py"); + std::fs::write(&module_path, py_source).unwrap(); + + let class_name = "TestUpdateOrElseTestFlattenInnerTestFlattenIfElse"; + let driver = format!( + r#" +import importlib.util, json, sys, pathlib +spec = importlib.util.spec_from_file_location("gen", r"{module}") +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +cls = m.reflectapi_demo.tests.serde.{class_name} +parsed = cls.model_validate(json.loads(r'''{payload}''')) +assert parsed.inner_a == 7, ("inner_a", parsed.inner_a) +assert parsed.inner_b == "hello", ("inner_b", parsed.inner_b) +assert parsed.if_else is not None and parsed.if_else.code == 409, ("if_else", parsed.if_else) +# Round-trip back through json: model_dump returns a dict; serialize and +# verify the wire format still has the flatten shape (T's fields at top). +out = parsed.model_dump(by_alias=True, exclude_none=False) +assert out["inner_a"] == 7 +assert out["inner_b"] == "hello" +assert out["if_else"]["code"] == 409 +print("OK") +"#, + module = module_path.display(), + class_name = class_name, + payload = wire_payload, + ); + + // Prefer `uv run python` from the python-runtime workspace (which + // declares pydantic as a dep). Fall back to bare `python3` if it + // already has pydantic on its path. Skip the test only if neither + // works — CI installs uv so it'll always exercise this. + let runtime_dir = format!( + "{}/../reflectapi-python-runtime", + env!("CARGO_MANIFEST_DIR") + ); + + let mut attempts: Vec<(String, std::process::Command)> = Vec::new(); + { + let mut c = std::process::Command::new("uv"); + c.args(["run", "--directory", &runtime_dir, "python", "-"]); + attempts.push(("uv run python".to_string(), c)); + } + if std::process::Command::new("python3") + .args(["-c", "import pydantic"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + let mut c = std::process::Command::new("python3"); + c.args(["-"]); + attempts.push(("system python3+pydantic".to_string(), c)); + } + + for (label, mut cmd) in attempts { + let spawn = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + let mut child = match spawn { + Ok(c) => c, + Err(e) => { + eprintln!("skip via {label}: spawn failed ({e})"); + continue; + } + }; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(driver.as_bytes()).unwrap(); + } + let output = child.wait_with_output().expect("python wait"); + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("OK"), + "{label}: missing OK marker.\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr), + ); + eprintln!("{label}: pydantic roundtrip OK"); + return; + } + // Distinguish "no pydantic" (skip) from "pydantic is there but + // assertions failed" (fail). We marshal the assertion error + // through the script's exit; pydantic missing is an + // ImportError before our prints fire. + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if stderr.contains("ModuleNotFoundError") && stderr.contains("pydantic") { + eprintln!("skip via {label}: pydantic not installed"); + continue; + } + panic!( + "{label}: roundtrip failed (exit={:?})\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + stderr, + ); + } + eprintln!("test_generic_flatten_pydantic_roundtrip skipped: no working python+pydantic found"); +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap index 135c9343..facf1fbd 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap @@ -25,14 +25,6 @@ from reflectapi_runtime import ReflectapiEmpty from reflectapi_runtime import ReflectapiInfallible -# Type variables for generic types - - -Additional = TypeVar("Additional") - -Payload = TypeVar("Payload") - - class ReflectapiDemoTestsSerdeA(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -45,16 +37,19 @@ class ReflectapiDemoTestsSerdeB(BaseModel): b: int -class ReflectapiDemoTestsSerdeS(BaseModel, Generic[Payload, Additional]): +class ReflectapiDemoTestsSerdeSAB(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) + a: int + b: int + class ReflectapiDemoTestsSerdeTestS(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) type: Literal["S"] = Field(default="S", description="Discriminator field") - payload: Annotated[Any, "External type: Payload"] - additional: Annotated[Any, "External type: Additional"] + a: int + b: int class ReflectapiDemoTestsSerdeTest(RootModel): @@ -73,7 +68,7 @@ class reflectapi_demo: A = ReflectapiDemoTestsSerdeA B = ReflectapiDemoTestsSerdeB - S = ReflectapiDemoTestsSerdeS + SAB = ReflectapiDemoTestsSerdeSAB TestS = ReflectapiDemoTestsSerdeTestS Test = ReflectapiDemoTestsSerdeTest @@ -172,7 +167,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] for _model in [ ReflectapiDemoTestsSerdeA, ReflectapiDemoTestsSerdeB, - ReflectapiDemoTestsSerdeS, + ReflectapiDemoTestsSerdeSAB, ReflectapiDemoTestsSerdeTest, ]: try: diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap index 72940cbf..de789d76 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap @@ -24,23 +24,17 @@ from reflectapi_runtime import ReflectapiEmpty from reflectapi_runtime import ReflectapiInfallible -# Type variables for generic types - - -Additional = TypeVar("Additional") - -Payload = TypeVar("Payload") - - class ReflectapiDemoTestsSerdeK(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) a: int -class ReflectapiDemoTestsSerdeS(BaseModel, Generic[Payload, Additional]): +class ReflectapiDemoTestsSerdeSKTuple0(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) + a: int + # Namespace classes for dotted access to types class reflectapi_demo: @@ -53,7 +47,7 @@ class reflectapi_demo: """Namespace for serde types.""" K = ReflectapiDemoTestsSerdeK - S = ReflectapiDemoTestsSerdeS + SKTuple0 = ReflectapiDemoTestsSerdeSKTuple0 class AsyncInoutClient: @@ -64,19 +58,15 @@ class AsyncInoutClient: async def test( self, - data: Optional[ - reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] - ] = None, - ) -> ApiResponse[ - reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] - ]: + data: Optional[reflectapi_demo.tests.serde.SKTuple0] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: """ Args: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None]]: Response containing reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] data + ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: Response containing reflectapi_demo.tests.serde.SKTuple0 data """ path = "/inout_test" @@ -85,9 +75,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.S[ - reflectapi_demo.tests.serde.K, None - ], + response_model=reflectapi_demo.tests.serde.SKTuple0, ) @@ -112,19 +100,15 @@ class InoutClient: def test( self, - data: Optional[ - reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] - ] = None, - ) -> ApiResponse[ - reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] - ]: + data: Optional[reflectapi_demo.tests.serde.SKTuple0] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: """ Args: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None]]: Response containing reflectapi_demo.tests.serde.S[reflectapi_demo.tests.serde.K, None] data + ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: Response containing reflectapi_demo.tests.serde.SKTuple0 data """ path = "/inout_test" @@ -133,9 +117,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.S[ - reflectapi_demo.tests.serde.K, None - ], + response_model=reflectapi_demo.tests.serde.SKTuple0, ) @@ -161,7 +143,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] # Rebuild models to resolve forward references for _model in [ ReflectapiDemoTestsSerdeK, - ReflectapiDemoTestsSerdeS, + ReflectapiDemoTestsSerdeSKTuple0, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-2.snap new file mode 100644 index 00000000..582befd2 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-2.snap @@ -0,0 +1,88 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestUpdateOrElse > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export interface TestFlattenInner { + inner_a: number /* u32 */; + inner_b: string; + } + + export type TestUpdateOrElse = { + if_else: C | null; + } & NullToEmptyObject; + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {}, + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-3.snap new file mode 100644 index 00000000..2fbbe17c --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-3.snap @@ -0,0 +1,87 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: <\nTestUpdateOrElse > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInner { + pub inner_a: u32, + pub inner_b: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestUpdateOrElse { + #[serde(flatten)] + pub inner: T, + pub if_else: std::option::Option, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-4.snap new file mode 100644 index 00000000..9647c78d --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-4.snap @@ -0,0 +1,134 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenInner": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInner", + "required": [ + "inner_a", + "inner_b" + ], + "properties": { + "inner_a": { + "$ref": "#/components/schemas/u32" + }, + "inner_b": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap new file mode 100644 index 00000000..035fd2bd --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap @@ -0,0 +1,173 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestUpdateOrElse > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + inner_a: int + inner_b: str + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner + TestUpdateOrElseTestFlattenInnerTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestFlattenInner, + ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields.snap new file mode 100644 index 00000000..0fa742ec --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields.snap @@ -0,0 +1,300 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-2.snap new file mode 100644 index 00000000..f6cf5157 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-2.snap @@ -0,0 +1,77 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: < TestOptionalFlatten > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.input.TestOptionalFlatten, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.output.TestOptionalFlatten, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenInner { + inner_a: number /* u32 */; + inner_b: string; + } + + export namespace input { + export type TestOptionalFlatten = { + code: number /* u16 */; + } & Partial; + } + + export namespace output { + export type TestOptionalFlatten = { + code: number /* u16 */; + } & NullToEmptyObject; + } + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.input.TestOptionalFlatten, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.input.TestOptionalFlatten, + {}, + reflectapi_demo.tests.serde.output.TestOptionalFlatten, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-3.snap new file mode 100644 index 00000000..3f06f7f1 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-3.snap @@ -0,0 +1,96 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: < TestOptionalFlatten > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::input::TestOptionalFlatten< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::output::TestOptionalFlatten< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInner { + pub inner_a: u32, + pub inner_b: std::string::String, + } + + pub mod input { + + #[derive(Debug, serde::Serialize)] + pub struct TestOptionalFlatten { + #[serde( + default = "Default::default", + skip_serializing_if = "std::option::Option::is_none", + flatten + )] + pub inner: std::option::Option, + pub code: u16, + } + } + pub mod output { + + #[derive(Debug, serde::Deserialize)] + pub struct TestOptionalFlatten { + #[serde(flatten)] + pub inner: std::option::Option, + pub code: u16, + } + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-4.snap new file mode 100644 index 00000000..b66a9eb3 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-4.snap @@ -0,0 +1,122 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.input.TestOptionalFlatten", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.output.TestOptionalFlatten", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenInner": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInner", + "required": [ + "inner_a", + "inner_b" + ], + "properties": { + "inner_a": { + "$ref": "#/components/schemas/u32" + }, + "inner_b": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap new file mode 100644 index 00000000..a6c5ddd3 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap @@ -0,0 +1,185 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: < TestOptionalFlatten > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + inner_a: int | None = None + inner_b: str | None = None + + +class ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + inner_a: int + inner_b: str + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner + + class input: + """Namespace for input types.""" + + TestOptionalFlattenTestFlattenInner = ( + ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner + ) + + class output: + """Namespace for output types.""" + + TestOptionalFlattenTestFlattenInner = ( + ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner + ) + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.input.TestOptionalFlattenTestFlattenInner + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.input.TestOptionalFlattenTestFlattenInner + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenInner, + ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner, + ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional.snap new file mode 100644 index 00000000..ae5c4745 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional.snap @@ -0,0 +1,257 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::input::TestOptionalFlatten", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::output::TestOptionalFlatten", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::input::TestOptionalFlatten", + "parameters": [ + { + "name": "T" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "T" + } + ] + }, + "flattened": true + }, + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::output::TestOptionalFlatten", + "parameters": [ + { + "name": "T" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "T" + } + ] + }, + "required": true, + "flattened": true + }, + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-2.snap new file mode 100644 index 00000000..146fea9f --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-2.snap @@ -0,0 +1,85 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: < TestTwoInstantiations > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestTwoInstantiations, + headers: {}, + options?: RequestOptions, + ) => AsyncResult; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export interface TestFlattenInner { + inner_a: number /* u32 */; + inner_b: string; + } + + export interface TestFlattenInnerAlt { + alt_x: boolean; + } + + export interface TestTwoInstantiations { + a: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInner, + reflectapi_demo.tests.serde.TestFlattenIfElse + >; + b: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestFlattenInnerAlt, + reflectapi_demo.tests.serde.TestFlattenIfElse + >; + } + + export type TestUpdateOrElse = { + if_else: C | null; + } & NullToEmptyObject; + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestTwoInstantiations, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestTwoInstantiations, + {}, + reflectapi_demo.tests.serde.TestTwoInstantiations, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-3.snap new file mode 100644 index 00000000..be0b0eb5 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-3.snap @@ -0,0 +1,98 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: < TestTwoInstantiations > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestTwoInstantiations, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestTwoInstantiations, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInner { + pub inner_a: u32, + pub inner_b: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInnerAlt { + pub alt_x: bool, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestTwoInstantiations { + pub a: super::super::super::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::super::super::reflectapi_demo::tests::serde::TestFlattenInner, + super::super::super::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + pub b: super::super::super::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::super::super::reflectapi_demo::tests::serde::TestFlattenInnerAlt, + super::super::super::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestUpdateOrElse { + #[serde(flatten)] + pub inner: T, + pub if_else: std::option::Option, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-4.snap new file mode 100644 index 00000000..83f10672 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-4.snap @@ -0,0 +1,166 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestTwoInstantiations" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestTwoInstantiations" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "bool": { + "description": "Boolean value", + "type": "boolean" + }, + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenInner": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInner", + "required": [ + "inner_a", + "inner_b" + ], + "properties": { + "inner_a": { + "$ref": "#/components/schemas/u32" + }, + "inner_b": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenInnerAlt": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInnerAlt", + "required": [ + "alt_x" + ], + "properties": { + "alt_x": { + "$ref": "#/components/schemas/bool" + } + } + }, + "reflectapi_demo.tests.serde.TestTwoInstantiations": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestTwoInstantiations", + "required": [ + "a", + "b" + ], + "properties": { + "a": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + }, + "b": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInnerAlt" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap new file mode 100644 index 00000000..b25597ce --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap @@ -0,0 +1,193 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: < TestTwoInstantiations > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeTestFlattenInnerAlt(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + alt_x: bool + + +class ReflectapiDemoTestsSerdeTestTwoInstantiations(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + a: reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + b: reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + alt_x: bool + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + inner_a: int + inner_b: str + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner + TestFlattenInnerAlt = ReflectapiDemoTestsSerdeTestFlattenInnerAlt + TestTwoInstantiations = ReflectapiDemoTestsSerdeTestTwoInstantiations + TestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse + TestUpdateOrElseTestFlattenInnerTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[reflectapi_demo.tests.serde.TestTwoInstantiations] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.TestTwoInstantiations]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestTwoInstantiations]: Response containing reflectapi_demo.tests.serde.TestTwoInstantiations data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestTwoInstantiations, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[reflectapi_demo.tests.serde.TestTwoInstantiations] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.TestTwoInstantiations]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestTwoInstantiations]: Response containing reflectapi_demo.tests.serde.TestTwoInstantiations data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestTwoInstantiations, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestFlattenInner, + ReflectapiDemoTestsSerdeTestFlattenInnerAlt, + ReflectapiDemoTestsSerdeTestTwoInstantiations, + ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations.snap new file mode 100644 index 00000000..b0cf68e5 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations.snap @@ -0,0 +1,400 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestTwoInstantiations" + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestTwoInstantiations" + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInnerAlt", + "fields": { + "named": [ + { + "name": "alt_x", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestTwoInstantiations", + "fields": { + "named": [ + { + "name": "a", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "b", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInnerAlt" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInnerAlt", + "fields": { + "named": [ + { + "name": "alt_x", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestTwoInstantiations", + "fields": { + "named": [ + { + "name": "a", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "b", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInnerAlt" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 777662d4..84e520fb 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -832,6 +832,23 @@ fn render_struct_with_flattened_internal_enum( schema.get_type(inner_name) { for sf in inner_struct.fields.iter() { + if sf.flattened() { + // The inner struct has its own flatten — expand + // those fields inline so the wire shape is + // preserved across the tuple-variant boundary. + let nested = collect_flattened_fields( + &sf.type_ref, + schema, + implemented_types, + active_generics, + sf.required, + 0, + used_type_vars, + Some(sf.name()), + )?; + fields.extend(nested); + continue; + } let field_type = type_ref_to_python_type( &sf.type_ref, schema, @@ -1280,6 +1297,11 @@ pub fn generate(mut schema: Schema, config: &Config) -> anyhow::Result { // Consolidate input/output types FIRST so both the SemanticSchema and // the raw Schema share the same unified type names. let all_type_names = schema.consolidate_types(); + // Pydantic cannot represent `serde(flatten)` over a generic parameter + // at runtime — the inner T's wire-level fields would silently be + // dropped. Monomorphize: emit one concrete class per (struct, args) + // pair so the flatten resolves against a concrete type. + monomorphize_flatten_generics(&mut schema)?; let implemented_types = build_implemented_types(); validate_type_references(&schema)?; @@ -1973,11 +1995,31 @@ fn collect_flattened_fields( field_name, )?; } - Some(reflectapi_schema::Type::Primitive(_)) | None => { - // Primitives (including unit types) and unresolved types cannot - // be meaningfully flattened — skip them, matching prior behavior. - // This handles cases like flattened generic parameters that resolve - // to () (std::tuple::Tuple0). + Some(reflectapi_schema::Type::Primitive(p)) => { + // Unit-like primitives (e.g. std::tuple::Tuple0) flatten to + // nothing — that's legitimate. Other primitives in a + // flatten position are a schema error: you can't flatten an + // i32 etc. + if p.name != "std::tuple::Tuple0" { + anyhow::bail!( + "python codegen: cannot flatten primitive type '{}' \ + (only structs, internally-tagged enums, and unit types \ + are valid flatten targets)", + p.name, + ); + } + } + None => { + // We resolved a flatten target by name and the type wasn't + // in the schema. After monomorphization, this should be + // impossible — generic parameters are substituted away + // before rendering. Bail rather than silently drop the + // inner type's fields (the bug fixed by monomorphization). + anyhow::bail!( + "python codegen: cannot resolve flatten target '{target_type_name}'. \ + If this is a generic parameter, monomorphization should \ + have replaced it; this is a codegen bug.", + ); } } @@ -2820,6 +2862,24 @@ fn render_internally_tagged_enum( }; for struct_field in struct_fields { + if struct_field.flattened() { + // Recurse into the flattened type and lift + // its fields into this variant; otherwise + // the wire shape gets a wrapper layer that + // serde never produced. + let nested = collect_flattened_fields( + &struct_field.type_ref, + schema, + implemented_types, + &generic_params, + struct_field.required, + 0, + used_type_vars, + Some(struct_field.name()), + )?; + fields.extend(nested); + continue; + } let field_type = type_ref_to_python_type( &struct_field.type_ref, schema, @@ -5384,6 +5444,271 @@ pub mod templates { } } +/// Pydantic equivalent of monomorphization: for any struct that uses +/// `serde(flatten)` over a generic parameter, rewrite every concrete +/// instantiation as its own non-generic struct in the schema. The +/// existing flatten-of-concrete-type rendering then handles them +/// correctly. +/// +/// Rust and TypeScript handle this case dynamically (serde flatten at +/// runtime; intersection types at the type level). Pydantic has no +/// equivalent — `Generic[T]` can't be told to lift T's fields into the +/// outer model when T is bound. So we resolve the polymorphism here. +fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { + use reflectapi_schema::{Instantiate as _, Visitor}; + use std::ops::ControlFlow; + + // 1. Mark structs whose fields flatten a generic parameter + // (directly, or wrapped in Option). + fn flattened_target_is_typevar(type_ref: &TypeReference, params: &BTreeSet<&str>) -> bool { + if params.contains(type_ref.name.as_str()) { + return true; + } + // serde unwraps Option in flatten position, so a flattened + // `Option` still requires monomorphization. + if (type_ref.name == "std::option::Option" || type_ref.name == "reflectapi::Option") + && !type_ref.arguments.is_empty() + { + return flattened_target_is_typevar(&type_ref.arguments[0], params); + } + false + } + + let marked: BTreeSet = { + let mut out = BTreeSet::new(); + for ts in [&schema.input_types, &schema.output_types] { + for typ in ts.types() { + if let Type::Struct(s) = typ { + if s.parameters.is_empty() { + continue; + } + let params: BTreeSet<&str> = + s.parameters.iter().map(|p| p.name.as_str()).collect(); + if s.fields + .iter() + .any(|f| f.flattened() && flattened_target_is_typevar(&f.type_ref, ¶ms)) + { + out.insert(s.name.clone()); + } + } + } + } + out + }; + if marked.is_empty() { + return Ok(()); + } + + // 2. Discover concrete instantiations transitively. + fn collect_uses( + type_ref: &TypeReference, + marked: &BTreeSet, + out: &mut Vec<(String, Vec)>, + ) { + if marked.contains(&type_ref.name) && !type_ref.arguments.is_empty() { + out.push((type_ref.name.clone(), type_ref.arguments.clone())); + } + for a in &type_ref.arguments { + collect_uses(a, marked, out); + } + } + + let mut worklist: Vec<(String, Vec)> = Vec::new(); + + for f in &schema.functions { + if let Some(t) = &f.input_type { + collect_uses(t, &marked, &mut worklist); + } + if let Some(t) = &f.input_headers { + collect_uses(t, &marked, &mut worklist); + } + match &f.output_type { + OutputType::Complete { output_type } => { + if let Some(t) = output_type { + collect_uses(t, &marked, &mut worklist); + } + } + OutputType::Stream { item_type } => { + collect_uses(item_type, &marked, &mut worklist); + } + } + if let Some(t) = &f.error_type { + collect_uses(t, &marked, &mut worklist); + } + } + for ts in [&schema.input_types, &schema.output_types] { + for typ in ts.types() { + match typ { + Type::Struct(s) => { + for field in s.fields.iter() { + collect_uses(&field.type_ref, &marked, &mut worklist); + } + } + Type::Enum(e) => { + for v in &e.variants { + for field in v.fields.iter() { + collect_uses(&field.type_ref, &marked, &mut worklist); + } + } + } + Type::Primitive(_) => {} + } + } + } + + // 3. Process worklist; refuse nested marked-struct args (out of scope). + let mut concrete: BTreeMap<(String, Vec), String> = BTreeMap::new(); + while let Some((name, args)) = worklist.pop() { + let key = (name.clone(), args.clone()); + if concrete.contains_key(&key) { + continue; + } + for arg in &args { + if marked.contains(&arg.name) { + anyhow::bail!( + "python codegen: nested generic flatten not supported: \ + {name}<{}> contains another marked struct '{}'. \ + Refactor or open an issue if you need this.", + args.iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "), + arg.name, + ); + } + } + + let struct_def = schema + .input_types + .get_type(&name) + .or_else(|| schema.output_types.get_type(&name)) + .and_then(|t| { + if let Type::Struct(s) = t { + Some(s.clone()) + } else { + None + } + }) + .ok_or_else(|| { + anyhow::anyhow!("monomorphization: marked struct '{name}' missing from schema") + })?; + + if struct_def.parameters.len() != args.len() { + anyhow::bail!( + "monomorphization: arity mismatch for '{name}': expected {}, got {}", + struct_def.parameters.len(), + args.len(), + ); + } + + let mangled = mangle_monomorphized_name(&name, &args); + concrete.insert(key, mangled); + + let mono_preview = struct_def.instantiate(&args); + for f in mono_preview.fields.iter() { + collect_uses(&f.type_ref, &marked, &mut worklist); + } + } + + // 4. Insert monomorphized types into both typespaces (mirroring the + // location of the original generic). + for ((name, args), mangled) in concrete.iter() { + for ts_is_input in [true, false] { + let ts = if ts_is_input { + &schema.input_types + } else { + &schema.output_types + }; + let s = match ts.get_type(name) { + Some(Type::Struct(s)) => s.clone(), + _ => continue, + }; + let mut mono = s.instantiate(args); + mono.name = mangled.clone(); + let target = if ts_is_input { + &mut schema.input_types + } else { + &mut schema.output_types + }; + target.insert_type(Type::Struct(mono)); + } + } + + // 5. Rewrite every type reference of the form `Marked` to the + // mangled name with no args. + struct Rewriter<'a> { + table: &'a BTreeMap<(String, Vec), String>, + } + impl<'a> Visitor for Rewriter<'a> { + type Output = (); + fn visit_type_ref( + &mut self, + type_ref: &mut TypeReference, + ) -> ControlFlow { + // Recurse into args first so inner refs resolve before outer + // lookup. Cleared args after the outer match means leaf-first + // is fine here too. + for a in type_ref.arguments.iter_mut() { + let _ = self.visit_type_ref(a); + } + let key = (type_ref.name.clone(), type_ref.arguments.clone()); + if let Some(mangled) = self.table.get(&key) { + type_ref.name = mangled.clone(); + type_ref.arguments.clear(); + } + ControlFlow::Continue(()) + } + } + let mut r = Rewriter { table: &concrete }; + let _ = r.visit_schema_inputs(schema); + let _ = r.visit_schema_outputs(schema); + + // 6. Drop the original generic marked structs — they have no live + // references after rewriting, and emitting them would produce a + // bare `Generic[T]` class with the flatten field missing (the + // bug we're fixing). + // + // Note: Typespace::remove_type leaves stale indices in its + // types_map (a separate-codepath bug). sort_types() rebuilds the + // map and incidentally also gives us a stable ordering. + for name in &marked { + let _ = schema.input_types.remove_type(name); + let _ = schema.output_types.remove_type(name); + } + schema.input_types.sort_types(); + schema.output_types.sort_types(); + + Ok(()) +} + +/// Mangle a `(struct, args)` instantiation into a fresh schema type +/// name. We keep the struct's full namespace-qualified name and append +/// an underscore-joined suffix built from each argument's *leaf* name +/// (the part after the final `::`). Using leaf names keeps the result +/// short enough that downstream class-name handling doesn't have to +/// hash-truncate it. Collisions across different fully-qualified args +/// with the same leaf are extremely rare in practice and would surface +/// as `insert_type` no-ops; if that ever happens we'll add a hash +/// disambiguator. +fn mangle_monomorphized_name(struct_name: &str, args: &[TypeReference]) -> String { + fn arg_suffix(arg: &TypeReference) -> String { + let leaf = arg.name.rsplit("::").next().unwrap_or(&arg.name); + if arg.arguments.is_empty() { + leaf.to_string() + } else { + let inner = arg + .arguments + .iter() + .map(arg_suffix) + .collect::>() + .join("_"); + format!("{leaf}_{inner}") + } + } + let suffix = args.iter().map(arg_suffix).collect::>().join("_"); + format!("{struct_name}_{suffix}") +} + #[cfg(test)] mod tests { use super::{build_python_class_name_map, generate_init_py, Config}; From ed65e9c20815c17683cac1a24ee6b0c228b06bc8 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 16:35:21 +1200 Subject: [PATCH 10/16] fix(codegen/py): support nested generic flatten via leaves-first monomorphization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous monomorphization pass refused to handle a marked struct whose own arg was another marked struct (e.g. UpdateOrElse, C>) — it bailed loud rather than silently corrupt, but the bail blocked real consumers (10 endpoints in the reported core-server schema). Restructure: instead of a worklist that processes raw discoveries, walk type refs bottom-up. For each marked ref encountered, recursively normalize its args first (replacing inner marked refs with their already-mangled names), then register the outer with those normalized args as the lookup key. The rewriter still walks bottom-up, so it produces the same key — both sides agree. Also: name-length budget. Deeper nesting produces longer mangled names that would trip downstream class-name truncation, leaving class definitions / namespace aliases / type-ref dotted paths with two different hashes. Compute the post-PascalCase length up front and hash-truncate the suffix here when needed; downstream sees one stable name at every reference site. And: Typespace::remove_type leaves stale indices in its types_map, so calling it twice in a row would silently remove the wrong type (took out std::option::Option in one repro). Sort_types between removals to force a map rebuild — a workaround for an upstream behaviour we shouldn't paper over silently long-term. Test: TestUpdateOrElse, TestFlattenIfElse> — outer marked struct flattens an inner marked struct whose own params are concrete. Snapshot confirms class def, namespace alias, and method signature all reference the same name with consistent fields. --- reflectapi-demo/src/tests/serde.rs | 36 ++ ...ests__serde__generic_flatten_nested-2.snap | 109 +++++ ...ests__serde__generic_flatten_nested-3.snap | 105 +++++ ...ests__serde__generic_flatten_nested-4.snap | 166 ++++++++ ...ests__serde__generic_flatten_nested-5.snap | 191 +++++++++ ..._tests__serde__generic_flatten_nested.snap | 396 ++++++++++++++++++ reflectapi/src/codegen/python.rs | 232 ++++++---- 7 files changed, 1149 insertions(+), 86 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested.snap diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index a85f264f..40309f2c 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1265,6 +1265,42 @@ fn test_generic_flatten_optional() { assert_snapshot!(TestOptionalFlatten); } +// Real-world pattern: an outer generic wrapper flattens an *inner* +// generic wrapper. UpdateOrElse, C> where +// IdentityData itself is a marked struct (flattens its own generic +// I and D). Codegen must monomorphize bottom-up. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestFlattenIdent { + job_id: u64, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestFlattenIdentData { + payload: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "I: serde::Serialize, D: serde::Serialize", + deserialize = "I: serde::de::DeserializeOwned, D: serde::de::DeserializeOwned", +))] +struct TestIdentityData { + #[serde(flatten)] + identity: I, + #[serde(flatten)] + data: D, +} + +#[test] +fn test_generic_flatten_nested() { + assert_snapshot!( + TestUpdateOrElse< + TestIdentityData, + TestFlattenIfElse, + > + ); +} + /// End-to-end Pydantic round-trip: confirms the generated class /// actually parses serde's wire format. Skipped if `uv` (or a Python /// with pydantic) isn't available locally — CI uses uv. diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-2.snap new file mode 100644 index 00000000..6cfa430e --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-2.snap @@ -0,0 +1,109 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestUpdateOrElse< TestIdentityData,\nTestFlattenIfElse, > > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestIdentityData< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestIdentityData< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIdent { + job_id: number /* u64 */; + } + + export interface TestFlattenIdentData { + payload: string; + } + + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export type TestIdentityData = {} & NullToEmptyObject & + NullToEmptyObject; + + export type TestUpdateOrElse = { + if_else: C | null; + } & NullToEmptyObject; + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestIdentityData< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestIdentityData< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {}, + reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.TestIdentityData< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + reflectapi_demo.tests.serde.TestFlattenIfElse + >, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-3.snap new file mode 100644 index 00000000..acc72805 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-3.snap @@ -0,0 +1,105 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: <\nTestUpdateOrElse< TestIdentityData,\nTestFlattenIfElse, > > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::types::reflectapi_demo::tests::serde::TestIdentityData< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::types::reflectapi_demo::tests::serde::TestIdentityData< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdent { + pub job_id: u64, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdentData { + pub payload: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestIdentityData { + #[serde(flatten)] + pub identity: I, + #[serde(flatten)] + pub data: D, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestUpdateOrElse { + #[serde(flatten)] + pub inner: T, + pub if_else: std::option::Option, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-4.snap new file mode 100644 index 00000000..2d135877 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-4.snap @@ -0,0 +1,166 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse, reflectapi_demo.tests.serde.TestFlattenIfElse>", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse, reflectapi_demo.tests.serde.TestFlattenIfElse>", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenIdent": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdent", + "required": [ + "job_id" + ], + "properties": { + "job_id": { + "$ref": "#/components/schemas/u64" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenIdentData": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdentData", + "required": [ + "payload" + ], + "properties": { + "payload": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u64": { + "description": "64-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap new file mode 100644 index 00000000..d77b786f --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap @@ -0,0 +1,191 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestUpdateOrElse< TestIdentityData,\nTestFlattenIfElse, > > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIdent(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + + +class ReflectapiDemoTestsSerdeTestFlattenIdentData(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + payload: str + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + payload: str + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + job_id: int + payload: str + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIdent = ReflectapiDemoTestsSerdeTestFlattenIdent + TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestIdentityDataTestFlattenIdentTestFlattenIdentData = ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData + TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a = ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIdent, + ReflectapiDemoTestsSerdeTestFlattenIdentData, + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData, + ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested.snap new file mode 100644 index 00000000..976f2c92 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested.snap @@ -0,0 +1,396 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 84e520fb..77b623b4 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5499,41 +5499,103 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { return Ok(()); } - // 2. Discover concrete instantiations transitively. - fn collect_uses( - type_ref: &TypeReference, + // 2. Walk every type reference reachable from functions and types, + // rewriting marked refs in place (bottom-up) to their mangled + // monomorphized names. Each new (struct, normalized_args) pair + // is registered in `concrete`. Nested patterns like + // `OuterMarked, C>` resolve correctly because + // the inner ref is normalized first, so the outer's key is + // `(OuterMarked, [InnerMarked_I_D, C])` — concrete on both + // sides. + let mut concrete: BTreeMap<(String, Vec), String> = BTreeMap::new(); + + fn normalize_marked_refs( + type_ref: &mut TypeReference, marked: &BTreeSet, - out: &mut Vec<(String, Vec)>, - ) { - if marked.contains(&type_ref.name) && !type_ref.arguments.is_empty() { - out.push((type_ref.name.clone(), type_ref.arguments.clone())); + concrete: &mut BTreeMap<(String, Vec), String>, + schema: &Schema, + ) -> anyhow::Result<()> { + // Bottom-up: arguments first, so this ref's args are already + // resolved by the time we form the lookup key. + for a in type_ref.arguments.iter_mut() { + normalize_marked_refs(a, marked, concrete, schema)?; } - for a in &type_ref.arguments { - collect_uses(a, marked, out); + if !marked.contains(&type_ref.name) || type_ref.arguments.is_empty() { + return Ok(()); } + let key = (type_ref.name.clone(), type_ref.arguments.clone()); + let mangled = if let Some(existing) = concrete.get(&key) { + existing.clone() + } else { + let struct_def = schema + .input_types + .get_type(&type_ref.name) + .or_else(|| schema.output_types.get_type(&type_ref.name)) + .and_then(|t| { + if let Type::Struct(s) = t { + Some(s.clone()) + } else { + None + } + }) + .ok_or_else(|| { + anyhow::anyhow!( + "monomorphization: marked struct '{}' missing from schema", + type_ref.name + ) + })?; + if struct_def.parameters.len() != type_ref.arguments.len() { + anyhow::bail!( + "monomorphization: arity mismatch for '{}': expected {}, got {}", + type_ref.name, + struct_def.parameters.len(), + type_ref.arguments.len(), + ); + } + let mangled = mangle_monomorphized_name(&type_ref.name, &type_ref.arguments); + // Register before recursing so cycles can't loop forever + // (a marked struct that transitively contains a ref to + // itself with the same args would otherwise spin). + concrete.insert(key, mangled.clone()); + + // Substitute with the (already-normalized) args and recurse + // into the resulting fields, so any further marked refs in + // the substituted struct's fields get registered too. + let mono = struct_def.instantiate(&type_ref.arguments); + for f in mono.fields.iter() { + let mut tr = f.type_ref.clone(); + normalize_marked_refs(&mut tr, marked, concrete, schema)?; + } + mangled + }; + type_ref.name = mangled; + type_ref.arguments.clear(); + Ok(()) } - let mut worklist: Vec<(String, Vec)> = Vec::new(); - + // We mutate clones to drive registration; the schema itself is + // rewritten in step 5 by the Visitor. Doing it here too would + // double-edit refs we've already normalized. + let mut seeds: Vec = Vec::new(); for f in &schema.functions { if let Some(t) = &f.input_type { - collect_uses(t, &marked, &mut worklist); + seeds.push(t.clone()); } if let Some(t) = &f.input_headers { - collect_uses(t, &marked, &mut worklist); + seeds.push(t.clone()); } match &f.output_type { OutputType::Complete { output_type } => { if let Some(t) = output_type { - collect_uses(t, &marked, &mut worklist); + seeds.push(t.clone()); } } OutputType::Stream { item_type } => { - collect_uses(item_type, &marked, &mut worklist); + seeds.push(item_type.clone()); } } if let Some(t) = &f.error_type { - collect_uses(t, &marked, &mut worklist); + seeds.push(t.clone()); } } for ts in [&schema.input_types, &schema.output_types] { @@ -5541,13 +5603,13 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { match typ { Type::Struct(s) => { for field in s.fields.iter() { - collect_uses(&field.type_ref, &marked, &mut worklist); + seeds.push(field.type_ref.clone()); } } Type::Enum(e) => { for v in &e.variants { for field in v.fields.iter() { - collect_uses(&field.type_ref, &marked, &mut worklist); + seeds.push(field.type_ref.clone()); } } } @@ -5555,59 +5617,8 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { } } } - - // 3. Process worklist; refuse nested marked-struct args (out of scope). - let mut concrete: BTreeMap<(String, Vec), String> = BTreeMap::new(); - while let Some((name, args)) = worklist.pop() { - let key = (name.clone(), args.clone()); - if concrete.contains_key(&key) { - continue; - } - for arg in &args { - if marked.contains(&arg.name) { - anyhow::bail!( - "python codegen: nested generic flatten not supported: \ - {name}<{}> contains another marked struct '{}'. \ - Refactor or open an issue if you need this.", - args.iter() - .map(|a| a.name.as_str()) - .collect::>() - .join(", "), - arg.name, - ); - } - } - - let struct_def = schema - .input_types - .get_type(&name) - .or_else(|| schema.output_types.get_type(&name)) - .and_then(|t| { - if let Type::Struct(s) = t { - Some(s.clone()) - } else { - None - } - }) - .ok_or_else(|| { - anyhow::anyhow!("monomorphization: marked struct '{name}' missing from schema") - })?; - - if struct_def.parameters.len() != args.len() { - anyhow::bail!( - "monomorphization: arity mismatch for '{name}': expected {}, got {}", - struct_def.parameters.len(), - args.len(), - ); - } - - let mangled = mangle_monomorphized_name(&name, &args); - concrete.insert(key, mangled); - - let mono_preview = struct_def.instantiate(&args); - for f in mono_preview.fields.iter() { - collect_uses(&f.type_ref, &marked, &mut worklist); - } + for mut seed in seeds { + normalize_marked_refs(&mut seed, &marked, &mut concrete, schema)?; } // 4. Insert monomorphized types into both typespaces (mirroring the @@ -5669,27 +5680,31 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // bug we're fixing). // // Note: Typespace::remove_type leaves stale indices in its - // types_map (a separate-codepath bug). sort_types() rebuilds the - // map and incidentally also gives us a stable ordering. + // internal types_map (a separate-codepath bug — not safe across + // multiple removals). sort_types() rebuilds the map. We sort after + // each individual removal so the next lookup is fresh. for name in &marked { - let _ = schema.input_types.remove_type(name); - let _ = schema.output_types.remove_type(name); + if schema.input_types.remove_type(name).is_some() { + schema.input_types.sort_types(); + } + if schema.output_types.remove_type(name).is_some() { + schema.output_types.sort_types(); + } } - schema.input_types.sort_types(); - schema.output_types.sort_types(); Ok(()) } /// Mangle a `(struct, args)` instantiation into a fresh schema type -/// name. We keep the struct's full namespace-qualified name and append -/// an underscore-joined suffix built from each argument's *leaf* name -/// (the part after the final `::`). Using leaf names keeps the result -/// short enough that downstream class-name handling doesn't have to -/// hash-truncate it. Collisions across different fully-qualified args -/// with the same leaf are extremely rare in practice and would surface -/// as `insert_type` no-ops; if that ever happens we'll add a hash -/// disambiguator. +/// name. The struct's full namespace-qualified name is preserved so +/// downstream namespace mangling stays sensible; the suffix encodes +/// each argument's *leaf* name (the part after the final `::`). +/// +/// If the resulting suffix is long enough that downstream class-name +/// handling would hash-truncate (and produce a mismatch between the +/// truncated class definition and the un-truncated dotted-path +/// reference), hash the suffix here. Same hash on both sides → all +/// references stay consistent. fn mangle_monomorphized_name(struct_name: &str, args: &[TypeReference]) -> String { fn arg_suffix(arg: &TypeReference) -> String { let leaf = arg.name.rsplit("::").next().unwrap_or(&arg.name); @@ -5706,7 +5721,52 @@ fn mangle_monomorphized_name(struct_name: &str, args: &[TypeReference]) -> Strin } } let suffix = args.iter().map(arg_suffix).collect::>().join("_"); - format!("{struct_name}_{suffix}") + + // Length budget: the FLAT PascalCase class name (namespace + + // struct + suffix, with `_` collapsed) must stay under 80 chars + // or downstream `finalize_class_name` will hash-truncate, while + // `type_name_to_python_ref` would still emit the un-truncated + // form — yielding two inconsistent names. Predict the + // post-PascalCase length from the struct's original name + the + // suffix, hash if it would overflow. + let candidate = format!("{struct_name}_{suffix}"); + let pascal_len = pascal_case_len(&candidate); + const FLAT_NAME_BUDGET: usize = 80; + if pascal_len <= FLAT_NAME_BUDGET { + return candidate; + } + // Trim the suffix to fit. We keep as much human-readable prefix as + // we can; the remaining bytes are a stable 8-hex-char hash. + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + struct_name.hash(&mut h); + suffix.hash(&mut h); + let hash = format!("{:08x}", h.finish() & 0xFFFF_FFFF); + // Pascal-collapsed budget for the suffix: + let head_pascal = pascal_case_len(&format!("{struct_name}_")); + let hash_pascal = pascal_case_len(&format!("_{hash}")); + let suffix_budget = FLAT_NAME_BUDGET.saturating_sub(head_pascal + hash_pascal); + // Walk the suffix one byte at a time tracking PascalCase length. + let mut keep = 0usize; + let mut pascal = 0usize; + let bytes = suffix.as_bytes(); + while keep < bytes.len() && pascal < suffix_budget { + if bytes[keep] != b'_' { + pascal += 1; + } + keep += 1; + } + let trimmed = std::str::from_utf8(&bytes[..keep]).unwrap_or(""); + format!("{struct_name}_{trimmed}_{hash}") +} + +/// Count the chars that a `_`-separated identifier would have once +/// PascalCased (to_pascal_case) — `_` becomes a word boundary that +/// disappears, so the post-Pascal length is the number of non-`_` +/// chars. +fn pascal_case_len(s: &str) -> usize { + s.bytes().filter(|&b| b != b'_' && b != b':').count() } #[cfg(test)] From f89a7eb9c065bceb5e381c1004c0edba4317353e Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 16:54:19 +1200 Subject: [PATCH 11/16] fix(cli, codegen/py): output-path UX + leaf-collision in monomorphization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues caught by the same review: 1. CLI: fresh output directories for multi-file codegen (TS, Python) were rejected. The "looks like a directory" check required the path to already exist or end with `/`, so `--output ./brand-new-dir` was treated as a file path and bailed because the filename didn't match a codegen output. Now the rule is: if `output_path.file_name()` matches one of the emitted files AND the path isn't already a directory, write the matching file there with siblings in the parent. Otherwise treat as a directory and create it. 2. CLI: `--output -` printed the alphabetically-first file in the BTreeMap. For TS that's `generated.transport.ts` (a helper); for Python that's `__init__.py` (a 5-line shim). Pipelines expecting the actual API surface got the wrong file. Now each language has a declared "primary" filename; stdout selects that. 3. Python codegen monomorphization mangling collided when two distinct generic args shared a leaf name across modules. `module_a::Sample` and `module_b::Sample` both produced the same mangled key, fusing two distinct UpdateOrElse instantiations into one and silently keeping just the second type's fields. Switched the suffix to use full namespace paths (`::` -> `_`); the length-budget hash truncation already handles the resulting longer names consistently across class def / namespace alias / dotted-path reference. Tests: - reflectapi-cli/tests/output_paths.rs: fresh-dir for TS and Python, file-shaped path with siblings, stdout primary-file selection. - test_generic_flatten_leaf_collision: two TestUpdateOrElse instantiations with same-leaf args from different modules; snapshot confirms two distinct classes with each type's fields. - Pydantic round-trip test no longer hardcodes the mangled name — it discovers the class by walking the namespace and matching on the expected field set, so future mangling changes don't break it. --- Cargo.lock | 1 + reflectapi-cli/Cargo.toml | 3 + reflectapi-cli/src/main.rs | 67 ++-- reflectapi-cli/tests/output_paths.rs | 153 +++++++ reflectapi-demo/src/tests/serde.rs | 56 ++- ...s__serde__flatten_internally_tagged-5.snap | 8 +- ...pi_demo__tests__serde__flatten_unit-5.snap | 32 +- ..._generic_flatten_drops_inner_fields-5.snap | 22 +- ...rde__generic_flatten_leaf_collision-2.snap | 88 ++++ ...rde__generic_flatten_leaf_collision-3.snap | 102 +++++ ...rde__generic_flatten_leaf_collision-4.snap | 158 ++++++++ ...rde__generic_flatten_leaf_collision-5.snap | 199 +++++++++ ...serde__generic_flatten_leaf_collision.snap | 376 ++++++++++++++++++ ...ests__serde__generic_flatten_nested-5.snap | 28 +- ...ts__serde__generic_flatten_optional-5.snap | 36 +- ..._generic_flatten_two_instantiations-5.snap | 22 +- reflectapi/src/codegen/python.rs | 11 +- 17 files changed, 1256 insertions(+), 106 deletions(-) create mode 100644 reflectapi-cli/tests/output_paths.rs create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision.snap diff --git a/Cargo.lock b/Cargo.lock index 4c460a96..80923ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1729,6 +1729,7 @@ dependencies = [ "reflectapi", "rouille", "serde_json", + "tempfile", ] [[package]] diff --git a/reflectapi-cli/Cargo.toml b/reflectapi-cli/Cargo.toml index 834b9405..c950bcb1 100644 --- a/reflectapi-cli/Cargo.toml +++ b/reflectapi-cli/Cargo.toml @@ -31,3 +31,6 @@ clap_derive = "4.5.3" anyhow = "1.0.81" serde_json = "1.0.114" + +[dev-dependencies] +tempfile = "3" diff --git a/reflectapi-cli/src/main.rs b/reflectapi-cli/src/main.rs index 83ae4134..253cd0fe 100644 --- a/reflectapi-cli/src/main.rs +++ b/reflectapi-cli/src/main.rs @@ -203,9 +203,24 @@ fn main() -> anyhow::Result<()> { } }; + // The "main" emitted file per language. Used both for + // stdout selection (--output -) and for matching a + // file-shaped --output path against the codegen output. + let primary_filename = match language { + Language::Typescript => "generated.ts", + Language::Rust => "generated.rs", + Language::Python => "generated.py", + Language::Openapi => "openapi.json", + }; + if output == Some(std::path::PathBuf::from("-")) { - // For stdout, output the first/main file - if let Some(content) = files.values().next() { + // Print the language's primary file, not the + // alphabetically-first one — for TS that would be + // generated.transport.ts (sibling), for Python it + // would be __init__.py. + if let Some(content) = files.get(primary_filename) { + println!("{content}"); + } else if let Some(content) = files.values().next() { println!("{content}"); } return Ok(()); @@ -213,23 +228,27 @@ fn main() -> anyhow::Result<()> { let output_path = output.unwrap_or_else(|| std::path::PathBuf::from("./")); - // Resolve where each generated file lands. + // Decide whether `output_path` names a single file or a + // directory. "File" means the path's filename matches one + // of the codegen-emitted filenames AND the path doesn't + // already exist as a directory; everything else is a + // directory (whether it exists yet or not). // - // - If the output path looks like a directory (existing dir, or - // ends with a separator), every file is placed inside it - // under its codegen-assigned filename. - // - If the output path looks like a file, the codegen file - // whose name matches goes there and any siblings land in the - // same parent directory under their codegen-assigned names. - // This preserves backward compat with existing scripts that - // pass `--output .../generated.ts`. - // - If the output path looks like a file but no codegen file - // matches its name, we error rather than create a directory - // at that path (which would surprise existing users). - let looks_like_dir = - output_path.is_dir() || output_path.to_string_lossy().ends_with('/'); + // This matters for two cases: + // --output ./clients/python/ → directory, write all files inside + // --output ./generated.ts → file path: write generated.ts there + // and place siblings (e.g. + // generated.transport.ts) next to it + // --output ./brand-new-dir → fresh directory, create + write + let primary_name_in_path = output_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + let looks_like_file = files.contains_key(primary_name_in_path) + && !output_path.is_dir() + && !output_path.to_string_lossy().ends_with('/'); - if looks_like_dir { + if !looks_like_file { std::fs::create_dir_all(&output_path).context(format!( "Failed to create output directory: {output_path:?}" ))?; @@ -237,23 +256,11 @@ fn main() -> anyhow::Result<()> { write_file(&output_path.join(filename), content)?; } } else { - let primary_name = output_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); let parent = parent_or_dot(&output_path); - if !files.contains_key(primary_name) { - let expected: Vec<&str> = files.keys().map(String::as_str).collect(); - anyhow::bail!( - "output path {output_path:?} looks like a file, but {language:?} \ - codegen now emits multiple files: {expected:?}. \ - Pass a directory path (e.g. --output {parent:?}) instead.", - ); - } std::fs::create_dir_all(&parent) .context(format!("Failed to create output directory: {parent:?}"))?; for (filename, content) in &files { - let dest = if filename == primary_name { + let dest = if filename == primary_name_in_path { output_path.clone() } else { parent.join(filename) diff --git a/reflectapi-cli/tests/output_paths.rs b/reflectapi-cli/tests/output_paths.rs new file mode 100644 index 00000000..ccc662c0 --- /dev/null +++ b/reflectapi-cli/tests/output_paths.rs @@ -0,0 +1,153 @@ +//! Integration tests for `reflectapi codegen --output …` path handling. +//! +//! Multi-file codegen (TS, Python) needs to handle: +//! - existing directories +//! - fresh directories (path doesn't exist yet) +//! - file-shaped paths whose filename matches one of the emitted files +//! (siblings land in the parent directory) +//! - stdout via `--output -`, which must print the language's *primary* +//! file rather than the alphabetically-first one. + +use std::process::Command; + +fn cargo_bin() -> std::path::PathBuf { + let bin = env!("CARGO_BIN_EXE_reflectapi"); + std::path::PathBuf::from(bin) +} + +fn demo_schema() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("reflectapi-demo") + .join("reflectapi.json") +} + +fn run(args: &[&str]) -> std::process::Output { + Command::new(cargo_bin()) + .args(args) + .output() + .expect("spawn reflectapi") +} + +#[test] +fn ts_output_into_fresh_directory() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("brand-new-dir"); + let schema = demo_schema(); + let out = run(&[ + "codegen", + "--language", + "typescript", + "--schema", + schema.to_str().unwrap(), + "--output", + target.to_str().unwrap(), + ]); + assert!( + out.status.success(), + "exit={:?}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + assert!(target.is_dir(), "expected fresh dir to be created"); + assert!(target.join("generated.ts").is_file()); + assert!(target.join("generated.transport.ts").is_file()); +} + +#[test] +fn python_output_into_fresh_directory() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("python-client"); + let schema = demo_schema(); + let out = run(&[ + "codegen", + "--language", + "python", + "--schema", + schema.to_str().unwrap(), + "--output", + target.to_str().unwrap(), + ]); + assert!( + out.status.success(), + "exit={:?}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + assert!(target.is_dir()); + assert!(target.join("generated.py").is_file()); + assert!(target.join("__init__.py").is_file()); +} + +#[test] +fn ts_output_to_file_path_writes_siblings() { + // --output …/generated.ts should still work; transport file lands + // alongside it in the parent directory. + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("generated.ts"); + let schema = demo_schema(); + let out = run(&[ + "codegen", + "--language", + "typescript", + "--schema", + schema.to_str().unwrap(), + "--output", + target.to_str().unwrap(), + ]); + assert!( + out.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(target.is_file(), "primary file at requested path"); + assert!(tmp.path().join("generated.transport.ts").is_file()); +} + +#[test] +fn ts_stdout_emits_primary_file_not_transport() { + // --output - should print generated.ts (the API surface), not + // generated.transport.ts (the helper). BTreeMap ordering would + // pick the latter alphabetically. + let schema = demo_schema(); + let out = run(&[ + "codegen", + "--language", + "typescript", + "--schema", + schema.to_str().unwrap(), + "--output", + "-", + ]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + // generated.ts contains the public `client` factory; the + // transport file does not. + assert!( + stdout.contains("export function client(") || stdout.contains("export function client "), + "expected stdout to be generated.ts (with the `client` factory). got:\n{stdout}", + ); +} + +#[test] +fn python_stdout_emits_generated_not_init() { + let schema = demo_schema(); + let out = run(&[ + "codegen", + "--language", + "python", + "--schema", + schema.to_str().unwrap(), + "--output", + "-", + ]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + // __init__.py is a 5-line shim; generated.py contains the actual + // pydantic models / client classes. + assert!( + stdout.contains("AsyncClientBase") || stdout.contains("class "), + "expected stdout to be generated.py. got:\n{stdout}", + ); +} diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index 40309f2c..df9c5018 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1327,14 +1327,30 @@ fn test_generic_flatten_pydantic_roundtrip() { let module_path = tmp.path().join("generated.py"); std::fs::write(&module_path, py_source).unwrap(); - let class_name = "TestUpdateOrElseTestFlattenInnerTestFlattenIfElse"; + // The mangled class name depends on namespace and length-budget + // hashing, so the test discovers it by walking the generated + // namespace and matching on the expected field set. let driver = format!( r#" -import importlib.util, json, sys, pathlib +import importlib.util, json +from pydantic import BaseModel spec = importlib.util.spec_from_file_location("gen", r"{module}") m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) -cls = m.reflectapi_demo.tests.serde.{class_name} +ns = m.reflectapi_demo.tests.serde +expected = {{"inner_a", "inner_b", "if_else"}} +candidates = [ + getattr(ns, attr) + for attr in dir(ns) + if isinstance(getattr(ns, attr, None), type) + and issubclass(getattr(ns, attr), BaseModel) + and set(getattr(ns, attr).model_fields.keys()) == expected +] +assert len(candidates) == 1, ( + "expected exactly one class with fields {{inner_a, inner_b, if_else}}; " + "got " + repr([c.__name__ for c in candidates]) +) +cls = candidates[0] parsed = cls.model_validate(json.loads(r'''{payload}''')) assert parsed.inner_a == 7, ("inner_a", parsed.inner_a) assert parsed.inner_b == "hello", ("inner_b", parsed.inner_b) @@ -1348,7 +1364,6 @@ assert out["if_else"]["code"] == 409 print("OK") "#, module = module_path.display(), - class_name = class_name, payload = wire_payload, ); @@ -1425,3 +1440,36 @@ print("OK") } eprintln!("test_generic_flatten_pydantic_roundtrip skipped: no working python+pydantic found"); } + +// Two args that share a *leaf* name but live in different modules +// must NOT collide when mangled. Earlier mangling used only the leaf +// name; both ./module_a::Sample and ./module_b::Sample produced +// identical keys, fusing two distinct UpdateOrElse instantiations +// into one and losing one type's wire fields. +mod module_a { + #[derive( + serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output, + )] + pub struct Sample { + pub a_field: u32, + } +} +mod module_b { + #[derive( + serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output, + )] + pub struct Sample { + pub b_field: bool, + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +struct TestLeafCollisionPair { + a: TestUpdateOrElse, + b: TestUpdateOrElse, +} + +#[test] +fn test_generic_flatten_leaf_collision() { + assert_snapshot!(TestLeafCollisionPair); +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap index facf1fbd..f2cda23e 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_internally_tagged-5.snap @@ -37,7 +37,9 @@ class ReflectapiDemoTestsSerdeB(BaseModel): b: int -class ReflectapiDemoTestsSerdeSAB(BaseModel): +class ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeAReflectapiDemoTestsSerdeB( + BaseModel +): model_config = ConfigDict(extra="ignore", populate_by_name=True) a: int @@ -68,7 +70,7 @@ class reflectapi_demo: A = ReflectapiDemoTestsSerdeA B = ReflectapiDemoTestsSerdeB - SAB = ReflectapiDemoTestsSerdeSAB + SReflectapiDemoTestsSerdeAReflectapiDemoTestsSerdeB = ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeAReflectapiDemoTestsSerdeB TestS = ReflectapiDemoTestsSerdeTestS Test = ReflectapiDemoTestsSerdeTest @@ -167,7 +169,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] for _model in [ ReflectapiDemoTestsSerdeA, ReflectapiDemoTestsSerdeB, - ReflectapiDemoTestsSerdeSAB, + ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeAReflectapiDemoTestsSerdeB, ReflectapiDemoTestsSerdeTest, ]: try: diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap index de789d76..3bdaeda1 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__flatten_unit-5.snap @@ -30,7 +30,7 @@ class ReflectapiDemoTestsSerdeK(BaseModel): a: int -class ReflectapiDemoTestsSerdeSKTuple0(BaseModel): +class ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeKStdTupleTuple0(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) a: int @@ -47,7 +47,9 @@ class reflectapi_demo: """Namespace for serde types.""" K = ReflectapiDemoTestsSerdeK - SKTuple0 = ReflectapiDemoTestsSerdeSKTuple0 + SReflectapiDemoTestsSerdeKStdTupleTuple0 = ( + ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeKStdTupleTuple0 + ) class AsyncInoutClient: @@ -58,15 +60,19 @@ class AsyncInoutClient: async def test( self, - data: Optional[reflectapi_demo.tests.serde.SKTuple0] = None, - ) -> ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: + data: Optional[ + reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 + ]: """ Args: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: Response containing reflectapi_demo.tests.serde.SKTuple0 data + ApiResponse[reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0]: Response containing reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 data """ path = "/inout_test" @@ -75,7 +81,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.SKTuple0, + response_model=reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0, ) @@ -100,15 +106,19 @@ class InoutClient: def test( self, - data: Optional[reflectapi_demo.tests.serde.SKTuple0] = None, - ) -> ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: + data: Optional[ + reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 + ]: """ Args: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.SKTuple0]: Response containing reflectapi_demo.tests.serde.SKTuple0 data + ApiResponse[reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0]: Response containing reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0 data """ path = "/inout_test" @@ -117,7 +127,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.SKTuple0, + response_model=reflectapi_demo.tests.serde.SReflectapiDemoTestsSerdeKStdTupleTuple0, ) @@ -143,7 +153,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] # Rebuild models to resolve forward references for _model in [ ReflectapiDemoTestsSerdeK, - ReflectapiDemoTestsSerdeSKTuple0, + ReflectapiDemoTestsSerdeSReflectapiDemoTestsSerdeKStdTupleTuple0, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap index 035fd2bd..6323b731 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_drops_inner_fields-5.snap @@ -38,7 +38,7 @@ class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): inner_b: str -class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse( +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69( BaseModel ): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -60,7 +60,7 @@ class reflectapi_demo: TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner - TestUpdateOrElseTestFlattenInnerTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse + TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 class AsyncInoutClient: @@ -72,10 +72,10 @@ class AsyncInoutClient: async def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 ]: """ @@ -83,7 +83,7 @@ class AsyncInoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 data """ path = "/inout_test" @@ -92,7 +92,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69, ) @@ -118,10 +118,10 @@ class InoutClient: def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 ]: """ @@ -129,7 +129,7 @@ class InoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 data """ path = "/inout_test" @@ -138,7 +138,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69, ) @@ -165,7 +165,7 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] for _model in [ ReflectapiDemoTestsSerdeTestFlattenIfElse, ReflectapiDemoTestsSerdeTestFlattenInner, - ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-2.snap new file mode 100644 index 00000000..13e7db56 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-2.snap @@ -0,0 +1,88 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: < TestLeafCollisionPair > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestLeafCollisionPair, + headers: {}, + options?: RequestOptions, + ) => AsyncResult; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export interface TestLeafCollisionPair { + a: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.module_a.Sample, + reflectapi_demo.tests.serde.TestFlattenIfElse + >; + b: reflectapi_demo.tests.serde.TestUpdateOrElse< + reflectapi_demo.tests.serde.module_b.Sample, + reflectapi_demo.tests.serde.TestFlattenIfElse + >; + } + + export type TestUpdateOrElse = { + if_else: C | null; + } & NullToEmptyObject; + + export namespace module_a { + export interface Sample { + a_field: number /* u32 */; + } + } + + export namespace module_b { + export interface Sample { + b_field: boolean; + } + } + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestLeafCollisionPair, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestLeafCollisionPair, + {}, + reflectapi_demo.tests.serde.TestLeafCollisionPair, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-3.snap new file mode 100644 index 00000000..3a71e8b5 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-3.snap @@ -0,0 +1,102 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: < TestLeafCollisionPair > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestLeafCollisionPair, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestLeafCollisionPair, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestLeafCollisionPair { + pub a: super::super::super::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::super::super::reflectapi_demo::tests::serde::module_a::Sample, + super::super::super::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + pub b: super::super::super::reflectapi_demo::tests::serde::TestUpdateOrElse< + super::super::super::reflectapi_demo::tests::serde::module_b::Sample, + super::super::super::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestUpdateOrElse { + #[serde(flatten)] + pub inner: T, + pub if_else: std::option::Option, + } + + pub mod module_a { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct Sample { + pub a_field: u32, + } + } + pub mod module_b { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct Sample { + pub b_field: bool, + } + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-4.snap new file mode 100644 index 00000000..7a7f8d47 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-4.snap @@ -0,0 +1,158 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestLeafCollisionPair" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestLeafCollisionPair" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "bool": { + "description": "Boolean value", + "type": "boolean" + }, + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "reflectapi_demo.tests.serde.TestLeafCollisionPair": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestLeafCollisionPair", + "required": [ + "a", + "b" + ], + "properties": { + "a": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.module_a.Sample" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + }, + "b": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.module_b.Sample" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + } + } + }, + "reflectapi_demo.tests.serde.module_a.Sample": { + "type": "object", + "title": "reflectapi_demo.tests.serde.module_a.Sample", + "required": [ + "a_field" + ], + "properties": { + "a_field": { + "$ref": "#/components/schemas/u32" + } + } + }, + "reflectapi_demo.tests.serde.module_b.Sample": { + "type": "object", + "title": "reflectapi_demo.tests.serde.module_b.Sample", + "required": [ + "b_field" + ], + "properties": { + "b_field": { + "$ref": "#/components/schemas/bool" + } + } + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-5.snap new file mode 100644 index 00000000..d0ecbc09 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision-5.snap @@ -0,0 +1,199 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: < TestLeafCollisionPair > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestLeafCollisionPair(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + a: reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeModuleASC2dd36fb + b: reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeModuleBS8b56ffdc + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleASC2dd36fb( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + a_field: int + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleBS8b56ffdc( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + b_field: bool + + +class ReflectapiDemoTestsSerdeModuleASample(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + a_field: int + + +class ReflectapiDemoTestsSerdeModuleBSample(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + b_field: bool + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestLeafCollisionPair = ReflectapiDemoTestsSerdeTestLeafCollisionPair + TestUpdateOrElseReflectapiDemoTestsSerdeModuleASC2dd36fb = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleASC2dd36fb + TestUpdateOrElseReflectapiDemoTestsSerdeModuleBS8b56ffdc = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleBS8b56ffdc + + class module_a: + """Namespace for module_a types.""" + + Sample = ReflectapiDemoTestsSerdeModuleASample + + class module_b: + """Namespace for module_b types.""" + + Sample = ReflectapiDemoTestsSerdeModuleBSample + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[reflectapi_demo.tests.serde.TestLeafCollisionPair] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.TestLeafCollisionPair]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestLeafCollisionPair]: Response containing reflectapi_demo.tests.serde.TestLeafCollisionPair data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestLeafCollisionPair, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[reflectapi_demo.tests.serde.TestLeafCollisionPair] = None, + ) -> ApiResponse[reflectapi_demo.tests.serde.TestLeafCollisionPair]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestLeafCollisionPair]: Response containing reflectapi_demo.tests.serde.TestLeafCollisionPair data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestLeafCollisionPair, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestLeafCollisionPair, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleASC2dd36fb, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeModuleBS8b56ffdc, + ReflectapiDemoTestsSerdeModuleASample, + ReflectapiDemoTestsSerdeModuleBSample, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision.snap new file mode 100644 index 00000000..042630a1 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_leaf_collision.snap @@ -0,0 +1,376 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestLeafCollisionPair" + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestLeafCollisionPair" + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestLeafCollisionPair", + "fields": { + "named": [ + { + "name": "a", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::module_a::Sample" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "b", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::module_b::Sample" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::module_a::Sample", + "fields": { + "named": [ + { + "name": "a_field", + "type": { + "name": "u32" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::module_b::Sample", + "fields": { + "named": [ + { + "name": "b_field", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestLeafCollisionPair", + "fields": { + "named": [ + { + "name": "a", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::module_a::Sample" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "b", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::module_b::Sample" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::module_a::Sample", + "fields": { + "named": [ + { + "name": "a_field", + "type": { + "name": "u32" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::module_b::Sample", + "fields": { + "named": [ + { + "name": "b_field", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap index d77b786f..81916c39 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap @@ -43,7 +43,7 @@ class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): code: int -class ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData( +class ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4( BaseModel ): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -52,7 +52,7 @@ class ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentDa payload: str -class ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a( +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1( BaseModel ): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -75,8 +75,8 @@ class reflectapi_demo: TestFlattenIdent = ReflectapiDemoTestsSerdeTestFlattenIdent TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse - TestIdentityDataTestFlattenIdentTestFlattenIdentData = ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData - TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a = ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 = ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 class AsyncInoutClient: @@ -88,10 +88,10 @@ class AsyncInoutClient: async def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ]: """ @@ -99,7 +99,7 @@ class AsyncInoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data """ path = "/inout_test" @@ -108,7 +108,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ) @@ -134,10 +134,10 @@ class InoutClient: def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ]: """ @@ -145,7 +145,7 @@ class InoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data """ path = "/inout_test" @@ -154,7 +154,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ) @@ -182,8 +182,8 @@ for _model in [ ReflectapiDemoTestsSerdeTestFlattenIdent, ReflectapiDemoTestsSerdeTestFlattenIdentData, ReflectapiDemoTestsSerdeTestFlattenIfElse, - ReflectapiDemoTestsSerdeTestIdentityDataTestFlattenIdentTestFlattenIdentData, - ReflectapiDemoTestsSerdeTestUpdateOrElseTestIdentityDataTestFlattenIdent1450694a, + ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap index a6c5ddd3..7b80c5c3 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_optional-5.snap @@ -32,7 +32,9 @@ class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): inner_b: str -class ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner(BaseModel): +class ReflectapiDemoTestsSerdeInputTestOptionalFlattenReflectapiDemoTestsSerde356d19e7( + BaseModel +): model_config = ConfigDict(extra="ignore", populate_by_name=True) code: int @@ -40,7 +42,9 @@ class ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner(BaseModel inner_b: str | None = None -class ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner(BaseModel): +class ReflectapiDemoTestsSerdeOutputTestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30( + BaseModel +): model_config = ConfigDict(extra="ignore", populate_by_name=True) code: int @@ -63,16 +67,12 @@ class reflectapi_demo: class input: """Namespace for input types.""" - TestOptionalFlattenTestFlattenInner = ( - ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner - ) + TestOptionalFlattenReflectapiDemoTestsSerde356d19e7 = ReflectapiDemoTestsSerdeInputTestOptionalFlattenReflectapiDemoTestsSerde356d19e7 class output: """Namespace for output types.""" - TestOptionalFlattenTestFlattenInner = ( - ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner - ) + TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 = ReflectapiDemoTestsSerdeOutputTestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 class AsyncInoutClient: @@ -84,10 +84,10 @@ class AsyncInoutClient: async def test( self, data: Optional[ - reflectapi_demo.tests.serde.input.TestOptionalFlattenTestFlattenInner + reflectapi_demo.tests.serde.input.TestOptionalFlattenReflectapiDemoTestsSerde356d19e7 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner + reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 ]: """ @@ -95,7 +95,7 @@ class AsyncInoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner data + ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 data """ path = "/inout_test" @@ -104,7 +104,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner, + response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30, ) @@ -130,10 +130,10 @@ class InoutClient: def test( self, data: Optional[ - reflectapi_demo.tests.serde.input.TestOptionalFlattenTestFlattenInner + reflectapi_demo.tests.serde.input.TestOptionalFlattenReflectapiDemoTestsSerde356d19e7 ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner + reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 ]: """ @@ -141,7 +141,7 @@ class InoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner data + ApiResponse[reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30]: Response containing reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30 data """ path = "/inout_test" @@ -150,7 +150,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenTestFlattenInner, + response_model=reflectapi_demo.tests.serde.output.TestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30, ) @@ -176,8 +176,8 @@ StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] # Rebuild models to resolve forward references for _model in [ ReflectapiDemoTestsSerdeTestFlattenInner, - ReflectapiDemoTestsSerdeInputTestOptionalFlattenTestFlattenInner, - ReflectapiDemoTestsSerdeOutputTestOptionalFlattenTestFlattenInner, + ReflectapiDemoTestsSerdeInputTestOptionalFlattenReflectapiDemoTestsSerde356d19e7, + ReflectapiDemoTestsSerdeOutputTestOptionalFlattenReflectapiDemoTestsSerd6d4b6d30, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap index b25597ce..b14e9899 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_two_instantiations-5.snap @@ -47,27 +47,27 @@ class ReflectapiDemoTestsSerdeTestFlattenInnerAlt(BaseModel): class ReflectapiDemoTestsSerdeTestTwoInstantiations(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) - a: reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerTestFlattenIfElse - b: reflectapi_demo.tests.serde.TestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse + a: reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 + b: reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestFlatCd6baf20 -class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse( +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69( BaseModel ): model_config = ConfigDict(extra="ignore", populate_by_name=True) if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None - alt_x: bool + inner_a: int + inner_b: str -class ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse( +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlatCd6baf20( BaseModel ): model_config = ConfigDict(extra="ignore", populate_by_name=True) if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None - inner_a: int - inner_b: str + alt_x: bool # Namespace classes for dotted access to types @@ -84,8 +84,8 @@ class reflectapi_demo: TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner TestFlattenInnerAlt = ReflectapiDemoTestsSerdeTestFlattenInnerAlt TestTwoInstantiations = ReflectapiDemoTestsSerdeTestTwoInstantiations - TestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse - TestUpdateOrElseTestFlattenInnerTestFlattenIfElse = ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse + TestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69 + TestUpdateOrElseReflectapiDemoTestsSerdeTestFlatCd6baf20 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlatCd6baf20 class AsyncInoutClient: @@ -184,8 +184,8 @@ for _model in [ ReflectapiDemoTestsSerdeTestFlattenInner, ReflectapiDemoTestsSerdeTestFlattenInnerAlt, ReflectapiDemoTestsSerdeTestTwoInstantiations, - ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerAltTestFlattenIfElse, - ReflectapiDemoTestsSerdeTestUpdateOrElseTestFlattenInnerTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlat3ae7cb69, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestFlatCd6baf20, ]: try: _model.model_rebuild() diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 77b623b4..4913e781 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5698,7 +5698,10 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { /// Mangle a `(struct, args)` instantiation into a fresh schema type /// name. The struct's full namespace-qualified name is preserved so /// downstream namespace mangling stays sensible; the suffix encodes -/// each argument's *leaf* name (the part after the final `::`). +/// each argument's *full* path (`::` flattened to `_`) so two args +/// that share a leaf name but live in different modules don't fuse +/// into one mangled name (which would silently lose one type's +/// fields). /// /// If the resulting suffix is long enough that downstream class-name /// handling would hash-truncate (and produce a mismatch between the @@ -5707,9 +5710,9 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { /// references stay consistent. fn mangle_monomorphized_name(struct_name: &str, args: &[TypeReference]) -> String { fn arg_suffix(arg: &TypeReference) -> String { - let leaf = arg.name.rsplit("::").next().unwrap_or(&arg.name); + let base = arg.name.replace("::", "_"); if arg.arguments.is_empty() { - leaf.to_string() + base } else { let inner = arg .arguments @@ -5717,7 +5720,7 @@ fn mangle_monomorphized_name(struct_name: &str, args: &[TypeReference]) -> Strin .map(arg_suffix) .collect::>() .join("_"); - format!("{leaf}_{inner}") + format!("{base}_{inner}") } } let suffix = args.iter().map(arg_suffix).collect::>().join("_"); From 9e2d07668128685eede053163162b26f1ecf31bc Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 17:07:37 +1200 Subject: [PATCH 12/16] fix(codegen/py): skip TypeVar args + transitive marking for chained generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues with the same root cause: a marked struct can be referenced from inside another generic context, where its args are the *outer* struct's TypeVars rather than concrete types. The previous monomorphizer registered such refs (treating "I" or "D" as real types), called instantiate which substituted I→I / D→D (no-op), and emitted a struct with parameters=[] but raw TypeVar field types still in place. The bail then fired with target_type_name='D' at depth=0. Two fixes: 1. is_concrete check in normalize_marked_refs: an arg ref with no sub-args and no schema entry is a TypeVar from an enclosing generic context. Refuse to register such instantiations — the enclosing context will resolve them when its own concrete usage is monomorphized. 2. Transitive marking. A generic struct G that itself has no flatten-over-typevar still needs monomorphization if any field references a marked struct using G's own TypeVars (e.g. `Outer { field: MarkedInner }`). Otherwise concrete usages of G would substitute the field to a now-concrete marked-struct ref but G's body still holds the unsubstituted form, and after removing the original marked struct the leftover ref dangles. Transitive marking lets G's own concrete instantiations drive the monomorphization chain. Iterate to fixed point so chains like `A → B → MarkedC` all get marked. Test: TestWithMarkedInner { body: TestIdentityData; extra: bool } instantiated with concrete (TestFlattenIdent, TestFlattenIdentData). Before this fix: validation failed because the original TestIdentityData was removed but TestWithMarkedInner still referenced it. After: both structs transitively monomorphize; the wrapper's body field references the inner mono'd type by mangled name. --- reflectapi-demo/src/tests/serde.rs | 23 ++ ...ests__serde__generic_flatten_nested-5.snap | 36 +-- ..._flatten_typevar_in_generic_context-2.snap | 91 ++++++ ..._flatten_typevar_in_generic_context-3.snap | 94 ++++++ ..._flatten_typevar_in_generic_context-4.snap | 132 ++++++++ ..._flatten_typevar_in_generic_context-5.snap | 181 +++++++++++ ...ic_flatten_typevar_in_generic_context.snap | 290 ++++++++++++++++++ reflectapi/src/codegen/python.rs | 77 ++++- 8 files changed, 903 insertions(+), 21 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context.snap diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index df9c5018..7a43d921 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1473,3 +1473,26 @@ struct TestLeafCollisionPair { fn test_generic_flatten_leaf_collision() { assert_snapshot!(TestLeafCollisionPair); } + +// Wrapper that USES a marked struct in generic position with its own +// TypeVars as args. Reproduces the case the previous monomorphizer +// tripped over: walking `IdentityData` inside a generic context +// where `I` and `D` are TypeVars (not real types). Should not +// register a monomorphization for that — only the concrete +// instantiation `WithMarkedInner` +// triggers monomorphization, and its substituted IdentityData ref is +// the one that gets a concrete monomorph. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "I: serde::Serialize, D: serde::Serialize", + deserialize = "I: serde::de::DeserializeOwned, D: serde::de::DeserializeOwned", +))] +struct TestWithMarkedInner { + body: TestIdentityData, + extra: bool, +} + +#[test] +fn test_generic_flatten_typevar_in_generic_context() { + assert_snapshot!(TestWithMarkedInner); +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap index 81916c39..3d90bd55 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap @@ -52,16 +52,6 @@ class ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF6 payload: str -class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1( - BaseModel -): - model_config = ConfigDict(extra="ignore", populate_by_name=True) - - if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None - job_id: int - payload: str - - # Namespace classes for dotted access to types class reflectapi_demo: """Namespace for reflectapi_demo types.""" @@ -76,7 +66,6 @@ class reflectapi_demo: TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 = ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 - TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 class AsyncInoutClient: @@ -88,10 +77,12 @@ class AsyncInoutClient: async def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 + Annotated[ + Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" + ] ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 + Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] ]: """ @@ -99,7 +90,7 @@ class AsyncInoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data + ApiResponse[Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"]]: Response containing Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] data """ path = "/inout_test" @@ -108,7 +99,9 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, + response_model=Annotated[ + Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" + ], ) @@ -134,10 +127,12 @@ class InoutClient: def test( self, data: Optional[ - reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 + Annotated[ + Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" + ] ] = None, ) -> ApiResponse[ - reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 + Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] ]: """ @@ -145,7 +140,7 @@ class InoutClient: data: Request data for the test operation. Returns: - ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data + ApiResponse[Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"]]: Response containing Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] data """ path = "/inout_test" @@ -154,7 +149,9 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, + response_model=Annotated[ + Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" + ], ) @@ -183,7 +180,6 @@ for _model in [ ReflectapiDemoTestsSerdeTestFlattenIdentData, ReflectapiDemoTestsSerdeTestFlattenIfElse, ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4, - ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-2.snap new file mode 100644 index 00000000..adef3203 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-2.snap @@ -0,0 +1,91 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestWithMarkedInner > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestWithMarkedInner< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestWithMarkedInner< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIdent { + job_id: number /* u64 */; + } + + export interface TestFlattenIdentData { + payload: string; + } + + export type TestIdentityData = {} & NullToEmptyObject & + NullToEmptyObject; + + export interface TestWithMarkedInner { + body: reflectapi_demo.tests.serde.TestIdentityData; + extra: boolean; + } + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestWithMarkedInner< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestWithMarkedInner< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {}, + reflectapi_demo.tests.serde.TestWithMarkedInner< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-3.snap new file mode 100644 index 00000000..fc6d5fbd --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-3.snap @@ -0,0 +1,94 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: <\nTestWithMarkedInner > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestWithMarkedInner< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestWithMarkedInner< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdent { + pub job_id: u64, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdentData { + pub payload: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestIdentityData { + #[serde(flatten)] + pub identity: I, + #[serde(flatten)] + pub data: D, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestWithMarkedInner { + pub body: + super::super::super::reflectapi_demo::tests::serde::TestIdentityData, + pub extra: bool, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-4.snap new file mode 100644 index 00000000..a34bbe7d --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-4.snap @@ -0,0 +1,132 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestWithMarkedInner", + "required": [ + "body", + "extra" + ], + "properties": { + "body": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + }, + "extra": { + "$ref": "#/components/schemas/bool" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestWithMarkedInner", + "required": [ + "body", + "extra" + ], + "properties": { + "body": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + }, + "extra": { + "$ref": "#/components/schemas/bool" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "bool": { + "description": "Boolean value", + "type": "boolean" + }, + "reflectapi_demo.tests.serde.TestFlattenIdent": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdent", + "required": [ + "job_id" + ], + "properties": { + "job_id": { + "$ref": "#/components/schemas/u64" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenIdentData": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdentData", + "required": [ + "payload" + ], + "properties": { + "payload": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u64": { + "description": "64-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-5.snap new file mode 100644 index 00000000..da6e813c --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context-5.snap @@ -0,0 +1,181 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestWithMarkedInner > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIdent(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + + +class ReflectapiDemoTestsSerdeTestFlattenIdentData(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + payload: str + + +class ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + payload: str + + +class ReflectapiDemoTestsSerdeTestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + body: reflectapi_demo.tests.serde.TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + extra: bool + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIdent = ReflectapiDemoTestsSerdeTestFlattenIdent + TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData + TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 = ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 = ReflectapiDemoTestsSerdeTestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66]: Response containing reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66]: Response containing reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66 data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIdent, + ReflectapiDemoTestsSerdeTestFlattenIdentData, + ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4, + ReflectapiDemoTestsSerdeTestWithMarkedInnerReflectapiDemoTestsSerdeTestF02f28d66, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context.snap new file mode 100644 index 00000000..9e5c1f23 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_in_generic_context.snap @@ -0,0 +1,290 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestWithMarkedInner", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestWithMarkedInner", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestWithMarkedInner", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "body", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + }, + { + "name": "extra", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "primitive", + "name": "bool", + "description": "Boolean value" + }, + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestWithMarkedInner", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "body", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + }, + { + "name": "extra", + "type": { + "name": "bool" + }, + "required": true + } + ] + } + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 4913e781..2d8e76c0 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5474,7 +5474,7 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { false } - let marked: BTreeSet = { + let mut marked: BTreeSet = { let mut out = BTreeSet::new(); for ts in [&schema.input_types, &schema.output_types] { for typ in ts.types() { @@ -5499,6 +5499,60 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { return Ok(()); } + // Transitive marking: a generic struct G that doesn't itself have + // flatten-over-typevar must still be monomorphized if any of its + // fields references a (transitively-)marked struct using G's + // TypeVars as args. Otherwise, when concrete usages of G are + // resolved to monomorphized forms, the inner marked-struct ref + // would be left dangling at G's TypeVar — and removing the + // original marked struct would leave G's leftover reference + // pointing at nothing. + // + // Iterate to fixed point so chains like A → B → MarkedC + // all get marked. + fn ref_uses_marked_with_typevar( + type_ref: &TypeReference, + marked: &BTreeSet, + typevars: &BTreeSet<&str>, + ) -> bool { + if marked.contains(&type_ref.name) + && type_ref + .arguments + .iter() + .any(|a| typevars.contains(a.name.as_str())) + { + return true; + } + type_ref + .arguments + .iter() + .any(|a| ref_uses_marked_with_typevar(a, marked, typevars)) + } + loop { + let mut new_marks: BTreeSet = BTreeSet::new(); + for ts in [&schema.input_types, &schema.output_types] { + for typ in ts.types() { + if let Type::Struct(s) = typ { + if s.parameters.is_empty() || marked.contains(&s.name) { + continue; + } + let typevars: BTreeSet<&str> = + s.parameters.iter().map(|p| p.name.as_str()).collect(); + if s.fields + .iter() + .any(|f| ref_uses_marked_with_typevar(&f.type_ref, &marked, &typevars)) + { + new_marks.insert(s.name.clone()); + } + } + } + } + if new_marks.is_empty() { + break; + } + marked.extend(new_marks); + } + // 2. Walk every type reference reachable from functions and types, // rewriting marked refs in place (bottom-up) to their mangled // monomorphized names. Each new (struct, normalized_args) pair @@ -5509,6 +5563,18 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // sides. let mut concrete: BTreeMap<(String, Vec), String> = BTreeMap::new(); + /// A type-ref is "concrete" if it resolves to a real schema type or + /// is built up from concrete refs. A bare ref like `T` with no + /// arguments and no schema entry is a TypeVar from some enclosing + /// generic context and isn't safe to monomorphize against — when + /// that context resolves, the substitution will replace it. + fn is_concrete(type_ref: &TypeReference, schema: &Schema) -> bool { + if type_ref.arguments.is_empty() && schema.get_type(&type_ref.name).is_none() { + return false; + } + type_ref.arguments.iter().all(|a| is_concrete(a, schema)) + } + fn normalize_marked_refs( type_ref: &mut TypeReference, marked: &BTreeSet, @@ -5523,6 +5589,15 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { if !marked.contains(&type_ref.name) || type_ref.arguments.is_empty() { return Ok(()); } + // If any arg is a TypeVar from an enclosing generic context + // (e.g. `Inner` inside `struct Outer { ... }`), this + // ref isn't a concrete instantiation — it's a generic-position + // use of a marked struct. Don't register: when the enclosing + // generic resolves to a concrete instantiation, the + // substituted args will be concrete and we'll register then. + if !type_ref.arguments.iter().all(|a| is_concrete(a, schema)) { + return Ok(()); + } let key = (type_ref.name.clone(), type_ref.arguments.clone()); let mangled = if let Some(existing) = concrete.get(&key) { existing.clone() From ac827e5c23baa42f748d78893ddc5046ecd6e45c Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 17:15:56 +1200 Subject: [PATCH 13/16] fix(codegen/py): extend monomorphization to generic enums Previous transitive marking and instantiation only considered structs. Generic enums whose variant fields reference a marked struct using the enum's own TypeVars (e.g. `enum IngestRelation { Insert(IdentityData), ... }`) slipped through: the original was kept, no concrete monomorphization was emitted, and after step 6 removed the inner marked struct, the enum's variant ref dangled. Now both passes operate on Type::Struct and Type::Enum: - transitive marking checks variant field type_refs - normalize_marked_refs looks up by name and instantiates either via Struct::instantiate or Enum::instantiate - step 4 inserts the appropriate Type wrapper Test: TestIngestRelation with two tuple variants carrying TestIdentityData + a unit variant, instantiated concretely. Snapshot confirms each tuple variant references the inner mono'd struct by mangled name; the enum itself is monomorphized as a discriminated union of variant classes + the bare unit literal. --- reflectapi-demo/src/tests/serde.rs | 21 ++ ...eneric_flatten_enum_variant_typevar-2.snap | 95 +++++ ...eneric_flatten_enum_variant_typevar-3.snap | 98 ++++++ ...eneric_flatten_enum_variant_typevar-4.snap | 182 ++++++++++ ...eneric_flatten_enum_variant_typevar-5.snap | 296 ++++++++++++++++ ..._generic_flatten_enum_variant_typevar.snap | 328 ++++++++++++++++++ reflectapi/src/codegen/python.rs | 142 +++++--- 7 files changed, 1119 insertions(+), 43 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar.snap diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index 7a43d921..f6d3bc1f 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1496,3 +1496,24 @@ struct TestWithMarkedInner { fn test_generic_flatten_typevar_in_generic_context() { assert_snapshot!(TestWithMarkedInner); } + +// Generic enum whose variants reference a marked struct using the +// enum's own TypeVars. Reproduces the regression where transitive +// marking only considered structs — generic enums would survive the +// pass while the marked structs they referenced got removed, +// dangling the enum's variant fields. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "I: serde::Serialize, D: serde::Serialize", + deserialize = "I: serde::de::DeserializeOwned, D: serde::de::DeserializeOwned", +))] +enum TestIngestRelation { + Insert(TestIdentityData), + Remove(TestIdentityData), + Empty, +} + +#[test] +fn test_generic_flatten_enum_variant_typevar() { + assert_snapshot!(TestIngestRelation); +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-2.snap new file mode 100644 index 00000000..6f6dac2d --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-2.snap @@ -0,0 +1,95 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestIngestRelation > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestIngestRelation< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestIngestRelation< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIdent { + job_id: number /* u64 */; + } + + export interface TestFlattenIdentData { + payload: string; + } + + export type TestIdentityData = {} & NullToEmptyObject & + NullToEmptyObject; + + export type TestIngestRelation = + | { + Insert: reflectapi_demo.tests.serde.TestIdentityData; + } + | { + Remove: reflectapi_demo.tests.serde.TestIdentityData; + } + | "Empty"; + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestIngestRelation< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestIngestRelation< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {}, + reflectapi_demo.tests.serde.TestIngestRelation< + reflectapi_demo.tests.serde.TestFlattenIdent, + reflectapi_demo.tests.serde.TestFlattenIdentData + >, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-3.snap new file mode 100644 index 00000000..5ef1fd32 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-3.snap @@ -0,0 +1,98 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: <\nTestIngestRelation > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestIngestRelation< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestIngestRelation< + super::types::reflectapi_demo::tests::serde::TestFlattenIdent, + super::types::reflectapi_demo::tests::serde::TestFlattenIdentData, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdent { + pub job_id: u64, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIdentData { + pub payload: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestIdentityData { + #[serde(flatten)] + pub identity: I, + #[serde(flatten)] + pub data: D, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub enum TestIngestRelation { + Insert( + super::super::super::reflectapi_demo::tests::serde::TestIdentityData, + ), + Remove( + super::super::super::reflectapi_demo::tests::serde::TestIdentityData, + ), + Empty, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-4.snap new file mode 100644 index 00000000..397aaf95 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-4.snap @@ -0,0 +1,182 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "title": "Insert", + "required": [ + "Insert" + ], + "properties": { + "Insert": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + } + } + }, + { + "type": "object", + "title": "Remove", + "required": [ + "Remove" + ], + "properties": { + "Remove": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + } + } + }, + { + "const": "Empty" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "title": "Insert", + "required": [ + "Insert" + ], + "properties": { + "Insert": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + } + } + }, + { + "type": "object", + "title": "Remove", + "required": [ + "Remove" + ], + "properties": { + "Remove": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdent" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIdentData" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestIdentityData", + "properties": {} + } + ] + } + } + }, + { + "const": "Empty" + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenIdent": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdent", + "required": [ + "job_id" + ], + "properties": { + "job_id": { + "$ref": "#/components/schemas/u64" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenIdentData": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIdentData", + "required": [ + "payload" + ], + "properties": { + "payload": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u64": { + "description": "64-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-5.snap new file mode 100644 index 00000000..9d637516 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar-5.snap @@ -0,0 +1,296 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestIngestRelation > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + model_serializer, + model_validator, +) + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +# Helper functions for externally tagged enum serialization/deserialization +def _parse_externally_tagged(data, variants: dict, types: tuple, enum_name: str): + """Parse an externally tagged enum from {key: value} format.""" + if types and isinstance(data, types): + return data + if isinstance(data, str) and data in variants: + handler = variants[data] + if handler == "_unit": + return data + if isinstance(data, dict): + if len(data) != 1: + raise ValueError("Externally tagged enum must have exactly one key") + key, value = next(iter(data.items())) + if key in variants: + handler = variants[key] + if handler == "_unit": + return key + return handler(value) + raise ValueError(f"Unknown variant for {enum_name}: {data}") + + +def _serialize_externally_tagged(root, serializers: dict, enum_name: str): + """Serialize an externally tagged enum to {key: value} format.""" + for variant_name, (check, serialize) in serializers.items(): + if check(root): + return serialize(root) + raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}") + + +class ReflectapiDemoTestsSerdeTestFlattenIdent(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + + +class ReflectapiDemoTestsSerdeTestFlattenIdentData(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + payload: str + + +class ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + job_id: int + payload: str + + +class ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant( + BaseModel +): + """Insert variant""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + field_0: reflectapi_demo.tests.serde.TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + + +class ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant( + BaseModel +): + """Remove variant""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + field_0: reflectapi_demo.tests.serde.TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + + +# Externally tagged enum using RootModel +ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451Variants = Union[ + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant, + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant, + Literal["Empty"], +] + + +class ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451( + RootModel[ + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451Variants + ] +): + """Externally tagged enum""" + + @model_validator(mode="before") + @classmethod + def _validate(cls, data): + return _parse_externally_tagged( + data, + { + "Insert": lambda v: ( + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant( + field_0=v + ) + ), + "Remove": lambda v: ( + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant( + field_0=v + ) + ), + "Empty": "_unit", + }, + ( + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant, + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant, + ), + "ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451", + ) + + @model_serializer + def _serialize(self): + return _serialize_externally_tagged( + self.root, + { + "Insert": ( + lambda r: isinstance( + r, + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant, + ), + lambda r: {"Insert": r.field_0}, + ), + "Remove": ( + lambda r: isinstance( + r, + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant, + ), + lambda r: {"Remove": r.field_0}, + ), + "Empty": (lambda r: r == "Empty", lambda r: "Empty"), + }, + "ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451", + ) + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIdent = ReflectapiDemoTestsSerdeTestFlattenIdent + TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData + TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 = ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant = ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451InsertVariant + TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant = ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451RemoveVariant + TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 = ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451]: Response containing reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451]: Response containing reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451 data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIdent, + ReflectapiDemoTestsSerdeTestFlattenIdentData, + ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4, + ReflectapiDemoTestsSerdeTestIngestRelationReflectapiDemoTestsSerdeTestFl0a834451, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar.snap new file mode 100644 index 00000000..df2ad080 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_enum_variant_typevar.snap @@ -0,0 +1,328 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestIngestRelation", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestIngestRelation", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdent" + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "enum", + "name": "reflectapi_demo::tests::serde::TestIngestRelation", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "variants": [ + { + "name": "Insert", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + } + ] + } + }, + { + "name": "Remove", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + } + ] + } + }, + { + "name": "Empty", + "fields": "none" + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdent", + "fields": { + "named": [ + { + "name": "job_id", + "type": { + "name": "u64" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIdentData", + "fields": { + "named": [ + { + "name": "payload", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "fields": { + "named": [ + { + "name": "identity", + "type": { + "name": "I" + }, + "required": true, + "flattened": true + }, + { + "name": "data", + "type": { + "name": "D" + }, + "required": true, + "flattened": true + } + ] + } + }, + { + "kind": "enum", + "name": "reflectapi_demo::tests::serde::TestIngestRelation", + "parameters": [ + { + "name": "I" + }, + { + "name": "D" + } + ], + "variants": [ + { + "name": "Insert", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + } + ] + } + }, + { + "name": "Remove", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "reflectapi_demo::tests::serde::TestIdentityData", + "arguments": [ + { + "name": "I" + }, + { + "name": "D" + } + ] + }, + "required": true + } + ] + } + }, + { + "name": "Empty", + "fields": "none" + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u64", + "description": "64-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 2d8e76c0..8e3b4634 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5532,18 +5532,36 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { let mut new_marks: BTreeSet = BTreeSet::new(); for ts in [&schema.input_types, &schema.output_types] { for typ in ts.types() { - if let Type::Struct(s) = typ { - if s.parameters.is_empty() || marked.contains(&s.name) { - continue; + match typ { + Type::Struct(s) => { + if s.parameters.is_empty() || marked.contains(&s.name) { + continue; + } + let typevars: BTreeSet<&str> = + s.parameters.iter().map(|p| p.name.as_str()).collect(); + if s.fields + .iter() + .any(|f| ref_uses_marked_with_typevar(&f.type_ref, &marked, &typevars)) + { + new_marks.insert(s.name.clone()); + } } - let typevars: BTreeSet<&str> = - s.parameters.iter().map(|p| p.name.as_str()).collect(); - if s.fields - .iter() - .any(|f| ref_uses_marked_with_typevar(&f.type_ref, &marked, &typevars)) - { - new_marks.insert(s.name.clone()); + Type::Enum(e) => { + if e.parameters.is_empty() || marked.contains(&e.name) { + continue; + } + let typevars: BTreeSet<&str> = + e.parameters.iter().map(|p| p.name.as_str()).collect(); + let any = e.variants.iter().any(|v| { + v.fields.iter().any(|f| { + ref_uses_marked_with_typevar(&f.type_ref, &marked, &typevars) + }) + }); + if any { + new_marks.insert(e.name.clone()); + } } + Type::Primitive(_) => {} } } } @@ -5602,44 +5620,67 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { let mangled = if let Some(existing) = concrete.get(&key) { existing.clone() } else { - let struct_def = schema + let typ = schema .input_types .get_type(&type_ref.name) .or_else(|| schema.output_types.get_type(&type_ref.name)) - .and_then(|t| { - if let Type::Struct(s) = t { - Some(s.clone()) - } else { - None - } - }) .ok_or_else(|| { anyhow::anyhow!( - "monomorphization: marked struct '{}' missing from schema", + "monomorphization: marked type '{}' missing from schema", type_ref.name ) })?; - if struct_def.parameters.len() != type_ref.arguments.len() { - anyhow::bail!( - "monomorphization: arity mismatch for '{}': expected {}, got {}", - type_ref.name, - struct_def.parameters.len(), - type_ref.arguments.len(), - ); - } - let mangled = mangle_monomorphized_name(&type_ref.name, &type_ref.arguments); // Register before recursing so cycles can't loop forever - // (a marked struct that transitively contains a ref to - // itself with the same args would otherwise spin). + // (a marked type that transitively contains a ref to itself + // with the same args would otherwise spin). + let mangled = mangle_monomorphized_name(&type_ref.name, &type_ref.arguments); concrete.insert(key, mangled.clone()); // Substitute with the (already-normalized) args and recurse - // into the resulting fields, so any further marked refs in - // the substituted struct's fields get registered too. - let mono = struct_def.instantiate(&type_ref.arguments); - for f in mono.fields.iter() { - let mut tr = f.type_ref.clone(); - normalize_marked_refs(&mut tr, marked, concrete, schema)?; + // into the resulting variants/fields so any further marked + // refs there get registered too. Both Struct and Enum + // implement Instantiate. + match typ { + Type::Struct(s) => { + let s = s.clone(); + if s.parameters.len() != type_ref.arguments.len() { + anyhow::bail!( + "monomorphization: arity mismatch for struct '{}': expected {}, got {}", + type_ref.name, + s.parameters.len(), + type_ref.arguments.len(), + ); + } + let mono = s.instantiate(&type_ref.arguments); + for f in mono.fields.iter() { + let mut tr = f.type_ref.clone(); + normalize_marked_refs(&mut tr, marked, concrete, schema)?; + } + } + Type::Enum(e) => { + let e = e.clone(); + if e.parameters.len() != type_ref.arguments.len() { + anyhow::bail!( + "monomorphization: arity mismatch for enum '{}': expected {}, got {}", + type_ref.name, + e.parameters.len(), + type_ref.arguments.len(), + ); + } + let mono = e.instantiate(&type_ref.arguments); + for v in mono.variants.iter() { + for f in v.fields.iter() { + let mut tr = f.type_ref.clone(); + normalize_marked_refs(&mut tr, marked, concrete, schema)?; + } + } + } + Type::Primitive(_) => { + anyhow::bail!( + "monomorphization: marked '{}' resolved to a primitive — only structs and enums can be monomorphized", + type_ref.name, + ); + } } mangled }; @@ -5697,7 +5738,11 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { } // 4. Insert monomorphized types into both typespaces (mirroring the - // location of the original generic). + // location of the original generic). Both struct and enum + // instantiations are supported — a generic enum used as a + // variant container for marked structs needs its own concrete + // monomorphizations or the variant's field references would + // dangle after step 6 removes the originals. for ((name, args), mangled) in concrete.iter() { for ts_is_input in [true, false] { let ts = if ts_is_input { @@ -5705,18 +5750,29 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { } else { &schema.output_types }; - let s = match ts.get_type(name) { - Some(Type::Struct(s)) => s.clone(), - _ => continue, + let typ = match ts.get_type(name) { + Some(t) => t.clone(), + None => continue, + }; + let mono = match typ { + Type::Struct(s) => { + let mut mono = s.instantiate(args); + mono.name = mangled.clone(); + Type::Struct(mono) + } + Type::Enum(e) => { + let mut mono = e.instantiate(args); + mono.name = mangled.clone(); + Type::Enum(mono) + } + Type::Primitive(_) => continue, }; - let mut mono = s.instantiate(args); - mono.name = mangled.clone(); let target = if ts_is_input { &mut schema.input_types } else { &mut schema.output_types }; - target.insert_type(Type::Struct(mono)); + target.insert_type(mono); } } From b75b6c9e69fed37920cfd461bc3fdc60dc7611e6 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 18:10:33 +1200 Subject: [PATCH 14/16] fix(codegen/py, schema): self-review tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four items from a self-review pass: 1. Schema crate: Typespace::remove_type left stale indices in its internal types_map after Vec::remove shifted slots. The next lookup either panicked or silently returned the wrong type (had observed: removing two marked structs took out std::option::Option in one repro). Fixed in schema by invalidating the map on remove; the python codegen pass no longer needs its sort_types() workaround. Two regression tests added in reflectapi-schema. 2. Python codegen: post-pass invariant assertion. Walks every reachable TypeReference in debug builds and panics on either (a) a ref to a removed marked type, or (b) a TypeVar leak inside a parameters-empty body. Caught a real bug while landing other items: when normalize_marked_refs replaced an inner ref with its mangled name, the outer ref's is_concrete check then rejected because the mangled name wasn't in the schema yet. Fixed by tracking a parallel set of registered mangled names and treating those as concrete. 3. Python codegen: mangled-name collision detection. Before inserting monomorphized types, scan the concrete table for distinct keys that produced the same mangled name (an insert_type would silently no-op the second one). Errors loudly with both keys. 4. Python codegen: transitive marking now considers TypeVars nested at any depth in arg trees, not just immediate args. Previously `Wrapper { body: Marked> }` slipped through because Marked's immediate arg was Option (not I), so Wrapper wasn't marked, the inner Marked got removed, and the wrapper's ref dangled. Test added. Self-recursive marked-struct case (Tree with #[flatten] + Vec>) was attempted but reflectapi's schema construction itself overflows on recursive types — separate concern, not in scope here. Note added in the test file. --- reflectapi-demo/src/tests/serde.rs | 27 ++ ...ests__serde__generic_flatten_nested-5.snap | 36 +- ...atten_typevar_nested_in_generic_arg-2.snap | 81 ++++ ...atten_typevar_nested_in_generic_arg-3.snap | 94 +++++ ...atten_typevar_nested_in_generic_arg-4.snap | 176 +++++++++ ...atten_typevar_nested_in_generic_arg-5.snap | 184 +++++++++ ...flatten_typevar_nested_in_generic_arg.snap | 374 ++++++++++++++++++ reflectapi-schema/src/lib.rs | 69 +++- reflectapi/src/codegen/python.rs | 238 +++++++++-- 9 files changed, 1233 insertions(+), 46 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg.snap diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index f6d3bc1f..f28d34d4 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1517,3 +1517,30 @@ enum TestIngestRelation { fn test_generic_flatten_enum_variant_typevar() { assert_snapshot!(TestIngestRelation); } + +// (Cycle / self-recursive marked struct test omitted: reflectapi's +// schema builder itself doesn't support recursive type definitions +// — `struct Tree { children: Vec> }` overflows during +// schema construction, before any codegen runs. The +// register-before-recurse guard in `normalize_marked_refs` is still +// the right defence in depth, but the case is unreachable in the +// current schema language.) + +// Marked struct used with a generic parameter wrapped inside another +// generic (`Marked>`). Earlier transitive marking only +// looked at the IMMEDIATE arg, so the wrapper wasn't marked, the +// inner Marked got removed, and the wrapper's ref dangled. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "I: serde::Serialize", + deserialize = "I: serde::de::DeserializeOwned", +))] +struct TestWrapperWithNestedTypevarArg { + body: TestUpdateOrElse, TestFlattenIfElse>, + extra: u32, +} + +#[test] +fn test_generic_flatten_typevar_nested_in_generic_arg() { + assert_snapshot!(TestWrapperWithNestedTypevarArg); +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap index 3d90bd55..81916c39 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_nested-5.snap @@ -52,6 +52,16 @@ class ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF6 payload: str +class ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + job_id: int + payload: str + + # Namespace classes for dotted access to types class reflectapi_demo: """Namespace for reflectapi_demo types.""" @@ -66,6 +76,7 @@ class reflectapi_demo: TestFlattenIdentData = ReflectapiDemoTestsSerdeTestFlattenIdentData TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse TestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 = ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4 + TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 = ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 class AsyncInoutClient: @@ -77,12 +88,10 @@ class AsyncInoutClient: async def test( self, data: Optional[ - Annotated[ - Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" - ] + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ] = None, ) -> ApiResponse[ - Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ]: """ @@ -90,7 +99,7 @@ class AsyncInoutClient: data: Request data for the test operation. Returns: - ApiResponse[Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"]]: Response containing Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data """ path = "/inout_test" @@ -99,9 +108,7 @@ class AsyncInoutClient: path, params=params if params else None, json_model=data, - response_model=Annotated[ - Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" - ], + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ) @@ -127,12 +134,10 @@ class InoutClient: def test( self, data: Optional[ - Annotated[ - Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" - ] + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ] = None, ) -> ApiResponse[ - Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] + reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 ]: """ @@ -140,7 +145,7 @@ class InoutClient: data: Request data for the test operation. Returns: - ApiResponse[Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"]]: Response containing Annotated[Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse"] data + ApiResponse[reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1]: Response containing reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1 data """ path = "/inout_test" @@ -149,9 +154,7 @@ class InoutClient: path, params=params if params else None, json_model=data, - response_model=Annotated[ - Any, "External type: reflectapi_demo::tests::serde::TestUpdateOrElse" - ], + response_model=reflectapi_demo.tests.serde.TestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ) @@ -180,6 +183,7 @@ for _model in [ ReflectapiDemoTestsSerdeTestFlattenIdentData, ReflectapiDemoTestsSerdeTestFlattenIfElse, ReflectapiDemoTestsSerdeTestIdentityDataReflectapiDemoTestsSerdeTestFlatF60133e4, + ReflectapiDemoTestsSerdeTestUpdateOrElseReflectapiDemoTestsSerdeTestIden9793afe1, ]: try: _model.model_rebuild() diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-2.snap new file mode 100644 index 00000000..24cfe75b --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-2.snap @@ -0,0 +1,81 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestWrapperWithNestedTypevarArg > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export interface TestFlattenInner { + inner_a: number /* u32 */; + inner_b: string; + } + + export type TestUpdateOrElse = { + if_else: C | null; + } & NullToEmptyObject; + + export interface TestWrapperWithNestedTypevarArg { + body: reflectapi_demo.tests.serde.TestUpdateOrElse< + I | null, + reflectapi_demo.tests.serde.TestFlattenIfElse + >; + extra: number /* u32 */; + } + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg, + {}, + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-3.snap new file mode 100644 index 00000000..31fdf3ea --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-3.snap @@ -0,0 +1,94 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: < TestWrapperWithNestedTypevarArg\n> ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg< + super::types::reflectapi_demo::tests::serde::TestFlattenInner, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInner { + pub inner_a: u32, + pub inner_b: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestUpdateOrElse { + #[serde(flatten)] + pub inner: T, + pub if_else: std::option::Option, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestWrapperWithNestedTypevarArg { + pub body: super::super::super::reflectapi_demo::tests::serde::TestUpdateOrElse< + std::option::Option, + super::super::super::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + pub extra: u32, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-4.snap new file mode 100644 index 00000000..ab92ea21 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-4.snap @@ -0,0 +1,176 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg", + "required": [ + "body", + "extra" + ], + "properties": { + "body": { + "allOf": [ + { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse, reflectapi_demo.tests.serde.TestFlattenIfElse>", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + }, + "extra": { + "$ref": "#/components/schemas/u32" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArg", + "required": [ + "body", + "extra" + ], + "properties": { + "body": { + "allOf": [ + { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + } + ] + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestUpdateOrElse, reflectapi_demo.tests.serde.TestFlattenIfElse>", + "required": [ + "if_else" + ], + "properties": { + "if_else": { + "oneOf": [ + { + "description": "Null", + "type": "null" + }, + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + ] + } + } + } + ] + }, + "extra": { + "$ref": "#/components/schemas/u32" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenInner": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInner", + "required": [ + "inner_a", + "inner_b" + ], + "properties": { + "inner_a": { + "$ref": "#/components/schemas/u32" + }, + "inner_b": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-5.snap new file mode 100644 index 00000000..a66eb782 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg-5.snap @@ -0,0 +1,184 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestWrapperWithNestedTypevarArg > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeTestUpdateOrElseStdOptionOptionReflectapiDemoTesB9dceb2e( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + if_else: reflectapi_demo.tests.serde.TestFlattenIfElse | None + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeTestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b( + BaseModel +): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + body: reflectapi_demo.tests.serde.TestUpdateOrElseStdOptionOptionReflectapiDemoTesB9dceb2e + extra: int + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner + TestUpdateOrElseStdOptionOptionReflectapiDemoTesB9dceb2e = ReflectapiDemoTestsSerdeTestUpdateOrElseStdOptionOptionReflectapiDemoTesB9dceb2e + TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b = ReflectapiDemoTestsSerdeTestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b]: Response containing reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b, + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b]: Response containing reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b, + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestFlattenInner, + ReflectapiDemoTestsSerdeTestUpdateOrElseStdOptionOptionReflectapiDemoTesB9dceb2e, + ReflectapiDemoTestsSerdeTestWrapperWithNestedTypevarArgReflectapiDemoTes59f9972b, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg.snap new file mode 100644 index 00000000..3b1c51d8 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_flatten_typevar_nested_in_generic_arg.snap @@ -0,0 +1,374 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg", + "parameters": [ + { + "name": "I" + } + ], + "fields": { + "named": [ + { + "name": "body", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "std::option::Option", + "arguments": [ + { + "name": "I" + } + ] + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "extra", + "type": { + "name": "u32" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "parameters": [ + { + "name": "T" + }, + { + "name": "C" + } + ], + "fields": { + "named": [ + { + "name": "inner", + "type": { + "name": "T" + }, + "required": true, + "flattened": true + }, + { + "name": "if_else", + "type": { + "name": "std::option::Option", + "arguments": [ + { + "name": "C" + } + ] + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestWrapperWithNestedTypevarArg", + "parameters": [ + { + "name": "I" + } + ], + "fields": { + "named": [ + { + "name": "body", + "type": { + "name": "reflectapi_demo::tests::serde::TestUpdateOrElse", + "arguments": [ + { + "name": "std::option::Option", + "arguments": [ + { + "name": "I" + } + ] + }, + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "required": true + }, + { + "name": "extra", + "type": { + "name": "u32" + }, + "required": true + } + ] + } + }, + { + "kind": "enum", + "name": "std::option::Option", + "description": "Optional nullable type", + "parameters": [ + { + "name": "T" + } + ], + "representation": "none", + "variants": [ + { + "name": "None", + "description": "The value is not provided, i.e. null", + "fields": "none" + }, + { + "name": "Some", + "description": "The value is provided and set to some value", + "fields": { + "unnamed": [ + { + "name": "0", + "type": { + "name": "T" + } + } + ] + } + } + ] + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi-schema/src/lib.rs b/reflectapi-schema/src/lib.rs index 08edb560..9961da71 100644 --- a/reflectapi-schema/src/lib.rs +++ b/reflectapi-schema/src/lib.rs @@ -359,8 +359,14 @@ impl Typespace { return None; } - self.types_map.borrow_mut().remove(ty); - Some(self.types.remove(index)) + let removed = self.types.remove(index); + // `Vec::remove` shifts every later element down by one, so all + // map entries that pointed past `index` are now stale. Drop + // the whole map; the next `ensure_types_map` rebuilds it. + // (Removing the single key for `ty` and leaving the rest + // alone — the previous behaviour — was the bug.) + self.invalidate_types_map(); + Some(removed) } pub fn sort_types(&mut self) { @@ -1395,3 +1401,62 @@ impl Representation { matches!(self, Representation::None) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn primitive(name: &str) -> Type { + Type::Primitive(Primitive { + name: name.to_string(), + description: String::new(), + parameters: vec![], + fallback: None, + codegen_config: LanguageSpecificTypeCodegenConfig::default(), + }) + } + + /// Regression: `remove_type` used to drop only the removed key + /// from `types_map` while `Vec::remove` shifted every later + /// element's index down by one. The next call would either + /// panic on an out-of-bounds slot or silently return the wrong + /// type. The fix is to invalidate the whole map after removal + /// so the next access rebuilds it. + #[test] + fn remove_type_keeps_map_consistent_across_multiple_removals() { + let mut ts = Typespace::default(); + ts.insert_type(primitive("a")); + ts.insert_type(primitive("b")); + ts.insert_type(primitive("c")); + ts.insert_type(primitive("d")); + + // Remove two — the second one used to land on a stale index. + let _ = ts.remove_type("b"); + let _ = ts.remove_type("c"); + + assert!(ts.has_type("a"), "untouched type 'a' should still resolve"); + assert!(ts.has_type("d"), "untouched type 'd' should still resolve"); + assert!(!ts.has_type("b")); + assert!(!ts.has_type("c")); + + // Each surviving lookup should return the right Type, not + // some other slot that the stale index pointed at. + assert_eq!(ts.get_type("a").map(|t| t.name()), Some("a")); + assert_eq!(ts.get_type("d").map(|t| t.name()), Some("d")); + } + + #[test] + fn remove_type_returns_the_value_we_asked_for() { + let mut ts = Typespace::default(); + ts.insert_type(primitive("first")); + ts.insert_type(primitive("second")); + ts.insert_type(primitive("third")); + + let removed = ts.remove_type("second").expect("should find 'second'"); + assert_eq!(removed.name(), "second"); + + // The other two must still be there at the right names. + assert_eq!(ts.get_type("first").map(|t| t.name()), Some("first")); + assert_eq!(ts.get_type("third").map(|t| t.name()), Some("third")); + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 8e3b4634..1b44bf4b 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5510,16 +5510,33 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // // Iterate to fixed point so chains like A → B → MarkedC // all get marked. + /// True if `tr`'s tree mentions any of the enclosing TypeVars at + /// any depth. A TypeVar leaf is a zero-arg ref whose name is one + /// of the enclosing parameters. + fn ref_tree_contains_typevar(tr: &TypeReference, typevars: &BTreeSet<&str>) -> bool { + if tr.arguments.is_empty() && typevars.contains(tr.name.as_str()) { + return true; + } + tr.arguments + .iter() + .any(|a| ref_tree_contains_typevar(a, typevars)) + } fn ref_uses_marked_with_typevar( type_ref: &TypeReference, marked: &BTreeSet, typevars: &BTreeSet<&str>, ) -> bool { + // Marked struct used with at least one arg that mentions an + // enclosing TypeVar somewhere in its tree (not just the + // immediate arg). `Marked>`, `Marked>>`, + // and `Marked` all qualify — without this, the wrapper + // doesn't get transitively marked, the inner marked struct + // gets removed in step 6, and the wrapper's ref dangles. if marked.contains(&type_ref.name) && type_ref .arguments .iter() - .any(|a| typevars.contains(a.name.as_str())) + .any(|a| ref_tree_contains_typevar(a, typevars)) { return true; } @@ -5580,29 +5597,43 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // `(OuterMarked, [InnerMarked_I_D, C])` — concrete on both // sides. let mut concrete: BTreeMap<(String, Vec), String> = BTreeMap::new(); - - /// A type-ref is "concrete" if it resolves to a real schema type or - /// is built up from concrete refs. A bare ref like `T` with no - /// arguments and no schema entry is a TypeVar from some enclosing - /// generic context and isn't safe to monomorphize against — when - /// that context resolves, the substitution will replace it. - fn is_concrete(type_ref: &TypeReference, schema: &Schema) -> bool { - if type_ref.arguments.is_empty() && schema.get_type(&type_ref.name).is_none() { + let mut registered: BTreeSet = BTreeSet::new(); + + /// A type-ref is "concrete" if it resolves to a real schema type, + /// to one of our own monomorphized names (which will be inserted + /// in step 4), or is built up from concrete refs. A bare ref like + /// `T` with no arguments, no schema entry, and not yet registered + /// as a mono'd name is a TypeVar from some enclosing generic + /// context — substituting it now would produce a struct body + /// that still references a TypeVar. + fn is_concrete( + type_ref: &TypeReference, + schema: &Schema, + registered: &BTreeSet, + ) -> bool { + if type_ref.arguments.is_empty() + && schema.get_type(&type_ref.name).is_none() + && !registered.contains(&type_ref.name) + { return false; } - type_ref.arguments.iter().all(|a| is_concrete(a, schema)) + type_ref + .arguments + .iter() + .all(|a| is_concrete(a, schema, registered)) } fn normalize_marked_refs( type_ref: &mut TypeReference, marked: &BTreeSet, concrete: &mut BTreeMap<(String, Vec), String>, + registered: &mut BTreeSet, schema: &Schema, ) -> anyhow::Result<()> { // Bottom-up: arguments first, so this ref's args are already // resolved by the time we form the lookup key. for a in type_ref.arguments.iter_mut() { - normalize_marked_refs(a, marked, concrete, schema)?; + normalize_marked_refs(a, marked, concrete, registered, schema)?; } if !marked.contains(&type_ref.name) || type_ref.arguments.is_empty() { return Ok(()); @@ -5613,7 +5644,11 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // use of a marked struct. Don't register: when the enclosing // generic resolves to a concrete instantiation, the // substituted args will be concrete and we'll register then. - if !type_ref.arguments.iter().all(|a| is_concrete(a, schema)) { + if !type_ref + .arguments + .iter() + .all(|a| is_concrete(a, schema, registered)) + { return Ok(()); } let key = (type_ref.name.clone(), type_ref.arguments.clone()); @@ -5635,6 +5670,7 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { // with the same args would otherwise spin). let mangled = mangle_monomorphized_name(&type_ref.name, &type_ref.arguments); concrete.insert(key, mangled.clone()); + registered.insert(mangled.clone()); // Substitute with the (already-normalized) args and recurse // into the resulting variants/fields so any further marked @@ -5654,7 +5690,7 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { let mono = s.instantiate(&type_ref.arguments); for f in mono.fields.iter() { let mut tr = f.type_ref.clone(); - normalize_marked_refs(&mut tr, marked, concrete, schema)?; + normalize_marked_refs(&mut tr, marked, concrete, registered, schema)?; } } Type::Enum(e) => { @@ -5671,7 +5707,7 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { for v in mono.variants.iter() { for f in v.fields.iter() { let mut tr = f.type_ref.clone(); - normalize_marked_refs(&mut tr, marked, concrete, schema)?; + normalize_marked_refs(&mut tr, marked, concrete, registered, schema)?; } } } @@ -5734,7 +5770,27 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { } } for mut seed in seeds { - normalize_marked_refs(&mut seed, &marked, &mut concrete, schema)?; + normalize_marked_refs(&mut seed, &marked, &mut concrete, &mut registered, schema)?; + } + + // 4a. Sanity-check the mangling table for collisions before + // inserting. If two distinct (struct, args) keys resolved to + // the same mangled name, `insert_type` would silently drop + // the second insert and one schema's body would survive + // under both keys' lookups — exactly the silent-data-loss + // class of bug we're trying to prevent. + { + let mut seen: BTreeMap<&String, &(String, Vec)> = BTreeMap::new(); + for (key, mangled) in concrete.iter() { + if let Some(prev) = seen.insert(mangled, key) { + anyhow::bail!( + "monomorphization: mangled-name collision: \ + '{mangled}' generated by both {key:?} and {prev:?}. \ + This is a codegen bug — please file an issue with \ + the offending schema.", + ); + } + } } // 4. Insert monomorphized types into both typespaces (mirroring the @@ -5805,27 +5861,153 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { let _ = r.visit_schema_inputs(schema); let _ = r.visit_schema_outputs(schema); - // 6. Drop the original generic marked structs — they have no live + // 6. Drop the original generic marked types — they have no live // references after rewriting, and emitting them would produce a // bare `Generic[T]` class with the flatten field missing (the - // bug we're fixing). - // - // Note: Typespace::remove_type leaves stale indices in its - // internal types_map (a separate-codepath bug — not safe across - // multiple removals). sort_types() rebuilds the map. We sort after - // each individual removal so the next lookup is fresh. + // bug we're fixing). `remove_type` invalidates the typespace's + // internal index map, so subsequent lookups rebuild it. for name in &marked { - if schema.input_types.remove_type(name).is_some() { - schema.input_types.sort_types(); - } - if schema.output_types.remove_type(name).is_some() { - schema.output_types.sort_types(); + let _ = schema.input_types.remove_type(name); + let _ = schema.output_types.remove_type(name); + } + + // 7. Post-pass invariant check (debug builds). Catches three + // classes of failure that previous bug reports walked us + // through one at a time: + // - dangling refs to a removed marked type + // - TypeVar leaks: a parameters-empty struct/enum body + // whose field types reference a name that isn't in the + // schema (a generic param that didn't get substituted) + // - mismatched typespace state between input_types and + // output_types after monomorphization + // + // Run this in debug builds only — the cost is a full + // schema walk per codegen invocation and the user-facing + // failure modes (validate_type_references, render-time bail) + // will still trip in release. Errors here mean the pass has + // a logic bug; that's a developer-visible problem. + debug_assert_monomorphization_invariants(schema, &marked); + + Ok(()) +} + +#[cfg(debug_assertions)] +fn debug_assert_monomorphization_invariants(schema: &Schema, marked: &BTreeSet) { + // Walk every type ref reachable from functions and types. + fn walk_ref(tr: &TypeReference, f: &mut F) { + f(tr); + for a in &tr.arguments { + walk_ref(a, f); } } - Ok(()) + let mut violations: Vec = Vec::new(); + for fn_def in &schema.functions { + for site in [ + fn_def.input_type.as_ref(), + fn_def.input_headers.as_ref(), + match &fn_def.output_type { + OutputType::Complete { output_type } => output_type.as_ref(), + OutputType::Stream { item_type } => Some(item_type), + }, + fn_def.error_type.as_ref(), + ] + .into_iter() + .flatten() + { + walk_ref(site, &mut |tr| { + if marked.contains(&tr.name) { + violations.push(format!( + "function {:?} still references removed marked type '{}' \ + (post-pass dangling ref)", + fn_def.name, tr.name, + )); + } + }); + } + } + for ts in [&schema.input_types, &schema.output_types] { + for typ in ts.types() { + match typ { + Type::Struct(s) => { + let params: BTreeSet<&str> = + s.parameters.iter().map(|p| p.name.as_str()).collect(); + for field in s.fields.iter() { + walk_ref(&field.type_ref, &mut |tr| { + if marked.contains(&tr.name) { + violations.push(format!( + "struct {:?}, field {:?} references removed marked type '{}'", + s.name, + field.name(), + tr.name, + )); + } + // TypeVar leak: a struct that was monomorphized + // (parameters cleared) shouldn't have any + // bare-name refs that aren't in the schema. + if s.parameters.is_empty() + && tr.arguments.is_empty() + && schema.get_type(&tr.name).is_none() + { + violations.push(format!( + "struct {:?}, field {:?} references unknown type '{}' \ + (likely TypeVar leak in monomorphized body)", + s.name, + field.name(), + tr.name, + )); + } + // Active TypeVar: if a still-generic struct's + // field references a name that's the SAME as + // a TypeVar but the parent is supposed to be + // monomorphized, we've already covered it. + // Generic-context TypeVar refs are legitimate. + let _ = ¶ms; + }); + } + } + Type::Enum(e) => { + for v in &e.variants { + for field in v.fields.iter() { + walk_ref(&field.type_ref, &mut |tr| { + if marked.contains(&tr.name) { + violations.push(format!( + "enum {:?}, variant {:?} references removed marked type '{}'", + e.name, v.name(), tr.name, + )); + } + if e.parameters.is_empty() + && tr.arguments.is_empty() + && schema.get_type(&tr.name).is_none() + { + violations.push(format!( + "enum {:?}, variant {:?} references unknown type '{}' \ + (likely TypeVar leak in monomorphized body)", + e.name, + v.name(), + tr.name, + )); + } + }); + } + } + } + Type::Primitive(_) => {} + } + } + } + if !violations.is_empty() { + panic!( + "monomorphize_flatten_generics: post-pass invariants violated:\n {}", + violations.join("\n "), + ); + } } +#[cfg(not(debug_assertions))] +#[allow(dead_code)] +fn debug_assert_monomorphization_invariants(_schema: &Schema, _marked: &BTreeSet) {} + /// Mangle a `(struct, args)` instantiation into a fresh schema type /// name. The struct's full namespace-qualified name is preserved so /// downstream namespace mangling stays sensible; the suffix encodes From aeb1bf7371d50eddd04a25606cc43e550ca91df8 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 18:18:50 +1200 Subject: [PATCH 15/16] test(codegen/py): close mangler / boundary / recursive gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test additions following the self-review: 1. mangle_monomorphized_name unit tests — direct assertions that the mangler produces distinct outputs for distinct inputs across the patterns we care about: same-leaf-different-namespace, arity, arg order, nested args. Plus a length-budget test that exercises the hash-truncation path against deep nesting. 2. recursive_marked_struct_terminates_and_renders — feeds a hand-built JSON schema into the codegen for a flatten-over- typevar struct that recursively contains itself (`Vec>`). Exercises the register-before-recurse guard in normalize_marked_refs. The reflectapi Rust derive can't construct such a type (overflows in schema-build), but the codegen pipeline itself terminates and renders correctly: FlatTreeItem with the flattened Item fields plus `children: list[FlatTreeItem]` (forward-ref via __future__). Earlier I dismissed this case as untestable — incorrectly. 3. test_generic_with_concrete_flatten_not_marked — boundary case: a generic struct whose flatten target is concrete (not a TypeVar). Confirms the marked-detection predicate doesn't over-trigger on any-flatten-on-a-generic-struct. Snapshot shows the class stays generic (Generic[T]) with the concrete flatten expanded inline; no monomorphization happens. Also updates the dismissive comment in serde.rs to point at the recursive-marked test in python.rs. --- reflectapi-demo/src/tests/serde.rs | 36 ++- ...ic_with_concrete_flatten_not_marked-2.snap | 73 ++++++ ...ic_with_concrete_flatten_not_marked-3.snap | 85 +++++++ ...ic_with_concrete_flatten_not_marked-4.snap | 118 ++++++++++ ...ic_with_concrete_flatten_not_marked-5.snap | 190 ++++++++++++++++ ...eric_with_concrete_flatten_not_marked.snap | 214 ++++++++++++++++++ reflectapi/src/codegen/python.rs | 146 +++++++++++- 7 files changed, 854 insertions(+), 8 deletions(-) create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-2.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-3.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-4.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-5.snap create mode 100644 reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked.snap diff --git a/reflectapi-demo/src/tests/serde.rs b/reflectapi-demo/src/tests/serde.rs index f28d34d4..7e7ba4dc 100644 --- a/reflectapi-demo/src/tests/serde.rs +++ b/reflectapi-demo/src/tests/serde.rs @@ -1518,13 +1518,35 @@ fn test_generic_flatten_enum_variant_typevar() { assert_snapshot!(TestIngestRelation); } -// (Cycle / self-recursive marked struct test omitted: reflectapi's -// schema builder itself doesn't support recursive type definitions -// — `struct Tree { children: Vec> }` overflows during -// schema construction, before any codegen runs. The -// register-before-recurse guard in `normalize_marked_refs` is still -// the right defence in depth, but the case is unreachable in the -// current schema language.) +// Note: a Rust-derive recursive marked struct (e.g. +// `struct Tree { #[flatten] value: T, children: Vec> }`) +// overflows the reflectapi derive macro during schema construction, +// before any codegen runs — that's a derive-side limitation, not a +// codegen one. The codegen pipeline itself handles a recursive +// marked struct fed in via JSON; that case is exercised by +// `recursive_marked_struct_terminates_and_renders` in +// reflectapi/src/codegen/python.rs. + +// Boundary case: a generic struct whose flatten target is a +// *concrete* type, not a TypeVar. Should NOT be marked, NOT +// monomorphized — the existing flatten-of-concrete rendering path +// handles it. Confirms the marked-detection predicate doesn't +// over-trigger on any-flatten-on-a-generic-struct. +#[derive(serde::Serialize, serde::Deserialize, Debug, reflectapi::Input, reflectapi::Output)] +#[serde(bound( + serialize = "T: serde::Serialize", + deserialize = "T: serde::de::DeserializeOwned", +))] +struct TestGenericWithConcreteFlatten { + #[serde(flatten)] + extra: TestFlattenInner, + other: T, +} + +#[test] +fn test_generic_with_concrete_flatten_not_marked() { + assert_snapshot!(TestGenericWithConcreteFlatten); +} // Marked struct used with a generic parameter wrapped inside another // generic (`Marked>`). Earlier transitive marking only diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-2.snap new file mode 100644 index 00000000..a5b0f974 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-2.snap @@ -0,0 +1,73 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_typescript_code :: <\nTestGenericWithConcreteFlatten > ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +export function client(base: string | Client): __definition.Interface { + return __implementation.__client(base); +} + +export namespace __definition { + export interface Interface { + inout_test: ( + input: reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten, + headers: {}, + options?: RequestOptions, + ) => AsyncResult< + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten, + {} + >; + } +} +export namespace reflectapi { + /** + * Struct object with no fields + */ + export interface Empty {} + + /** + * Error object which is expected to be never returned + */ + export interface Infallible {} +} + +export namespace reflectapi_demo { + export namespace tests { + export namespace serde { + export interface TestFlattenIfElse { + code: number /* u16 */; + } + + export interface TestFlattenInner { + inner_a: number /* u32 */; + inner_b: string; + } + + export type TestGenericWithConcreteFlatten = { + other: T; + } & NullToEmptyObject; + } + } +} + +namespace __implementation { + + function inout_test(client: Client) { + return ( + input: reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten, + headers: {}, + options?: RequestOptions, + ) => + __request< + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten, + {}, + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten, + {} + >(client, "/inout_test", input, headers, options); + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-3.snap new file mode 100644 index 00000000..adea7e45 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-3.snap @@ -0,0 +1,85 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_rust_code :: < TestGenericWithConcreteFlatten\n> ()" +--- +// DO NOT MODIFY THIS FILE MANUALLY +// This file was generated by reflectapi-cli +// +// Schema name: +// + +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +pub use interface::Interface; +pub use reflectapi::rt::*; + +pub mod interface { + + #[derive(Debug)] + pub struct Interface { + client: C, + } + + impl Interface { + pub fn new(client: C) -> Self { + Self { client } + } + pub async fn inout_test( + &self, + input: super::types::reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten< + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + headers: reflectapi::Empty, + ) -> Result< + super::types::reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten< + super::types::reflectapi_demo::tests::serde::TestFlattenIfElse, + >, + reflectapi::rt::Error, + > { + reflectapi::rt::__request_impl(&self.client, "/inout_test", input, headers).await + } + } + + #[cfg(feature = "reqwest")] + impl Interface> { + /// Convenience: build the client backed by a bare `reqwest::Client` + /// and the given base URL. Hides the + /// [`reflectapi::rt::ReqwestClient`] adapter so callers don't need + /// to name it. + pub fn try_new( + client: reqwest::Client, + base_url: reflectapi::rt::Url, + ) -> std::result::Result { + Ok(Self::new(reflectapi::rt::ReqwestClient::try_new( + client, base_url, + )?)) + } + } +} +pub mod types { + pub mod reflectapi_demo { + pub mod tests { + pub mod serde { + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenIfElse { + pub code: u16, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestFlattenInner { + pub inner_a: u32, + pub inner_b: std::string::String, + } + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct TestGenericWithConcreteFlatten { + #[serde(flatten)] + pub extra: super::super::super::reflectapi_demo::tests::serde::TestFlattenInner, + pub other: T, + } + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-4.snap new file mode 100644 index 00000000..d1e15fbd --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-4.snap @@ -0,0 +1,118 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "", + "description": "", + "version": "1.0.0" + }, + "paths": { + "/inout_test": { + "description": "", + "post": { + "operationId": "inout_test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten", + "required": [ + "other" + ], + "properties": { + "other": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 OK", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenInner" + }, + { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten", + "required": [ + "other" + ], + "properties": { + "other": { + "$ref": "#/components/schemas/reflectapi_demo.tests.serde.TestFlattenIfElse" + } + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "reflectapi_demo.tests.serde.TestFlattenIfElse": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenIfElse", + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/u16" + } + } + }, + "reflectapi_demo.tests.serde.TestFlattenInner": { + "type": "object", + "title": "reflectapi_demo.tests.serde.TestFlattenInner", + "required": [ + "inner_a", + "inner_b" + ], + "properties": { + "inner_a": { + "$ref": "#/components/schemas/u32" + }, + "inner_b": { + "$ref": "#/components/schemas/std.string.String" + } + } + }, + "std.string.String": { + "description": "UTF-8 encoded string", + "type": "string" + }, + "u16": { + "description": "16-bit unsigned integer", + "type": "integer" + }, + "u32": { + "description": "32-bit unsigned integer", + "type": "integer" + } + } + } +} diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-5.snap new file mode 100644 index 00000000..9dbd8cc8 --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked-5.snap @@ -0,0 +1,190 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: "super :: into_python_code :: <\nTestGenericWithConcreteFlatten > ()" +--- +""" +Generated Python client for api_client. + +DO NOT MODIFY THIS FILE MANUALLY. +This file is automatically generated by ReflectAPI. +""" + +from __future__ import annotations + + +# Standard library imports +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +# Third-party imports +from pydantic import BaseModel, ConfigDict, Field + +# Runtime imports +from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse +from reflectapi_runtime import ReflectapiEmpty +from reflectapi_runtime import ReflectapiInfallible + + +# Type variables for generic types + + +T = TypeVar("T") + + +class ReflectapiDemoTestsSerdeTestFlattenIfElse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + code: int + + +class ReflectapiDemoTestsSerdeTestFlattenInner(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + inner_a: int + inner_b: str + + +class ReflectapiDemoTestsSerdeTestGenericWithConcreteFlatten(BaseModel, Generic[T]): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + other: T + inner_a: int + inner_b: str + + +# Namespace classes for dotted access to types +class reflectapi_demo: + """Namespace for reflectapi_demo types.""" + + class tests: + """Namespace for tests types.""" + + class serde: + """Namespace for serde types.""" + + TestFlattenIfElse = ReflectapiDemoTestsSerdeTestFlattenIfElse + TestFlattenInner = ReflectapiDemoTestsSerdeTestFlattenInner + TestGenericWithConcreteFlatten = ( + ReflectapiDemoTestsSerdeTestGenericWithConcreteFlatten + ) + + +class AsyncInoutClient: + """Async client for inout operations.""" + + def __init__(self, client: AsyncClientBase) -> None: + self._client = client + + async def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ] + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ] + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[reflectapi_demo.tests.serde.TestFlattenIfElse]]: Response containing reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[reflectapi_demo.tests.serde.TestFlattenIfElse] data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return await self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ], + ) + + +class AsyncClient(AsyncClientBase): + """Async client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = AsyncInoutClient(self) + + +class InoutClient: + """Synchronous client for inout operations.""" + + def __init__(self, client: ClientBase) -> None: + self._client = client + + def test( + self, + data: Optional[ + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ] + ] = None, + ) -> ApiResponse[ + reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ] + ]: + """ + + Args: + data: Request data for the test operation. + + Returns: + ApiResponse[reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[reflectapi_demo.tests.serde.TestFlattenIfElse]]: Response containing reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[reflectapi_demo.tests.serde.TestFlattenIfElse] data + """ + path = "/inout_test" + + params: dict[str, Any] = {} + return self._client._make_request( + path, + params=params if params else None, + json_model=data, + response_model=reflectapi_demo.tests.serde.TestGenericWithConcreteFlatten[ + reflectapi_demo.tests.serde.TestFlattenIfElse + ], + ) + + +class Client(ClientBase): + """Synchronous client for the API.""" + + def __init__( + self, + base_url: str, + **kwargs: Any, + ) -> None: + super().__init__(base_url, **kwargs) + + self.inout = InoutClient(self) + + +# External type definitions +StdNumNonZeroU32 = Annotated[int, "Rust NonZero u32 type"] +StdNumNonZeroU64 = Annotated[int, "Rust NonZero u64 type"] +StdNumNonZeroI32 = Annotated[int, "Rust NonZero i32 type"] +StdNumNonZeroI64 = Annotated[int, "Rust NonZero i64 type"] + +# Rebuild models to resolve forward references +for _model in [ + ReflectapiDemoTestsSerdeTestFlattenIfElse, + ReflectapiDemoTestsSerdeTestFlattenInner, + ReflectapiDemoTestsSerdeTestGenericWithConcreteFlatten, +]: + try: + _model.model_rebuild() + except Exception: + pass diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked.snap new file mode 100644 index 00000000..a561d76a --- /dev/null +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__serde__generic_with_concrete_flatten_not_marked.snap @@ -0,0 +1,214 @@ +--- +source: reflectapi-demo/src/tests/serde.rs +expression: schema +--- +{ + "name": "", + "functions": [ + { + "name": "inout_test", + "path": "", + "input_type": { + "name": "reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "output_kind": "complete", + "output_type": { + "name": "reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten", + "arguments": [ + { + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse" + } + ] + }, + "serialization": [ + "json", + "msgpack" + ] + } + ], + "input_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Empty", + "description": "Struct object with no fields", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten", + "parameters": [ + { + "name": "T" + } + ], + "fields": { + "named": [ + { + "name": "extra", + "type": { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + "required": true, + "flattened": true + }, + { + "name": "other", + "type": { + "name": "T" + }, + "required": true + } + ] + } + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + }, + "output_types": { + "types": [ + { + "kind": "struct", + "name": "reflectapi::Infallible", + "description": "Error object which is expected to be never returned", + "fields": "none" + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenIfElse", + "fields": { + "named": [ + { + "name": "code", + "type": { + "name": "u16" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestFlattenInner", + "fields": { + "named": [ + { + "name": "inner_a", + "type": { + "name": "u32" + }, + "required": true + }, + { + "name": "inner_b", + "type": { + "name": "std::string::String" + }, + "required": true + } + ] + } + }, + { + "kind": "struct", + "name": "reflectapi_demo::tests::serde::TestGenericWithConcreteFlatten", + "parameters": [ + { + "name": "T" + } + ], + "fields": { + "named": [ + { + "name": "extra", + "type": { + "name": "reflectapi_demo::tests::serde::TestFlattenInner" + }, + "required": true, + "flattened": true + }, + { + "name": "other", + "type": { + "name": "T" + }, + "required": true + } + ] + } + }, + { + "kind": "primitive", + "name": "std::string::String", + "description": "UTF-8 encoded string" + }, + { + "kind": "primitive", + "name": "u16", + "description": "16-bit unsigned integer" + }, + { + "kind": "primitive", + "name": "u32", + "description": "32-bit unsigned integer" + } + ] + } +} diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 1b44bf4b..f853aa8a 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -6087,7 +6087,8 @@ fn pascal_case_len(s: &str) -> usize { #[cfg(test)] mod tests { - use super::{build_python_class_name_map, generate_init_py, Config}; + use super::{build_python_class_name_map, generate_init_py, mangle_monomorphized_name, Config}; + use crate::TypeReference; #[test] fn python_init_exports_client() { @@ -6120,4 +6121,147 @@ mod tests { "SystemVersionInfo" ); } + + fn tref(name: &str) -> TypeReference { + TypeReference { + name: name.to_string(), + arguments: vec![], + } + } + + fn tref_args(name: &str, args: Vec) -> TypeReference { + TypeReference { + name: name.to_string(), + arguments: args, + } + } + + #[test] + fn mangler_distinguishes_namespaces_with_same_leaf() { + // The pre-fix mangler used only the leaf, so a::Sample and + // b::Sample collided and one struct's fields were silently + // dropped (cf. test_generic_flatten_leaf_collision). + let m1 = mangle_monomorphized_name("Wrap", &[tref("a::Sample")]); + let m2 = mangle_monomorphized_name("Wrap", &[tref("b::Sample")]); + assert_ne!( + m1, m2, + "same-leaf args from distinct namespaces must mangle distinctly" + ); + } + + #[test] + fn mangler_distinguishes_arity_and_order() { + let foo_bar = mangle_monomorphized_name("Wrap", &[tref("Foo"), tref("Bar")]); + let bar_foo = mangle_monomorphized_name("Wrap", &[tref("Bar"), tref("Foo")]); + let foo_only = mangle_monomorphized_name("Wrap", &[tref("Foo")]); + assert_ne!(foo_bar, bar_foo, "arg order must affect mangling"); + assert_ne!(foo_bar, foo_only, "arity must affect mangling"); + } + + #[test] + fn mangler_distinguishes_nested_args() { + // Foo> vs Foo — the latter is a single concrete + // arg, the former wraps. The mangler must not collapse them. + let plain = mangle_monomorphized_name("Wrap", &[tref("Bar")]); + let nested = + mangle_monomorphized_name("Wrap", &[tref_args("std::vec::Vec", vec![tref("Bar")])]); + assert_ne!(plain, nested); + } + + #[test] + fn mangler_long_input_stays_under_class_name_budget() { + // Realistic deep-nesting case. The post-PascalCase length + // must not exceed 80 — otherwise downstream + // `finalize_class_name` would hash-truncate while + // `type_name_to_python_ref` would emit the un-truncated form + // and the two refs would disagree. + let deep = mangle_monomorphized_name( + "very::long::module::path::OuterWrapper", + &[ + tref_args( + "another::very::long::module::path::InnerWrapper", + vec![tref("nested::module::ConcreteTypeName")], + ), + tref("yet::another::module::path::SecondArg"), + ], + ); + let pascal_len = super::pascal_case_len(&deep); + assert!( + pascal_len <= 80, + "post-Pascal mangled name '{deep}' exceeds 80 chars (got {pascal_len})", + ); + } + + /// End-to-end: a marked struct that flattens its generic param + /// AND references itself recursively (`Vec>`). The + /// reflectapi Rust derive macros can't construct such a type + /// (they overflow during schema building), but a hand-built or + /// JSON-loaded schema can — and the codegen must terminate + /// thanks to the register-before-recurse guard in + /// `normalize_marked_refs`. Without that guard, the second + /// recursive lookup would re-register and spin. + #[test] + fn recursive_marked_struct_terminates_and_renders() { + let schema_json = r#"{ + "name": "test", + "functions": [{ + "name": "f", + "path": "", + "input_type": { "name": "FlatTree", "arguments": [{ "name": "Item" }] }, + "output_kind": "complete", + "output_type": { "name": "FlatTree", "arguments": [{ "name": "Item" }] }, + "serialization": ["json"] + }], + "input_types": { "types": [ + { "kind": "primitive", "name": "std::string::String", "description": "UTF-8" }, + { "kind": "primitive", "name": "u32", "description": "u32" }, + { "kind": "primitive", "name": "std::vec::Vec", "description": "Vec", + "parameters": [{ "name": "T" }] }, + { "kind": "struct", "name": "Item", + "fields": { "named": [ + { "name": "id", "type": { "name": "u32" }, "required": true }, + { "name": "label", "type": { "name": "std::string::String" }, "required": true } + ] } }, + { "kind": "struct", "name": "FlatTree", + "parameters": [{ "name": "T" }], + "fields": { "named": [ + { "name": "value", "type": { "name": "T" }, "required": true, "flattened": true }, + { "name": "children", + "type": { "name": "std::vec::Vec", + "arguments": [{ "name": "FlatTree", "arguments": [{ "name": "T" }] }] }, + "required": true } + ] } } + ] }, + "output_types": { "types": [ + { "kind": "primitive", "name": "std::string::String", "description": "UTF-8" }, + { "kind": "primitive", "name": "u32", "description": "u32" }, + { "kind": "primitive", "name": "std::vec::Vec", "description": "Vec", + "parameters": [{ "name": "T" }] }, + { "kind": "struct", "name": "Item", + "fields": { "named": [ + { "name": "id", "type": { "name": "u32" }, "required": true }, + { "name": "label", "type": { "name": "std::string::String" }, "required": true } + ] } }, + { "kind": "struct", "name": "FlatTree", + "parameters": [{ "name": "T" }], + "fields": { "named": [ + { "name": "value", "type": { "name": "T" }, "required": true, "flattened": true }, + { "name": "children", + "type": { "name": "std::vec::Vec", + "arguments": [{ "name": "FlatTree", "arguments": [{ "name": "T" }] }] }, + "required": true } + ] } } + ] } + }"#; + let schema: crate::Schema = serde_json::from_str(schema_json).unwrap(); + let py = super::generate(schema, &Config::default()).expect("codegen must terminate"); + // The monomorphized class is named with both struct path and + // arg leaf (no namespace prefix on either here, both are + // top-level). The flatten pulled Item's id+label up; the + // recursive children list refers back to the same class. + assert!(py.contains("class FlatTreeItem")); + assert!(py.contains("children: list[FlatTreeItem]")); + assert!(py.contains(" id: int")); + assert!(py.contains(" label: str")); + } } From 2549017b1aa89cba5b9f72deeeae954f28180f38 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 May 2026 19:50:52 +1200 Subject: [PATCH 16/16] fix(codegen/py): narrow post-pass invariant check to monomorphized bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier post-pass check flagged any zero-arg ref whose name wasn't in the schema as a "TypeVar leak". That tripped on legitimate schema shapes: - chrono::DateTime — chrono::Utc is a phantom timezone marker that rides along as a type-arg of the implemented-types-mapped chrono::DateTime. It's never looked up as a standalone field type; the codegen translates the whole DateTime to `datetime` directly. Demo schema reproduced this on every DateTime field — ~280 false positives reported by an internal consumer. - Const-generic scalars: `[T; 2]` represents the 2 as a type-ref- shaped node whose name is a numeric literal, not a real type. Narrow the check: it only fires inside bodies of types this pass actually monomorphized (the values of `concrete`). Original schema bodies are not our concern — if they had problems, they had them before. The dangling-marked-ref check stays unchanged (no false positives, narrow definition). Threads `registered: BTreeSet` through to the assertion as the set of mono'd names. Verified by: - Demo schema codegen runs clean (no panics, 753-line output). - New regression test `invariant_check_ignores_phantom_type_args_in_unmodified_bodies` builds a schema with Holder { ts: DateTime } + a marked Wrapper and asserts codegen completes without invariant panic. --- reflectapi/src/codegen/python.rs | 212 +++++++++++++++++++------------ 1 file changed, 133 insertions(+), 79 deletions(-) diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index f853aa8a..bb62f1a4 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5871,28 +5871,32 @@ fn monomorphize_flatten_generics(schema: &mut Schema) -> anyhow::Result<()> { let _ = schema.output_types.remove_type(name); } - // 7. Post-pass invariant check (debug builds). Catches three - // classes of failure that previous bug reports walked us - // through one at a time: - // - dangling refs to a removed marked type - // - TypeVar leaks: a parameters-empty struct/enum body - // whose field types reference a name that isn't in the - // schema (a generic param that didn't get substituted) - // - mismatched typespace state between input_types and - // output_types after monomorphization + // 7. Post-pass invariant check (debug builds). Catches two + // failure classes: + // - dangling refs to a removed marked type (anywhere) + // - TypeVar leak in a body we *just monomorphized* (only + // on types we created — original schema bodies can + // legitimately reference external names like + // chrono::Utc as type-arg phantoms or const-generic + // scalars that aren't standalone schema entries) // - // Run this in debug builds only — the cost is a full - // schema walk per codegen invocation and the user-facing - // failure modes (validate_type_references, render-time bail) - // will still trip in release. Errors here mean the pass has + // Run in debug builds only. Errors here mean the pass has // a logic bug; that's a developer-visible problem. - debug_assert_monomorphization_invariants(schema, &marked); + debug_assert_monomorphization_invariants(schema, &marked, ®istered); Ok(()) } #[cfg(debug_assertions)] -fn debug_assert_monomorphization_invariants(schema: &Schema, marked: &BTreeSet) { +// `monomorphized` holds the names of types we just created (the +// values of the `concrete` table). Only those bodies need the +// TypeVar-leak check — everything else was in the schema before +// this pass and isn't our concern here. +fn debug_assert_monomorphization_invariants( + schema: &Schema, + marked: &BTreeSet, + monomorphized: &BTreeSet, +) { // Walk every type ref reachable from functions and types. fn walk_ref(tr: &TypeReference, f: &mut F) { f(tr); @@ -5902,6 +5906,9 @@ fn debug_assert_monomorphization_invariants(schema: &Schema, marked: &BTreeSet = Vec::new(); + + // Check 1: no dangling refs to removed marked types, anywhere + // they could appear. Function-level refs first. for fn_def in &schema.functions { for site in [ fn_def.input_type.as_ref(), @@ -5926,73 +5933,48 @@ fn debug_assert_monomorphization_invariants(schema: &Schema, marked: &BTreeSet { - let params: BTreeSet<&str> = - s.parameters.iter().map(|p| p.name.as_str()).collect(); - for field in s.fields.iter() { - walk_ref(&field.type_ref, &mut |tr| { - if marked.contains(&tr.name) { - violations.push(format!( - "struct {:?}, field {:?} references removed marked type '{}'", - s.name, - field.name(), - tr.name, - )); - } - // TypeVar leak: a struct that was monomorphized - // (parameters cleared) shouldn't have any - // bare-name refs that aren't in the schema. - if s.parameters.is_empty() - && tr.arguments.is_empty() - && schema.get_type(&tr.name).is_none() - { - violations.push(format!( - "struct {:?}, field {:?} references unknown type '{}' \ - (likely TypeVar leak in monomorphized body)", - s.name, - field.name(), - tr.name, - )); - } - // Active TypeVar: if a still-generic struct's - // field references a name that's the SAME as - // a TypeVar but the parent is supposed to be - // monomorphized, we've already covered it. - // Generic-context TypeVar refs are legitimate. - let _ = ¶ms; - }); + let (type_name, fields_iter): (&str, Box>) = + match typ { + Type::Struct(s) => ( + s.name.as_str(), + Box::new(s.fields.iter().map(|f| (f.name(), &f.type_ref))), + ), + Type::Enum(e) => ( + e.name.as_str(), + Box::new( + e.variants + .iter() + .flat_map(|v| v.fields.iter().map(|f| (f.name(), &f.type_ref))), + ), + ), + Type::Primitive(_) => continue, + }; + let we_monomorphized_this = monomorphized.contains(type_name); + for (field_name, type_ref) in fields_iter { + walk_ref(type_ref, &mut |tr| { + if marked.contains(&tr.name) { + violations.push(format!( + "{type_name:?}, field {field_name:?} references removed marked type '{}'", + tr.name, + )); } - } - Type::Enum(e) => { - for v in &e.variants { - for field in v.fields.iter() { - walk_ref(&field.type_ref, &mut |tr| { - if marked.contains(&tr.name) { - violations.push(format!( - "enum {:?}, variant {:?} references removed marked type '{}'", - e.name, v.name(), tr.name, - )); - } - if e.parameters.is_empty() - && tr.arguments.is_empty() - && schema.get_type(&tr.name).is_none() - { - violations.push(format!( - "enum {:?}, variant {:?} references unknown type '{}' \ - (likely TypeVar leak in monomorphized body)", - e.name, - v.name(), - tr.name, - )); - } - }); - } + if we_monomorphized_this + && tr.arguments.is_empty() + && schema.get_type(&tr.name).is_none() + { + violations.push(format!( + "{type_name:?}, field {field_name:?} references unknown type '{}' \ + (TypeVar leak in monomorphized body)", + tr.name, + )); } - } - Type::Primitive(_) => {} + }); } } } @@ -6006,7 +5988,12 @@ fn debug_assert_monomorphization_invariants(schema: &Schema, marked: &BTreeSet) {} +fn debug_assert_monomorphization_invariants( + _schema: &Schema, + _marked: &BTreeSet, + _monomorphized: &BTreeSet, +) { +} /// Mangle a `(struct, args)` instantiation into a fresh schema type /// name. The struct's full namespace-qualified name is preserved so @@ -6192,6 +6179,73 @@ mod tests { ); } + /// Regression: the post-pass invariant check used to flag + /// chrono::Utc as a "TypeVar leak" because it appears as an arg + /// of chrono::DateTime but isn't a standalone schema type. + /// Const-generic scalars (`[T; 2]`'s 2) had the same issue. + /// Both are external/structural names that legitimately don't + /// have schema entries — the check should only fire on bodies + /// of types we actually monomorphized. + #[test] + fn invariant_check_ignores_phantom_type_args_in_unmodified_bodies() { + // Marked struct + a struct holding a chrono::DateTime + // field. After monomorphization, the marked struct goes + // away and the holder remains, with its DateTime field + // intact. `chrono::Utc` is registered nowhere — the + // earlier validator would panic; the narrowed one must not. + let schema_json = r#"{ + "name": "test", + "functions": [{ + "name": "f", "path": "", + "input_type": { "name": "Holder" }, + "output_kind": "complete", + "output_type": { "name": "Wrapper", "arguments": [{ "name": "Holder" }] }, + "serialization": ["json"] + }], + "input_types": { "types": [ + { "kind": "primitive", "name": "chrono::DateTime", "description": "ts", + "parameters": [{ "name": "Tz" }], + "fallback": { "name": "std::string::String" } }, + { "kind": "primitive", "name": "std::string::String", "description": "s" }, + { "kind": "struct", "name": "Holder", + "fields": { "named": [ + { "name": "ts", + "type": { "name": "chrono::DateTime", + "arguments": [{ "name": "chrono::Utc" }] }, + "required": true } + ] } }, + { "kind": "struct", "name": "Wrapper", + "parameters": [{ "name": "T" }], + "fields": { "named": [ + { "name": "inner", "type": { "name": "T" }, "required": true, "flattened": true } + ] } } + ] }, + "output_types": { "types": [ + { "kind": "primitive", "name": "chrono::DateTime", "description": "ts", + "parameters": [{ "name": "Tz" }], + "fallback": { "name": "std::string::String" } }, + { "kind": "primitive", "name": "std::string::String", "description": "s" }, + { "kind": "struct", "name": "Holder", + "fields": { "named": [ + { "name": "ts", + "type": { "name": "chrono::DateTime", + "arguments": [{ "name": "chrono::Utc" }] }, + "required": true } + ] } }, + { "kind": "struct", "name": "Wrapper", + "parameters": [{ "name": "T" }], + "fields": { "named": [ + { "name": "inner", "type": { "name": "T" }, "required": true, "flattened": true } + ] } } + ] } + }"#; + let schema: crate::Schema = serde_json::from_str(schema_json).unwrap(); + // No panic: the validator must not flag chrono::Utc. + let py = super::generate(schema, &Config::default()).expect("codegen must run cleanly"); + // Sanity: the chrono mapping made it to the output. + assert!(py.contains("datetime") || py.contains("Holder")); + } + /// End-to-end: a marked struct that flattens its generic param /// AND references itself recursively (`Vec>`). The /// reflectapi Rust derive macros can't construct such a type