Skip to main content

olai_codegen/analysis/
types.rs

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