Skip to main content

progenitor_macro/
lib.rs

1// Copyright 2026 Oxide Computer Company
2
3//! Macros for the progenitor OpenAPI client generator.
4
5#![deny(missing_docs)]
6
7use std::{collections::HashMap, fs::File, path::PathBuf};
8
9use openapiv3::OpenAPI;
10use proc_macro::TokenStream;
11use progenitor_impl::{
12    CrateVers, GenerationSettings, Generator, InterfaceStyle, TagStyle, TypePatch, UnknownPolicy,
13};
14use quote::{ToTokens, quote};
15use schemars::schema::SchemaObject;
16use serde::Deserialize;
17use serde_tokenstream::{OrderedMap, ParseWrapper};
18use syn::LitStr;
19use token_utils::TypeAndImpls;
20
21mod token_utils;
22
23/// Where to resolve the spec path relative to.
24#[derive(Debug, Clone, Copy, Deserialize)]
25enum RelativeTo {
26    /// Resolve relative to CARGO_MANIFEST_DIR (the default).
27    ManifestDir,
28    /// Resolve relative to OUT_DIR.
29    OutDir,
30}
31
32/// Specification of where to find the OpenAPI document.
33#[derive(Debug)]
34struct SpecSource {
35    /// The path to the spec file.
36    path: LitStr,
37    /// Where to resolve the path relative to.
38    relative_to: RelativeTo,
39}
40
41impl syn::parse::Parse for SpecSource {
42    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
43        /// Helper struct for deserializing the struct form of SpecSource.
44        #[derive(Deserialize)]
45        struct SpecSourceStruct {
46            path: ParseWrapper<LitStr>,
47            relative_to: RelativeTo,
48        }
49
50        let lookahead = input.lookahead1();
51        if lookahead.peek(LitStr) {
52            // spec = "path/to/spec.json"
53            let path: LitStr = input.parse()?;
54            Ok(SpecSource {
55                path,
56                relative_to: RelativeTo::ManifestDir,
57            })
58        } else if lookahead.peek(syn::token::Brace) {
59            // spec = { path = "...", relative_to = ... }
60            let content;
61            let brace_token = syn::braced!(content in input);
62            let stream: proc_macro2::TokenStream = content.parse()?;
63            let helper: SpecSourceStruct =
64                serde_tokenstream::from_tokenstream_spanned(&brace_token.span, &stream)?;
65            Ok(SpecSource {
66                path: helper.path.into_inner(),
67                relative_to: helper.relative_to,
68            })
69        } else {
70            Err(lookahead.error())
71        }
72    }
73}
74
75/// Generates a client from the given OpenAPI document
76///
77/// `generate_api!` can be invoked in two ways. The simple form, takes a path
78/// to the OpenAPI document:
79/// ```ignore
80/// generate_api!("path/to/spec.json");
81/// ```
82///
83/// The more complex form accepts the following key-value pairs in any order:
84/// ```ignore
85/// generate_api!(
86///     // spec can be a simple path string:
87///     spec = "path/to/spec.json",
88///     // Or a struct with path and relative_to:
89///     spec = { path = "path/to/spec.json", relative_to = OutDir },
90///     [ interface = ( Positional | Builder ), ]
91///     [ tags = ( Merged | Separate ), ]
92///     [ pre_hook = closure::or::path::to::function, ]
93///     [ post_hook = closure::or::path::to::function, ]
94///     [ pre_hook_async = closure::or::path::to::function, ]
95///     [ post_hook_async = closure::or::path::to::function, ]
96///
97///     [ derives = [ path::to::DeriveMacro ], ]
98///
99///     [ unknown_crates = (Generate | Allow | Deny ), ]
100///     [ crates = { "<crate-name>" = ("<version>" | "*" | "!" ) } ]
101///
102///     [ patch = { TypeName = { [rename = NewTypeName], [derives = []] }, } ]
103///     [ replace = { TypeName = full_path::to::other::TypeName, }]
104///     [ convert = { { <schema> } = full_path::to::TypeName, }]
105///     [ timeout = u64 ]
106/// );
107/// ```
108///
109/// The `spec` key is required; it is the OpenAPI document (JSON or YAML) from
110/// which the client is derived. It can be specified as a simple string path, or
111/// as a struct with `path` and `relative_to` fields. The `relative_to`
112/// field controls where the path is resolved from:
113///
114/// - `ManifestDir`: relative to `CARGO_MANIFEST_DIR`. This is the default when
115///   the spec is provided as a string path.
116/// - `OutDir`: relative to `OUT_DIR` (useful for build script outputs).
117///
118/// The optional `interface` lets you specify either a `Positional` argument or
119/// `Builder` argument style; `Positional` is the default.
120///
121/// The optional `tags` may be `Merged` in which case all operations are
122/// methods on the `Client` struct or `Separate` in which case each tag is
123/// represented by an "extension trait" that `Client` implements. The default
124/// is `Merged`.
125///
126/// The optional `inner_type` is for ancillary data, stored with the generated
127/// client that can be used by the pre- and post-hooks.
128///
129/// The optional `pre_hook` is either a closure (that must be within
130/// parentheses: `(fn |[inner,] request| { .. })`) or a path to a function. The
131/// closure or function must take one or two parameters: the inner type (if one
132/// is specified) and a `&reqwest::Request`. This allows clients to examine
133/// requests before they're sent to the server, for example to log them. The
134/// optional `pre_hook_async` is the `async` variant of the same.
135///
136/// The optional `post_hook` is either a closure (that must be within
137/// parentheses: `(fn |[inner,] result| { .. })`) or a path to a function. The
138/// closure or function must take one or two parameters: the inner type (if one
139/// is specified) and a `&Result<reqwest::Response, reqwest::Error>`. This
140/// allows clients to examine responses, for example to log them. The optional
141/// `post_hook_async` is the `async` variant of the same.
142///
143/// Additional options control type generation:
144/// - `derives`: optional array of derive macro paths; the derive macros to be
145///   applied to all generated types
146///
147/// - `struct_builder`: optional boolean; (if true) generates a `::builder()`
148///   method for each generated struct that can be used to specify each
149///   property and construct the struct
150///
151/// - `unknown_crates`: optional policy regarding the handling of schemas that
152///   contain the `x-rust-type` extension whose crates are not explicitly named
153///   in the `crates` section. The options are `generate` to ignore the
154///   extension and generate a *de novo* type, `allow` to use the named type
155///   (which may require the addition of a new dependency to compile, and which
156///   ignores version compatibility checks), or `deny` to produce a
157///   compile-time error (requiring the user to specify the crate's disposition
158///   in the `crates` section).
159///
160/// - `crates`: optional map from crate name to the version of the crate in
161///   use. Types encountered with the Rust type extension (`x-rust-type`) will
162///   use types from the specified crates rather than generating them (within
163///   the constraints of type compatibility).
164///
165/// - `patch`: optional map from type to an object with the optional members
166///   `rename` and `derives`. This may be used to rename generated types or
167///   to apply additional (non-default) derive macros to them.
168///
169/// - `replace`: optional map from definition name to a replacement type. This
170///   may be used to skip generation of the named type and use a existing Rust
171///   type.
172///
173/// - `convert`: optional map from a JSON schema type defined in `$defs` to a
174///   replacement type. This may be used to skip generation of the schema and
175///   use an existing Rust type.
176///
177/// - `timeout`: the default connection timeout for the underlying reqwest
178///   client (15s if not specified)
179#[proc_macro]
180pub fn generate_api(item: TokenStream) -> TokenStream {
181    match do_generate_api(item) {
182        Err(err) => err.to_compile_error().into(),
183        Ok(out) => out,
184    }
185}
186
187#[derive(Deserialize)]
188struct MacroSettings {
189    spec: ParseWrapper<SpecSource>,
190    #[serde(default)]
191    interface: InterfaceStyle,
192    #[serde(default)]
193    tags: TagStyle,
194
195    inner_type: Option<ParseWrapper<syn::Type>>,
196    pre_hook: Option<ParseWrapper<ClosureOrPath>>,
197    pre_hook_async: Option<ParseWrapper<ClosureOrPath>>,
198    post_hook: Option<ParseWrapper<ClosureOrPath>>,
199    post_hook_async: Option<ParseWrapper<ClosureOrPath>>,
200
201    map_type: Option<ParseWrapper<syn::Type>>,
202
203    #[serde(default)]
204    derives: Vec<ParseWrapper<syn::Path>>,
205
206    #[serde(default)]
207    unknown_crates: UnknownPolicy,
208    #[serde(default)]
209    crates: HashMap<CrateName, MacroCrateSpec>,
210
211    #[serde(default)]
212    patch: HashMap<ParseWrapper<syn::Ident>, MacroPatch>,
213    #[serde(default)]
214    replace: HashMap<ParseWrapper<syn::Ident>, ParseWrapper<TypeAndImpls>>,
215    #[serde(default)]
216    convert: OrderedMap<SchemaObject, ParseWrapper<TypeAndImpls>>,
217    timeout: Option<u64>,
218}
219
220#[derive(Deserialize)]
221struct MacroPatch {
222    #[serde(default)]
223    rename: Option<String>,
224    #[serde(default)]
225    derives: Vec<ParseWrapper<syn::Path>>,
226}
227
228impl From<MacroPatch> for TypePatch {
229    fn from(a: MacroPatch) -> Self {
230        let mut s = Self::default();
231        a.rename.iter().for_each(|rename| {
232            s.with_rename(rename);
233        });
234        a.derives.iter().for_each(|derive| {
235            s.with_derive(derive.to_token_stream().to_string());
236        });
237        s
238    }
239}
240
241#[derive(Debug)]
242struct ClosureOrPath(proc_macro2::TokenStream);
243
244impl syn::parse::Parse for ClosureOrPath {
245    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
246        let lookahead = input.lookahead1();
247
248        if lookahead.peek(syn::token::Paren) {
249            let group: proc_macro2::Group = input.parse()?;
250            return syn::parse2::<Self>(group.stream());
251        }
252
253        if let Ok(closure) = input.parse::<syn::ExprClosure>() {
254            return Ok(Self(closure.to_token_stream()));
255        }
256
257        input
258            .parse::<syn::Path>()
259            .map(|path| Self(path.to_token_stream()))
260    }
261}
262
263struct MacroCrateSpec {
264    original: Option<String>,
265    version: CrateVers,
266}
267
268impl<'de> Deserialize<'de> for MacroCrateSpec {
269    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
270    where
271        D: serde::Deserializer<'de>,
272    {
273        let ss = String::deserialize(deserializer)?;
274
275        let (original, vers_str) = if let Some(ii) = ss.find('@') {
276            let original_str = &ss[..ii];
277            let rest = &ss[ii + 1..];
278            if !is_crate(original_str) {
279                return Err(<D::Error as serde::de::Error>::invalid_value(
280                    serde::de::Unexpected::Str(&ss),
281                    &"valid crate name",
282                ));
283            }
284
285            (Some(original_str.to_string()), rest)
286        } else {
287            (None, ss.as_ref())
288        };
289
290        let Some(version) = CrateVers::parse(vers_str) else {
291            return Err(<D::Error as serde::de::Error>::invalid_value(
292                serde::de::Unexpected::Str(&ss),
293                &"valid version",
294            ));
295        };
296
297        Ok(Self { original, version })
298    }
299}
300
301#[derive(Hash, PartialEq, Eq)]
302struct CrateName(String);
303impl<'de> Deserialize<'de> for CrateName {
304    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
305    where
306        D: serde::Deserializer<'de>,
307    {
308        let ss = String::deserialize(deserializer)?;
309
310        if is_crate(&ss) {
311            Ok(Self(ss))
312        } else {
313            Err(<D::Error as serde::de::Error>::invalid_value(
314                serde::de::Unexpected::Str(&ss),
315                &"valid crate name",
316            ))
317        }
318    }
319}
320
321fn is_crate(s: &str) -> bool {
322    !s.contains(|cc: char| !cc.is_alphanumeric() && cc != '_' && cc != '-')
323}
324
325fn open_file(path: PathBuf, span: proc_macro2::Span) -> Result<File, syn::Error> {
326    File::open(path.clone()).map_err(|e| {
327        let path_str = path.to_string_lossy();
328        syn::Error::new(span, format!("couldn't read file {}: {}", path_str, e))
329    })
330}
331
332fn do_generate_api(item: TokenStream) -> Result<TokenStream, syn::Error> {
333    let (spec_source, settings) = if let Ok(spec) = syn::parse::<LitStr>(item.clone()) {
334        let spec_source = SpecSource {
335            path: spec,
336            relative_to: RelativeTo::ManifestDir,
337        };
338        (spec_source, GenerationSettings::default())
339    } else {
340        let MacroSettings {
341            spec,
342            interface,
343            tags,
344            inner_type,
345            pre_hook,
346            pre_hook_async,
347            post_hook,
348            post_hook_async,
349            map_type,
350            unknown_crates,
351            crates,
352            derives,
353            patch,
354            replace,
355            convert,
356            timeout,
357        } = serde_tokenstream::from_tokenstream(&item.into())?;
358
359        let spec = spec.into_inner();
360
361        let mut settings = GenerationSettings::default();
362        settings.with_interface(interface);
363        settings.with_tag(tags);
364        inner_type.map(|inner_type| settings.with_inner_type(inner_type.to_token_stream()));
365        pre_hook.map(|pre_hook| settings.with_pre_hook(pre_hook.into_inner().0));
366        pre_hook_async
367            .map(|pre_hook_async| settings.with_pre_hook_async(pre_hook_async.into_inner().0));
368        post_hook.map(|post_hook| settings.with_post_hook(post_hook.into_inner().0));
369        post_hook_async
370            .map(|post_hook_async| settings.with_post_hook_async(post_hook_async.into_inner().0));
371        map_type.map(|map_type| settings.with_map_type(map_type.to_token_stream()));
372
373        settings.with_unknown_crates(unknown_crates);
374        crates.into_iter().for_each(
375            |(CrateName(crate_name), MacroCrateSpec { original, version })| {
376                if let Some(original_crate) = original {
377                    settings.with_crate(original_crate, version, Some(&crate_name));
378                } else {
379                    settings.with_crate(crate_name, version, None);
380                }
381            },
382        );
383
384        derives.into_iter().for_each(|derive| {
385            settings.with_derive(derive.to_token_stream());
386        });
387        patch.into_iter().for_each(|(type_name, patch)| {
388            settings.with_patch(type_name.to_token_stream().to_string(), &patch.into());
389        });
390        replace.into_iter().for_each(|(type_name, type_and_impls)| {
391            let type_name = type_name.to_token_stream();
392            let (replace_name, impls) = type_and_impls.into_inner().into_name_and_impls();
393            settings.with_replacement(type_name, replace_name, impls);
394        });
395        convert.into_iter().for_each(|(schema, type_and_impls)| {
396            let (type_name, impls) = type_and_impls.into_inner().into_name_and_impls();
397            settings.with_conversion(schema, type_name, impls);
398        });
399        if let Some(timeout) = timeout {
400            settings.with_timeout(timeout);
401        }
402        (spec, settings)
403    };
404
405    let spec_path = spec_source.path;
406    let base_dir = match spec_source.relative_to {
407        RelativeTo::ManifestDir => std::env::var("CARGO_MANIFEST_DIR")
408            .map_or_else(|_| std::env::current_dir().unwrap(), PathBuf::from),
409        RelativeTo::OutDir => {
410            let out_dir = std::env::var("OUT_DIR").map_err(|_| {
411                syn::Error::new(
412                    spec_path.span(),
413                    "relative_to = OutDir requires OUT_DIR to be set \
414                     (are you using this from a build script?)",
415                )
416            })?;
417            PathBuf::from(out_dir)
418        }
419    };
420
421    let path = base_dir.join(spec_path.value());
422    let path_str = path.to_string_lossy();
423
424    let mut f = open_file(path.clone(), spec_path.span())?;
425    let oapi: OpenAPI = match serde_json::from_reader(f) {
426        Ok(json_value) => json_value,
427        _ => {
428            f = open_file(path.clone(), spec_path.span())?;
429            serde_yaml::from_reader(f).map_err(|e| {
430                syn::Error::new(
431                    spec_path.span(),
432                    format!("failed to parse {}: {}", path_str, e),
433                )
434            })?
435        }
436    };
437
438    let mut builder = Generator::new(&settings);
439
440    let code = builder.generate_tokens(&oapi).map_err(|e| {
441        syn::Error::new(
442            spec_path.span(),
443            format!("generation error for {}: {}", spec_path.value(), e),
444        )
445    })?;
446
447    let output = quote! {
448        // The progenitor_client is tautologically visible from macro
449        // consumers.
450        use progenitor::progenitor_client;
451
452        #code
453
454        // Force a rebuild when the given file is modified.
455        const _: &str = include_str!(#path_str);
456    };
457
458    Ok(output.into())
459}