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