Trait-based OpenAPI-driven migration of Node.js services to Rust. API traits (Dropshot) → OpenAPI specs → client libraries (Progenitor) → CLIs.
Tooling: The /restify-conversion skill automates Node.js Restify → Rust Dropshot conversion. Migration plans are in conversion-plans/.
apis/— API trait definitions (fast to compile):cloudapi-api,vmapi-api,bugview-api,jira-apiservices/— Trait implementations:bugview-service,jira-stub-serverclients/internal/— Progenitor-generated clients:cloudapi-client,vmapi-client,bugview-client,jira-clientcli/— CLIs:triton-cli,vmapi-cli,bugview-cli,manatee-echo-resolverlibs/— Shared crates:cueball*,fast,libmanta,moray,quickcheck-helpers,rebalancer-legacy,rust-utils,sharkspotter,triton-authclient-generator/— Progenitor-based code generatoropenapi-manager/— Spec management (dropshot-api-manager)openapi-specs/generated/— Generated specs (checked into git)openapi-specs/patched/— Post-generation patched specs
- Dropshot: HTTP server framework with API trait support
- Dropshot API Manager: OpenAPI document management and versioning
- Progenitor: OpenAPI client generator for Rust
- Oxide RFD 479: Dropshot API Traits design documentation
- Implement a single, focused change
make formatmake package-test PACKAGE=<pkg>andmake package-build PACKAGE=<pkg>make audit(check for vulnerabilities)- Commit only files related to this change — one commit = one logical change
Known audit exceptions (pre-existing, do not block commits): RUSTSEC-2023-0071 (rsa), RUSTSEC-2026-0009 (time), RUSTSEC-2024-0436 (paste), RUSTSEC-2025-0134 (rustls-pemfile).
Run make help to see all available targets. Key commands:
| Target | Description |
|---|---|
make build |
Build all crates |
make test |
Run all tests |
make check |
Run all validation (tests + OpenAPI check) |
make format |
Format all code |
make lint |
Run clippy linter |
make audit |
Security audit dependencies |
make list |
List all APIs, services, and clients |
| Target | Description |
|---|---|
make service-build SERVICE=X |
Build specific service |
make service-test SERVICE=X |
Test specific service |
make service-run SERVICE=X |
Run a service |
make client-build CLIENT=X |
Build specific client |
make client-test CLIENT=X |
Test specific client |
make package-build PACKAGE=X |
Build specific package |
make package-test PACKAGE=X |
Test specific package |
| Target | Description |
|---|---|
make openapi-generate |
Generate specs from API traits |
make openapi-check |
Verify specs are up-to-date |
make openapi-list |
List managed APIs |
| Target | Description |
|---|---|
make clients-generate |
Generate all client src/generated.rs files |
make clients-check |
Verify generated client code is up-to-date |
make clients-list |
List managed clients |
make regen-clients |
Regenerate OpenAPI specs + client code |
| Target | Description |
|---|---|
make api-new API=X |
Create new API trait crate |
make service-new SERVICE=X API=Y |
Create new service |
make client-new CLIENT=X API=Y |
Create new client |
Several CloudAPI endpoints use a single POST endpoint to dispatch multiple operations (mirrors Node.js Restify routes). Each has: an action enum, an optional query struct, per-action request body structs, and the endpoint uses TypedBody<serde_json::Value> to accept the action and fields in the body.
Body-first precedence: Node.js Restify's mapParams: true merges query and body params, so clients may send action in either the query string (?action=stop) or the request body ({"action": "stop"}). Our Rust client sends it in the body (matching node-triton's wire format). The query parameter is optional (Option<*Action>) so both old and new clients work. Service implementations should check the body first, then fall back to the query parameter.
Client wrapper pattern: The ActionBody struct in cloudapi-client uses #[serde(flatten)] to merge the action field into the request body, e.g. {"action": "stop", "origin": null}.
Endpoints: MachineAction (start/stop/reboot/resize/rename/firewall/deletion-protection), ImageAction (update/export/clone/import-from-datacenter), DiskAction (resize), VolumeAction (update).
See apis/cloudapi-api/src/types/machine.rs for the canonical example.
Dropshot supports WebSocket endpoints via #[channel { protocol = WEBSOCKETS, ... }]. Use WebsocketConnection as the last parameter, return WebsocketChannelResult. These are not covered by Progenitor-generated clients.
Existing endpoints: /{account}/changefeed, /{account}/migrations/{machine}/watch, /{account}/machines/{machine}/vnc. See apis/cloudapi-api/src/types/changefeed.rs for message types.
These rules prevent type-safety issues in CLI and client code. They are enforced by the /type-safety-audit skill (see .claude/commands/type-safety-audit.md) and should be followed in all new code. Violations found by the audit should be filed as beads issues with the type-safety label (see Issue Tracking with Beads).
Never use string literals that match enum variant wire names. Use enum_to_display() or direct enum comparison.
// WRONG: hardcoded string matching an enum variant
if state_str == "running" { ... }
// RIGHT: compare typed enums directly
if machine.state == MachineState::Running { ... }
// RIGHT: use enum_to_display() when you need the wire-format string
println!("State: {}", enum_to_display(&machine.state));If an enum is used as a CLI argument (a clap::Args field), it must have clap::ValueEnum derived on the canonical API type definition in apis/*/src/types/. Progenitor generates separate types — the derive must be on the source type if the CLI imports via re-export.
Every enum used as a CLI argument must also have with_patch(EnumName, &value_enum_patch) in the corresponding client's configuration in client-generator/src/main.rs. This ensures the Progenitor-generated copy also gets ValueEnum for cases where the CLI uses types::EnumName.
Never reimplement enums that exist in API types or are generated by Progenitor. Import from <service>_client::types::* (Progenitor types) or the re-exported API types.
Enums deserializing untrusted or evolving input (state fields, status fields) must include a #[serde(other)] Unknown catch-all variant. See apis/cloudapi-api/src/types/changefeed.rs for the established pattern.
CLIs import types from <service>_client re-exports, not directly from API crates. The client crate re-exports canonical API types alongside Progenitor-generated types in src/lib.rs.
Never use {:?} (Debug format) for values shown to users. Use enum_to_display() for serde enums, .join(", ") for collections, or implement Display. Debug format exposes Rust internals (e.g., Brand::Bhyve instead of bhyve).
// WRONG: Debug format in user-facing output
println!(" Brand: {:?}", brand);
println!("Waiting for {:?}", target_names);
// RIGHT: use enum_to_display() for serde enums
println!(" Brand: {}", enum_to_display(brand));
// RIGHT: use .join() for collections
println!("Waiting for {}", target_names.join(", "));Most CloudAPI response structs use #[serde(rename_all = "camelCase")] to match the JSON wire format. However, some fields from the original Node.js CloudAPI are returned in snake_case or other non-camelCase formats. These must use explicit #[serde(rename = "...")] overrides.
Known exceptions in Machine (camelCase struct):
dns_names-- returned as"dns_names"(snake_case) by CloudAPI despite other fields being camelCasefree_space-- returned as"free_space"(snake_case) for bhyve flexible disk VMsdelegate_dataset-- returned as"delegate_dataset"(snake_case)
Other common rename patterns:
typefields -- Rust reservestypeas a keyword, so fields likemachine_typeandvolume_typeuse#[serde(rename = "type")]role-tagfields -- CloudAPI uses hyphenated"role-tag"in JSON, mapped torole_tagin Rust with#[serde(rename = "role-tag")]- Enum variants with hyphens -- e.g.,
#[serde(rename = "joyent-minimal")],#[serde(rename = "zone-dataset")]
When adding new fields: Always check the actual JSON wire format from the original Node.js service. If a field does not follow the struct-level rename_all convention, add an explicit #[serde(rename = "...")] override.
Type alias: API crates define pub type Uuid = uuid::Uuid; in their common types module (see apis/vmapi-api/src/types/common.rs; cloudapi-api re-exports it). Use this alias in all struct fields and function signatures rather than raw uuid::Uuid or String.
Serialization: The uuid crate's serde support handles serialization/deserialization as lowercase hyphenated strings (e.g., "28faa36c-2031-4632-a819-f7defa1299a3"). No custom serde logic is needed.
Path parameters: UUID path parameters (machine IDs, image IDs, etc.) are parsed automatically by Dropshot via the Uuid type in Path<> structs. Invalid UUIDs produce a 400 error.
String UUIDs: Some fields use String instead of Uuid when the upstream API may return non-UUID values or when the field serves double duty. Prefer typed Uuid unless there is a specific reason for String.
Testing: When constructing test UUIDs, use uuid::Uuid::parse_str("...") or uuid::Uuid::nil() rather than placeholder strings.
This repo uses Beads (bd CLI) for lightweight issue tracking. Issues are stored in .beads/ with JSONL export tracked in git.
bd ready # See work queue
bd update <id> --claim # Claim an issue
bd show <id> # View details
bd close <id> # Close after fixing
bd close <id> -r "wontfix: reason" # Close as won't-fix
bd create --title "..." --description "..." --labels type-safety
bd comments add <id> "comment text" # Add a comment to an issueSession convention: When asked to "work on the next bead" (or similar), run bd ready to find the next unclaimed issue, then bd show <id> for details, bd update <id> --claim to claim it, implement the fix, and bd close <id> when done. Always commit code changes together with the updated .beads/issues.jsonl. Create new issues for any follow-up work discovered.
Tutorial content for less-frequent tasks has been moved to dedicated files:
- API Workflow — Creating APIs, implementing services, managing OpenAPI specs, generating clients
- CLI Development — Building CLI applications on generated clients
- External APIs — Hand-writing minimal clients for legacy/external APIs
- Testing Guide — Test types, fixture management, doctests policy
- Checked-in Client Generation — Design rationale for committing generated client code