Skip to main content

olai_codegen/analysis/
types.rs

1use std::collections::HashSet;
2
3use convert_case::{Case, Casing};
4use syn::Ident;
5
6use crate::error::{Error, Result};
7use crate::google::api::{ResourceDescriptor, http_rule::Pattern};
8use crate::parsing::types::UnifiedType;
9use crate::parsing::{CodeGenMetadata, HttpPattern, MethodMetadata, OneofVariant};
10
11/// The Operation a method is performing
12///
13/// There are standard CRUD operations, as well as custom operations.
14///
15/// Standard operations on collections are:
16/// - List: Retrieve a list of resources
17/// - Create: Create a new resource
18///
19/// Standard operations on individual resources are:
20/// - Get: Retrieve a single resource
21/// - Update: Update an existing resource
22/// - Delete: Delete a resource
23///
24/// Custom operations are:
25/// - Custom(Pattern): custom HTTP operation
26#[derive(Debug, Clone, PartialEq)]
27pub enum RequestType {
28    List,
29    Create,
30    Get,
31    Update,
32    Delete,
33    Custom(Pattern),
34}
35
36/// Where a method sits relative to a resource, independent of its CRUD verb.
37///
38/// Computed once during analysis from the method's [`RequestType`], name, and whether the owning
39/// service manages a `google.api.resource`. This makes the scoped/flat and collection/instance
40/// decisions explicit on the IR so emitters stop re-deriving them (replacing the old
41/// `MethodHandler::is_collection_method()` + `BindingMode` logic scattered across emit sites).
42///
43/// The three cases map onto the binding layout:
44/// - [`MethodShape::CollectionScoped`] and [`MethodShape::InstanceScoped`] both belong to a
45///   resource-scoped service; collection methods live on the root client, instance methods on the
46///   per-resource scoped client.
47/// - [`MethodShape::Unbound`] belongs to a resource-less service; every method lives on the root
48///   client and passes all params (the old "flat" lowering).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MethodShape {
51    /// Collection-style operation on a resource service (list / create / factory RPC). Lives on the
52    /// root/aggregate client and passes every param.
53    CollectionScoped,
54    /// Instance operation on a resource service (get / update / delete / resource-targeted custom).
55    /// Lives on the per-resource scoped client, which already holds the path params.
56    InstanceScoped,
57    /// Method of a resource-less ("flat") service. Lives on the root client and passes every param,
58    /// including path params.
59    Unbound,
60}
61
62/// The structural template a language binding uses to emit a method, independent of language.
63///
64/// Collapses the per-binding `match request_type { … }` dispatch (which Python, Node, and
65/// TypeScript each repeated identically) into a single analysis decision. Each variant corresponds
66/// to one emit-shape helper every binding already has (`*_list_method_impl`,
67/// `*_create_method_impl`, `*_get_update_method_impl`, `*_delete_method_impl`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EmitShape {
70    /// Streamed/collected list — returns the repeated item type. (`List`, or a custom `GET` named
71    /// `List*`.)
72    List,
73    /// Factory/create — body in, returns a (possibly unrelated) response message. (`Create`, or a
74    /// collection-style custom `POST`/`PATCH` factory like `Generate*`.)
75    Create,
76    /// Get-or-update — path params (+ optional body), returns the resource/response. (`Get`,
77    /// `Update`, a resource-targeted custom `POST`/`PATCH`, or any other custom verb on a flat
78    /// service.)
79    GetUpdate,
80    /// Delete — path params, no response. (`Delete`.)
81    Delete,
82}
83
84/// Classify a method's [`EmitShape`] from its request type and proto name.
85///
86/// Mirrors **exactly** the per-impl dispatch the bindings previously each performed inline (the
87/// `match request_type` arms in `collection_client_method` / `flat_client_method` /
88/// `resource_client_method`), so emit shape is decided in one place without behavior drift.
89///
90/// `is_collection` (from [`is_collection_method`]) only distinguishes a collection-style custom
91/// `POST`/`PATCH` factory (→ [`EmitShape::Create`]) from a resource-targeted custom `POST`/`PATCH`
92/// (→ [`EmitShape::GetUpdate`]). Note a resource-less `Custom(Get)` named `List*` is **not**
93/// List-shaped here: the old flat dispatch routed it through the `Custom(_) => get_update` arm
94/// (returning the full response message, keeping `page_token`), and only a true
95/// [`RequestType::List`] gets streamed/`List` emit. Collection *routing* (which client a method
96/// lands on) is a separate decision driven by [`is_collection_method`].
97pub(crate) fn emit_shape(request_type: &RequestType, is_collection: bool) -> EmitShape {
98    match request_type {
99        RequestType::List => EmitShape::List,
100        RequestType::Create => EmitShape::Create,
101        RequestType::Delete => EmitShape::Delete,
102        RequestType::Get | RequestType::Update => EmitShape::GetUpdate,
103        // A collection-style custom POST/PATCH is a factory (Create-shaped); every other custom verb
104        // — including a `Custom(Get)` named `List*` — is GetUpdate-shaped (path + optional body,
105        // return the full response).
106        RequestType::Custom(Pattern::Post(_) | Pattern::Patch(_)) if is_collection => {
107            EmitShape::Create
108        }
109        RequestType::Custom(_) => EmitShape::GetUpdate,
110    }
111}
112
113/// A method that was skipped during analysis due to incomplete metadata.
114#[derive(Debug, Clone)]
115pub struct SkippedMethod {
116    /// Fully-qualified name of the service containing the skipped method.
117    pub service_name: String,
118    /// Name of the skipped method (e.g. `"GetCatalog"`).
119    pub method_name: String,
120    /// Human-readable reason the method was skipped (e.g. `"missing HTTP annotation"`).
121    pub reason: String,
122}
123
124/// High-level plan for what code to generate
125#[derive(Debug)]
126pub struct GenerationPlan {
127    /// Services to generate handlers for
128    pub services: Vec<ServicePlan>,
129    /// Methods that were excluded from the plan due to incomplete metadata.
130    ///
131    /// Callers can inspect this list to distinguish "service has zero methods" from "all methods
132    /// were skipped due to missing HTTP annotations", and to surface actionable warnings.
133    pub skipped_methods: Vec<SkippedMethod>,
134}
135
136/// Plan for generating code for a single service
137#[derive(Debug, Clone)]
138pub struct ServicePlan {
139    /// Service name (e.g., "CatalogsService")
140    pub service_name: String,
141    /// Handler trait name (e.g., "CatalogHandler")
142    pub handler_name: String,
143    /// Base URL path for this service (e.g., "catalogs")
144    pub base_path: String,
145    /// Proto package name (e.g., "unitycatalog.catalogs.v1")
146    pub package: String,
147    /// Methods to generate for this service
148    pub methods: Vec<MethodPlan>,
149    /// Resources managed by this service
150    pub managed_resources: Vec<ManagedResource>,
151    /// Documentation from protobuf service comments
152    pub documentation: Option<String>,
153    /// Ancestor chain for this service's managed resource, derived cross-service from
154    /// `resource_reference { child_type }` annotations.
155    ///
156    /// Entries are ordered **root-first** (shallowest ancestor first). For example, for
157    /// a Table service this would be `[catalog_name (depth 0), schema_name (depth 1)]`.
158    ///
159    /// Empty when no `resource_reference` annotations are present — codegen falls back to
160    /// naming heuristics in that case.
161    pub hierarchy: Vec<ResourceHierarchy>,
162    /// Parameter names for this service's resource accessor on the aggregate client, e.g.
163    /// `["catalog_name", "schema_name"]` for a nested Schema or `["name"]` for a flat Catalog.
164    ///
165    /// The trailing element is the resource's own `<singular>_name`; any preceding elements are
166    /// ancestor identifiers. A list of length > 1 means the resource is *nested* and its full name
167    /// is the dot-joined components.
168    ///
169    /// `None` for resource-less services (no accessor is generated). Computed once during analysis
170    /// (Phase 2, after `hierarchy` is populated) so the four accessor emitters — Rust aggregate,
171    /// Python, Node, TypeScript — all read the same list instead of each re-deriving it.
172    pub resource_accessor_params: Option<Vec<String>>,
173    /// Direct child resources of this service's managed resource, used to generate child-navigation
174    /// accessors (e.g. `catalog.schema(name)`) and child-create methods (e.g. `catalog.create_schema`)
175    /// on the scoped client.
176    ///
177    /// A resource C is a *direct child* of P when C's [`Self::resource_accessor_params`] equals P's
178    /// plus exactly one additional trailing component (P's params are a prefix of C's, and
179    /// `C.len() == P.len() + 1`). Sorted by `child_singular`. Empty for leaf or flat resources.
180    /// Computed in Phase 2 of `analyze_metadata` after every service's accessor params are known.
181    pub direct_children: Vec<ChildLink>,
182}
183
184/// A direct child resource of a parent resource, for generating child-navigation and child-create
185/// methods on the parent's scoped client. See [`ServicePlan::direct_children`].
186#[derive(Debug, Clone)]
187pub struct ChildLink {
188    /// The child resource singular — used as the navigation accessor method name (e.g. `schema`).
189    pub child_singular: String,
190    /// The child service's `base_path` / module segment (e.g. `schemas`), used to reference the
191    /// child's generated clients as `crate::codegen::<base_path>::…`.
192    pub child_base_path: String,
193    /// The child's full accessor params (ancestors + own leaf), e.g. `["catalog_name","schema_name"]`.
194    /// The leading entries equal the parent's accessor params (the prefix relation); the final entry
195    /// is the child's own name component.
196    pub child_accessor_params: Vec<String>,
197}
198
199/// Plan for generating code for a single method
200#[derive(Debug, Clone)]
201pub struct MethodPlan {
202    /// Original method metadata
203    pub metadata: MethodMetadata,
204    /// Rust function name for the handler method
205    pub handler_function_name: String,
206    /// Pre-parsed HTTP URL pattern
207    pub http_pattern: HttpPattern,
208    /// HTTP method string for routing (e.g., "GET", "POST")
209    pub http_method: String,
210    /// Parameters passed to the method (path, query, and body)
211    pub parameters: Vec<RequestParam>,
212    /// Whether this method returns a response body
213    pub has_response: bool,
214    /// Request type for this method
215    pub request_type: RequestType,
216    /// The resource type name returned by this method (if any)
217    pub output_resource_type: Option<String>,
218    /// Whether the HTTP request carries a JSON body (create / update / custom POST·PATCH).
219    ///
220    /// Derived once from [`RequestType`] so the client emitter doesn't re-match the enum to
221    /// decide whether to attach `.json(request)`.
222    pub has_request_body: bool,
223    /// Whether the Axum server extractor reads from request *parts* (path/query only) rather
224    /// than the body — i.e. this is a `FromRequestParts` method, not `FromRequest`.
225    ///
226    /// Derived once from [`RequestType`] so the server emitter (extractor selection and the
227    /// `RequestPartsExt` import) doesn't re-match the enum.
228    pub needs_request_parts: bool,
229    /// The verb name to call on a resource-scoped client (`get` / `update` / `delete`), or
230    /// `None` for methods that are not standard instance verbs.
231    ///
232    /// Derived once from [`RequestType`]; the scoped binding call uses this instead of
233    /// re-deciding the verb from the enum (see `MethodPlan::resource_client_method`).
234    pub scoped_verb: Option<String>,
235    /// Where this method sits relative to a resource (collection / instance / unbound).
236    ///
237    /// Assigned in `analyze_service` once the owning service's managed resources are known, so it
238    /// reflects both the method's verb/name shape and whether the service is resource-scoped.
239    pub shape: MethodShape,
240    /// Whether this method has a `google.api.http` route.
241    ///
242    /// Analysis is protocol-agnostic: every method gets a plan. A method without an HTTP
243    /// annotation still produces a plan (with all request fields as body, no path/query) so the
244    /// ConnectRPC emitter can use it — but it has no REST route, so the REST client/handler
245    /// emitters skip it. `true` for methods with an HTTP annotation, `false` otherwise.
246    pub has_http_route: bool,
247}
248
249impl MethodPlan {
250    pub fn path_parameters(&self) -> impl Iterator<Item = &PathParam> {
251        self.parameters.iter().filter_map(|param| match param {
252            RequestParam::Path(path_param) => Some(path_param),
253            _ => None,
254        })
255    }
256
257    /// Whether the REST client formats path parameters into the URL.
258    pub fn formats_path_values(&self) -> bool {
259        self.has_http_route
260            && self.path_parameters().next().is_some()
261            && !self.http_pattern.parameter_names().is_empty()
262    }
263
264    pub fn query_parameters(&self) -> impl Iterator<Item = &QueryParam> {
265        self.parameters.iter().filter_map(|param| match param {
266            RequestParam::Query(query_param) => Some(query_param),
267            _ => None,
268        })
269    }
270
271    pub fn body_fields(&self) -> impl Iterator<Item = &BodyField> {
272        self.parameters.iter().filter_map(|param| match param {
273            RequestParam::Body(body_field) => Some(body_field),
274            _ => None,
275        })
276    }
277}
278
279#[derive(Debug, Clone)]
280pub enum RequestParam {
281    Path(PathParam),
282    Query(QueryParam),
283    Body(BodyField),
284}
285
286impl RequestParam {
287    pub fn name(&self) -> &str {
288        match self {
289            RequestParam::Path(param) => &param.name,
290            RequestParam::Query(param) => &param.name,
291            RequestParam::Body(param) => &param.name,
292        }
293    }
294
295    pub fn field_type(&self) -> &UnifiedType {
296        match self {
297            RequestParam::Path(param) => &param.field_type,
298            RequestParam::Query(param) => &param.field_type,
299            RequestParam::Body(param) => &param.field_type,
300        }
301    }
302
303    pub fn field_ident(&self) -> Ident {
304        crate::utils::field_ident(self.name())
305    }
306
307    pub fn is_optional(&self) -> bool {
308        match self {
309            RequestParam::Path(_) => false,
310            RequestParam::Query(param) => param.is_optional(),
311            RequestParam::Body(param) => param.is_optional(),
312        }
313    }
314
315    pub fn is_path_param(&self) -> bool {
316        matches!(self, RequestParam::Path(_))
317    }
318
319    pub fn documentation(&self) -> Option<&str> {
320        match self {
321            RequestParam::Path(param) => param.documentation.as_deref(),
322            RequestParam::Query(param) => param.documentation.as_deref(),
323            RequestParam::Body(param) => param.documentation.as_deref(),
324        }
325    }
326}
327
328/// A path parameter in a URL template
329#[derive(Debug, Clone)]
330pub struct PathParam {
331    /// Field name in the request struct (e.g., "full_name")
332    pub name: String,
333    /// Parsed type of the path parameter
334    pub field_type: UnifiedType,
335    /// Documentation from protobuf field comments
336    pub documentation: Option<String>,
337}
338
339impl From<PathParam> for RequestParam {
340    fn from(param: PathParam) -> Self {
341        RequestParam::Path(param)
342    }
343}
344
345/// A query parameter for HTTP requests
346#[derive(Debug, Clone)]
347pub struct QueryParam {
348    /// Parameter name
349    pub name: String,
350    /// The `json_name` of the field (see [`QueryParam::key`]).
351    pub json_name: Option<String>,
352    /// Parsed type of the query parameter
353    pub field_type: UnifiedType,
354    /// Documentation from protobuf field comments
355    pub documentation: Option<String>,
356    /// Resource reference annotation, if present on the corresponding proto field.
357    ///
358    /// - `child_type` non-empty: this param scopes a parent of that resource type
359    ///   (e.g. `catalog_name` with `child_type = "unitycatalog.io/Schema"`).
360    /// - `r#type` non-empty: this param directly identifies a resource of that type.
361    pub resource_reference: Option<crate::google::api::ResourceReference>,
362}
363
364impl QueryParam {
365    /// The query-string key: the explicit `json_name`, else the lowerCamelCase JSON name with
366    /// `json_names`, else the proto name.
367    pub fn key(&self, json_names: bool) -> std::borrow::Cow<'_, str> {
368        match &self.json_name {
369            Some(json) => json.into(),
370            None if json_names => crate::utils::strings::lower_camel(&self.name).into(),
371            None => self.name.as_str().into(),
372        }
373    }
374
375    /// Denotes if the parameter is optional
376    pub fn is_optional(&self) -> bool {
377        self.field_type.is_optional
378    }
379}
380
381impl From<QueryParam> for RequestParam {
382    fn from(param: QueryParam) -> Self {
383        RequestParam::Query(param)
384    }
385}
386
387/// A body field that should be extracted from the request body
388#[derive(Debug, Clone)]
389pub struct BodyField {
390    /// Field name
391    pub name: String,
392    /// Parsed type of the body parameter
393    pub field_type: UnifiedType,
394    /// Whether this field is a repeated (Vec) type
395    pub repeated: bool,
396    /// Whether the field is explicitly marked `(google.api.field_behavior) = REQUIRED`.
397    pub required: bool,
398    /// For oneof fields, the variants with their names and types
399    pub oneof_variants: Option<Vec<OneofVariant>>,
400    /// Documentation from protobuf field comments
401    pub documentation: Option<String>,
402}
403
404impl BodyField {
405    /// Denotes whether this field should be treated as optional in builder APIs.
406    ///
407    /// A field marked `REQUIRED` is never optional — even for complex types — so it becomes a
408    /// required constructor parameter rather than a `with_*` setter. (Without this, a required
409    /// message body like `tag_assignment` would be a no-arg-constructor + optional setter, and the
410    /// NAPI binding would execute with an empty body.)
411    ///
412    /// Otherwise a field is optional when its `UnifiedType.is_optional` flag is set, when it is
413    /// repeated, or when its base type is `Map`, `Message`, or `OneOf` (complex types have a valid
414    /// default and are treated as optional constructor parameters).
415    pub fn is_optional(&self) -> bool {
416        use crate::parsing::types::BaseType;
417        if self.required {
418            return false;
419        }
420        self.field_type.is_optional
421            || self.repeated
422            || matches!(
423                self.field_type.base_type,
424                BaseType::Map(_, _) | BaseType::Message(_) | BaseType::OneOf(_)
425            )
426    }
427}
428
429impl From<BodyField> for RequestParam {
430    fn from(field: BodyField) -> Self {
431        RequestParam::Body(field)
432    }
433}
434
435/// Information about a resource managed by a service
436#[derive(Debug, Clone)]
437pub struct ManagedResource {
438    /// Resource type name (e.g., "Catalog")
439    pub type_name: String,
440    /// Resource descriptor information
441    pub descriptor: ResourceDescriptor,
442}
443
444/// Describes one ancestor step in a managed resource's parent chain, derived from
445/// `google.api.resource_reference { child_type }` annotations on List request fields.
446///
447/// Entries in [`ServicePlan::hierarchy`] are ordered **root-first** (shallowest ancestor first),
448/// so iterating them in order produces the correct param list for resource accessors (e.g.
449/// `["catalog_name", "schema_name"]` for a Table, where catalog is depth 0 and schema depth 1).
450///
451/// Built during analysis via the cross-service global parent map and stored on [`ServicePlan`].
452#[derive(Debug, Clone)]
453#[non_exhaustive]
454pub struct ResourceHierarchy {
455    /// The service's managed resource type string (e.g. `"unitycatalog.io/Table"`).
456    ///
457    /// Note: on flat APIs this equals the `child_type` annotation value, but the *actual*
458    /// resource type of the ancestor is `parent_resource_type`, which may differ.
459    pub child_resource_type: String,
460    /// The actual resource type of the ancestor identified by `parent_field_name`
461    /// (e.g. `"unitycatalog.io/Catalog"` for the `catalog_name` field on ListTablesRequest).
462    ///
463    /// This may differ from `child_resource_type` for grandparent fields on flat APIs
464    /// (e.g. `catalog_name` on ListTablesRequest has `child_type = Table` but the field
465    /// actually identifies a Catalog resource).
466    pub parent_resource_type: String,
467    /// The proto field name carrying the ancestor identifier (e.g. `"catalog_name"`).
468    pub parent_field_name: String,
469    /// The singular name of the ancestor resource (e.g. `"catalog"`), resolved by stripping
470    /// `"_name"` from `parent_field_name` and matching against known resource descriptors.
471    /// `None` when the singular cannot be resolved.
472    pub parent_singular: Option<String>,
473}
474
475/// Classifies an RPC method as a standard CRUD operation or custom operation.
476///
477/// This is an internal helper used by [`super::analyze_method`]. Construct via
478/// [`MethodPlanner::try_new`] and consume with [`MethodPlanner::request_type`] and
479/// [`MethodPlanner::http_pattern`].
480pub(crate) struct MethodPlanner<'a> {
481    method: &'a MethodMetadata,
482    pattern: Pattern,
483    path: HttpPattern,
484    metadata: &'a CodeGenMetadata,
485}
486
487impl<'a> MethodPlanner<'a> {
488    pub(crate) fn try_new(
489        method: &'a MethodMetadata,
490        metadata: &'a CodeGenMetadata,
491    ) -> Result<Self> {
492        let Some(pattern) = &method.http_rule.pattern else {
493            return Err(Error::MissingAnnotation {
494                object: method.method_name.clone(),
495                message: "Missing HTTP rule pattern".to_string(),
496            });
497        };
498        Ok(Self {
499            method,
500            path: method.http_pattern.clone(),
501            pattern: pattern.clone(),
502            metadata,
503        })
504    }
505
506    /// Consume the planner and return the pre-parsed HTTP URL pattern.
507    pub(crate) fn into_http_pattern(self) -> HttpPattern {
508        self.path
509    }
510
511    /// Classify the RPC as a standard CRUD operation per Google AIP 131-135.
512    ///
513    /// Each standard operation is identified by matching (verb, HTTP method, path shape,
514    /// resource lookup). See:
515    /// - [AIP-131](https://google.aip.dev/131) Get
516    /// - [AIP-132](https://google.aip.dev/132) List
517    /// - [AIP-133](https://google.aip.dev/133) Create
518    /// - [AIP-134](https://google.aip.dev/134) Update
519    /// - [AIP-135](https://google.aip.dev/135) Delete
520    pub(crate) fn request_type(&self) -> RequestType {
521        let snake_name = self.method.method_name.to_case(Case::Snake);
522        let verb_resource = snake_name.split_once('_');
523
524        if let Some((verb, resource)) = verb_resource {
525            // Table of (verb, expected pattern, path must end with parameter?, lookup by plural?)
526            #[allow(clippy::type_complexity)]
527            let standard_ops: &[(
528                &str,
529                fn(&Pattern) -> bool,
530                bool,
531                bool,
532                RequestType,
533            )] = &[
534                (
535                    "get",
536                    |p| matches!(p, Pattern::Get(_)),
537                    true,
538                    false,
539                    RequestType::Get,
540                ),
541                (
542                    "list",
543                    |p| matches!(p, Pattern::Get(_)),
544                    false,
545                    true,
546                    RequestType::List,
547                ),
548                (
549                    "create",
550                    |p| matches!(p, Pattern::Post(_)),
551                    false,
552                    false,
553                    RequestType::Create,
554                ),
555                (
556                    "update",
557                    |p| matches!(p, Pattern::Patch(_)),
558                    true,
559                    false,
560                    RequestType::Update,
561                ),
562                (
563                    "delete",
564                    |p| matches!(p, Pattern::Delete(_)),
565                    true,
566                    false,
567                    RequestType::Delete,
568                ),
569            ];
570
571            for &(expected_verb, pattern_check, ends_with_param, use_plural, ref result_type) in
572                standard_ops
573            {
574                if verb != expected_verb || !pattern_check(&self.pattern) {
575                    continue;
576                }
577                if ends_with_param && self.path.ends_with_static() {
578                    continue;
579                }
580                if !ends_with_param && self.path.ends_with_parameter() {
581                    continue;
582                }
583                let found = if use_plural {
584                    self.metadata.resource_from_plural(resource).is_some()
585                } else {
586                    self.metadata.resource_from_singular(resource).is_some()
587                };
588                if found {
589                    return result_type.clone();
590                }
591            }
592        }
593
594        RequestType::Custom(self.pattern.clone())
595    }
596
597    pub(crate) fn has_response(&self) -> bool {
598        !self.method.output_type.is_empty() && !self.method.output_type.ends_with("Empty")
599    }
600
601    /// Extract the simple resource type name from the method's output type.
602    ///
603    /// Strips the package prefix (e.g., `.example.catalog.v1.Catalog` → `Catalog`).
604    pub(crate) fn output_resource_type(&self) -> Option<String> {
605        if self.has_response() {
606            let output_type = &self.method.output_type;
607            let simple = output_type
608                .rfind('.')
609                .map(|i| &output_type[i + 1..])
610                .unwrap_or(output_type);
611            Some(simple.to_string())
612        } else {
613            None
614        }
615    }
616}
617
618/// Whether a request of this type carries a JSON body (create / update / custom POST·PATCH).
619///
620/// Single source of truth for the body-attach decision the client emitter previously re-derived.
621pub(crate) fn request_has_body(request_type: &RequestType) -> bool {
622    matches!(
623        request_type,
624        RequestType::Create
625            | RequestType::Update
626            | RequestType::Custom(Pattern::Post(_))
627            | RequestType::Custom(Pattern::Patch(_))
628    )
629}
630
631/// Whether the Axum server extractor reads request *parts* (path/query) rather than the body.
632///
633/// Single source of truth for the `FromRequestParts`-vs-`FromRequest` decision (and the
634/// `RequestPartsExt` import) the server emitter previously re-derived.
635pub(crate) fn request_needs_request_parts(request_type: &RequestType) -> bool {
636    matches!(
637        request_type,
638        RequestType::List | RequestType::Get | RequestType::Delete
639    ) || matches!(
640        request_type,
641        RequestType::Custom(Pattern::Get(_) | Pattern::Delete(_))
642    )
643}
644
645/// Whether a method is a collection-style operation (list / create / factory RPC).
646///
647/// Single source of truth for the collection/instance split. Standard `List`/`Create` always
648/// qualify; custom RPCs qualify by name convention — a `GET` named `List*` (a resource-less list)
649/// or a `POST` named `Generate*` (a factory RPC like `GenerateCredentials`). This is the only place
650/// the `List`/`Generate` proto-name heuristics live.
651pub(crate) fn is_collection_method(request_type: &RequestType, method_name: &str) -> bool {
652    matches!(request_type, RequestType::List | RequestType::Create)
653        || (matches!(request_type, RequestType::Custom(Pattern::Get(_)))
654            && method_name.starts_with("List"))
655        || (matches!(request_type, RequestType::Custom(Pattern::Post(_)))
656            && method_name.starts_with("Generate"))
657}
658
659/// Classify a method's [`MethodShape`] from its request type, proto name, and whether the owning
660/// service manages a `google.api.resource`.
661///
662/// - Resource-less service → [`MethodShape::Unbound`] for every method.
663/// - Resource service, collection-style method → [`MethodShape::CollectionScoped`].
664/// - Resource service, otherwise → [`MethodShape::InstanceScoped`].
665pub(crate) fn method_shape(
666    request_type: &RequestType,
667    method_name: &str,
668    service_has_resource: bool,
669) -> MethodShape {
670    if !service_has_resource {
671        MethodShape::Unbound
672    } else if is_collection_method(request_type, method_name) {
673        MethodShape::CollectionScoped
674    } else {
675        MethodShape::InstanceScoped
676    }
677}
678
679/// The verb to call on a resource-scoped client for standard instance operations, or `None`.
680///
681/// Single source of truth for the scoped-verb mapping the binding emitters previously re-derived.
682pub(crate) fn scoped_verb(request_type: &RequestType) -> Option<String> {
683    match request_type {
684        RequestType::Get => Some("get".to_string()),
685        RequestType::Update => Some("update".to_string()),
686        RequestType::Delete => Some("delete".to_string()),
687        _ => None,
688    }
689}
690
691/// Split body fields from a `MethodPlan` into required and optional subsets.
692///
693/// Delegates to [`BodyField::is_optional`] for the classification. Optional fields
694/// become `with_*` setter methods; required fields become constructor parameters.
695pub fn split_body_fields(plan: &MethodPlan) -> (Vec<&BodyField>, Vec<&BodyField>) {
696    let mut required = Vec::new();
697    let mut optional = Vec::new();
698    for field in plan.body_fields() {
699        if field.is_optional() {
700            optional.push(field);
701        } else {
702            required.push(field);
703        }
704    }
705    (required, optional)
706}
707
708/// Extract managed resources from service methods, deduplicating by type name.
709pub fn extract_managed_resources(
710    metadata: &CodeGenMetadata,
711    methods: &[MethodPlan],
712) -> Vec<ManagedResource> {
713    let mut resources = Vec::new();
714    let mut seen_types = HashSet::<String>::new();
715
716    for method in methods {
717        if let Some(ref resource_type) = method.output_resource_type {
718            if seen_types.contains(resource_type) {
719                continue;
720            }
721            if let Some(descriptor) = metadata.get_resource_descriptor(resource_type) {
722                resources.push(ManagedResource {
723                    type_name: resource_type.clone(),
724                    descriptor: descriptor.clone(),
725                });
726                seen_types.insert(resource_type.clone());
727            }
728        }
729    }
730
731    resources
732}
733
734#[cfg(test)]
735mod tests {
736
737    #[test]
738    fn query_key_is_the_proto_name_unless_json_names_are_asked_or_explicit() {
739        let param = |name: &str, json: Option<&str>| QueryParam {
740            name: name.to_string(),
741            json_name: json.map(str::to_string),
742            field_type: crate::parsing::types::UnifiedType {
743                base_type: crate::parsing::types::BaseType::Int32,
744                is_optional: true,
745                is_repeated: false,
746            },
747            documentation: None,
748            resource_reference: None,
749        };
750        // No `json_name` in the proto: the proto name, unless the package sends JSON names.
751        assert_eq!(param("start_index", None).key(false), "start_index");
752        assert_eq!(param("start_index", None).key(true), "startIndex");
753        // A `json_name` that the proto writes out, even when it is the default one.
754        assert_eq!(param("first", Some("startIndex")).key(false), "startIndex");
755        assert_eq!(
756            param("page_token", Some("pageToken")).key(false),
757            "pageToken"
758        );
759    }
760
761    use super::*;
762
763    fn custom(p: Pattern) -> RequestType {
764        RequestType::Custom(p)
765    }
766
767    #[test]
768    fn request_has_body_matches_write_verbs() {
769        assert!(request_has_body(&RequestType::Create));
770        assert!(request_has_body(&RequestType::Update));
771        assert!(request_has_body(&custom(Pattern::Post(String::new()))));
772        assert!(request_has_body(&custom(Pattern::Patch(String::new()))));
773
774        assert!(!request_has_body(&RequestType::List));
775        assert!(!request_has_body(&RequestType::Get));
776        assert!(!request_has_body(&RequestType::Delete));
777        assert!(!request_has_body(&custom(Pattern::Get(String::new()))));
778        assert!(!request_has_body(&custom(Pattern::Delete(String::new()))));
779    }
780
781    #[test]
782    fn needs_request_parts_matches_read_verbs() {
783        assert!(request_needs_request_parts(&RequestType::List));
784        assert!(request_needs_request_parts(&RequestType::Get));
785        assert!(request_needs_request_parts(&RequestType::Delete));
786        assert!(request_needs_request_parts(&custom(Pattern::Get(
787            String::new()
788        ))));
789        assert!(request_needs_request_parts(&custom(Pattern::Delete(
790            String::new()
791        ))));
792
793        assert!(!request_needs_request_parts(&RequestType::Create));
794        assert!(!request_needs_request_parts(&RequestType::Update));
795        assert!(!request_needs_request_parts(&custom(Pattern::Post(
796            String::new()
797        ))));
798        assert!(!request_needs_request_parts(&custom(Pattern::Patch(
799            String::new()
800        ))));
801    }
802
803    #[test]
804    fn has_body_and_needs_parts_are_mutually_exclusive_for_known_verbs() {
805        // A method either reads from parts or carries a body — never both, never neither —
806        // for the verb shapes the emitters actually produce extractors/clients for.
807        for rt in [
808            RequestType::List,
809            RequestType::Create,
810            RequestType::Get,
811            RequestType::Update,
812            RequestType::Delete,
813            custom(Pattern::Get(String::new())),
814            custom(Pattern::Post(String::new())),
815            custom(Pattern::Patch(String::new())),
816            custom(Pattern::Delete(String::new())),
817        ] {
818            assert_ne!(
819                request_has_body(&rt),
820                request_needs_request_parts(&rt),
821                "body vs request-parts should partition {rt:?}"
822            );
823        }
824    }
825
826    #[test]
827    fn is_collection_method_standard_and_name_heuristics() {
828        // Standard collection verbs always qualify, regardless of name.
829        assert!(is_collection_method(&RequestType::List, "ListThings"));
830        assert!(is_collection_method(&RequestType::Create, "CreateThing"));
831
832        // Custom GET named `List*` (resource-less list) qualifies; other custom GETs don't.
833        assert!(is_collection_method(
834            &custom(Pattern::Get(String::new())),
835            "ListTagAssignments"
836        ));
837        assert!(!is_collection_method(
838            &custom(Pattern::Get(String::new())),
839            "GetTagAssignment"
840        ));
841
842        // Custom POST named `Generate*` (factory RPC) qualifies; other custom POSTs don't.
843        assert!(is_collection_method(
844            &custom(Pattern::Post(String::new())),
845            "GenerateCredentials"
846        ));
847        assert!(!is_collection_method(
848            &custom(Pattern::Post(String::new())),
849            "RotateToken"
850        ));
851
852        // Instance verbs never qualify.
853        assert!(!is_collection_method(&RequestType::Get, "GetThing"));
854        assert!(!is_collection_method(&RequestType::Update, "UpdateThing"));
855        assert!(!is_collection_method(&RequestType::Delete, "DeleteThing"));
856    }
857
858    #[test]
859    fn method_shape_classification() {
860        // Resource-less service → everything Unbound, regardless of verb.
861        assert_eq!(
862            method_shape(&RequestType::List, "ListTagAssignments", false),
863            MethodShape::Unbound
864        );
865        assert_eq!(
866            method_shape(&RequestType::Get, "GetTagAssignment", false),
867            MethodShape::Unbound
868        );
869        assert_eq!(
870            method_shape(&custom(Pattern::Post(String::new())), "TouchTag", false),
871            MethodShape::Unbound
872        );
873
874        // Resource service → collection verbs are CollectionScoped, instance verbs InstanceScoped.
875        assert_eq!(
876            method_shape(&RequestType::List, "ListCatalogs", true),
877            MethodShape::CollectionScoped
878        );
879        assert_eq!(
880            method_shape(&RequestType::Create, "CreateCatalog", true),
881            MethodShape::CollectionScoped
882        );
883        assert_eq!(
884            method_shape(
885                &custom(Pattern::Post(String::new())),
886                "GenerateCredentials",
887                true
888            ),
889            MethodShape::CollectionScoped
890        );
891        assert_eq!(
892            method_shape(&RequestType::Get, "GetCatalog", true),
893            MethodShape::InstanceScoped
894        );
895        assert_eq!(
896            method_shape(&RequestType::Update, "UpdateCatalog", true),
897            MethodShape::InstanceScoped
898        );
899        assert_eq!(
900            method_shape(&RequestType::Delete, "DeleteCatalog", true),
901            MethodShape::InstanceScoped
902        );
903        // Resource-targeted custom POST (not a factory) stays instance-scoped.
904        assert_eq!(
905            method_shape(&custom(Pattern::Post(String::new())), "RotateToken", true),
906            MethodShape::InstanceScoped
907        );
908    }
909
910    #[test]
911    fn emit_shape_classification() {
912        // Standard verbs map directly.
913        assert_eq!(emit_shape(&RequestType::List, false), EmitShape::List);
914        assert_eq!(emit_shape(&RequestType::Create, false), EmitShape::Create);
915        assert_eq!(emit_shape(&RequestType::Get, false), EmitShape::GetUpdate);
916        assert_eq!(
917            emit_shape(&RequestType::Update, false),
918            EmitShape::GetUpdate
919        );
920        assert_eq!(emit_shape(&RequestType::Delete, false), EmitShape::Delete);
921
922        // A collection-style custom POST/PATCH (a factory, e.g. `Generate*`) is Create-shaped.
923        assert_eq!(
924            emit_shape(&custom(Pattern::Post(String::new())), true),
925            EmitShape::Create
926        );
927        assert_eq!(
928            emit_shape(&custom(Pattern::Patch(String::new())), true),
929            EmitShape::Create
930        );
931
932        // A resource-targeted (non-collection) custom POST/PATCH is GetUpdate-shaped.
933        assert_eq!(
934            emit_shape(&custom(Pattern::Post(String::new())), false),
935            EmitShape::GetUpdate
936        );
937
938        // A custom GET (even named `List*`, is_collection=true) is NOT List-shaped — it returns the
939        // full response message, matching the old flat dispatch's `Custom(_) => get_update` arm.
940        assert_eq!(
941            emit_shape(&custom(Pattern::Get(String::new())), true),
942            EmitShape::GetUpdate
943        );
944        assert_eq!(
945            emit_shape(&custom(Pattern::Get(String::new())), false),
946            EmitShape::GetUpdate
947        );
948        // A custom DELETE is GetUpdate-shaped (the old flat `Custom(_)` arm), not Delete.
949        assert_eq!(
950            emit_shape(&custom(Pattern::Delete(String::new())), false),
951            EmitShape::GetUpdate
952        );
953    }
954
955    #[test]
956    fn scoped_verb_only_for_standard_instance_ops() {
957        assert_eq!(scoped_verb(&RequestType::Get).as_deref(), Some("get"));
958        assert_eq!(scoped_verb(&RequestType::Update).as_deref(), Some("update"));
959        assert_eq!(scoped_verb(&RequestType::Delete).as_deref(), Some("delete"));
960
961        assert_eq!(scoped_verb(&RequestType::List), None);
962        assert_eq!(scoped_verb(&RequestType::Create), None);
963        assert_eq!(scoped_verb(&custom(Pattern::Post(String::new()))), None);
964        assert_eq!(scoped_verb(&custom(Pattern::Get(String::new()))), None);
965    }
966}