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