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::HashMap;
23use std::path::PathBuf;
24
25use convert_case::{Case, Casing};
26use itertools::Itertools;
27use proc_macro2::TokenStream;
28use quote::{format_ident, quote};
29use syn::{File, Ident};
30
31use crate::error::{Error, Result};
32
33use crate::analysis::{
34    BodyField, GenerationPlan, ManagedResource, MethodPlan, RequestParam, RequestType, ServicePlan,
35    analyze_metadata, split_body_fields,
36};
37use crate::google::api::http_rule::Pattern;
38use crate::output;
39use crate::parsing::types::{self, RenderContext, UnifiedType};
40use crate::parsing::{CodeGenMetadata, MessageField, MessageInfo};
41
42mod builder;
43mod client;
44mod handler;
45pub(crate) mod node;
46mod python;
47mod resources;
48mod server;
49
50impl MethodPlan {
51    pub(crate) fn resource_client_method(&self) -> Ident {
52        match self.request_type {
53            RequestType::Get => format_ident!("get"),
54            RequestType::Update => format_ident!("update"),
55            RequestType::Delete => format_ident!("delete"),
56            _ => format_ident!("{}", self.handler_function_name),
57        }
58    }
59
60    pub(crate) fn base_method_ident(&self) -> Ident {
61        format_ident!("{}", self.handler_function_name)
62    }
63}
64
65/// Validated model import path derived from a `{service}` template string.
66///
67/// Constructed once from [`CodeGenConfig`] template fields. `resolve` performs the
68/// `{service}` substitution and parses the result as a [`syn::Path`], catching
69/// malformed templates at construction time rather than at code-generation time.
70#[derive(Debug, Clone)]
71pub struct ModelsPath {
72    template: String,
73}
74
75impl ModelsPath {
76    /// Build a `ModelsPath` from a template string containing `{service}`.
77    ///
78    /// Performs a test substitution at construction to validate the template.
79    pub fn new(template: &str) -> Result<Self> {
80        let test = template.replace("{service}", "test");
81        syn::parse_str::<syn::Path>(&test).map_err(|e| Error::InvalidModelsPathTemplate {
82            template: template.to_string(),
83            source: e,
84        })?;
85        Ok(Self {
86            template: template.to_string(),
87        })
88    }
89
90    /// Replace `{service}` with `service` and return the parsed [`syn::Path`].
91    ///
92    /// # Panics
93    ///
94    /// Cannot panic in practice: `new` already validates that every possible
95    /// `{service}` substitution produces a valid path.
96    pub fn resolve(&self, service: &str) -> syn::Path {
97        let path = self.template.replace("{service}", service);
98        syn::parse_str(&path)
99            .unwrap_or_else(|e| panic!("Invalid models path `{path}` after substitution: {e}"))
100    }
101}
102
103/// Configuration for language-binding code generation (Python / Node.js).
104#[derive(Debug, Clone)]
105pub struct BindingsConfig {
106    /// Name of the aggregate client struct (e.g. `"MyServiceClient"`).
107    pub aggregate_client_name: String,
108    /// Rust crate name used in `use` statements for the client crate
109    /// (e.g. `"my_service_client"`).
110    ///
111    /// This must be set explicitly because the crate name may not match the
112    /// snake_case form of `aggregate_client_name` (e.g. `"MyServiceClient"`
113    /// snake_cases to `"my_service_client"`).
114    pub client_crate_name: String,
115    /// Fully-qualified Python error type (e.g. `"PyMyServiceError"`).
116    pub py_error_type: String,
117    /// Fully-qualified Python result alias (e.g. `"PyMyServiceResult"`).
118    pub py_result_type: String,
119    /// Name of the NAPI error extension trait (e.g. `"NapiErrorExt"`).
120    pub napi_error_ext_trait: String,
121    /// Optional substring filter for the Python typings package.
122    ///
123    /// When `Some(s)`, only messages/enums whose fully-qualified name contains
124    /// `s` are included in the generated `.pyi` file.  When `None`, all
125    /// reachable types are included.
126    pub typings_package_filter: Option<String>,
127    /// Base class name for TypeScript errors (e.g. `"MyServiceError"`).
128    pub ts_error_base_class: String,
129    /// Prefix used in native NAPI error messages (e.g. `"UC"`).
130    pub ts_error_code_prefix: String,
131}
132
133/// Configuration for code generation, including import paths and output directories.
134///
135/// Construct this struct directly and set the fields you need.
136#[derive(Debug, Clone)]
137pub struct CodeGenConfig {
138    /// Fully-qualified path to the request context type used in handler methods.
139    ///
140    /// Default: `"crate::api::RequestContext"`
141    pub context_type_path: String,
142
143    /// Fully-qualified path to the `Result` alias used in generated handler and client code.
144    ///
145    /// Default: `"crate::Result"`
146    pub result_type_path: String,
147
148    /// Template for the external model import path. `{service}` is replaced with the service's
149    /// base path (e.g. `"catalogs"`).
150    ///
151    /// Example: `"my_common::models::{service}::v1"`
152    pub models_path_template: String,
153
154    /// Template for crate-local model import path. `{service}` is replaced with the service's
155    /// base path.
156    ///
157    /// Default: `"crate::models::{service}::v1"`
158    pub models_path_crate_template: String,
159
160    /// Output directory configuration.
161    pub output: CodeGenOutput,
162
163    /// When `true`, generate `labels.rs` with `Resource` / `ObjectLabel` enums derived
164    /// from `google.api.resource` annotations. Requires `output.models` to be `Some`.
165    ///
166    /// Store-specific output (`Label` impl, `RESOURCE_DESCRIPTORS`) is only emitted when
167    /// `generate_store_integration` is also `true`.
168    pub generate_resource_enum: bool,
169
170    /// When `true` (and `generate_resource_enum` is set), emit the `olai_store` integration
171    /// code in `labels.rs`:
172    /// - `impl olai_store::Label for ObjectLabel`
173    /// - `pub static RESOURCE_DESCRIPTORS: &[olai_store::ResourceTypeDescriptor<ObjectLabel>]`
174    ///
175    /// Set to `false` for crates that use the enums without a store dependency.
176    pub generate_store_integration: bool,
177
178    /// Fully-qualified path to the `Error` type used in generated `TryFrom<Resource>` impls.
179    ///
180    /// E.g. `"crate::Error"`. When `None`, `TryFrom` impls are not generated.
181    pub error_type_path: Option<String>,
182
183    /// When `true` and `generate_resource_enum` is set, emit `TryFrom<Object>`/`TryFrom<T>`
184    /// and `ResourceExt` impl blocks in `labels.rs` for all resource types that have an
185    /// `IDENTIFIER`-annotated field, plus a `qualified_name()` inherent method on each
186    /// resource type.
187    pub generate_object_conversions: bool,
188
189    /// Configuration for language-binding generation. Required when `output.python`,
190    /// `output.node`, or `output.node_ts` is `Some`.
191    pub bindings: Option<BindingsConfig>,
192
193    /// Relative path of the prost-generated `gen/` dir from the models subdirectory.
194    /// Required when `output.models` is `Some`. E.g. `"../gen"`.
195    pub models_gen_dir: Option<String>,
196
197    /// Crate name for the resource store types used in generated `RESOURCE_DESCRIPTORS`.
198    ///
199    /// Default: `"olai_store"`
200    pub resource_store_crate_name: String,
201}
202
203impl CodeGenConfig {
204    /// Validate this config without running code generation.
205    ///
206    /// Checks that:
207    /// - `models_path_template` and `models_path_crate_template` produce valid Rust paths after
208    ///   `{service}` substitution.
209    /// - `bindings` is `Some` whenever `output.python`, `output.node`, or `output.node_ts` is
210    ///   `Some`.
211    ///
212    /// Call this at construction time to surface misconfiguration early, before generation runs.
213    pub fn validate(&self) -> Result<()> {
214        ModelsPath::new(&self.models_path_template)?;
215        ModelsPath::new(&self.models_path_crate_template)?;
216        if (self.output.python.is_some()
217            || self.output.node.is_some()
218            || self.output.node_ts.is_some())
219            && self.bindings.is_none()
220        {
221            return Err(Error::MissingBindingsConfig);
222        }
223        Ok(())
224    }
225}
226
227/// Output directory configuration for code generation.
228///
229/// Only `common` is required. All other outputs are optional — set to `None` to skip that
230/// output entirely. For example, a server-only crate can omit `client`, and a client-only
231/// crate can omit `server`.
232#[derive(Debug, Clone)]
233pub struct CodeGenOutput {
234    /// Output directory for common (shared extractor) code.
235    pub common: PathBuf,
236    /// Parent models directory (e.g. `crates/common/src/models`).
237    ///
238    /// When `Some`, the generator writes both `labels.rs` and `mod.rs` into a
239    /// subdirectory named [`models_subdir`](CodeGenOutput::models_subdir) inside this path.
240    /// The prost-generated `gen/` directory is expected to be a sibling of that subdirectory.
241    pub models: Option<PathBuf>,
242    /// Name of the generated subdirectory inside [`models`](CodeGenOutput::models).
243    ///
244    /// Defaults to `"_gen"`.
245    pub models_subdir: String,
246    /// Output directory for server-side handler and route code. Generation is skipped when `None`.
247    pub server: Option<PathBuf>,
248    /// Output directory for HTTP client code. Generation is skipped when `None`.
249    pub client: Option<PathBuf>,
250    /// Output directory for Python bindings. Generation is skipped when `None`.
251    pub python: Option<PathBuf>,
252    /// Output directory for Node.js NAPI bindings. Generation is skipped when `None`.
253    pub node: Option<PathBuf>,
254    /// Output directory for Node.js TypeScript client. Generation is skipped when `None`.
255    pub node_ts: Option<PathBuf>,
256    /// Filename for the generated Python typings stub.
257    ///
258    /// Default: `"client.pyi"`
259    pub python_typings_filename: String,
260}
261
262impl CodeGenOutput {
263    /// Absolute path of the generated subdirectory (`models/models_subdir`), if `models` is set.
264    pub fn models_subdir_path(&self) -> Option<PathBuf> {
265        self.models.as_ref().map(|m| m.join(&self.models_subdir))
266    }
267}
268
269/// Generate all code described by `config` from `metadata`.
270///
271/// Writes the following outputs, depending on which [`CodeGenOutput`] fields are `Some`:
272///
273/// | Field | Contents |
274/// |-------|----------|
275/// | `output.common` | Axum extractor code, per-service `mod.rs` (always written) |
276/// | `output.models_gen` | `labels.rs` resource-enum file (falls back to `common` if `None`) |
277/// | `output.server` | Handler trait + Axum route wiring per service |
278/// | `output.client` | HTTP client structs and request builders per service |
279/// | `output.python` | PyO3 binding wrappers + `.pyi` typings stub |
280/// | `output.node` | NAPI binding wrappers |
281/// | `output.node_ts` | TypeScript client (`client.ts`) |
282///
283/// # Required fields
284///
285/// - `output.common` is always required.
286/// - `bindings` must be `Some` when any of `output.python`, `output.node`, or `output.node_ts`
287///   is `Some`; otherwise returns [`Error::MissingBindingsConfig`].
288/// - `models_path_template` and `models_path_crate_template` must be valid Rust path templates
289///   (containing `{service}`); invalid templates return [`Error::InvalidModelsPathTemplate`].
290///
291/// # Optional fields
292///
293/// Setting `generate_resource_enum` to `false` skips `labels.rs` generation.
294/// Setting `bindings` to `None` skips all language-binding output.
295///
296/// Call [`CodeGenConfig::validate`] before this function to surface config errors at
297/// construction time rather than mid-generation.
298pub fn generate_code(metadata: &CodeGenMetadata, config: &CodeGenConfig) -> Result<()> {
299    // Validate templates early so callers get a clean error before any generation starts.
300    ModelsPath::new(&config.models_path_template)?;
301    ModelsPath::new(&config.models_path_crate_template)?;
302
303    // Validate that bindings config is present when language-binding output is requested.
304    if (config.output.python.is_some()
305        || config.output.node.is_some()
306        || config.output.node_ts.is_some())
307        && config.bindings.is_none()
308    {
309        return Err(Error::MissingBindingsConfig);
310    }
311
312    let plan = analyze_metadata(metadata)?;
313
314    let common_code = generate_common_code(&plan, metadata, config)?;
315    output::write_generated_code(&common_code, &config.output.common)?;
316
317    if config.output.models.is_some() {
318        let subdir = config
319            .output
320            .models_subdir_path()
321            .expect("models is Some so subdir is Some");
322        std::fs::create_dir_all(&subdir).map_err(Error::Io)?;
323
324        if config.generate_resource_enum {
325            let resource_enum = resources::generate_resource_enum(
326                &plan,
327                metadata,
328                config,
329                config.error_type_path.as_deref(),
330            );
331            let mut models_files = GeneratedCode {
332                files: std::collections::HashMap::new(),
333            };
334            models_files
335                .files
336                .insert("labels.rs".to_string(), resource_enum);
337            output::write_generated_code(&models_files, &subdir)?;
338        }
339
340        let gen_dir = config.models_gen_dir.as_deref().unwrap_or("../gen");
341        let include_labels = config.generate_resource_enum;
342        let mod_content = generate_models_mod(&plan.services, gen_dir, include_labels, metadata);
343        let mod_path = subdir.join("mod.rs");
344        std::fs::write(&mod_path, mod_content).map_err(Error::Io)?;
345    }
346
347    if let Some(ref server_dir) = config.output.server {
348        let server_code = generate_server_code(&plan, metadata, config)?;
349        output::write_generated_code(&server_code, server_dir)?;
350    }
351
352    if let Some(ref client_dir) = config.output.client {
353        let client_code = generate_client_code(&plan, metadata, config)?;
354        output::write_generated_code(&client_code, client_dir)?;
355    }
356
357    if let Some(ref python_dir) = config.output.python {
358        let python_code = generate_python_code(&plan, metadata, config)?;
359        output::write_generated_code(&python_code, python_dir)?;
360    }
361
362    if let Some(ref node_dir) = config.output.node {
363        let node_code = generate_node_code(&plan, metadata, config)?;
364        output::write_generated_code(&node_code, node_dir)?;
365    }
366
367    if let Some(ref node_ts_dir) = config.output.node_ts {
368        let node_ts_code = generate_node_ts_code(&plan, metadata, config)?;
369        output::write_generated_code(&node_ts_code, node_ts_dir)?;
370    }
371
372    Ok(())
373}
374
375fn generate_common_code(
376    plan: &GenerationPlan,
377    metadata: &CodeGenMetadata,
378    config: &CodeGenConfig,
379) -> Result<GeneratedCode> {
380    let mut files = HashMap::new();
381
382    for service in &plan.services {
383        let handler = ServiceHandler {
384            plan: service,
385            metadata,
386            config,
387        };
388        let server_code = server::generate_common(&handler);
389        files.insert(format!("{}/server.rs", service.base_path), server_code);
390        let module_code = generate_common_module();
391        files.insert(format!("{}/mod.rs", service.base_path), module_code);
392    }
393
394    let module_code = main_module(&plan.services);
395    files.insert("mod.rs".to_string(), module_code);
396
397    Ok(GeneratedCode { files })
398}
399
400fn generate_server_code(
401    plan: &GenerationPlan,
402    metadata: &CodeGenMetadata,
403    config: &CodeGenConfig,
404) -> Result<GeneratedCode> {
405    let mut files = HashMap::new();
406
407    for service in &plan.services {
408        let handler = ServiceHandler {
409            plan: service,
410            metadata,
411            config,
412        };
413        let trait_code = handler::generate(&handler)?;
414        files.insert(format!("{}/handler.rs", service.base_path), trait_code);
415        let server_code = server::generate_server(&handler);
416        files.insert(format!("{}/server.rs", service.base_path), server_code);
417        let module_code = generate_server_module(service);
418        files.insert(format!("{}/mod.rs", service.base_path), module_code);
419    }
420
421    let module_code = main_module(&plan.services);
422    files.insert("mod.rs".to_string(), module_code);
423
424    Ok(GeneratedCode { files })
425}
426
427fn generate_python_code(
428    plan: &GenerationPlan,
429    metadata: &CodeGenMetadata,
430    config: &CodeGenConfig,
431) -> Result<GeneratedCode> {
432    let mut files = HashMap::new();
433
434    let handlers = plan
435        .services
436        .iter()
437        .map(|service| ServiceHandler {
438            plan: service,
439            metadata,
440            config,
441        })
442        .collect_vec();
443
444    for service in &handlers {
445        let python_code = python::generate(service);
446        files.insert(format!("{}.rs", service.plan.base_path), python_code);
447    }
448
449    let module_code = python::main_module(&handlers);
450    files.insert("mod.rs".to_string(), module_code);
451
452    let python_typings_code = python::generate_typings(&handlers);
453    files.insert(
454        config.output.python_typings_filename.clone(),
455        python_typings_code,
456    );
457
458    Ok(GeneratedCode { files })
459}
460
461fn generate_node_code(
462    plan: &GenerationPlan,
463    metadata: &CodeGenMetadata,
464    config: &CodeGenConfig,
465) -> Result<GeneratedCode> {
466    let mut files = HashMap::new();
467
468    let handlers = plan
469        .services
470        .iter()
471        .map(|service| ServiceHandler {
472            plan: service,
473            metadata,
474            config,
475        })
476        .collect_vec();
477
478    for service in &handlers {
479        let napi_code = node::generate(service);
480        files.insert(format!("{}.rs", service.plan.base_path), napi_code);
481    }
482
483    let module_code = node::main_module(&handlers);
484    files.insert("mod.rs".to_string(), module_code);
485
486    Ok(GeneratedCode { files })
487}
488
489fn generate_node_ts_code(
490    plan: &GenerationPlan,
491    metadata: &CodeGenMetadata,
492    config: &CodeGenConfig,
493) -> Result<GeneratedCode> {
494    let handlers = plan
495        .services
496        .iter()
497        .map(|service| ServiceHandler {
498            plan: service,
499            metadata,
500            config,
501        })
502        .collect_vec();
503
504    let ts_code = node::typescript::generate_client_ts(&handlers);
505    let mut files = HashMap::new();
506    files.insert("client.ts".to_string(), ts_code);
507
508    Ok(GeneratedCode { files })
509}
510
511fn generate_client_code(
512    plan: &GenerationPlan,
513    metadata: &CodeGenMetadata,
514    config: &CodeGenConfig,
515) -> Result<GeneratedCode> {
516    let mut files = HashMap::new();
517
518    for service in &plan.services {
519        let handler = ServiceHandler {
520            plan: service,
521            metadata,
522            config,
523        };
524        let client_code = client::generate(&handler)?;
525        files.insert(format!("{}/client.rs", service.base_path), client_code);
526        let builder_code = builder::generate(&handler)?;
527        files.insert(format!("{}/builders.rs", service.base_path), builder_code);
528        let module_code = generate_client_module();
529        files.insert(format!("{}/mod.rs", service.base_path), module_code);
530    }
531
532    let module_code = generate_client_main_module(&plan.services);
533    files.insert("mod.rs".to_string(), module_code);
534
535    Ok(GeneratedCode { files })
536}
537
538fn generate_common_module() -> String {
539    let tokens = quote! {
540        #[cfg(feature = "axum")]
541        pub mod server;
542    };
543    format_tokens(tokens)
544}
545
546fn generate_server_module(service: &ServicePlan) -> String {
547    let handler_ident = format_ident!("{}", service.handler_name);
548    let tokens = quote! {
549        pub use handler::#handler_ident;
550
551        mod handler;
552        #[cfg(feature = "axum")]
553        pub mod server;
554    };
555    format_tokens(tokens)
556}
557
558fn generate_client_module() -> String {
559    let tokens = quote! {
560        pub use client::*;
561        pub use builders::*;
562
563        pub mod client;
564        pub mod builders;
565    };
566    format_tokens(tokens)
567}
568
569pub fn main_module(services: &[ServicePlan]) -> String {
570    let service_modules: Vec<TokenStream> = services
571        .iter()
572        .map(|s| {
573            let module_name = format_ident!("{}", s.base_path);
574            quote! { pub mod #module_name; }
575        })
576        .collect();
577
578    let tokens = quote! {
579        #(#service_modules)*
580    };
581    format_tokens(tokens)
582}
583
584fn generate_client_main_module(services: &[ServicePlan]) -> String {
585    let service_modules: Vec<TokenStream> = services
586        .iter()
587        .map(|s| {
588            let module_name = format_ident!("{}", s.base_path);
589            quote! { pub mod #module_name; }
590        })
591        .collect();
592
593    let tokens = quote! {
594        #(#service_modules)*
595
596        use futures::Future;
597
598        pub(super) fn stream_paginated<F, Fut, S, T>(
599            state: S,
600            op: F,
601        ) -> impl futures::Stream<Item = crate::Result<T>>
602        where
603            F: Fn(S, Option<String>) -> Fut + Copy,
604            Fut: Future<Output = crate::Result<(T, S, Option<String>)>>,
605        {
606            enum PaginationState<T> {
607                Start(T),
608                HasMore(T, String),
609                Done,
610            }
611
612            futures::stream::unfold(
613                PaginationState::Start(state),
614                move |state| async move {
615                    let (s, page_token) = match state {
616                        PaginationState::Start(s) => (s, None),
617                        PaginationState::HasMore(s, page_token) if !page_token.is_empty() => {
618                            (s, Some(page_token))
619                        }
620                        _ => {
621                            return None;
622                        }
623                    };
624
625                    let (resp, s, continuation) = match op(s, page_token).await {
626                        Ok(resp) => resp,
627                        Err(e) => return Some((Err(e), PaginationState::Done)),
628                    };
629
630                    let next_state = match continuation {
631                        Some(token) => PaginationState::HasMore(s, token),
632                        None => PaginationState::Done,
633                    };
634
635                    Some((Ok(resp), next_state))
636                },
637            )
638        }
639    };
640    format_tokens(tokens)
641}
642
643/// Convert optional documentation into `#[doc = "..."]` token stream attributes.
644pub(crate) fn doc_tokens(documentation: Option<&str>) -> TokenStream {
645    let Some(doc) = documentation else {
646        return quote! {};
647    };
648    let doc = doc.trim();
649    if doc.is_empty() {
650        return quote! {};
651    }
652    let attrs: Vec<TokenStream> = doc
653        .lines()
654        .map(|line| {
655            let line = line.trim();
656            if line.is_empty() {
657                quote! { #[doc = ""] }
658            } else {
659                let spaced = format!(" {}", line);
660                quote! { #[doc = #spaced] }
661            }
662        })
663        .collect();
664    quote! { #(#attrs)* }
665}
666
667/// Generate the `mod.rs` for `crates/common/src/models/`.
668///
669/// Emits `pub mod <service> { pub mod <version> { include!(...) } }` blocks for every
670/// service in the plan, plus static re-exports and module declarations.
671///
672/// `gen_dir` is the relative path (from the subdir) to the prost-generated files,
673/// e.g. `"../gen"`.
674///
675/// When `include_labels` is `true`, also emits `pub mod labels; pub use labels::{ObjectLabel, Resource};`.
676///
677/// `metadata` is used to discover all resource-annotated messages (even those not returned
678/// directly by an RPC) so they can be included in `pub use` re-exports.
679pub fn generate_models_mod(
680    services: &[ServicePlan],
681    gen_dir: &str,
682    include_labels: bool,
683    metadata: &CodeGenMetadata,
684) -> String {
685    let mut sorted_services: Vec<&ServicePlan> = services.iter().collect();
686    sorted_services.sort_by_key(|s| &s.base_path);
687
688    // Build the `pub mod` blocks
689    let service_mods: Vec<TokenStream> = sorted_services
690        .iter()
691        .map(|svc| {
692            // package = "unitycatalog.catalogs.v1"
693            // parts   = ["unitycatalog", "catalogs", "v1"]
694            let parts: Vec<&str> = svc.package.split('.').collect();
695            // service module = second-to-last segment (e.g. "catalogs")
696            // version module = last segment (e.g. "v1")
697            let (svc_seg, ver_seg) = if parts.len() >= 2 {
698                (parts[parts.len() - 2], parts[parts.len() - 1])
699            } else {
700                (svc.base_path.as_str(), "v1")
701            };
702
703            let svc_mod = format_ident!("{}", svc_seg);
704            let ver_mod = format_ident!("{}", ver_seg);
705
706            let main_include = format!("./{}/{}.rs", gen_dir, svc.package);
707            let tonic_include = format!("./{}/{}.tonic.rs", gen_dir, svc.package);
708
709            quote! {
710                pub mod #svc_mod {
711                    pub mod #ver_mod {
712                        include!(#main_include);
713                        #[cfg(feature = "grpc")]
714                        include!(#tonic_include);
715                    }
716                }
717            }
718        })
719        .collect();
720
721    // Collect `pub use` re-exports: include managed resources AND all resource-descriptor
722    // messages in the same package (catches nested types like Column that aren't direct
723    // RPC return types but still have google.api.resource annotations).
724    let mut reexports: Vec<TokenStream> = Vec::new();
725    for svc in &sorted_services {
726        let package = &svc.package;
727        let fq_prefix = format!(".{}.", package);
728
729        let parts: Vec<&str> = svc.package.split('.').collect();
730        let (svc_seg, ver_seg) = if parts.len() >= 2 {
731            (parts[parts.len() - 2], parts[parts.len() - 1])
732        } else {
733            (svc.base_path.as_str(), "v1")
734        };
735        let svc_mod = format_ident!("{}", svc_seg);
736        let ver_mod = format_ident!("{}", ver_seg);
737
738        // Gather from managed_resources first.
739        let mut type_names: std::collections::BTreeSet<String> = svc
740            .managed_resources
741            .iter()
742            .map(|r| r.type_name.clone())
743            .collect();
744
745        // Also include all resource-annotated messages in this package.
746        for (fq_name, msg_info) in &metadata.messages {
747            if msg_info.resource_descriptor.is_some()
748                && (fq_name.starts_with(&fq_prefix)
749                    || fq_name.starts_with(&format!(".{}", package)))
750            {
751                // Simple name = last component after '.'
752                let simple = fq_name
753                    .rfind('.')
754                    .map(|i| &fq_name[i + 1..])
755                    .unwrap_or(fq_name.as_str());
756                type_names.insert(simple.to_string());
757            }
758        }
759
760        for type_name in &type_names {
761            let type_ident = format_ident!("{}", type_name);
762            reexports.push(quote! {
763                pub use #svc_mod::#ver_mod::#type_ident;
764            });
765        }
766    }
767
768    let labels_decl: TokenStream = if include_labels {
769        quote! {
770            pub mod labels;
771            pub use labels::{ObjectLabel, Resource};
772        }
773    } else {
774        quote! {}
775    };
776
777    let tokens = quote! {
778        use std::collections::HashMap;
779
780        #labels_decl
781
782        #(#reexports)*
783
784        pub type PropertyMap = HashMap<String, serde_json::Value>;
785
786        #(#service_mods)*
787    };
788
789    format_tokens(tokens)
790}
791
792pub(crate) fn format_tokens(tokens: TokenStream) -> String {
793    let tokens_string = tokens.to_string();
794    let syntax_tree = syn::parse2::<File>(tokens).unwrap_or_else(|_| {
795        syn::parse_str::<File>(&tokens_string).unwrap_or_else(|_| {
796            syn::parse_quote! {
797                // Failed to parse generated code
798            }
799        })
800    });
801    prettyplease::unparse(&syntax_tree)
802}
803
804/// Generated code ready for output
805#[derive(Debug)]
806pub struct GeneratedCode {
807    /// Generated files mapped by relative path
808    pub files: HashMap<String, String>,
809}
810
811impl CodeGenMetadata {
812    fn get_message_meta(&self, message_name: &str) -> Option<MessageMeta<'_>> {
813        self.messages.get(message_name).map(|info| MessageMeta {
814            info,
815            metadata: self,
816        })
817    }
818}
819
820pub(crate) struct MessageMeta<'a> {
821    info: &'a MessageInfo,
822    #[allow(dead_code)]
823    metadata: &'a CodeGenMetadata,
824}
825
826pub(crate) struct ServiceHandler<'a> {
827    pub(crate) plan: &'a ServicePlan,
828    pub(crate) metadata: &'a CodeGenMetadata,
829    pub(crate) config: &'a CodeGenConfig,
830}
831
832impl ServiceHandler<'_> {
833    pub(crate) fn resource(&self) -> Option<&ManagedResource> {
834        self.plan.managed_resources.first()
835    }
836
837    pub(crate) fn methods(&self) -> impl Iterator<Item = MethodHandler<'_>> {
838        self.plan.methods.iter().map(|plan| MethodHandler {
839            plan,
840            metadata: self.metadata,
841        })
842    }
843
844    pub(crate) fn client_type(&self) -> Ident {
845        if let Some(resource) = self.resource() {
846            format_ident!(
847                "{}",
848                format!("{} client", resource.descriptor.singular).to_case(Case::Pascal)
849            )
850        } else {
851            format_ident!(
852                "{}Client",
853                self.plan
854                    .service_name
855                    .trim_end_matches("Service")
856                    .trim_end_matches('s')
857            )
858        }
859    }
860
861    pub(crate) fn models_path(&self) -> syn::Path {
862        // Templates are validated by `generate_code` before any `ServiceHandler` is used,
863        // so this substitution is guaranteed to succeed.
864        ModelsPath::new(&self.config.models_path_template)
865            .expect("models_path_template already validated by generate_code")
866            .resolve(&self.plan.base_path)
867    }
868
869    pub(crate) fn models_path_crate(&self) -> syn::Path {
870        ModelsPath::new(&self.config.models_path_crate_template)
871            .expect("models_path_crate_template already validated by generate_code")
872            .resolve(&self.plan.base_path)
873    }
874}
875
876pub(crate) struct MethodHandler<'a> {
877    plan: &'a MethodPlan,
878    metadata: &'a CodeGenMetadata,
879}
880
881impl MethodHandler<'_> {
882    pub(crate) fn is_collection_method(&self) -> bool {
883        matches!(
884            self.plan.request_type,
885            RequestType::List | RequestType::Create
886        ) || (matches!(self.plan.request_type, RequestType::Custom(Pattern::Get(_)))
887            && self.plan.metadata.method_name.starts_with("List"))
888            || (matches!(
889                self.plan.request_type,
890                RequestType::Custom(Pattern::Post(_))
891            ) && self.plan.metadata.method_name.starts_with("Generate"))
892    }
893
894    pub(crate) fn output_message(&self) -> Option<MessageMeta<'_>> {
895        if self.plan.metadata.output_type.ends_with("Empty") {
896            return None;
897        }
898        self.metadata
899            .get_message_meta(&self.plan.metadata.output_type)
900    }
901
902    pub(crate) fn output_type(&self) -> Option<Ident> {
903        self.output_message()
904            .map(|t| extract_type_ident(&t.info.name))
905    }
906
907    pub(crate) fn list_output_field(&self) -> Option<&MessageField> {
908        self.output_message()?
909            .info
910            .fields
911            .iter()
912            .find(|f| !f.name.contains("page_token"))
913    }
914
915    pub(crate) fn input_message(&self) -> Option<MessageMeta<'_>> {
916        if self.plan.metadata.input_type == "Empty" {
917            return None;
918        }
919        self.metadata
920            .get_message_meta(&self.plan.metadata.input_type)
921    }
922
923    pub(crate) fn input_type(&self) -> Option<Ident> {
924        self.input_message()
925            .map(|t| extract_type_ident(&t.info.name))
926    }
927
928    pub(crate) fn builder_type(&self) -> Ident {
929        format_ident!("{}Builder", self.plan.metadata.method_name)
930    }
931
932    /// Get type representation for rust depending on context
933    ///
934    /// Depending on context we may want concrete types (e.g. 'String') or more flexible types (e.g. 'Into<String d>')
935    pub(crate) fn field_type(&self, field_type: &UnifiedType, ctx: RenderContext) -> syn::Type {
936        let rust_type = types::unified_to_rust(field_type, ctx);
937        syn::parse_str(&rust_type).expect("proper field type")
938    }
939
940    /// Get field assignment TokenStream for constructor
941    pub(crate) fn field_assignment(
942        &self,
943        field_type: &UnifiedType,
944        field_ident: &proc_macro2::Ident,
945        ctx: &RenderContext,
946    ) -> TokenStream {
947        types::field_assignment(field_type, field_ident, ctx)
948    }
949
950    pub(crate) fn required_parameters(&self) -> impl Iterator<Item = &RequestParam> {
951        self.plan
952            .parameters
953            .iter()
954            .filter(|param| !param.is_optional())
955    }
956
957    pub(crate) fn optional_parameters(&self) -> impl Iterator<Item = &RequestParam> {
958        self.plan
959            .parameters
960            .iter()
961            .filter(|param| param.is_optional())
962    }
963
964    /// Split body fields into required and optional subsets.
965    pub(crate) fn split_body_fields(&self) -> (Vec<&BodyField>, Vec<&BodyField>) {
966        split_body_fields(self.plan)
967    }
968}
969
970/// Extract the final type name from a fully qualified protobuf type and convert to Ident
971pub(crate) fn extract_type_ident(full_type: &str) -> Ident {
972    let type_name = full_type.split('.').next_back().unwrap_or(full_type);
973    format_ident!("{}", type_name)
974}