-
Notifications
You must be signed in to change notification settings - Fork 119
[3/n] allow JSON schemas relative to the out dir #1301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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"] } |
| 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(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| release = false |
| 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(), | ||
| }, | ||
| )); | ||
| } |
| 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; | ||
|
|
@@ -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<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 | ||
|
|
@@ -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<LitStr>, | ||
| // Note this is a TokenStreamWrapper and not a ParseWrapper<SpecSource>. The | ||
| // latter loses span information. | ||
|
Comment on lines
+189
to
+190
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's an artifact of the Can this be fixed? Maybe we could use a side channel similar to the This probably doesn't matter for scalars like 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| spec: TokenStreamWrapper, | ||
| #[serde(default)] | ||
| interface: InterfaceStyle, | ||
| #[serde(default)] | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -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), | ||
| ) | ||
| })? | ||
| } | ||
| }; | ||
|
|
@@ -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), | ||
| ) | ||
| })?; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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 shouldRelativeToderiveDefault?There was a problem hiding this comment.
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.There was a problem hiding this comment.
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