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