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) => ¶m.name,
290 RequestParam::Query(param) => ¶m.name,
291 RequestParam::Body(param) => ¶m.name,
292 }
293 }
294
295 pub fn field_type(&self) -> &UnifiedType {
296 match self {
297 RequestParam::Path(param) => ¶m.field_type,
298 RequestParam::Query(param) => ¶m.field_type,
299 RequestParam::Body(param) => ¶m.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}