Skip to main content

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}