Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"cargo-progenitor",
"example-build",
"example-macro",
"example-out-dir",
"example-wasm",
"progenitor",
"progenitor-client",
Expand Down
12 changes: 12 additions & 0 deletions example-out-dir/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "example-out-dir"
version = "0.0.1"
edition.workspace = true
rust-version.workspace = true

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
progenitor = { path = "../progenitor" }
reqwest = { version = "0.13.1", features = ["json", "query", "stream"] }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.20", features = ["serde", "v4"] }
17 changes: 17 additions & 0 deletions example-out-dir/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2026 Oxide Computer Company

use std::{env, fs, path::Path};

fn main() {
// Example build script showing integration with OUT_DIR.
//
// This build script copies a sample OpenAPI document to OUT_DIR. A more
// complex build script might, for example, extract the OpenAPI document
// from a non-file source, writing it out to OUT_DIR.
let src = "../sample_openapi/keeper.json";
println!("cargo:rerun-if-changed={}", src);

let out_dir = env::var("OUT_DIR").unwrap();
let dest = Path::new(&out_dir).join("keeper.json");
fs::copy(src, dest).unwrap();
}
1 change: 1 addition & 0 deletions example-out-dir/release.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
release = false
21 changes: 21 additions & 0 deletions example-out-dir/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright 2026 Oxide Computer Company

//! Test that `generate_api!` works with `relative_to = OutDir`, where a build
//! script copies the spec into `OUT_DIR`.

use progenitor::generate_api;

generate_api!(
spec = { path = "keeper.json", relative_to = OutDir },
);

fn main() {
let client = Client::new("https://example.com");
std::mem::drop(client.enrol(
"auth-token",
&types::EnrolBody {
host: "".to_string(),
key: "".to_string(),
},
));
}
2 changes: 1 addition & 1 deletion progenitor-macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ schemars = "0.8.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
serde_tokenstream = "0.2.0"
serde_tokenstream = "0.2.2"
syn = { version = "2.0", features = ["full", "extra-traits"] }
123 changes: 101 additions & 22 deletions progenitor-macro/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
// Copyright 2022 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

//! Macros for the progenitor OpenAPI client generator.

#![deny(missing_docs)]

use std::{
collections::HashMap,
fs::File,
path::{Path, PathBuf},
};
use std::{collections::HashMap, fs::File, path::PathBuf};

use openapiv3::OpenAPI;
use proc_macro::TokenStream;
Expand All @@ -18,12 +14,64 @@ use progenitor_impl::{
use quote::{quote, ToTokens};
use schemars::schema::SchemaObject;
use serde::Deserialize;
use serde_tokenstream::{OrderedMap, ParseWrapper};
use serde_tokenstream::{OrderedMap, ParseWrapper, TokenStreamWrapper};
use syn::LitStr;
use token_utils::TypeAndImpls;

mod token_utils;

/// Where to resolve the spec path relative to.
#[derive(Debug, Clone, Copy, Deserialize)]
enum RelativeTo {
/// Resolve relative to CARGO_MANIFEST_DIR (the default).
ManifestDir,
/// Resolve relative to OUT_DIR.
OutDir,
}

/// Specification of where to find the OpenAPI document.
#[derive(Debug)]
struct SpecSource {
/// The path to the spec file.
path: LitStr,
/// Where to resolve the path relative to.
relative_to: RelativeTo,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be #[serde(default)] and should RelativeTo derive Default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? That's what I had at first, but I figured if you're using the struct style in the first place, you're probably intending to specify relative_to.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I was thrown off by a description of a default variant of RelativeTo

}

impl syn::parse::Parse for SpecSource {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
/// Helper struct for deserializing the struct form of SpecSource.
#[derive(Deserialize)]
struct SpecSourceStruct {
path: ParseWrapper<LitStr>,
relative_to: RelativeTo,
}

let lookahead = input.lookahead1();
if lookahead.peek(LitStr) {
// spec = "path/to/spec.json"
let path: LitStr = input.parse()?;
Ok(SpecSource {
path,
relative_to: RelativeTo::ManifestDir,
})
} else if lookahead.peek(syn::token::Brace) {
// spec = { path = "...", relative_to = ... }
let content;
let brace_token = syn::braced!(content in input);
let stream: proc_macro2::TokenStream = content.parse()?;
let helper: SpecSourceStruct =
serde_tokenstream::from_tokenstream_spanned(&brace_token.span, &stream)?;
Ok(SpecSource {
path: helper.path.into_inner(),
relative_to: helper.relative_to,
})
} else {
Err(lookahead.error())
}
}
}

/// Generates a client from the given OpenAPI document
///
/// `generate_api!` can be invoked in two ways. The simple form, takes a path
Expand All @@ -35,7 +83,10 @@ mod token_utils;
/// The more complex form accepts the following key-value pairs in any order:
/// ```ignore
/// generate_api!(
/// // spec can be a simple path string:
/// spec = "path/to/spec.json",
/// // Or a struct with path and relative_to:
/// spec = { path = "path/to/spec.json", relative_to = OutDir },
/// [ interface = ( Positional | Builder ), ]
/// [ tags = ( Merged | Separate ), ]
/// [ pre_hook = closure::or::path::to::function, ]
Expand All @@ -56,7 +107,13 @@ mod token_utils;
/// ```
///
/// The `spec` key is required; it is the OpenAPI document (JSON or YAML) from
/// which the client is derived.
/// which the client is derived. It can be specified as a simple string path, or
/// as a struct with `path` and `relative_to` fields. The `relative_to`
/// field controls where the path is resolved from:
///
/// - `ManifestDir`: relative to `CARGO_MANIFEST_DIR`. This is the default when
/// the spec is provided as a string path.
/// - `OutDir`: relative to `OUT_DIR` (useful for build script outputs).
///
/// The optional `interface` lets you specify either a `Positional` argument or
/// `Builder` argument style; `Positional` is the default.
Expand Down Expand Up @@ -129,7 +186,9 @@ pub fn generate_api(item: TokenStream) -> TokenStream {

#[derive(Deserialize)]
struct MacroSettings {
spec: ParseWrapper<LitStr>,
// Note this is a TokenStreamWrapper and not a ParseWrapper<SpecSource>. The
// latter loses span information.
Comment on lines +189 to +190
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. is that an intrinsic property of ParseWrapper or is it fixable?
  2. How much do we care? We use ParseWrapper in several other places.

Copy link
Contributor Author

@sunshowers sunshowers Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an artifact of the D::Error::custom being called here: https://docs.rs/serde_tokenstream/latest/src/serde_tokenstream/ibidem.rs.html#59 -- serde flattens custom errors into their Display implementation, so that loses span information. The span used ends up being the span of the entire value associated with the key (so for spec, the entire { path = ..., relative_to = ... }).

Can this be fixed? Maybe we could use a side channel similar to the WRAPPER_TOKENS thread local in ibidem.rs. Worth investigating.

This probably doesn't matter for scalars like ParseWrapper<syn::Ident>. It only matters for compound structures like SpecSource. But I see that we have:

    pre_hook: Option<ParseWrapper<ClosureOrPath>>,
    pre_hook_async: Option<ParseWrapper<ClosureOrPath>>,
    post_hook: Option<ParseWrapper<ClosureOrPath>>,
    post_hook_async: Option<ParseWrapper<ClosureOrPath>>,

which would all be impacted.

Copy link
Contributor Author

@sunshowers sunshowers Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oxidecomputer/serde_tokenstream#221 fixes this. Once that lands and a new serde_tokenstream version is out, we can change this code to use ParseWrapper directly. (Note that ParseWrapper isn't totally compatible with the error collector/diagnostic sink proc macro pattern where we collect all errors into a common place, but that is a pre-existing limitation.)

spec: TokenStreamWrapper,
#[serde(default)]
interface: InterfaceStyle,
#[serde(default)]
Expand Down Expand Up @@ -273,8 +332,12 @@ fn open_file(path: PathBuf, span: proc_macro2::Span) -> Result<File, syn::Error>
}

fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {
let (spec, settings) = if let Ok(spec) = syn::parse::<LitStr>(item.clone()) {
(spec, GenerationSettings::default())
let (spec_source, settings) = if let Ok(spec) = syn::parse::<LitStr>(item.clone()) {
let spec_source = SpecSource {
path: spec,
relative_to: RelativeTo::ManifestDir,
};
(spec_source, GenerationSettings::default())
} else {
let MacroSettings {
spec,
Expand All @@ -295,6 +358,8 @@ fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {
timeout,
} = serde_tokenstream::from_tokenstream(&item.into())?;

let spec = syn::parse2::<SpecSource>(spec.into_inner())?;

let mut settings = GenerationSettings::default();
settings.with_interface(interface);
settings.with_tag(tags);
Expand Down Expand Up @@ -336,24 +401,38 @@ fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {
if let Some(timeout) = timeout {
settings.with_timeout(timeout);
}
(spec.into_inner(), settings)
(spec, settings)
};

let dir = std::env::var("CARGO_MANIFEST_DIR").map_or_else(
|_| std::env::current_dir().unwrap(),
|s| Path::new(&s).to_path_buf(),
);
let spec_path = spec_source.path;
let base_dir = match spec_source.relative_to {
RelativeTo::ManifestDir => std::env::var("CARGO_MANIFEST_DIR")
.map_or_else(|_| std::env::current_dir().unwrap(), PathBuf::from),
RelativeTo::OutDir => {
let out_dir = std::env::var("OUT_DIR").map_err(|_| {
syn::Error::new(
spec_path.span(),
"relative_to = OutDir requires OUT_DIR to be set \
(are you using this from a build script?)",
)
})?;
PathBuf::from(out_dir)
}
};

let path = dir.join(spec.value());
let path = base_dir.join(spec_path.value());
let path_str = path.to_string_lossy();

let mut f = open_file(path.clone(), spec.span())?;
let mut f = open_file(path.clone(), spec_path.span())?;
let oapi: OpenAPI = match serde_json::from_reader(f) {
Ok(json_value) => json_value,
_ => {
f = open_file(path.clone(), spec.span())?;
f = open_file(path.clone(), spec_path.span())?;
serde_yaml::from_reader(f).map_err(|e| {
syn::Error::new(spec.span(), format!("failed to parse {}: {}", path_str, e))
syn::Error::new(
spec_path.span(),
format!("failed to parse {}: {}", path_str, e),
)
})?
}
};
Expand All @@ -362,8 +441,8 @@ fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {

let code = builder.generate_tokens(&oapi).map_err(|e| {
syn::Error::new(
spec.span(),
format!("generation error for {}: {}", spec.value(), e),
spec_path.span(),
format!("generation error for {}: {}", spec_path.value(), e),
)
})?;

Expand Down
2 changes: 1 addition & 1 deletion progenitor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ reqwest = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["macros"] }
uuid = { workspace = true }