diff --git a/Cargo.lock b/Cargo.lock index d78939fd..66a8b7eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "example-out-dir" +version = "0.0.1" +dependencies = [ + "chrono", + "progenitor", + "reqwest", + "serde", + "uuid", +] + [[package]] name = "example-wasm" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ebf6d5ad..6a9b081d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "cargo-progenitor", "example-build", "example-macro", + "example-out-dir", "example-wasm", "progenitor", "progenitor-client", diff --git a/example-out-dir/Cargo.toml b/example-out-dir/Cargo.toml new file mode 100644 index 00000000..9b2c3713 --- /dev/null +++ b/example-out-dir/Cargo.toml @@ -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"] } diff --git a/example-out-dir/build.rs b/example-out-dir/build.rs new file mode 100644 index 00000000..b1fe23a5 --- /dev/null +++ b/example-out-dir/build.rs @@ -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(); +} diff --git a/example-out-dir/release.toml b/example-out-dir/release.toml new file mode 100644 index 00000000..95456409 --- /dev/null +++ b/example-out-dir/release.toml @@ -0,0 +1 @@ +release = false diff --git a/example-out-dir/src/main.rs b/example-out-dir/src/main.rs new file mode 100644 index 00000000..b8950e18 --- /dev/null +++ b/example-out-dir/src/main.rs @@ -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(), + }, + )); +} diff --git a/progenitor-macro/Cargo.toml b/progenitor-macro/Cargo.toml index f314918e..2d92d168 100644 --- a/progenitor-macro/Cargo.toml +++ b/progenitor-macro/Cargo.toml @@ -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"] } diff --git a/progenitor-macro/src/lib.rs b/progenitor-macro/src/lib.rs index e15eae3f..d2dcdaf9 100644 --- a/progenitor-macro/src/lib.rs +++ b/progenitor-macro/src/lib.rs @@ -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; @@ -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, +} + +impl syn::parse::Parse for SpecSource { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + /// Helper struct for deserializing the struct form of SpecSource. + #[derive(Deserialize)] + struct SpecSourceStruct { + path: ParseWrapper, + 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 @@ -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, ] @@ -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. @@ -129,7 +186,9 @@ pub fn generate_api(item: TokenStream) -> TokenStream { #[derive(Deserialize)] struct MacroSettings { - spec: ParseWrapper, + // Note this is a TokenStreamWrapper and not a ParseWrapper. The + // latter loses span information. + spec: TokenStreamWrapper, #[serde(default)] interface: InterfaceStyle, #[serde(default)] @@ -273,8 +332,12 @@ fn open_file(path: PathBuf, span: proc_macro2::Span) -> Result } fn do_generate_api(item: TokenStream) -> Result { - let (spec, settings) = if let Ok(spec) = syn::parse::(item.clone()) { - (spec, GenerationSettings::default()) + let (spec_source, settings) = if let Ok(spec) = syn::parse::(item.clone()) { + let spec_source = SpecSource { + path: spec, + relative_to: RelativeTo::ManifestDir, + }; + (spec_source, GenerationSettings::default()) } else { let MacroSettings { spec, @@ -295,6 +358,8 @@ fn do_generate_api(item: TokenStream) -> Result { timeout, } = serde_tokenstream::from_tokenstream(&item.into())?; + let spec = syn::parse2::(spec.into_inner())?; + let mut settings = GenerationSettings::default(); settings.with_interface(interface); settings.with_tag(tags); @@ -336,24 +401,38 @@ fn do_generate_api(item: TokenStream) -> Result { 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), + ) })? } }; @@ -362,8 +441,8 @@ fn do_generate_api(item: TokenStream) -> Result { 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), ) })?; diff --git a/progenitor/Cargo.toml b/progenitor/Cargo.toml index ca0757b9..474f1257 100644 --- a/progenitor/Cargo.toml +++ b/progenitor/Cargo.toml @@ -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 }