Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Changelog

## 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<Output = Result<Response<Self::Error>, Self::Error>>;
}

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. 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
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.

**`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<reqwest_middleware::ClientWithMiddleware>`.

**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)?
);
```

`ReqwestClient::try_new` returns `Result<Self, reflectapi::rt::UrlParseError>` (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)

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:

```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.

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<String>;

// after
pub fn generate(schema: Schema, config: &Config) -> Result<BTreeMap<String, String>>;
```

The map is keyed by filename (`"generated.ts"`, `"generated.transport.ts"`). The CLI handles `--output <file>.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

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<I, D>`, `UpdateOrElse<T, C>`, `InsertManyOrElse<T, C>`) 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.
10 changes: 6 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions docs/src/clients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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 \
Expand Down Expand Up @@ -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` |

Expand All @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions reflectapi-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -23,11 +23,14 @@ 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"] }
clap_derive = "4.5.3"

anyhow = "1.0.81"
serde_json = "1.0.114"

[dev-dependencies]
tempfile = "3"
116 changes: 76 additions & 40 deletions reflectapi-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ enum DocSubcommand {
},
}

#[derive(ValueEnum, Clone, PartialEq)]
#[derive(ValueEnum, Clone, Debug, PartialEq)]
enum Language {
Typescript,
Rust,
Expand Down Expand Up @@ -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<String, String> = 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,
Expand Down Expand Up @@ -208,46 +203,87 @@ 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(());
}

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
// 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).
//
// 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_file {
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 parent = parent_or_dot(&output_path);
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_in_path {
output_path.clone()
} else {
parent.join(filename)
};
write_file(&dest, content)?;
}
}
Ok(())
}
}
}

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:?}"))?;
file.write_all(content.as_bytes())
.context(format!("Failed to write to file: {path:?}"))?;
Ok(())
}
Loading
Loading