olai-codegen 0.0.11

Proto-driven code generation for REST handlers, clients, and resource registries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Generation of the Rust *aggregate root client* (e.g. `ExampleClient`).
//!
//! The aggregate client is the generated root the language bindings call into.
//! Generated Python/Node aggregate bindings invoke `self.client.<method>(...)` where `self.client`
//! is this aggregate; the method surface emitted here is built to EXACTLY MATCH those call sites
//! (see [`super::python::bindings::collection_client_struct`] and
//! [`super::python::bindings::generate_resource_accessor_method`], which this file mirrors).
//!
//! Structure:
//! - stores only a `CloudClient` + base `Url`; per-service low-level clients are built **on demand**
//!   (they hold just those two cheaply-cloneable values), so nothing is allocated per service in
//!   `new(client, base_url)`;
//! - for each service, collection-style builder constructors (and, for resource-scoped services, a
//!   resource accessor returning the generated scoped client — see [`super::resource_client`]);
//!   resource-less ("flat") services contribute *every* method as a flat builder constructor.

use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};

use super::config::CodeGenConfig;
use super::{BindingMode, MethodHandler, ServiceHandler, doc_tokens, format_tokens};
use crate::analysis::{GenerationPlan, RequestParam};
use crate::parsing::CodeGenMetadata;
use crate::parsing::types::RenderContext;

/// Generate the top-level `client.rs` aggregate root client.
///
/// Returns `None` when `config.bindings` is `None` (no aggregate name configured), in which case
/// no `client.rs` should be written.
pub(crate) fn generate(
    plan: &GenerationPlan,
    metadata: &CodeGenMetadata,
    config: &CodeGenConfig,
) -> crate::error::Result<Option<String>> {
    let Some(bindings) = config.bindings.as_ref() else {
        return Ok(None);
    };

    // The aggregate root client is REST-shaped: it stores a `CloudClient` + base `Url` and builds
    // HTTP low-level clients on demand. It is only meaningful for the REST client; when REST is not
    // emitted there is no REST per-service client for it to aggregate. (A ConnectRPC aggregate is a
    // follow-up; ConnectRPC dispatch owns its own transport.)
    if !config.client_protocols.rest {
        return Ok(None);
    }

    let aggregate_ident = format_ident!("{}", bindings.aggregate_client_name);

    let mut services = ServiceHandler::for_plan(plan, metadata, config);
    services.sort_by(|a, b| a.plan.service_name.cmp(&b.plan.service_name));

    // Per service: the low-level passthrough accessor `<base>_client(&self) -> <LowLevelClient>` and
    // the methods (collection constructors, flat constructors, resource accessors). With
    // per-resource features, each service's accessors move into their own `impl` block behind the
    // service's feature gate; the main block then keeps only the constructors.
    let (passthrough_accessors, methods, gated_blocks) = if config.rest_client.resource_features {
        let blocks = services
            .iter()
            .map(|s| {
                let cfg = s.client_feature_cfg();
                let accessor = passthrough_accessor(s);
                let methods = service_methods(s);
                quote! {
                    #cfg
                    impl #aggregate_ident {
                        #accessor
                        #(#methods)*
                    }
                }
            })
            .collect_vec();
        (Vec::new(), Vec::new(), blocks)
    } else {
        let passthrough_accessors = services.iter().map(passthrough_accessor).collect_vec();
        let methods = services.iter().flat_map(service_methods).collect_vec();
        (passthrough_accessors, methods, Vec::new())
    };

    let (transport_import, transport_ident) = super::client::transport_tokens(config);
    let convenience_ctors = convenience_ctors(config, &transport_ident);

    let imports = generate_imports(&services, &transport_import);

    let tokens = quote! {
        // The per-service (`crate::codegen::<svc>::*`) and model globs below are
        // convenience-wide; the aggregate root only references a subset directly,
        // so a model whose types it never names (common under buffa) would trip
        // `unused_imports` under `-D warnings`.
        #![allow(unused_imports)]
        #imports

        #[derive(Clone)]
        pub struct #aggregate_ident {
            // `pub(crate)` so the client crate can add hand-written requests.
            pub(crate) client: #transport_ident,
            pub(crate) base_url: Url,
        }

        impl #aggregate_ident {
            /// Create a new aggregate client from a cloud client and base URL.
            ///
            /// Per-service clients are constructed on demand (they only hold a cheaply-cloneable
            /// `CloudClient` + `Url`), so nothing is allocated per service here.
            pub fn new(client: #transport_ident, mut base_url: Url) -> Self {
                if !base_url.path().ends_with('/') {
                    base_url.set_path(&format!("{}/", base_url.path()));
                }
                Self { client, base_url }
            }

            #convenience_ctors

            #(#passthrough_accessors)*

            #(#methods)*
        }

        #(#gated_blocks)*
    };

    Ok(Some(format_tokens(tokens)?))
}

/// The transport-specific convenience constructors of the aggregate client.
fn convenience_ctors(config: &CodeGenConfig, transport_ident: &syn::Ident) -> TokenStream {
    // Convenience constructors are transport-specific:
    // - `CloudClient` (native): unauthenticated + bearer-token variants.
    // - `WasmClient` (browser): the browser attaches the session, so only a
    //   parameterless `new_in_browser(base_url)`.
    // In dual-transport mode each ctor carries its own target-arch `cfg` (the
    // gate must sit on every fn, not the group); in single (default cloud) mode
    // the cloud ctors are emitted ungated.
    let dual = config.dual_transport();
    let native_cfg = if dual {
        quote! { #[cfg(not(target_arch = "wasm32"))] }
    } else {
        quote! {}
    };
    // `new_unauthenticated`/`new_with_token` for the transports that support them; a custom
    // transport gets only `new(transport, base_url)`.
    let token_ctors = if dual || config.uses_default_transport() {
        Some((
            quote! { #transport_ident::new_unauthenticated() },
            quote! { #transport_ident::new_with_token(token) },
        ))
    } else if config.uses_reqwest_transport() {
        Some((
            quote! { #transport_ident::default() },
            quote! { #transport_ident::default().with_token(token.to_string()) },
        ))
    } else {
        None
    };
    let token_ctors = token_ctors.map(|(unauthenticated, with_token)| {
        quote! {
            #native_cfg
            /// Create a new aggregate client with no authentication.
            pub fn new_unauthenticated(base_url: Url) -> Self {
                Self::new(#unauthenticated, base_url)
            }

            #native_cfg
            /// Create a new aggregate client authenticating with a bearer token.
            // `token` stays `impl ToString` to match `CloudClient::new_with_token`'s own signature.
            pub fn new_with_token(base_url: Url, token: impl ToString) -> Self {
                Self::new(#with_token, base_url)
            }
        }
    });
    let wasm_ctor = dual.then(|| {
        quote! {
            #[cfg(target_arch = "wasm32")]
            /// Create a new aggregate client. The browser supplies the session
            /// (cookies / forwarded auth) on each request.
            pub fn new_in_browser(base_url: Url) -> Self {
                Self::new(#transport_ident::new(), base_url)
            }
        }
    });
    let user_agent = config.uses_reqwest_transport().then(|| {
        quote! {
            /// The same client, sending `user_agent` as the `User-Agent` of every request.
            pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
                self.client = self.client.with_user_agent(user_agent);
                self
            }
        }
    });
    quote! {
        #token_ctors
        #wasm_ctor
        #user_agent
    }
}

/// The low-level passthrough accessor `<base>_client(&self) -> <LowLevelClient>`.
///
/// It builds a fresh per-service low-level client (cheap: `CloudClient`/`Url` are O(1) to clone) so
/// callers (e.g. a hybrid proxy server, or hand-written ergonomic wrappers) can drive requests
/// directly.
fn passthrough_accessor(service: &ServiceHandler<'_>) -> TokenStream {
    let accessor = format_ident!("{}_client", service.plan.base_path);
    let client_ty = low_level_client_path(service);
    let ctor = low_level_client_ctor(service);
    let doc = format!(
        "Low-level `{}` client exposing request/response passthrough methods.",
        service.plan.base_path
    );
    quote! {
        #[doc = #doc]
        pub fn #accessor(&self) -> #client_ty {
            #ctor
        }
    }
}

/// The fully-qualified path to a service's generated low-level per-service client,
/// e.g. `crate::codegen::catalogs::CatalogServiceClient`.
fn low_level_client_path(service: &ServiceHandler<'_>) -> TokenStream {
    let module = format_ident!("{}", service.plan.base_path);
    let client = service.low_level_client_type(crate::codegen::ClientProtocol::Rest);
    quote! { crate::codegen::#module::#client }
}

/// An expression constructing a fresh low-level per-service client from the aggregate's stored
/// `CloudClient` + `base_url` (both cheaply cloneable), e.g.
/// `crate::codegen::catalogs::CatalogServiceClient::new(self.client.clone(), self.base_url.clone())`.
fn low_level_client_ctor(service: &ServiceHandler<'_>) -> TokenStream {
    let path = low_level_client_path(service);
    quote! { #path::new(self.client.clone(), self.base_url.clone()) }
}

/// Emit `use` statements the aggregate needs: cloud client + url, per-service low-level clients,
/// builder types, models (for enum/message param types), and hand-written scoped clients.
fn generate_imports(
    services: &[ServiceHandler<'_>],
    transport_import: &TokenStream,
) -> TokenStream {
    // Per-service low-level clients and their builders both live under `crate::codegen::<base>`.
    let codegen_imports = services.iter().map(|s| {
        let module = format_ident!("{}", s.plan.base_path);
        let cfg = s.client_feature_cfg();
        quote! {
            #cfg
            use crate::codegen::#module::*;
        }
    });

    // Models, for enum/message parameter types that appear in builder `::new` signatures.
    // Services sharing a models path share its feature too, so deduping keeps the right gate.
    let model_imports = services
        .iter()
        .map(|s| (s.models_path(), s.client_feature_cfg()))
        .unique_by(|(p, _)| quote! { #p }.to_string())
        .map(|(mod_path, cfg)| quote! { #cfg use #mod_path::*; });

    // The generated scoped clients (`CatalogClient`, …) live alongside the low-level clients and
    // builders under `crate::codegen::<base>`, so the `codegen_imports` globs above already bring
    // them into scope — no separate import needed.
    quote! {
        #transport_import
        use url::Url;
        #(#codegen_imports)*
        #(#model_imports)*
    }
}

/// Emit all aggregate methods contributed by a single service.
fn service_methods(service: &ServiceHandler<'_>) -> Vec<TokenStream> {
    let mode = service.binding_mode();

    // The aggregate is REST-only, so it surfaces only routed methods. Scoped services contribute
    // only collection-style methods (list/create/factory); their instance methods live on the
    // generated scoped client. Resource-less services contribute every method, lowered flat (all
    // params passed, including path params).
    service
        .rest_methods()
        .filter(|method| mode == BindingMode::Flat || method.is_collection_method())
        .map(|method| builder_constructor_method(service, &method))
        .chain(resource_accessor_method(service))
        .collect()
}

/// Build a doc-comment token stream for an aggregate method: the proto method documentation,
/// followed by a `# Arguments` section listing each required param that has a field comment.
///
/// Falls back to just the method doc (or nothing) when no params carry documentation, so methods
/// with documented protos still get useful rustdoc on the aggregate surface.
fn doc_with_arguments(method: &MethodHandler<'_>) -> TokenStream {
    let mut doc = method
        .plan
        .metadata
        .documentation
        .as_deref()
        .map(|d| d.trim_end().to_string())
        .unwrap_or_default();

    let arg_lines: Vec<String> = method
        .required_parameters()
        .filter_map(|p| {
            p.documentation()
                .map(|d| format!("* `{}` - {}", p.name(), d.trim()))
        })
        .collect();

    if !arg_lines.is_empty() {
        if !doc.is_empty() {
            doc.push('\n');
        }
        doc.push_str("\n# Arguments\n\n");
        doc.push_str(&arg_lines.join("\n"));
    }

    if doc.is_empty() {
        quote! {}
    } else {
        doc_tokens(Some(&doc))
    }
}

/// Render the constructor parameter list (name: type) and the forwarding arg list for a builder
/// constructor, given the params to forward (in `Builder::new` order).
fn builder_params(
    method: &MethodHandler<'_>,
    params: &[&RequestParam],
) -> (Vec<TokenStream>, Vec<TokenStream>) {
    let param_defs = params
        .iter()
        .map(|p| {
            let ident = p.field_ident();
            // Match the builder's `::new` param types (RenderContext::Constructor) so the forward
            // call type-checks: `impl Into<String>` for required strings, concrete types otherwise.
            let ty = method.field_type(p.field_type(), RenderContext::Constructor);
            quote! { #ident: #ty }
        })
        .collect();
    let args = params
        .iter()
        .map(|p| {
            let ident = p.field_ident();
            quote! { #ident }
        })
        .collect();
    (param_defs, args)
}

/// An aggregate builder-constructor method: forward all required params (in
/// `required_parameters()` order, incl. path params) to the builder's `::new`, which takes exactly
/// those params, constructing the per-service low-level client on demand. The binding method name
/// mirrors what the Python aggregate calls.
fn builder_constructor_method(
    service: &ServiceHandler<'_>,
    method: &MethodHandler<'_>,
) -> TokenStream {
    let doc = doc_with_arguments(method);
    let method_name = method.binding_method_name();
    let builder_ty = method.builder_type();
    let params: Vec<&RequestParam> = method.required_parameters().collect();
    let (param_defs, args) = builder_params(method, &params);
    let ctor = low_level_client_ctor(service);

    quote! {
        #doc
        pub fn #method_name(&self, #(#param_defs),*) -> #builder_ty {
            #builder_ty::new(#ctor, #(#args),*)
        }
    }
}

/// Emit the resource accessor for a resource-scoped service:
/// `<singular>(<components>: impl Into<String>…) -> <Singular>Client`.
///
/// Passes the captured name components **directly** to the generated scoped client's `new` — no
/// dot-join / split round-trip. The components come from the shared [`super::AccessorSpec`] (built
/// from `ServicePlan::resource_accessor_params`): the parent chain plus the leaf name. The
/// per-service low-level client is built on demand from the aggregate's `CloudClient` + `base_url`.
fn resource_accessor_method(service: &ServiceHandler<'_>) -> Option<TokenStream> {
    // The accessor returns the generated scoped client, which only exists when resource-client
    // generation is enabled. Without it there is nothing to hand back.
    if !service.config.output.generate_resource_clients {
        return None;
    }
    // The scoped client is skipped for resources with a non-string path parameter (see
    // `supports_scoped_client`); don't emit an accessor to a client that won't exist.
    if !service.supports_scoped_client() {
        return None;
    }
    let spec = service.accessor_spec()?;
    let client_ty = service.scoped_client_type()?;
    let method_name = format_ident!("{}", spec.singular);
    let ctor = low_level_client_ctor(service);

    let param_idents: Vec<_> = spec.params.iter().map(|p| format_ident!("{}", p)).collect();
    let param_defs: Vec<_> = param_idents
        .iter()
        .map(|id| quote! { #id: impl Into<String> })
        .collect();
    let args = param_idents.iter().map(|id| quote! { #id });

    let doc = format!(
        " Access the `{}` resource scoped to the given name.",
        spec.singular
    );

    // For nested resources, also offer a `<singular>_from_full_name(full_name)` convenience that
    // delegates to the scoped client's `from_full_name` (which splits the dot-joined name once).
    let from_full_name = if spec.nested {
        let ffn_method = format_ident!("{}_from_full_name", spec.singular);
        let ffn_doc = format!(
            " Access the `{}` resource from its dot-joined full name.",
            spec.singular
        );
        quote! {
            #[doc = #ffn_doc]
            pub fn #ffn_method(&self, full_name: impl Into<String>) -> #client_ty {
                #client_ty::from_full_name(full_name, #ctor)
            }
        }
    } else {
        quote! {}
    };

    Some(quote! {
        #[doc = #doc]
        pub fn #method_name(&self, #(#param_defs),*) -> #client_ty {
            #client_ty::new(#(#args,)* #ctor)
        }

        #from_full_name
    })
}