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