Skip to content
Merged
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
15 changes: 13 additions & 2 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.3"
syn = { version = "2.0", features = ["full", "extra-traits"] }
119 changes: 98 additions & 21 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 @@ -24,6 +20,58 @@ 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,7 @@ pub fn generate_api(item: TokenStream) -> TokenStream {

#[derive(Deserialize)]
struct MacroSettings {
spec: ParseWrapper<LitStr>,
spec: ParseWrapper<SpecSource>,
#[serde(default)]
interface: InterfaceStyle,
#[serde(default)]
Expand Down Expand Up @@ -273,8 +330,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 +356,8 @@ fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {
timeout,
} = serde_tokenstream::from_tokenstream(&item.into())?;

let spec = spec.into_inner();

let mut settings = GenerationSettings::default();
settings.with_interface(interface);
settings.with_tag(tags);
Expand Down Expand Up @@ -336,24 +399,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 +439,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 }