chore: Split Rust codegen into ploidy-codegen-rust.

This commit is contained in:
Lina Butler
2026-01-13 00:11:45 -08:00
parent 4b4e868902
commit a3e4fbbf6d
28 changed files with 185 additions and 121 deletions
Generated
+21 -3
View File
@@ -626,10 +626,31 @@ dependencies = [
"clap",
"miette",
"mimalloc",
"ploidy-codegen-rust",
"ploidy-core",
"semver",
]
[[package]]
name = "ploidy-codegen-rust"
version = "0.4.0"
dependencies = [
"cargo_toml",
"heck",
"itertools",
"miette",
"ploidy-core",
"prettyplease",
"proc-macro2",
"quote",
"serde",
"syn",
"textwrap",
"thiserror",
"toml",
"unicode-ident",
]
[[package]]
name = "ploidy-core"
version = "0.4.0"
@@ -637,7 +658,6 @@ dependencies = [
"atomic_refcell",
"by_address",
"cargo_toml",
"heck",
"indexmap",
"indoc",
"itertools",
@@ -655,11 +675,9 @@ dependencies = [
"serde_path_to_error",
"serde_yaml",
"syn",
"textwrap",
"thiserror",
"toml",
"unicase",
"unicode-ident",
"winnow",
]
+2
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"ploidy",
"ploidy-codegen-rust",
"ploidy-core",
"ploidy-pointer",
"ploidy-pointer-derive",
@@ -16,6 +17,7 @@ repository = "https://github.com/linabutler/ploidy"
keywords = ["codegen", "openapi", "swagger"]
[workspace.dependencies]
ploidy-codegen-rust = { path = "ploidy-codegen-rust", version = "0.4.0" }
ploidy-core = { path = "ploidy-core", version = "0.4.0" }
ploidy-pointer = { path = "ploidy-pointer", version = "0.4.0" }
ploidy-pointer-derive = { path = "ploidy-pointer-derive", version = "0.4.0" }
+60 -29
View File
@@ -63,7 +63,7 @@ This produces a ready-to-use crate that includes:
* You'd like to use a custom template for the generated code, or a different HTTP client; or to generate synchronous code. For these cases, consider a template-based generator like **openapi-generator**.
* You need to target a language other than Rust. **openapi-generator** supports many more languages; as does [**swagger-codegen**](https://github.com/swagger-api/swagger-codegen), if you don't need OpenAPI 3.1+ support.
* Your spec uses OpenAPI (Swagger) 2.0. Ploidy only supports OpenAPI 3.0+, but **openapi-generator** and **swagger-codegen** support older versions.
* You need to generate server stubs. Ploidy only generates clients, but **openapi-generator** can produce stubs for different Rust web frameworks. Alternatively, you can define your models and endpoints in Rust, and use [Dropshot](https://github.com/oxidecomputer/dropshot) to generate a Ploidy-compatible OpenAPI spec from those definitions.
* You need to generate server stubs. Ploidy only generates clients, but **openapi-generator** can produce stubs for different Rust web frameworks. Alternatively, you can define your models and endpoints in Rust, and use [Dropshot](https://github.com/oxidecomputer/dropshot) to generate a Ploidy- or Progenitor-compatible OpenAPI spec from those definitions.
* You'd like a more mature, established tool.
Here are some of the things that make Ploidy different.
@@ -98,18 +98,35 @@ Generated code looks like it was written by an experienced Rust developer:
* **Boxing** for recursive types.
* **A RESTful client with async endpoints**, using [Reqwest](https://docs.rs/reqwest) with the [Tokio](https://tokio.rs) runtime.
For example:
For example, given this schema:
```yaml
Customer:
type: object
required: [id, email]
properties:
id:
type: string
email:
type: string
name:
type: string
```
Ploidy generates:
```rust
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Customer {
pub id: String,
pub email: String,
#[serde(skip_serializing_if = "Absent::is_absent")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "AbsentOr::is_absent")]
pub name: AbsentOr<String>,
}
```
The optional `name` field uses [`AbsentOr<T>`](https://docs.rs/ploidy-util/latest/ploidy_util/absent/enum.AbsentOr.html), a three-valued type that matches how OpenAPI represents optional fields: either "present with a value", "present and explicitly set to `null`", or "absent from the payload".
## Under the Hood
Ploidy takes a somewhat different approach to code generation. If you're curious about how it works, this section is for you!
@@ -136,32 +153,33 @@ For example, given a schema like:
```yaml
Comment:
type: object
properties:
text:
type: string
required: true
parent:
schema:
$ref: "#/components/schemas/Comment"
children:
type: array
items:
$ref: "#/components/schemas/Comment"
type: object
required: [text]
properties:
text:
type: string
parent:
$ref: "#/components/schemas/Comment"
children:
type: array
items:
$ref: "#/components/schemas/Comment"
```
Ploidy generates:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Comment {
pub text: String,
pub parent: Option<Box<Comment>>,
pub children: Vec<Comment>,
#[serde(default, skip_serializing_if = "AbsentOr::is_absent")]
pub parent: AbsentOr<Box<Comment>>,
#[serde(default, skip_serializing_if = "AbsentOr::is_absent")]
pub children: AbsentOr<Vec<Comment>>,
}
```
(Since `Vec<T>` is already indirect, only the `parent` field needs boxing).
Since `Vec<T>` is already heap-allocated, only the `parent` field needs boxing to break the cycle.
### Inline schemas
@@ -175,12 +193,20 @@ For example, given an operation with an inline response schema:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
required: [id, email, name]
properties:
id:
type: string
@@ -193,8 +219,13 @@ For example, given an operation with an inline response schema:
Ploidy generates:
```rust
mod types {
#[derive(Clone, Debug, Serialize, Deserialize)]
impl Client {
pub async fn get_user(&self, id: &str) -> Result<types::GetUserResponse, Error> {
// ...
}
}
pub mod types {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GetUserResponse {
pub id: String,
pub email: String,
@@ -203,21 +234,21 @@ mod types {
}
```
The inline schema gets a descriptive name, and the same derives as any named schema. This "just works": inline schemas are first-class types in the generated code.
The inline schema gets a descriptive name (in this case, `GetUserResponse`; derived from the `operationId` and its use as a response schema), and the same derives as any named schema. This "just works": inline schemas are first-class types in the generated code.
## Contributing
We love contributions: issues, feature requests, discussions, code, documentation, and examples are all welcome!
If you find a case where Ploidy fails, or generates incorrect or unidiomatic code, please [open an issue](https://github.com/linabutler/ploidy/issues/new) with your OpenAPI spec.
If you find a case where Ploidy fails, or generates incorrect or unidiomatic code, please [open an issue](https://github.com/linabutler/ploidy/issues/new) with your OpenAPI spec. For questions, or for planning larger contributions, please [start a discussion](https://github.com/linabutler/ploidy/discussions).
Other areas where we'd love help are:
Some areas where we'd especially love help are:
* Additional examples, with real-world specs.
* Test coverage, especially for edge cases.
* Documentation improvements.
We welcome LLM-assisted contributions, but hold them to the same quality bar: the code should fit in with the existing architecture and style of the project. Please [start a discussion](https://github.com/linabutler/ploidy/discussions) before vibing non-trivial features, as these usually take a bit more up-front design work.
🤖 We welcome LLM-assisted contributions, but hold them to the same quality bar: the code should fit in with the existing architecture, approach, and overall style of the project.
Thanks!
@@ -225,8 +256,8 @@ Thanks!
Ploidy only targets Rust now, but its architecture is designed to support other languages. Our philosophy is to only support languages where we can:
1. Parse the target language properly.
2. Generate valid syntax trees that are correct by construction, rather than interpolating string templates.
1. Generate code from valid syntax trees that are correct by construction, rather than from string templates.
2. Leverage existing tools for those languages, like parsers, linters, and formatters, that are written _in_ Rust.
3. Maintain the same correctness guarantees and generated code quality as our Rust pipeline.
This does mean that Ploidy won't target every language. We'd rather support three languages perfectly, than a dozen languages with gaps.
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "ploidy-codegen-rust"
description = "A Ploidy generator that emits Rust code"
readme = "README.md"
version.workspace = true
license.workspace = true
edition.workspace = true
repository.workspace = true
keywords.workspace = true
[dependencies]
cargo_toml = "0.22"
heck = "0.5"
itertools = "0.14"
serde = { version = "1", features = ["derive"] }
miette = "7"
ploidy-core = { workspace = true, features = ["cargo_toml", "proc-macro2"] }
prettyplease = "0.2"
proc-macro2 = { version = "1", default-features = false }
quote = { version = "1", default-features = false }
syn = { version = "2", default-features = false, features = [
"parsing",
"printing",
] }
textwrap = { version = "0.16", default-features = false, features = [
"unicode-linebreak",
"unicode-width",
] }
thiserror = "2"
toml = { version = "0.9", default-features = false, features = [
"display",
"parse",
"serde",
] }
unicode-ident = "1"
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
../LICENSE
+7
View File
@@ -0,0 +1,7 @@
# ploidy-codegen-rust
This crate is part of the [Ploidy](https://crates.io/crates/ploidy) OpenAPI code generator. It transforms [**ploidy-core**](https://crates.io/crates/ploidy-core) types into Rust syntax trees, pretty-prints them, and saves the output to disk.
⚠️ The **ploidy-codegen-rust** API isn't stable yet.
One of the goals of this crate is to support usage from [build scripts](https://doc.rust-lang.org/cargo/reference/build-scripts.html), as an alternative to the `ploidy` CLI. This can be useful if you're generating an OpenAPI client as part of a larger Rust project, and don't need the complete crate that the CLI generates.
@@ -2,10 +2,11 @@ use std::collections::{BTreeMap, BTreeSet};
use cargo_toml::{Edition, Manifest};
use itertools::Itertools;
use ploidy_core::codegen::IntoCode;
use serde::{Deserialize, Serialize};
use toml::Value as TomlValue;
use crate::codegen::{IntoCode, rust::CodegenGraph};
use super::graph::CodegenGraph;
type TomlMap = toml::map::Map<String, TomlValue>;
@@ -1,9 +1,8 @@
use itertools::Itertools;
use ploidy_core::codegen::IntoCode;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::codegen::IntoCode;
use super::{graph::CodegenGraph, naming::CodegenIdent};
/// Generates the `client/mod.rs` source file.
@@ -1,9 +1,8 @@
use ploidy_core::ir::{IrEnumVariant, IrEnumView};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, format_ident, quote};
use syn::{Ident, parse_quote};
use crate::ir::{IrEnumVariant, IrEnumView};
use super::{
doc_attrs,
naming::{CodegenIdent, CodegenTypeName},
@@ -1,6 +1,6 @@
use std::ops::Deref;
use crate::{
use ploidy_core::{
codegen::UniqueNameSpace,
ir::{IrGraph, View},
};
@@ -4,7 +4,7 @@ use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::quote;
use crate::{
use ploidy_core::{
codegen::{IntoCode, write_to_disk},
ir::View,
};
@@ -1,13 +1,12 @@
use std::borrow::Cow;
use heck::{ToPascalCase, ToSnakeCase};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{IdentFragment, ToTokens, TokenStreamExt, format_ident};
use crate::ir::{
use ploidy_core::ir::{
InlineIrTypePath, InlineIrTypePathSegment, IrStructFieldName, IrStructFieldNameHint,
IrUntaggedVariantNameHint, PrimitiveIrType,
};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{IdentFragment, ToTokens, TokenStreamExt, format_ident};
/// A name for a schema type that's guaranteed to be unique through
/// different identifier case transformations.
@@ -1,16 +1,15 @@
use itertools::Itertools;
use proc_macro2::{Span, TokenStream};
use quote::{ToTokens, TokenStreamExt, quote};
use syn::Ident;
use crate::{
codegen::unique::UniqueNameSpace,
use ploidy_core::{
codegen::UniqueNameSpace,
ir::{
IrOperationView, IrParameterStyle, IrParameterView, IrPathParameter, IrQueryParameter,
IrRequestView, IrResponseView, IrTypeView,
},
parse::{Method, path::PathFragment},
};
use proc_macro2::{Span, TokenStream};
use quote::{ToTokens, TokenStreamExt, quote};
use syn::Ident;
use super::{doc_attrs, naming::CodegenIdent, ref_::CodegenRef};
@@ -1,10 +1,9 @@
use heck::ToSnakeCase;
use ploidy_core::ir::{InlineIrTypePathRoot, IrTypeView, PrimitiveIrType, View};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, format_ident, quote};
use syn::parse_quote;
use crate::ir::{InlineIrTypePathRoot, IrTypeView, PrimitiveIrType, View};
use super::{
naming::CodegenTypeName,
naming::{CodegenIdent, SchemaIdent},
@@ -1,11 +1,10 @@
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{
use ploidy_core::{
codegen::IntoCode,
ir::{InlineIrTypePathRoot, InlineIrTypeView, IrOperationView},
};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::{
enum_::CodegenEnum, naming::CodegenTypeName, operation::CodegenOperation,
@@ -1,10 +1,9 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{
use ploidy_core::{
codegen::IntoCode,
ir::{InlineIrTypeView, SchemaIrTypeView, View},
};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::{
enum_::CodegenEnum, naming::CodegenTypeName, struct_::CodegenStruct, tagged::CodegenTagged,
@@ -1,8 +1,7 @@
use ploidy_core::codegen::IntoCode;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::codegen::IntoCode;
#[derive(Clone, Copy, Debug)]
pub struct CodegenLibrary;
@@ -1,12 +1,11 @@
use ploidy_core::{
codegen::UniqueNameSpace,
ir::{IrStructFieldName, IrStructFieldView, IrStructView, IrTypeView, PrimitiveIrType, View},
};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use syn::{Ident, parse_quote};
use crate::{
codegen::unique::UniqueNameSpace,
ir::{IrStructFieldName, IrStructFieldView, IrStructView, IrTypeView, PrimitiveIrType, View},
};
use super::{
derives::ExtraDerive,
doc_attrs,
@@ -1,11 +1,10 @@
use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{
codegen::unique::UniqueNameSpace,
use ploidy_core::{
codegen::UniqueNameSpace,
ir::{IrTaggedView, IrTypeView, PrimitiveIrType, View},
};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::{
derives::ExtraDerive, doc_attrs, naming::CodegenIdent, naming::CodegenTypeName,
@@ -1,10 +1,9 @@
use std::collections::BTreeSet;
use ploidy_core::{codegen::IntoCode, ir::View};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{codegen::IntoCode, ir::View};
use super::{graph::CodegenGraph, naming::SchemaIdent};
/// Generates the `types/mod.rs` module.
@@ -1,8 +1,7 @@
use ploidy_core::ir::{IrTypeView, IrUntaggedView, PrimitiveIrType, SomeIrUntaggedVariant};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::ir::{IrTypeView, IrUntaggedView, PrimitiveIrType, SomeIrUntaggedVariant};
use super::{
derives::ExtraDerive,
doc_attrs,
+9 -25
View File
@@ -1,6 +1,6 @@
[package]
name = "ploidy-core"
description = "OpenAPI type definitions and code generators for Ploidy"
description = "An OpenAPI IR and type graph for Ploidy"
readme = "README.md"
version.workspace = true
license.workspace = true
@@ -12,7 +12,6 @@ keywords.workspace = true
atomic_refcell = "0.1"
by_address = "1"
cargo_toml = { version = "0.22", optional = true }
heck = "0.5"
indexmap = { version = "2", features = ["serde"] }
itertools = "0.14"
miette = "7"
@@ -28,22 +27,10 @@ proc-macro2 = { version = "1", default-features = false, optional = true }
quote = { version = "1", default-features = false, optional = true }
ref-cast = "1"
rustc-hash = "2"
syn = { version = "2", default-features = false, features = [
"parsing",
"printing",
], optional = true }
textwrap = { version = "0.16", default-features = false, features = [
"unicode-linebreak",
"unicode-width",
], optional = true }
syn = { version = "2", default-features = false, optional = true }
thiserror = "2"
toml = { version = "0.9", default-features = false, features = [
"display",
"parse",
"serde",
], optional = true }
toml = { version = "0.9", default-features = false, optional = true }
unicase = "2"
unicode-ident = { version = "1", optional = true }
winnow = "0.7"
[dev-dependencies]
@@ -51,15 +38,12 @@ indoc = "2"
[features]
default = []
rust = [
"cargo_toml",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"textwrap",
"toml",
"unicode-ident",
cargo_toml = ["dep:cargo_toml", "toml/display", "toml/serde"]
proc-macro2 = [
"dep:prettyplease",
"dep:proc-macro2",
"dep:quote",
"syn/parsing",
]
[lints]
+2 -2
View File
@@ -1,5 +1,5 @@
# ploidy-core
This crate is the core (or nucleus! 🧬) of the [Ploidy](https://crates.io/crates/ploidy) OpenAPI code generator. It exposes structures for parsing OpenAPI types, and code generators for different languages.
This crate is the nucleus (🧬) of the [Ploidy](https://crates.io/crates/ploidy) OpenAPI code generator. It exposes structures for constructing a language-agnostic **intermediate representation** of an OpenAPI spec, which code generators then use to target different languages.
You can use **ploidy-core** as part of a larger project, or from a [build script](https://doc.rust-lang.org/cargo/reference/build-scripts.html) if you're targeting Rust.
⚠️ The **ploidy-core** API isn't stable yet.
+2 -5
View File
@@ -2,9 +2,6 @@ use std::path::Path;
use miette::{Context, IntoDiagnostic};
#[cfg(feature = "rust")]
pub mod rust;
mod unique;
pub use unique::{UniqueNameSpace, WordSegments};
@@ -29,7 +26,7 @@ pub trait Code {
fn into_string(self) -> miette::Result<String>;
}
#[cfg(feature = "rust")]
#[cfg(feature = "proc-macro2")]
impl<T: AsRef<str>> Code for (T, proc_macro2::TokenStream) {
fn path(&self) -> &str {
self.0.as_ref()
@@ -45,7 +42,7 @@ impl<T: AsRef<str>> Code for (T, proc_macro2::TokenStream) {
}
}
#[cfg(feature = "rust")]
#[cfg(feature = "cargo_toml")]
impl<T: serde::Serialize> Code for (&'static str, cargo_toml::Manifest<T>) {
fn path(&self) -> &str {
self.0
+2 -1
View File
@@ -13,7 +13,8 @@ cargo_toml = "0.22"
clap = { version = "4", features = ["cargo", "derive"] }
miette = { version = "7", features = ["fancy"] }
mimalloc = { version = "0.1", optional = true }
ploidy-core = { workspace = true, features = ["rust"] }
ploidy-codegen-rust = { workspace = true }
ploidy-core = { workspace = true }
semver = "1"
[features]
+1 -2
View File
@@ -5,10 +5,9 @@ use clap::{
CommandFactory, FromArgMatches,
error::{Error as ClapError, ErrorKind as ClapErrorKind, Result as ClapResult},
};
use ploidy_codegen_rust::CargoMetadata;
use semver::Version;
use ploidy_core::codegen::rust::CargoMetadata;
const DEFAULT_VERSION: Version = Version::new(0, 1, 0);
#[derive(Debug)]
+8 -11
View File
@@ -1,9 +1,9 @@
use std::collections::BTreeMap;
use miette::{Context, IntoDiagnostic, Result};
use ploidy_codegen_rust::{CodegenCargoManifest, CodegenErrorModule, CodegenGraph, CodegenLibrary};
use ploidy_core::{
codegen::{rust, write_to_disk},
codegen::write_to_disk,
ir::{IrGraph, IrSpec},
parse::Document,
};
@@ -35,24 +35,21 @@ fn main() -> Result<()> {
println!("OpenAPI: {} (version {})", doc.info.title, doc.info.version);
let spec = IrSpec::from_doc(&doc).into_diagnostic()?;
let graph = rust::CodegenGraph::new(IrGraph::new(&spec));
let graph = CodegenGraph::new(IrGraph::new(&spec));
println!("Writing generated code to `{}`...", output.display());
println!("Generating `Cargo.toml`...");
write_to_disk(
&output,
rust::CodegenCargoManifest::new(&graph, &config.manifest),
)?;
write_to_disk(&output, CodegenCargoManifest::new(&graph, &config.manifest))?;
println!("Generating `lib.rs`...");
write_to_disk(&output, rust::CodegenLibrary)?;
write_to_disk(&output, CodegenLibrary)?;
println!("Generating `error.rs`...");
write_to_disk(&output, rust::CodegenErrorModule)?;
write_to_disk(&output, CodegenErrorModule)?;
println!("Generating {} types...", graph.schemas().count());
rust::write_types_to_disk(&output, &graph)?;
ploidy_codegen_rust::write_types_to_disk(&output, &graph)?;
let counts =
graph
@@ -66,7 +63,7 @@ fn main() -> Result<()> {
counts.values().copied().sum::<usize>(),
counts.keys().count(),
);
rust::write_client_to_disk(&output, &graph)?;
ploidy_codegen_rust::write_client_to_disk(&output, &graph)?;
println!("Generation complete");