olai_codegen/codegen/mod.rs
1//! Code generation module for REST API handlers and language bindings.
2//!
3//! This module provides the core functionality for generating Rust code from
4//! protobuf metadata extracted from service definitions.
5//!
6//! ## Architecture
7//!
8//! The code generation process follows these phases:
9//! 1. **Analysis**: Process collected metadata to understand service structure
10//! 2. **Planning**: Determine what code needs to be generated
11//! 3. **Generation**: Create Rust code using templates and metadata
12//! 4. **Output**: Write generated code to appropriate files
13//!
14//! ## Generated Code Types
15//!
16//! - **Handler Traits**: Async trait definitions for service operations
17//! - **Request Extractors**: Axum FromRequest/FromRequestParts implementations
18//! - **Route Handlers**: Axum handler functions that delegate to traits
19//! - **Client Code**: HTTP client implementations for services
20//! - **Type Mappings**: Conversions between protobuf and Rust types
21
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23
24use convert_case::{Case, Casing};
25use itertools::Itertools;
26use proc_macro2::TokenStream;
27use quote::{format_ident, quote};
28use syn::Ident;
29
30use crate::error::{Error, Result};
31
32use crate::analysis::{
33 BodyField, GenerationPlan, ManagedResource, MethodPlan, RequestParam, RequestType, ServicePlan,
34 analyze_metadata, split_body_fields,
35};
36use crate::output;
37use crate::parsing::types::{self, RenderContext, UnifiedType};
38use crate::parsing::{CodeGenMetadata, MessageField, MessageInfo};
39
40mod aggregate;
41mod bindings;
42mod builder;
43mod client;
44mod config;
45mod connect_client;
46mod handler;
47pub(crate) mod node;
48mod python;
49mod resource_client;
50mod resources;
51mod server;
52mod tokens;
53mod wasm;
54
55pub use config::{
56 BindingsConfig, ClientProtocol, ClientProtocols, CodeGenConfig, CodeGenOutput,
57 DEFAULT_TRANSPORT_TYPE_PATH, ModelsPath, PythonApi, Runtime,
58};
59pub(crate) use tokens::{doc_tokens, format_tokens, format_tokens_static};
60
61/// How a language binding lowers a method's call into the underlying Rust client.
62///
63/// Resource services expose a scoped client (e.g. `CatalogClient`) that captures path params;
64/// resource-less services have no such client, so their methods live on the root client and must
65/// pass every param themselves (the "flat" shape, treated like collection methods).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) enum BindingMode {
68 /// Method lives on a resource-scoped client that already holds the path params.
69 Scoped,
70 /// Method lives on the root client and receives all params, including path params.
71 Flat,
72}
73
74/// Language-agnostic description of a service's resource accessor method on the aggregate client.
75///
76/// Computed once from the IR (`ServicePlan::resource_accessor_params`) and rendered by each language
77/// emitter, so the param list and nested-vs-flat decision are shared by construction.
78pub(crate) struct AccessorSpec {
79 /// The resource singular, used as the accessor method name (e.g. `catalog`).
80 pub(crate) singular: String,
81 /// The ordered accessor params (ancestors + `<singular>_name` leaf), e.g.
82 /// `["catalog_name", "schema_name"]` or `["name"]`.
83 pub(crate) params: Vec<String>,
84 /// Whether the resource is nested (has parent components); its full name is the dot-joined
85 /// params and the accessor delegates through a `<singular>_from_full_name` helper.
86 pub(crate) nested: bool,
87}
88
89impl AccessorSpec {
90 /// The `format!` template for joining the params into a composite full name
91 /// (e.g. `"{}.{}"` for two params). Only meaningful when [`Self::nested`] is true.
92 pub(crate) fn join_format(&self) -> String {
93 std::iter::repeat_n("{}", self.params.len())
94 .collect::<Vec<_>>()
95 .join(".")
96 }
97}
98
99impl MethodPlan {
100 pub(crate) fn resource_client_method(&self) -> Ident {
101 match &self.scoped_verb {
102 Some(verb) => format_ident!("{}", verb),
103 None => format_ident!("{}", self.handler_function_name),
104 }
105 }
106
107 pub(crate) fn base_method_ident(&self) -> Ident {
108 format_ident!("{}", self.handler_function_name)
109 }
110}
111
112/// Generate all code described by `config` from `metadata`.
113///
114/// Writes the following outputs, depending on which [`CodeGenOutput`] fields are `Some`:
115///
116/// | Field | Contents |
117/// |-------|----------|
118/// | `output.common` | Axum extractor code, per-service `mod.rs` (always written) |
119/// | `output.models_gen` | `labels.rs` resource-enum file (falls back to `common` if `None`) |
120/// | `output.server` | Handler trait + Axum route wiring per service |
121/// | `output.client` | HTTP client structs and request builders per service |
122/// | `output.python` | PyO3 binding wrappers + `.pyi` typings stub |
123/// | `output.node` | NAPI binding wrappers |
124/// | `output.node_ts` | TypeScript client (`client.ts`) |
125///
126/// # Required fields
127///
128/// - `output.common` is always required.
129/// - `bindings` must be `Some` when any of `output.python`, `output.node`, or `output.node_ts`
130/// is `Some`; otherwise returns [`Error::MissingBindingsConfig`].
131/// - `models_path_template` and `models_path_crate_template` must be valid Rust path templates
132/// (containing `{service}`); invalid templates return [`Error::InvalidModelsPathTemplate`].
133///
134/// # Optional fields
135///
136/// Setting `generate_resource_enum` to `false` skips `labels.rs` generation.
137/// Setting `bindings` to `None` skips all language-binding output.
138///
139/// Call [`CodeGenConfig::validate`] before this function to surface config errors at
140/// construction time rather than mid-generation.
141///
142/// # Errors
143///
144/// Returns:
145///
146/// - [`Error::InvalidModelsPathTemplate`] if `models_path_template` or `models_path_crate_template`
147/// is not a valid Rust path template.
148/// - [`Error::InvalidConfigPath`] if `context_type_path` or `result_type_path` is not a valid Rust
149/// path.
150/// - [`Error::MissingBindingsConfig`] if any of the `python`, `node`, or `node_ts` outputs is
151/// requested without a `bindings` config.
152/// - [`Error::MissingAnnotation`] or [`Error::Build`] surfaced while analyzing the metadata (e.g. a
153/// required `google.api` annotation is missing, or a service cannot be planned).
154/// - [`Error::InvalidRustPath`] if a proto message or field maps to an invalid Rust path, and
155/// [`Error::InvalidErrorTypePath`] if the configured `error_type_path` is not a valid Rust path.
156/// - [`Error::GeneratedParse`] if an internal generator produces tokens that do not parse as a Rust
157/// file.
158/// - [`Error::Io`] if creating the models directory or writing any generated file fails.
159pub fn generate_code(metadata: &CodeGenMetadata, config: &CodeGenConfig) -> Result<()> {
160 // Validate templates early so callers get a clean error before any generation starts.
161 let models_path = ModelsPath::new(&config.models_path_template)?;
162 let models_path_crate = ModelsPath::new(&config.models_path_crate_template)?;
163
164 // The context/result type paths come from user config and are parsed as `syn::Path` deep in
165 // the per-method generators (handler/client/server/builder). Validate them once here so a
166 // typo surfaces as a clean error instead of panicking mid-generation.
167 for (field, path) in [
168 ("context_type", &config.context_type_path),
169 ("result_type", &config.result_type_path),
170 ] {
171 syn::parse_str::<syn::Path>(path).map_err(|source| Error::InvalidConfigPath {
172 field,
173 path: path.clone(),
174 source,
175 })?;
176 }
177
178 // Validate that bindings config is present when language-binding output is requested.
179 if (config.output.python.is_some()
180 || config.output.node.is_some()
181 || config.output.node_ts.is_some())
182 && config.bindings.is_none()
183 {
184 return Err(Error::MissingBindingsConfig);
185 }
186
187 // The Connect client emitter needs the connect-rust client's import path.
188 if config.client_protocols.connect
189 && config.output.client.is_some()
190 && config.connect_client_path.is_none()
191 {
192 return Err(Error::MissingConnectClientPath);
193 }
194
195 let plan = analyze_metadata(metadata)?;
196
197 // Validate every service's `{service}` substitution up front so a base path that isn't a
198 // valid Rust path segment fails cleanly here rather than panicking inside per-service codegen.
199 for service in &plan.services {
200 models_path.validate_for(&service.base_path)?;
201 models_path_crate.validate_for(&service.base_path)?;
202 }
203
204 let common_code = generate_common_code(&plan, metadata, config)?;
205 output::write_generated_code(&common_code, &config.output.common)?;
206
207 if config.output.models.is_some() {
208 let subdir = config
209 .output
210 .models_subdir_path()
211 .expect("models is Some so subdir is Some");
212 std::fs::create_dir_all(&subdir).map_err(Error::Io)?;
213
214 if config.generate_resource_enum {
215 let resource_enum = resources::generate_resource_enum(
216 &plan,
217 metadata,
218 config,
219 config.error_type_path.as_deref(),
220 )?;
221 let mut models_files = GeneratedCode {
222 files: std::collections::HashMap::new(),
223 };
224 models_files
225 .files
226 .insert("labels.rs".to_string(), resource_enum);
227 output::write_generated_code(&models_files, &subdir)?;
228 }
229
230 // When Python bindings are generated, the bindings pass model types
231 // across the FFI boundary by their bare type, so the model crate must
232 // carry `FromPyObject`/`IntoPyObject` impls. Emit them here (orphan rule:
233 // the model types are local to this crate). Gated on the `python` feature
234 // in the generated `mod.rs`.
235 let mut include_pyo3_impls = false;
236 if config.output.python.is_some() {
237 let pyo3_impls = python::generate_pyo3_impls(&plan, metadata, config.runtime)?;
238 if !pyo3_impls.is_empty() {
239 let mut pyo3_files = GeneratedCode {
240 files: std::collections::HashMap::new(),
241 };
242 pyo3_files
243 .files
244 .insert("pyo3_impls.rs".to_string(), pyo3_impls);
245 output::write_generated_code(&pyo3_files, &subdir)?;
246 include_pyo3_impls = true;
247 }
248 }
249
250 let include_labels = config.generate_resource_enum;
251 // The Axum extractors are co-located in this same directory (the common
252 // generator wrote `<svc>/server.rs` + `<svc>/mod.rs` here), and this
253 // `mod.rs` overwrites the common generator's top-level `mod.rs` — so it
254 // re-declares the per-service extractor submodules it emitted.
255 let mod_content = generate_models_mod(
256 &plan.services,
257 include_labels,
258 include_pyo3_impls,
259 metadata,
260 config.runtime,
261 );
262 // Route through write_generated_code so the `// @generated` header is prepended like
263 // every other emitted file, rather than writing it raw.
264 let mut mod_files = GeneratedCode {
265 files: std::collections::HashMap::new(),
266 };
267 mod_files.files.insert("mod.rs".to_string(), mod_content);
268 output::write_generated_code(&mod_files, &subdir)?;
269 }
270
271 if let Some(ref server_dir) = config.output.server {
272 let server_code = generate_server_code(&plan, metadata, config)?;
273 output::write_generated_code(&server_code, server_dir)?;
274 }
275
276 if let Some(ref client_dir) = config.output.client {
277 let client_code = generate_client_code(&plan, metadata, config)?;
278 output::write_generated_code(&client_code, client_dir)?;
279 }
280
281 if let Some(ref python_dir) = config.output.python {
282 let python_code = generate_python_code(&plan, metadata, config)?;
283 output::write_generated_code(&python_code, python_dir)?;
284 }
285
286 if let Some(ref node_dir) = config.output.node {
287 let node_code = generate_node_code(&plan, metadata, config)?;
288 output::write_generated_code(&node_code, node_dir)?;
289 }
290
291 if let Some(ref node_ts_dir) = config.output.node_ts {
292 let node_ts_code = generate_node_ts_code(&plan, metadata, config)?;
293 output::write_generated_code(&node_ts_code, node_ts_dir)?;
294 }
295
296 if let Some(ref wasm_dir) = config.output.wasm {
297 let wasm_code = generate_wasm_code(&plan, metadata, config)?;
298 output::write_generated_code(&wasm_code, wasm_dir)?;
299 }
300
301 Ok(())
302}
303
304fn generate_common_code(
305 plan: &GenerationPlan,
306 metadata: &CodeGenMetadata,
307 config: &CodeGenConfig,
308) -> Result<GeneratedCode> {
309 let mut files = HashMap::new();
310
311 for service in &plan.services {
312 let handler = ServiceHandler {
313 plan: service,
314 metadata,
315 config,
316 };
317 let server_code = server::generate_common(&handler)?;
318 // Nest the extractors under an `extractors/` subdir so their per-service
319 // module names can't collide with the co-located model package modules
320 // (e.g. a `unitycatalog.agent_skills.v0alpha1` package yields a model
321 // module `agent_skills` that would otherwise clash with the `agent_skills`
322 // service extractor module).
323 files.insert(
324 format!("{EXTRACTORS_SUBDIR}/{}/server.rs", service.base_path),
325 server_code,
326 );
327 let module_code = generate_common_module();
328 files.insert(
329 format!("{EXTRACTORS_SUBDIR}/{}/mod.rs", service.base_path),
330 module_code,
331 );
332 }
333
334 // The `extractors/mod.rs` that declares the per-service extractor submodules.
335 files.insert(
336 format!("{EXTRACTORS_SUBDIR}/mod.rs"),
337 generate_extractors_module(&plan.services),
338 );
339
340 let module_code = main_module(&plan.services);
341 files.insert("mod.rs".to_string(), module_code);
342
343 Ok(GeneratedCode { files })
344}
345
346fn generate_server_code(
347 plan: &GenerationPlan,
348 metadata: &CodeGenMetadata,
349 config: &CodeGenConfig,
350) -> Result<GeneratedCode> {
351 let mut files = HashMap::new();
352
353 for service in &plan.services {
354 let handler = ServiceHandler {
355 plan: service,
356 metadata,
357 config,
358 };
359 let trait_code = handler::generate(&handler)?;
360 files.insert(format!("{}/handler.rs", service.base_path), trait_code);
361 let server_code = server::generate_server(&handler)?;
362 files.insert(format!("{}/server.rs", service.base_path), server_code);
363 let module_code = generate_server_module(service);
364 files.insert(format!("{}/mod.rs", service.base_path), module_code);
365 }
366
367 let module_code = main_module(&plan.services);
368 files.insert("mod.rs".to_string(), module_code);
369
370 Ok(GeneratedCode { files })
371}
372
373fn generate_python_code(
374 plan: &GenerationPlan,
375 metadata: &CodeGenMetadata,
376 config: &CodeGenConfig,
377) -> Result<GeneratedCode> {
378 let mut files = HashMap::new();
379
380 let handlers = plan
381 .services
382 .iter()
383 .map(|service| ServiceHandler {
384 plan: service,
385 metadata,
386 config,
387 })
388 .collect_vec();
389
390 // Per-service scoped client modules are only meaningful for services with a scoped binding.
391 // The methods of resource-less and flat-lowered services live on the root client (emitted by
392 // `main_module`), so they don't get a scoped module of their own.
393 for service in handlers.iter().filter(|s| s.emits_scoped_binding()) {
394 let python_code = python::generate(service)?;
395 files.insert(format!("{}.rs", service.plan.base_path), python_code);
396 }
397
398 let module_code = python::main_module(&handlers)?;
399 files.insert("mod.rs".to_string(), module_code);
400
401 let python_api = config
402 .bindings
403 .as_ref()
404 .map(|b| b.python_api)
405 .unwrap_or_default();
406 files.insert(
407 "runtime.rs".to_string(),
408 python::generate_runtime(python_api),
409 );
410
411 let python_typings_code = python::generate_typings(&handlers);
412 files.insert(
413 config.output.python_typings_filename.clone(),
414 python_typings_code,
415 );
416
417 Ok(GeneratedCode { files })
418}
419
420fn generate_node_code(
421 plan: &GenerationPlan,
422 metadata: &CodeGenMetadata,
423 config: &CodeGenConfig,
424) -> Result<GeneratedCode> {
425 let mut files = HashMap::new();
426
427 let handlers = plan
428 .services
429 .iter()
430 .map(|service| ServiceHandler {
431 plan: service,
432 metadata,
433 config,
434 })
435 .collect_vec();
436
437 // See `generate_python_code`: only services with a scoped binding get a per-service scoped
438 // module; resource-less and flat-lowered services' methods live on the root client.
439 for service in handlers.iter().filter(|s| s.emits_scoped_binding()) {
440 let napi_code = node::generate(service)?;
441 files.insert(format!("{}.rs", service.plan.base_path), napi_code);
442 }
443
444 let module_code = node::main_module(&handlers)?;
445 files.insert("mod.rs".to_string(), module_code);
446
447 Ok(GeneratedCode { files })
448}
449
450fn generate_node_ts_code(
451 plan: &GenerationPlan,
452 metadata: &CodeGenMetadata,
453 config: &CodeGenConfig,
454) -> Result<GeneratedCode> {
455 let handlers = plan
456 .services
457 .iter()
458 .map(|service| ServiceHandler {
459 plan: service,
460 metadata,
461 config,
462 })
463 .collect_vec();
464
465 let ts_code = node::typescript::generate_client_ts(&handlers);
466 let mut files = HashMap::new();
467 files.insert("client.ts".to_string(), ts_code);
468
469 // The `models/index.ts` barrel re-exports every service's generated protobuf-es modules so
470 // `client.ts`'s `from "./models"` imports (message and service request/response types) resolve.
471 let models_barrel = node::typescript::generate_models_barrel(&handlers);
472 files.insert("models/index.ts".to_string(), models_barrel);
473
474 Ok(GeneratedCode { files })
475}
476
477/// Generate the WASM/browser `#[wasm_bindgen]` binding layer plus its `.d.ts`.
478///
479/// Emits `bindings.rs` (a `#[wasm_bindgen]` wrapper per service client, exchanging request/response
480/// values as plain JS objects via `serde-wasm-bindgen`) and `client.d.ts` (TypeScript declarations
481/// for JS/TS consumers). The wrapped clients are the generated low-level clients, which must be
482/// generated with the WASM transport (`transport_type_path = "olai_http_wasm::WasmClient"`).
483fn generate_wasm_code(
484 plan: &GenerationPlan,
485 metadata: &CodeGenMetadata,
486 config: &CodeGenConfig,
487) -> Result<GeneratedCode> {
488 let handlers = plan
489 .services
490 .iter()
491 .map(|service| ServiceHandler {
492 plan: service,
493 metadata,
494 config,
495 })
496 .collect_vec();
497
498 let mut files = HashMap::new();
499 // Emit as `mod.rs` so the client crate mounts the bindings with a plain
500 // `mod wasm;` (no `#[path]` alias) over the `wasm/` convention subdir.
501 files.insert("mod.rs".to_string(), wasm::generate_bindings(&handlers)?);
502 files.insert("client.d.ts".to_string(), wasm::generate_dts(&handlers));
503
504 Ok(GeneratedCode { files })
505}
506
507fn generate_client_code(
508 plan: &GenerationPlan,
509 metadata: &CodeGenMetadata,
510 config: &CodeGenConfig,
511) -> Result<GeneratedCode> {
512 let mut files = HashMap::new();
513 let protocols = config.client_protocols.selected();
514 let multi = config.client_protocols.is_multi();
515
516 for service in &plan.services {
517 let handler = ServiceHandler {
518 plan: service,
519 metadata,
520 config,
521 };
522
523 // Emit each selected protocol's low-level client + builders. With a single protocol the
524 // files are flat under `<service>/`; with both they go under `<service>/<protocol>/` to
525 // avoid the two `client.rs`/`builders.rs` colliding. The shared builder layer binds to the
526 // protocol's low-level client and only calls `client.<method>(&request)`.
527 let mut emitted_resource_client = false;
528 for &protocol in &protocols {
529 let prefix = if multi {
530 format!("{}/{}", service.base_path, protocol.subdir())
531 } else {
532 service.base_path.clone()
533 };
534
535 let client_code = match protocol {
536 ClientProtocol::Rest => client::generate(&handler)?,
537 ClientProtocol::Connect => connect_client::generate(&handler)?,
538 };
539 files.insert(format!("{prefix}/client.rs"), client_code);
540 files.insert(
541 format!("{prefix}/builders.rs"),
542 builder::generate(&handler, protocol)?,
543 );
544
545 // Ergonomic resource-scoped clients are a REST-only feature (driven by
546 // `google.api.resource` hierarchies). ConnectRPC-only protos rarely carry those, so the
547 // Connect client emits just the flat per-service client + builders.
548 let has_resource_client = protocol == ClientProtocol::Rest
549 && config.output.generate_resource_clients
550 && resource_client::generate(&handler, plan)?
551 .map(|code| {
552 files.insert(format!("{prefix}/resource.rs"), code);
553 })
554 .is_some();
555
556 files.insert(
557 format!("{prefix}/mod.rs"),
558 generate_client_module(has_resource_client),
559 );
560 emitted_resource_client |= has_resource_client;
561 }
562
563 // When both protocols are emitted under subdirs, the `<service>/mod.rs` mounts and re-exports
564 // them. (Single-protocol output already wrote a flat `<service>/mod.rs` above.)
565 if multi {
566 files.insert(
567 format!("{}/mod.rs", service.base_path),
568 generate_protocol_split_module(&protocols),
569 );
570 }
571 let _ = emitted_resource_client;
572 }
573
574 // Top-level aggregate root client (e.g. `UnityCatalogClient`), emitted only when a bindings
575 // config (which carries the aggregate name) is present.
576 let has_aggregate = aggregate::generate(plan, metadata, config)?
577 .map(|aggregate_code| {
578 files.insert("client.rs".to_string(), aggregate_code);
579 })
580 .is_some();
581
582 let module_code = generate_client_main_module(&plan.services, has_aggregate);
583 files.insert("mod.rs".to_string(), module_code);
584
585 Ok(GeneratedCode { files })
586}
587
588fn generate_common_module() -> String {
589 let tokens = quote! {
590 #[cfg(feature = "axum")]
591 pub mod server;
592 };
593 format_tokens_static(tokens)
594}
595
596/// Subdirectory (under the co-located models `_gen/` dir) that holds the Axum
597/// request extractors, one `<service>/server.rs` per service. Nesting them keeps
598/// their module names from colliding with the model package modules that share
599/// the dir.
600pub(crate) const EXTRACTORS_SUBDIR: &str = "extractors";
601
602/// The `extractors/mod.rs` that declares one `pub mod <service>;` per service.
603/// Axum-coupled, so gated on the `axum` feature.
604fn generate_extractors_module(services: &[ServicePlan]) -> String {
605 let decls: Vec<TokenStream> = services
606 .iter()
607 .map(|s| format_ident!("{}", s.base_path))
608 // Dedupe: multiple services can share a base_path.
609 .collect::<BTreeSet<_>>()
610 .into_iter()
611 .map(|m| quote! { #[cfg(feature = "axum")] pub mod #m; })
612 .collect();
613 format_tokens_static(quote! { #(#decls)* })
614}
615
616fn generate_server_module(service: &ServicePlan) -> String {
617 let handler_ident = format_ident!("{}", service.handler_name);
618 let tokens = quote! {
619 pub use handler::#handler_ident;
620
621 mod handler;
622 #[cfg(feature = "axum")]
623 pub mod server;
624 };
625 format_tokens_static(tokens)
626}
627
628fn generate_client_module(has_resource_client: bool) -> String {
629 // Emit the scoped resource client module only when it was generated for this service.
630 // Generated client modules structurally trip a few lints: the full client
631 // surface is emitted whether or not a given consumer calls every builder
632 // (`dead_code`), flat methods can exceed the arg-count threshold
633 // (`too_many_arguments`), and proto comment prose may wrap list items
634 // (`doc_lazy_continuation`). Allow these on the module so consumers' own
635 // `-D warnings` builds stay clean; genuine issues are fixed in the generator.
636 let allow = quote! {
637 #[allow(dead_code, clippy::too_many_arguments, clippy::doc_lazy_continuation)]
638 };
639 // Glob re-exports of the per-service submodules. Not every submodule
640 // contributes a name that the parent's consumers use directly, so a
641 // `pub use <mod>::*` can trip `unused_imports` under `-D warnings` (notably
642 // under buffa, where some helper types are unreferenced); allow it.
643 let reexport_allow = quote! { #[allow(unused_imports)] };
644 let resource_module = if has_resource_client {
645 quote! {
646 #reexport_allow
647 pub use resource::*;
648 #allow
649 pub mod resource;
650 }
651 } else {
652 quote! {}
653 };
654 let tokens = quote! {
655 #reexport_allow
656 pub use client::*;
657 #reexport_allow
658 pub use builders::*;
659 #resource_module
660
661 #allow
662 pub mod client;
663 #allow
664 pub mod builders;
665 };
666 format_tokens_static(tokens)
667}
668
669/// The `<service>/mod.rs` for a service that emits **both** protocols: mount each protocol's client
670/// as its own submodule (`pub mod rest;` / `pub mod connect;`).
671///
672/// They are *not* glob-re-exported into the service module: both protocols define identically-named
673/// types (`GetThingBuilder`, …), so a flat re-export would collide. Callers disambiguate by module
674/// (`read::rest::GetThingBuilder` vs `read::connect::GetThingBuilder`).
675fn generate_protocol_split_module(protocols: &[ClientProtocol]) -> String {
676 let allow = quote! {
677 #[allow(dead_code, clippy::too_many_arguments, clippy::doc_lazy_continuation)]
678 };
679 let mods: Vec<TokenStream> = protocols
680 .iter()
681 .map(|p| {
682 let m = format_ident!("{}", p.subdir());
683 quote! { #allow pub mod #m; }
684 })
685 .collect();
686 format_tokens_static(quote! { #(#mods)* })
687}
688
689pub fn main_module(services: &[ServicePlan]) -> String {
690 let service_modules: Vec<TokenStream> = services
691 .iter()
692 .map(|s| {
693 let module_name = format_ident!("{}", s.base_path);
694 quote! { pub mod #module_name; }
695 })
696 .collect();
697
698 let tokens = quote! {
699 #(#service_modules)*
700 };
701 format_tokens_static(tokens)
702}
703
704fn generate_client_main_module(services: &[ServicePlan], has_aggregate: bool) -> String {
705 let service_modules: Vec<TokenStream> = services
706 .iter()
707 .map(|s| {
708 let module_name = format_ident!("{}", s.base_path);
709 quote! { pub mod #module_name; }
710 })
711 .collect();
712
713 // Export the aggregate root client (`client.rs`) when it was generated. Per-service files live
714 // under `<base_path>/`, so a top-level `client` module never collides with them.
715 // The aggregate client has many flat methods (arg-count) and renders proto
716 // comment prose (doc list wrapping), so allow those structural lints.
717 let aggregate_module = if has_aggregate {
718 quote! {
719 #[allow(clippy::too_many_arguments, clippy::doc_lazy_continuation)]
720 pub mod client;
721 #[allow(unused_imports)]
722 pub use client::*;
723 }
724 } else {
725 quote! {}
726 };
727
728 // The `stream_paginated` helper (and its `futures::Future` import) is used
729 // only by `List` builders' `into_stream`. Emit it solely when some service
730 // has a List method, so it isn't dead code otherwise.
731 let has_list = services
732 .iter()
733 .flat_map(|s| s.methods.iter())
734 .any(|m| matches!(m.request_type, RequestType::List));
735 let pagination = if has_list {
736 quote! {
737 use futures::Future;
738
739 pub(super) fn stream_paginated<F, Fut, S, T>(
740 state: S,
741 op: F,
742 ) -> impl futures::Stream<Item = crate::Result<T>>
743 where
744 F: Fn(S, Option<String>) -> Fut + Copy,
745 Fut: Future<Output = crate::Result<(T, S, Option<String>)>>,
746 {
747 enum PaginationState<T> {
748 Start(T),
749 HasMore(T, String),
750 Done,
751 }
752
753 futures::stream::unfold(
754 PaginationState::Start(state),
755 move |state| async move {
756 let (s, page_token) = match state {
757 PaginationState::Start(s) => (s, None),
758 PaginationState::HasMore(s, page_token) if !page_token.is_empty() => {
759 (s, Some(page_token))
760 }
761 _ => {
762 return None;
763 }
764 };
765
766 let (resp, s, continuation) = match op(s, page_token).await {
767 Ok(resp) => resp,
768 Err(e) => return Some((Err(e), PaginationState::Done)),
769 };
770
771 let next_state = match continuation {
772 Some(token) => PaginationState::HasMore(s, token),
773 None => PaginationState::Done,
774 };
775
776 Some((Ok(resp), next_state))
777 },
778 )
779 }
780 }
781 } else {
782 quote! {}
783 };
784
785 let tokens = quote! {
786 #(#service_modules)*
787
788 #aggregate_module
789
790 #pagination
791 };
792 format_tokens_static(tokens)
793}
794
795/// Generate the `mod.rs` for `crates/common/src/models/`.
796///
797/// Emits `pub mod <service> { pub mod <version> { include!("./<pkg>.rs") } }` blocks
798/// for every service in the plan, plus static re-exports and module declarations.
799/// The model files are co-located in this directory (written by the buf plugin), so
800/// the includes are plain sibling references.
801///
802/// When `include_labels` is `true`, also emits `pub mod labels; pub use labels::{ObjectLabel, Resource};`.
803///
804/// `metadata` is used to discover all resource-annotated messages (even those not returned
805/// directly by an RPC) so they can be included in `pub use` re-exports.
806pub fn generate_models_mod(
807 services: &[ServicePlan],
808 include_labels: bool,
809 include_pyo3_impls: bool,
810 metadata: &CodeGenMetadata,
811 runtime: Runtime,
812) -> String {
813 // Services are keyed by proto package, not by service: multiple services can share a
814 // package (e.g. `CatalogService` and `SchemaService` both in `example.catalog.v1`), and
815 // emitting one `pub mod`/`pub use` block per service would produce duplicate modules and
816 // imports (E0428). Group by package first, then emit once per package.
817 //
818 // package = "unitycatalog.catalogs.v1"
819 // parts = ["unitycatalog", "catalogs", "v1"]
820 // service module = second-to-last segment ("catalogs"); version = last segment ("v1").
821 let mod_segments = |svc: &ServicePlan| -> (String, String) {
822 let parts: Vec<&str> = svc.package.split('.').collect();
823 if parts.len() >= 2 {
824 (
825 parts[parts.len() - 2].to_string(),
826 parts[parts.len() - 1].to_string(),
827 )
828 } else {
829 (svc.base_path.clone(), "v1".to_string())
830 }
831 };
832
833 // One entry per package, in sorted order, with the union of re-exported type names.
834 let mut packages: BTreeMap<String, (String, String, BTreeSet<String>)> = BTreeMap::new();
835 for svc in services {
836 let (svc_seg, ver_seg) = mod_segments(svc);
837 let entry = packages
838 .entry(svc.package.clone())
839 .or_insert_with(|| (svc_seg, ver_seg, BTreeSet::new()));
840 let type_names = &mut entry.2;
841
842 // Re-export managed resources for this service...
843 type_names.extend(svc.managed_resources.iter().map(|r| r.type_name.clone()));
844
845 // ...plus every resource-annotated message in the same package (catches nested types
846 // like `Column` that aren't direct RPC return types but still carry annotations).
847 let fq_prefix = format!(".{}.", svc.package);
848 let fq_pkg = format!(".{}", svc.package);
849 for (fq_name, msg_info) in &metadata.messages {
850 if msg_info.resource_descriptor.is_some()
851 && (fq_name.starts_with(&fq_prefix) || fq_name.starts_with(&fq_pkg))
852 {
853 let simple = fq_name
854 .rfind('.')
855 .map(|i| &fq_name[i + 1..])
856 .unwrap_or(fq_name.as_str());
857 type_names.insert(simple.to_string());
858 }
859 }
860 }
861
862 let mut service_mods: Vec<TokenStream> = Vec::new();
863 let mut reexports: Vec<TokenStream> = Vec::new();
864 for (package, (svc_seg, ver_seg, type_names)) in &packages {
865 let svc_mod = format_ident!("{}", svc_seg);
866 let ver_mod = format_ident!("{}", ver_seg);
867
868 // Models are co-located: the buf plugin writes `<package>.rs` (and, for
869 // prost, `<package>.serde.rs` / `<package>.tonic.rs`) into this same
870 // directory, so the include is a plain sibling reference.
871 let main_include = format!("./{package}.rs");
872 // The separate `<package>.tonic.rs` (grpc service stubs over the prost
873 // `ProstCodec`) is only emitted by the prost model plugin. The buffa plugin
874 // produces no such file, so including it would break the build whenever the
875 // `grpc` feature is enabled (e.g. CI's `--all-features`). Gate the include on
876 // the runtime, not just the `grpc` cfg.
877 let tonic_include_tokens = match runtime {
878 Runtime::Prost => {
879 let tonic_include = format!("./{package}.tonic.rs");
880 quote! {
881 #[cfg(feature = "grpc")]
882 include!(#tonic_include);
883 }
884 }
885 Runtime::Buffa => quote! {},
886 };
887 service_mods.push(quote! {
888 pub mod #svc_mod {
889 pub mod #ver_mod {
890 include!(#main_include);
891 #tonic_include_tokens
892 }
893 }
894 });
895
896 for type_name in type_names {
897 let type_ident = format_ident!("{}", type_name);
898 reexports.push(quote! {
899 pub use #svc_mod::#ver_mod::#type_ident;
900 });
901 }
902 }
903
904 let labels_decl: TokenStream = if include_labels {
905 quote! {
906 pub mod labels;
907 pub use labels::{ObjectLabel, Resource};
908 }
909 } else {
910 quote! {}
911 };
912
913 // PyO3 boundary conversions for the model types (see `python::generate_pyo3_impls`).
914 // Gated on the `python` feature so non-Python consumers never pull in pyo3.
915 let pyo3_impls_decl: TokenStream = if include_pyo3_impls {
916 quote! {
917 // pyo3's `#[pyclass(from_py_object)]` extracts via `Clone::clone`, which trips
918 // `clippy::clone_on_copy` on the (`Copy`) enum wrappers. The call lives in the
919 // macro-generated impl, so only a module-level allow can reach it.
920 #[cfg(feature = "python")]
921 #[allow(clippy::clone_on_copy)]
922 pub mod pyo3_impls;
923 // Re-export the `Py*` wrapper `#[pyclass]` types at the models root so the
924 // generated language bindings (and consumers) can name them without
925 // reaching into the `pyo3_impls` submodule.
926 #[cfg(feature = "python")]
927 pub use pyo3_impls::*;
928 }
929 } else {
930 quote! {}
931 };
932
933 // The Axum extractors are always co-located in this directory under an
934 // `extractors/` subdir (the common generator wrote `extractors/<svc>/...`
935 // here), and this `mod.rs` overwrites the top-level `mod.rs` the common
936 // generator emitted — so it must re-declare the `extractors` module.
937 // Axum-coupled, so gate it on the `axum` feature. Without this, the
938 // `FromRequest`/`FromRequestParts` impls never compile and the generated
939 // route fns can't be mounted. Nesting under `extractors` also avoids the
940 // per-service module names clashing with the model package modules above.
941 let extractors_mod = format_ident!("{EXTRACTORS_SUBDIR}");
942 let common_mods: TokenStream = quote! {
943 #[cfg(feature = "axum")]
944 pub mod #extractors_mod;
945 };
946
947 // Lints to silence across this module and every `include!`d model file below.
948 // These are inner attributes, so they cover the descendant `pub mod <svc>::<v1>`
949 // modules the per-package files are pasted into.
950 let lint_allows = match runtime {
951 // The buffa model plugin emits enum variants with their proto SCREAMING_SNAKE
952 // names (`non_camel_case_types`) and explicit `Default` impls a `#[derive]`
953 // could replace (`clippy::derivable_impls`). The generated files carry no
954 // allows of their own, so silence both here to keep `clippy -D warnings` green.
955 Runtime::Buffa => quote! {
956 #![allow(non_camel_case_types)]
957 #![allow(clippy::derivable_impls)]
958 },
959 Runtime::Prost => quote! {},
960 };
961
962 let tokens = quote! {
963 // The per-package modules below gate a `tonic.rs` include on
964 // `#[cfg(feature = "grpc")]`. Consumers that don't define a `grpc`
965 // feature would otherwise see `unexpected_cfgs`; allow it here.
966 #![allow(unexpected_cfgs)]
967 // The grpc-gated `tonic.rs` includes carry empty (`///`) doc comments on
968 // generated service methods; allow them so `clippy -D warnings` stays
969 // green when the `grpc` feature is enabled.
970 #![allow(clippy::empty_docs)]
971 // The prost-serde plugin emits `write!(f, "...", &FIELDS)` for the
972 // deserializer's expected-fields message; newer clippy flags the `&` as a
973 // useless borrow in a formatting macro. Allow it here so plugin output
974 // stays `-D warnings` clean without post-processing each generated serde file.
975 #![allow(clippy::useless_borrows_in_formatting)]
976 #lint_allows
977
978 use std::collections::HashMap;
979
980 #labels_decl
981
982 #pyo3_impls_decl
983
984 #(#reexports)*
985
986 pub type PropertyMap = HashMap<String, serde_json::Value>;
987
988 #(#service_mods)*
989
990 #common_mods
991 };
992
993 format_tokens_static(tokens)
994}
995
996/// An in-memory bundle of generated source files awaiting output.
997///
998/// Each generator phase (common, server, client, bindings, …) returns one of these; the
999/// [`crate::output`] writer then materializes the [`files`](Self::files) onto disk, prepending the
1000/// `// @generated` header to each. Keeping generation and writing separate lets generation stay
1001/// pure (no I/O) and makes the produced files easy to assert on in tests.
1002#[derive(Debug)]
1003pub struct GeneratedCode {
1004 /// Generated files keyed by path relative to the phase's output directory.
1005 pub files: HashMap<String, String>,
1006}
1007
1008impl CodeGenMetadata {
1009 fn get_message_meta(&self, message_name: &str) -> Option<MessageMeta<'_>> {
1010 self.messages.get(message_name).map(|info| MessageMeta {
1011 info,
1012 metadata: self,
1013 })
1014 }
1015}
1016
1017/// A protobuf message paired with the metadata needed to resolve its references.
1018///
1019/// Wraps a single [`MessageInfo`](crate::parsing::MessageInfo) together with a borrow of the whole
1020/// [`CodeGenMetadata`] it came from, so the generators can look up a message's fields while still
1021/// being able to resolve types that point at other messages in the same descriptor set. Obtained
1022/// via `CodeGenMetadata::get_message_meta`.
1023pub(crate) struct MessageMeta<'a> {
1024 info: &'a MessageInfo,
1025 #[allow(dead_code)]
1026 metadata: &'a CodeGenMetadata,
1027}
1028
1029/// The per-service generation context shared by every code generator.
1030///
1031/// Bundles the three borrows each generator phase needs for one service: its analyzed
1032/// [`ServicePlan`](crate::analysis::ServicePlan), the full [`CodeGenMetadata`] (for resolving
1033/// types referenced from other services), and the active [`CodeGenConfig`]. The various
1034/// `codegen::*` submodules take a `&ServiceHandler` and read these plus the derived accessors on
1035/// this type (client type names, models paths, [`Self::methods`]) rather than threading the three
1036/// borrows separately. Methods are exposed as [`MethodHandler`]s via [`Self::methods`].
1037pub(crate) struct ServiceHandler<'a> {
1038 pub(crate) plan: &'a ServicePlan,
1039 pub(crate) metadata: &'a CodeGenMetadata,
1040 pub(crate) config: &'a CodeGenConfig,
1041}
1042
1043impl ServiceHandler<'_> {
1044 pub(crate) fn resource(&self) -> Option<&ManagedResource> {
1045 self.plan.managed_resources.first()
1046 }
1047
1048 /// Whether this service manages a `google.api.resource` and therefore gets a resource-scoped
1049 /// client (e.g. `CatalogClient` bound to a name).
1050 ///
1051 /// Resource-less services (entity tag assignments, temporary credentials, delta commits) have
1052 /// no scoped client; their methods are emitted on the root client and pass every param
1053 /// directly ([`BindingMode::Flat`]). See [`Self::binding_mode`].
1054 pub(crate) fn is_resource_scoped(&self) -> bool {
1055 self.resource().is_some()
1056 }
1057
1058 /// The [`BindingMode`] used to lower this service's methods in language bindings.
1059 ///
1060 /// A resource service whose identity can't be modeled by a string-component scoped client
1061 /// (see [`Self::supports_scoped_client`]) is lowered flat, exactly like a resource-less
1062 /// service: every method lives on the root client and passes all params directly.
1063 pub(crate) fn binding_mode(&self) -> BindingMode {
1064 if self.emits_scoped_binding() {
1065 BindingMode::Scoped
1066 } else {
1067 BindingMode::Flat
1068 }
1069 }
1070
1071 /// Whether language bindings emit a per-service scoped client module + accessor for this
1072 /// service. True only for resource services whose identity a string-component scoped client
1073 /// can express; others are lowered flat onto the root client. See [`Self::binding_mode`].
1074 pub(crate) fn emits_scoped_binding(&self) -> bool {
1075 self.is_resource_scoped() && self.supports_scoped_client()
1076 }
1077
1078 /// The language-agnostic spec for this service's resource accessor on the aggregate client, or
1079 /// `None` for resource-less services.
1080 ///
1081 /// All four accessor emitters (Rust aggregate, Python, Node, TypeScript) render from this single
1082 /// spec rather than re-deriving the param list and nesting decision, which previously diverged
1083 /// between the Rust aggregate and the bindings.
1084 pub(crate) fn accessor_spec(&self) -> Option<AccessorSpec> {
1085 let resource = self.resource()?;
1086 let params = self.plan.resource_accessor_params.clone()?;
1087 // A resource is nested when its accessor params include parent components beyond its own
1088 // name; its full name is then the dot-joined components. This is the single, converged
1089 // nesting rule — previously the Rust aggregate used `len > 1` while Python/Node also gated
1090 // on `name_field`, which could disagree.
1091 let nested = params.len() > 1;
1092 Some(AccessorSpec {
1093 singular: resource.descriptor.singular.clone(),
1094 params,
1095 nested,
1096 })
1097 }
1098
1099 pub(crate) fn methods(&self) -> impl Iterator<Item = MethodHandler<'_>> {
1100 self.plan.methods.iter().map(|plan| MethodHandler {
1101 plan,
1102 metadata: self.metadata,
1103 config: self.config,
1104 })
1105 }
1106
1107 /// The methods that have a `google.api.http` route — the ones the REST emitters (client,
1108 /// handler, server, aggregate) can generate. Routeless methods (no HTTP annotation) are emitted
1109 /// only by the ConnectRPC client, which iterates [`Self::methods`] directly.
1110 pub(crate) fn rest_methods(&self) -> impl Iterator<Item = MethodHandler<'_>> {
1111 self.methods().filter(|m| m.plan.has_http_route)
1112 }
1113
1114 /// The ergonomic, public-facing client type for this service.
1115 ///
1116 /// For a resource-scoped service this is the **scoped resource client** (e.g. `CatalogClient`,
1117 /// bound to a name) — the type callers and language bindings use. For a resource-less service it
1118 /// is the single flat client (e.g. `TagAssignmentClient`). The low-level transport client (one
1119 /// async method per RPC) is [`Self::low_level_client_type`].
1120 pub(crate) fn client_type(&self) -> Ident {
1121 if let Some(resource) = self.resource() {
1122 format_ident!(
1123 "{}",
1124 format!("{} client", resource.descriptor.singular).to_case(Case::Pascal)
1125 )
1126 } else {
1127 format_ident!(
1128 "{}Client",
1129 self.plan
1130 .service_name
1131 .trim_end_matches("Service")
1132 .trim_end_matches('s')
1133 )
1134 }
1135 }
1136
1137 /// The low-level transport client type (`{ client: CloudClient, base_url: Url }` with one async
1138 /// method per RPC), emitted by [`client`](super::client).
1139 ///
1140 /// For a resource-scoped service this is `<Singular>ServiceClient` (e.g. `CatalogServiceClient`),
1141 /// distinct from the ergonomic scoped [`Self::client_type`] (`CatalogClient`). For a
1142 /// resource-less service there is no scoped client, so the low-level client *is* the
1143 /// [`Self::client_type`] (e.g. `TagAssignmentClient`).
1144 pub(crate) fn low_level_client_type(&self, protocol: crate::codegen::ClientProtocol) -> Ident {
1145 // ConnectRPC: a single adapter type per service, named deterministically from the proto
1146 // service so it never collides with (nor is confused for) the connect-rust `{Service}Client`
1147 // it wraps. Connect has no scoped resource client, so this is always the low-level type.
1148 if protocol == crate::codegen::ClientProtocol::Connect {
1149 return format_ident!(
1150 "{}ConnectClient",
1151 self.plan.service_name.to_case(Case::Pascal)
1152 );
1153 }
1154 if let Some(resource) = self.resource() {
1155 format_ident!(
1156 "{}ServiceClient",
1157 resource.descriptor.singular.to_case(Case::Pascal)
1158 )
1159 } else {
1160 self.client_type()
1161 }
1162 }
1163
1164 /// The connect-rust generated client type for this service, as a fully-qualified path.
1165 ///
1166 /// Resolves to `<connect_client_path>::<ServiceName>Client` (e.g.
1167 /// `headwaters_proto::connect_gen::headwaters::read::v1::ReadServiceClient`). Used only in
1168 /// [`ClientProtocol::Connect`](crate::codegen::ClientProtocol) mode; errors if
1169 /// `connect_client_path` is unset.
1170 pub(crate) fn connect_inner_client_path(&self) -> crate::Result<syn::Path> {
1171 let base = self.config.connect_client_path.as_deref().ok_or_else(|| {
1172 crate::Error::Build(
1173 "client_protocol = Connect requires `connect_client_path` to be set".to_string(),
1174 )
1175 })?;
1176 let full = format!(
1177 "{base}::{}Client",
1178 self.plan.service_name.to_case(Case::Pascal)
1179 );
1180 syn::parse_str(&full).map_err(|source| crate::error::Error::InvalidRustPath {
1181 path: full.clone(),
1182 message: "connect_client_path + service client name".to_string(),
1183 source,
1184 })
1185 }
1186
1187 /// The ergonomic scoped resource client type, or `None` for a resource-less service.
1188 ///
1189 /// Same as [`Self::client_type`] but `Option`-typed to make "resource-scoped only" explicit at
1190 /// call sites that generate the scoped client / its accessor.
1191 pub(crate) fn scoped_client_type(&self) -> Option<Ident> {
1192 self.resource().map(|_| self.client_type())
1193 }
1194
1195 /// Whether this service's resource can be modeled by an ergonomic resource-scoped client.
1196 ///
1197 /// The scoped client (and the aggregate client's `<singular>(…)` accessor) captures a
1198 /// resource's identity as an ordered list of *string* name components and fills each method's
1199 /// path parameters from them. A resource whose scoped instance methods carry a non-string path
1200 /// parameter — e.g. a model version addressed by its parent's composite `{full_name}` plus an
1201 /// integer `{version}` — cannot be expressed that way, so no scoped client is emitted for it
1202 /// (its methods remain available on the low-level service client). Returns `false` in that case.
1203 pub(crate) fn supports_scoped_client(&self) -> bool {
1204 !self
1205 .methods()
1206 .filter(|m| m.is_scoped_instance_method())
1207 .any(|m| {
1208 m.required_parameters().any(|p| {
1209 p.is_path_param()
1210 && !matches!(
1211 p.field_type().base_type,
1212 crate::parsing::types::BaseType::String
1213 )
1214 })
1215 })
1216 }
1217
1218 /// The module segment under which this service's generated models live.
1219 ///
1220 /// Models are emitted per proto *package* (e.g. `unitycatalog.tags.v1` → module `tags`),
1221 /// which is not always the same as the service's `base_path` (derived from the service
1222 /// *name*, e.g. `TagPoliciesService` → `tag_policies`). Use the package's leaf-before-version
1223 /// segment so the models import path matches where the proto plugin actually wrote the types.
1224 /// Falls back to `base_path` when the package is empty or unparsable.
1225 fn models_segment(&self) -> String {
1226 let segs: Vec<&str> = self
1227 .plan
1228 .package
1229 .split('.')
1230 .filter(|s| !s.is_empty())
1231 .collect();
1232 match segs.as_slice() {
1233 // `unitycatalog.tags.v1` → `tags`
1234 [.., name, version] if is_version_segment(version) => name.to_string(),
1235 // `unitycatalog.tags` → `tags`
1236 [.., name] => name.to_string(),
1237 // No package info: fall back to the service-name-derived base path.
1238 _ => self.plan.base_path.clone(),
1239 }
1240 }
1241
1242 /// The version segment under which this service's generated models live
1243 /// (e.g. `unitycatalog.tags.v1` → `v1`, `unitycatalog.agents.v0alpha1` → `v0alpha1`).
1244 ///
1245 /// Matches where the proto plugin actually wrote the types. Falls back to `v1` when the
1246 /// package carries no recognizable version segment.
1247 fn models_version(&self) -> String {
1248 let segs: Vec<&str> = self
1249 .plan
1250 .package
1251 .split('.')
1252 .filter(|s| !s.is_empty())
1253 .collect();
1254 match segs.as_slice() {
1255 [.., _name, version] if is_version_segment(version) => version.to_string(),
1256 _ => "v1".to_string(),
1257 }
1258 }
1259
1260 pub(crate) fn models_path(&self) -> syn::Path {
1261 // Templates (and each service's substitution) are validated by `generate_code` before
1262 // any `ServiceHandler` is used, so skip the redundant re-validation `new` would do.
1263 ModelsPath::from_template(&self.config.models_path_template)
1264 .resolve(&self.models_segment(), &self.models_version())
1265 }
1266
1267 pub(crate) fn models_path_crate(&self) -> syn::Path {
1268 ModelsPath::from_template(&self.config.models_path_crate_template)
1269 .resolve(&self.models_segment(), &self.models_version())
1270 }
1271
1272 /// The models *root* module path — the prefix of [`Self::models_path`] before the
1273 /// per-service/version segments (e.g. `example_common::models` for the template
1274 /// `example_common::models::{service}::v1`).
1275 ///
1276 /// The `Py*` wrapper `#[pyclass]` types are re-exported here (see
1277 /// `generate_models_mod`), so the Python bindings import them from this path.
1278 /// Returns `None` if the template has no `{service}` placeholder to split on.
1279 pub(crate) fn models_root_path(&self) -> Option<syn::Path> {
1280 let template = &self.config.models_path_template;
1281 let prefix = template.split("{service}").next()?;
1282 let trimmed = prefix.trim_end_matches(':');
1283 if trimmed.is_empty() {
1284 return None;
1285 }
1286 syn::parse_str(trimmed).ok()
1287 }
1288}
1289
1290/// Whether a proto package segment is a version marker like `v1`, `v2beta1`, etc.
1291fn is_version_segment(seg: &str) -> bool {
1292 seg.strip_prefix('v')
1293 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
1294}
1295
1296/// The per-method counterpart of [`ServiceHandler`], scoped to a single RPC.
1297///
1298/// Pairs one analyzed [`MethodPlan`](crate::analysis::MethodPlan) with the same [`CodeGenMetadata`]
1299/// and [`CodeGenConfig`] borrows, and exposes the per-method derivations the generators rely on:
1300/// input/output message lookup, builder and binding method names, parameter plans
1301/// ([`Self::param_plan`]), and the language-agnostic [`EmitShape`](crate::analysis::EmitShape).
1302/// Yielded by [`ServiceHandler::methods`]; each generator iterates these to emit one unit of code
1303/// (a trait method, a client method, a builder, …) per RPC.
1304pub(crate) struct MethodHandler<'a> {
1305 plan: &'a MethodPlan,
1306 metadata: &'a CodeGenMetadata,
1307 config: &'a CodeGenConfig,
1308}
1309
1310impl MethodHandler<'_> {
1311 pub(crate) fn is_collection_method(&self) -> bool {
1312 crate::analysis::is_collection_method(
1313 &self.plan.request_type,
1314 &self.plan.metadata.method_name,
1315 )
1316 }
1317
1318 /// The language-agnostic [`EmitShape`] for this method, derived once from its request type and
1319 /// name. The Python/Node/TypeScript emitters branch on this instead of each re-matching
1320 /// `RequestType`.
1321 pub(crate) fn emit_shape(&self) -> crate::analysis::EmitShape {
1322 crate::analysis::emit_shape(&self.plan.request_type, self.is_collection_method())
1323 }
1324
1325 /// Whether this method is emitted as an instance method on a resource-scoped client
1326 /// (`get`/`update`/`delete` or a resource-targeted custom `POST`/`PATCH`).
1327 ///
1328 /// This is the exact set the bindings previously matched in their `resource_client_method`
1329 /// dispatch: standard `Get`/`Update`/`Delete`, plus a non-collection custom `POST`/`PATCH`
1330 /// (e.g. `POST /catalogs/{name}:rotateToken`). Other custom verbs (a custom `GET`/`DELETE`) are
1331 /// deliberately **not** surfaced on the scoped client, matching prior behavior.
1332 pub(crate) fn is_scoped_instance_method(&self) -> bool {
1333 // Standard instance verbs are always scoped.
1334 if matches!(
1335 self.plan.request_type,
1336 RequestType::Get | RequestType::Update | RequestType::Delete
1337 ) {
1338 return true;
1339 }
1340 // A *resource-targeted* custom RPC (any verb) is a scoped instance method: it has at least
1341 // one path param (so it operates on a specific resource) and is not a collection method
1342 // (those — list / create / factory — live on the root client). This surfaces custom reads
1343 // like `GetTableExists` / `GetPermissions` (`GET …/{name}/exists`) and resource-targeted
1344 // custom POST/PATCH (`…/{name}:rotateToken`) on the scoped client, rather than leaving their
1345 // generated builders orphaned/unreachable.
1346 matches!(self.plan.request_type, RequestType::Custom(_))
1347 && !self.is_collection_method()
1348 && self.plan.path_parameters().next().is_some()
1349 }
1350
1351 /// The ordered binding parameter list for `mode`, with shared filtering applied once.
1352 ///
1353 /// Required params come before optional ones (matching the previous
1354 /// `required_parameters().chain(optional_parameters())` order). Filtering reproduces exactly what
1355 /// the old per-language `collection_method_parameters` / `resource_method_parameters` did,
1356 /// keyed on [`EmitShape`]:
1357 ///
1358 /// - **collection shapes** (`List`, `Create`): never drop path params (collection methods live
1359 /// on the root client and pass everything); drop `page_token` for `List` (streaming handles
1360 /// pagination).
1361 /// - **instance shapes** (`GetUpdate`, `Delete`): drop path params in [`BindingMode::Scoped`]
1362 /// (the scoped client already holds them); never filter `page_token`.
1363 ///
1364 /// Per-language emitters render the returned `&RequestParam`s (applying their own type mapping /
1365 /// capability filters) instead of each re-deriving this filtered list.
1366 pub(crate) fn param_plan(&self, mode: BindingMode) -> Vec<&RequestParam> {
1367 let (required, optional) = self.param_plan_split(mode);
1368 required.into_iter().chain(optional).collect()
1369 }
1370
1371 /// Like [`Self::param_plan`] but keeps the required/optional partition, for emitters (e.g.
1372 /// TypeScript) that render the two groups differently (required positional args vs an optional
1373 /// `options` object). Both groups have the same shared path/`page_token` filtering applied.
1374 pub(crate) fn param_plan_split(
1375 &self,
1376 mode: BindingMode,
1377 ) -> (Vec<&RequestParam>, Vec<&RequestParam>) {
1378 use crate::analysis::EmitShape;
1379 let shape = self.emit_shape();
1380 let is_collection_shape = matches!(shape, EmitShape::List | EmitShape::Create);
1381 let drop_path = !is_collection_shape && mode == BindingMode::Scoped;
1382 let drop_page_token = shape == EmitShape::List;
1383 let keep = |p: &&RequestParam| {
1384 let is_dropped_path = drop_path && p.is_path_param();
1385 let is_dropped_page_token = drop_page_token && p.name() == "page_token";
1386 !is_dropped_path && !is_dropped_page_token
1387 };
1388 let required = self.required_parameters().filter(keep).collect();
1389 let optional = self.optional_parameters().filter(keep).collect();
1390 (required, optional)
1391 }
1392
1393 pub(crate) fn output_message(&self) -> Option<MessageMeta<'_>> {
1394 if self.plan.metadata.output_type.ends_with("Empty") {
1395 return None;
1396 }
1397 self.metadata
1398 .get_message_meta(&self.plan.metadata.output_type)
1399 }
1400
1401 pub(crate) fn output_type(&self) -> Option<Ident> {
1402 self.output_message()
1403 .map(|t| extract_type_ident(&t.info.name))
1404 }
1405
1406 pub(crate) fn list_output_field(&self) -> Option<&MessageField> {
1407 self.output_message()?
1408 .info
1409 .fields
1410 .iter()
1411 .find(|f| !f.name.contains("page_token"))
1412 }
1413
1414 pub(crate) fn input_message(&self) -> Option<MessageMeta<'_>> {
1415 if self.plan.metadata.input_type == "Empty" {
1416 return None;
1417 }
1418 self.metadata
1419 .get_message_meta(&self.plan.metadata.input_type)
1420 }
1421
1422 pub(crate) fn input_type(&self) -> Option<Ident> {
1423 self.input_message()
1424 .map(|t| extract_type_ident(&t.info.name))
1425 }
1426
1427 pub(crate) fn builder_type(&self) -> Ident {
1428 format_ident!("{}Builder", self.plan.metadata.method_name)
1429 }
1430
1431 /// The method name to expose in language bindings.
1432 ///
1433 /// Prefers the gnostic `operation_id` annotation (`gnostic.openapi.v3.operation`) when present,
1434 /// snake-cased, so proto authors can give bindings a hand-tuned name. Falls back to the
1435 /// proto-derived name ([`MethodPlan::base_method_ident`]).
1436 ///
1437 /// This affects only the *emitted* binding method name — the call into the underlying Rust
1438 /// client still uses [`MethodPlan::base_method_ident`], so the handler/client API is unchanged.
1439 pub(crate) fn binding_method_name(&self) -> Ident {
1440 format_ident!("{}", self.binding_method_name_str())
1441 }
1442
1443 /// The snake_case binding method name (see [`Self::binding_method_name`]).
1444 ///
1445 /// Provided as a `String` so the string-templated TypeScript generator can camel-case it and
1446 /// stay consistent with the snake→camel name NAPI derives for the native method.
1447 pub(crate) fn binding_method_name_str(&self) -> String {
1448 match self.plan.metadata.operation.as_ref() {
1449 Some(op) if !op.operation_id.is_empty() => op.operation_id.to_case(Case::Snake),
1450 _ => self.plan.handler_function_name.clone(),
1451 }
1452 }
1453
1454 /// Get type representation for rust depending on context
1455 ///
1456 /// Depending on context we may want concrete types (e.g. 'String') or more flexible types (e.g. 'Into<String d>')
1457 pub(crate) fn field_type(&self, field_type: &UnifiedType, ctx: RenderContext) -> syn::Type {
1458 let rust_type = types::unified_to_rust(field_type, ctx);
1459 // `rust_type` comes from this crate's own proto→Rust mapping, not user input, so an
1460 // unparsable result is a bug in `unified_to_rust`. Panic with the offending string so
1461 // the broken mapping is obvious (the generated-code parse test in
1462 // `tests/generation_integration.rs` is the broader guard for output validity).
1463 syn::parse_str(&rust_type).unwrap_or_else(|e| {
1464 panic!("internal: unified_to_rust produced an invalid type `{rust_type}`: {e}")
1465 })
1466 }
1467
1468 /// Get field assignment TokenStream for constructor
1469 pub(crate) fn field_assignment(
1470 &self,
1471 field_type: &UnifiedType,
1472 field_ident: &proc_macro2::Ident,
1473 ctx: &RenderContext,
1474 ) -> TokenStream {
1475 types::field_assignment(field_type, field_ident, ctx, self.config.runtime)
1476 }
1477
1478 pub(crate) fn required_parameters(&self) -> impl Iterator<Item = &RequestParam> {
1479 self.plan
1480 .parameters
1481 .iter()
1482 .filter(|param| !param.is_optional())
1483 }
1484
1485 pub(crate) fn optional_parameters(&self) -> impl Iterator<Item = &RequestParam> {
1486 self.plan
1487 .parameters
1488 .iter()
1489 .filter(|param| param.is_optional())
1490 }
1491
1492 /// Split body fields into required and optional subsets.
1493 pub(crate) fn split_body_fields(&self) -> (Vec<&BodyField>, Vec<&BodyField>) {
1494 split_body_fields(self.plan)
1495 }
1496}
1497
1498/// Extract the final type name from a fully qualified protobuf type and convert to Ident.
1499///
1500/// Thin `Ident` adapter over [`crate::utils::extract_simple_type_name`] — the single source of
1501/// truth for last-segment extraction. (Not [`crate::utils::extract_qualified_type_name`], which
1502/// parent-prefixes nested types — different behavior.)
1503pub(crate) fn extract_type_ident(full_type: &str) -> Ident {
1504 format_ident!("{}", crate::utils::extract_simple_type_name(full_type))
1505}