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/// A method that was skipped during analysis due to incomplete metadata.
38#[derive(Debug, Clone)]
39pub struct SkippedMethod {
40 /// Fully-qualified name of the service containing the skipped method.
41 pub service_name: String,
42 /// Name of the skipped method (e.g. `"GetCatalog"`).
43 pub method_name: String,
44 /// Human-readable reason the method was skipped (e.g. `"missing HTTP annotation"`).
45 pub reason: String,
46}
47
48/// High-level plan for what code to generate
49#[derive(Debug)]
50pub struct GenerationPlan {
51 /// Services to generate handlers for
52 pub services: Vec<ServicePlan>,
53 /// Methods that were excluded from the plan due to incomplete metadata.
54 ///
55 /// Callers can inspect this list to distinguish "service has zero methods" from "all methods
56 /// were skipped due to missing HTTP annotations", and to surface actionable warnings.
57 pub skipped_methods: Vec<SkippedMethod>,
58}
59
60/// Plan for generating code for a single service
61#[derive(Debug, Clone)]
62pub struct ServicePlan {
63 /// Service name (e.g., "CatalogsService")
64 pub service_name: String,
65 /// Handler trait name (e.g., "CatalogHandler")
66 pub handler_name: String,
67 /// Base URL path for this service (e.g., "catalogs")
68 pub base_path: String,
69 /// Proto package name (e.g., "unitycatalog.catalogs.v1")
70 pub package: String,
71 /// Methods to generate for this service
72 pub methods: Vec<MethodPlan>,
73 /// Resources managed by this service
74 pub managed_resources: Vec<ManagedResource>,
75 /// Documentation from protobuf service comments
76 pub documentation: Option<String>,
77 /// Ancestor chain for this service's managed resource, derived cross-service from
78 /// `resource_reference { child_type }` annotations.
79 ///
80 /// Entries are ordered **root-first** (shallowest ancestor first). For example, for
81 /// a Table service this would be `[catalog_name (depth 0), schema_name (depth 1)]`.
82 ///
83 /// Empty when no `resource_reference` annotations are present — codegen falls back to
84 /// naming heuristics in that case.
85 pub hierarchy: Vec<ResourceHierarchy>,
86}
87
88/// Plan for generating code for a single method
89#[derive(Debug, Clone)]
90pub struct MethodPlan {
91 /// Original method metadata
92 pub metadata: MethodMetadata,
93 /// Rust function name for the handler method
94 pub handler_function_name: String,
95 /// Pre-parsed HTTP URL pattern
96 pub http_pattern: HttpPattern,
97 /// HTTP method string for routing (e.g., "GET", "POST")
98 pub http_method: String,
99 /// Parameters passed to the method (path, query, and body)
100 pub parameters: Vec<RequestParam>,
101 /// Whether this method returns a response body
102 pub has_response: bool,
103 /// Request type for this method
104 pub request_type: RequestType,
105 /// The resource type name returned by this method (if any)
106 pub output_resource_type: Option<String>,
107}
108
109impl MethodPlan {
110 pub fn path_parameters(&self) -> impl Iterator<Item = &PathParam> {
111 self.parameters.iter().filter_map(|param| match param {
112 RequestParam::Path(path_param) => Some(path_param),
113 _ => None,
114 })
115 }
116
117 pub fn query_parameters(&self) -> impl Iterator<Item = &QueryParam> {
118 self.parameters.iter().filter_map(|param| match param {
119 RequestParam::Query(query_param) => Some(query_param),
120 _ => None,
121 })
122 }
123
124 pub fn body_fields(&self) -> impl Iterator<Item = &BodyField> {
125 self.parameters.iter().filter_map(|param| match param {
126 RequestParam::Body(body_field) => Some(body_field),
127 _ => None,
128 })
129 }
130}
131
132#[derive(Debug, Clone)]
133pub enum RequestParam {
134 Path(PathParam),
135 Query(QueryParam),
136 Body(BodyField),
137}
138
139impl RequestParam {
140 pub fn name(&self) -> &str {
141 match self {
142 RequestParam::Path(param) => ¶m.name,
143 RequestParam::Query(param) => ¶m.name,
144 RequestParam::Body(param) => ¶m.name,
145 }
146 }
147
148 pub fn field_type(&self) -> &UnifiedType {
149 match self {
150 RequestParam::Path(param) => ¶m.field_type,
151 RequestParam::Query(param) => ¶m.field_type,
152 RequestParam::Body(param) => ¶m.field_type,
153 }
154 }
155
156 pub fn field_ident(&self) -> Ident {
157 format_ident!("{}", self.name())
158 }
159
160 pub fn is_optional(&self) -> bool {
161 match self {
162 RequestParam::Path(_) => false,
163 RequestParam::Query(param) => param.is_optional(),
164 RequestParam::Body(param) => param.is_optional(),
165 }
166 }
167
168 pub fn is_path_param(&self) -> bool {
169 matches!(self, RequestParam::Path(_))
170 }
171
172 pub fn documentation(&self) -> Option<&str> {
173 match self {
174 RequestParam::Path(param) => param.documentation.as_deref(),
175 RequestParam::Query(param) => param.documentation.as_deref(),
176 RequestParam::Body(param) => param.documentation.as_deref(),
177 }
178 }
179}
180
181/// A path parameter in a URL template
182#[derive(Debug, Clone)]
183pub struct PathParam {
184 /// Field name in the request struct (e.g., "full_name")
185 pub name: String,
186 /// Parsed type of the path parameter
187 pub field_type: UnifiedType,
188 /// Documentation from protobuf field comments
189 pub documentation: Option<String>,
190}
191
192impl From<PathParam> for RequestParam {
193 fn from(param: PathParam) -> Self {
194 RequestParam::Path(param)
195 }
196}
197
198/// A query parameter for HTTP requests
199#[derive(Debug, Clone)]
200pub struct QueryParam {
201 /// Parameter name
202 pub name: String,
203 /// Parsed type of the query parameter
204 pub field_type: UnifiedType,
205 /// Documentation from protobuf field comments
206 pub documentation: Option<String>,
207 /// Resource reference annotation, if present on the corresponding proto field.
208 ///
209 /// - `child_type` non-empty: this param scopes a parent of that resource type
210 /// (e.g. `catalog_name` with `child_type = "unitycatalog.io/Schema"`).
211 /// - `r#type` non-empty: this param directly identifies a resource of that type.
212 pub resource_reference: Option<crate::google::api::ResourceReference>,
213}
214
215impl QueryParam {
216 /// Denotes if the parameter is optional
217 pub fn is_optional(&self) -> bool {
218 self.field_type.is_optional
219 }
220}
221
222impl From<QueryParam> for RequestParam {
223 fn from(param: QueryParam) -> Self {
224 RequestParam::Query(param)
225 }
226}
227
228/// A body field that should be extracted from the request body
229#[derive(Debug, Clone)]
230pub struct BodyField {
231 /// Field name
232 pub name: String,
233 /// Parsed type of the body parameter
234 pub field_type: UnifiedType,
235 /// Whether this field is a repeated (Vec) type
236 pub repeated: bool,
237 /// For oneof fields, the variants with their names and types
238 pub oneof_variants: Option<Vec<OneofVariant>>,
239 /// Documentation from protobuf field comments
240 pub documentation: Option<String>,
241}
242
243impl BodyField {
244 /// Denotes whether this field should be treated as optional in builder APIs.
245 ///
246 /// A field is optional when its `UnifiedType.is_optional` flag is set, when it is
247 /// repeated, or when its base type is `Map`, `Message`, or `OneOf` (complex types
248 /// always have a valid default and are therefore optional constructor parameters).
249 pub fn is_optional(&self) -> bool {
250 use crate::parsing::types::BaseType;
251 self.field_type.is_optional
252 || self.repeated
253 || matches!(
254 self.field_type.base_type,
255 BaseType::Map(_, _) | BaseType::Message(_) | BaseType::OneOf(_)
256 )
257 }
258}
259
260impl From<BodyField> for RequestParam {
261 fn from(field: BodyField) -> Self {
262 RequestParam::Body(field)
263 }
264}
265
266/// Information about a resource managed by a service
267#[derive(Debug, Clone)]
268pub struct ManagedResource {
269 /// Resource type name (e.g., "Catalog")
270 pub type_name: String,
271 /// Resource descriptor information
272 pub descriptor: ResourceDescriptor,
273}
274
275/// Describes one ancestor step in a managed resource's parent chain, derived from
276/// `google.api.resource_reference { child_type }` annotations on List request fields.
277///
278/// Entries in [`ServicePlan::hierarchy`] are ordered **root-first** (shallowest ancestor first),
279/// so iterating them in order produces the correct param list for resource accessors (e.g.
280/// `["catalog_name", "schema_name"]` for a Table, where catalog is depth 0 and schema depth 1).
281///
282/// Built during analysis via the cross-service global parent map and stored on [`ServicePlan`].
283#[derive(Debug, Clone)]
284#[non_exhaustive]
285pub struct ResourceHierarchy {
286 /// The service's managed resource type string (e.g. `"unitycatalog.io/Table"`).
287 ///
288 /// Note: on flat APIs this equals the `child_type` annotation value, but the *actual*
289 /// resource type of the ancestor is `parent_resource_type`, which may differ.
290 pub child_resource_type: String,
291 /// The actual resource type of the ancestor identified by `parent_field_name`
292 /// (e.g. `"unitycatalog.io/Catalog"` for the `catalog_name` field on ListTablesRequest).
293 ///
294 /// This may differ from `child_resource_type` for grandparent fields on flat APIs
295 /// (e.g. `catalog_name` on ListTablesRequest has `child_type = Table` but the field
296 /// actually identifies a Catalog resource).
297 pub parent_resource_type: String,
298 /// The proto field name carrying the ancestor identifier (e.g. `"catalog_name"`).
299 pub parent_field_name: String,
300 /// The singular name of the ancestor resource (e.g. `"catalog"`), resolved by stripping
301 /// `"_name"` from `parent_field_name` and matching against known resource descriptors.
302 /// `None` when the singular cannot be resolved.
303 pub parent_singular: Option<String>,
304}
305
306/// Classifies an RPC method as a standard CRUD operation or custom operation.
307///
308/// This is an internal helper used by [`super::analyze_method`]. Construct via
309/// [`MethodPlanner::try_new`] and consume with [`MethodPlanner::request_type`] and
310/// [`MethodPlanner::http_pattern`].
311pub(crate) struct MethodPlanner<'a> {
312 method: &'a MethodMetadata,
313 pattern: Pattern,
314 path: HttpPattern,
315 metadata: &'a CodeGenMetadata,
316}
317
318impl<'a> MethodPlanner<'a> {
319 pub(crate) fn try_new(
320 method: &'a MethodMetadata,
321 metadata: &'a CodeGenMetadata,
322 ) -> Result<Self> {
323 let Some(pattern) = &method.http_rule.pattern else {
324 return Err(Error::MissingAnnotation {
325 object: method.method_name.clone(),
326 message: "Missing HTTP rule pattern".to_string(),
327 });
328 };
329 Ok(Self {
330 method,
331 path: method.http_pattern.clone(),
332 pattern: pattern.clone(),
333 metadata,
334 })
335 }
336
337 /// Consume the planner and return the pre-parsed HTTP URL pattern.
338 pub(crate) fn into_http_pattern(self) -> HttpPattern {
339 self.path
340 }
341
342 /// Classify the RPC as a standard CRUD operation per Google AIP 131-135.
343 ///
344 /// Each standard operation is identified by matching (verb, HTTP method, path shape,
345 /// resource lookup). See:
346 /// - [AIP-131](https://google.aip.dev/131) Get
347 /// - [AIP-132](https://google.aip.dev/132) List
348 /// - [AIP-133](https://google.aip.dev/133) Create
349 /// - [AIP-134](https://google.aip.dev/134) Update
350 /// - [AIP-135](https://google.aip.dev/135) Delete
351 pub(crate) fn request_type(&self) -> RequestType {
352 let snake_name = self.method.method_name.to_case(Case::Snake);
353 let verb_resource = snake_name.split_once('_');
354
355 if let Some((verb, resource)) = verb_resource {
356 // Table of (verb, expected pattern, path must end with parameter?, lookup by plural?)
357 #[allow(clippy::type_complexity)]
358 let standard_ops: &[(
359 &str,
360 fn(&Pattern) -> bool,
361 bool,
362 bool,
363 RequestType,
364 )] = &[
365 (
366 "get",
367 |p| matches!(p, Pattern::Get(_)),
368 true,
369 false,
370 RequestType::Get,
371 ),
372 (
373 "list",
374 |p| matches!(p, Pattern::Get(_)),
375 false,
376 true,
377 RequestType::List,
378 ),
379 (
380 "create",
381 |p| matches!(p, Pattern::Post(_)),
382 false,
383 false,
384 RequestType::Create,
385 ),
386 (
387 "update",
388 |p| matches!(p, Pattern::Patch(_)),
389 true,
390 false,
391 RequestType::Update,
392 ),
393 (
394 "delete",
395 |p| matches!(p, Pattern::Delete(_)),
396 true,
397 false,
398 RequestType::Delete,
399 ),
400 ];
401
402 for &(expected_verb, pattern_check, ends_with_param, use_plural, ref result_type) in
403 standard_ops
404 {
405 if verb != expected_verb || !pattern_check(&self.pattern) {
406 continue;
407 }
408 if ends_with_param && self.path.ends_with_static() {
409 continue;
410 }
411 if !ends_with_param && self.path.ends_with_parameter() {
412 continue;
413 }
414 let found = if use_plural {
415 self.metadata.resource_from_plural(resource).is_some()
416 } else {
417 self.metadata.resource_from_singular(resource).is_some()
418 };
419 if found {
420 return result_type.clone();
421 }
422 }
423 }
424
425 RequestType::Custom(self.pattern.clone())
426 }
427
428 pub(crate) fn has_response(&self) -> bool {
429 !self.method.output_type.is_empty() && !self.method.output_type.ends_with("Empty")
430 }
431
432 /// Extract the simple resource type name from the method's output type.
433 ///
434 /// Strips the package prefix (e.g., `.example.catalog.v1.Catalog` → `Catalog`).
435 pub(crate) fn output_resource_type(&self) -> Option<String> {
436 if self.has_response() {
437 let output_type = &self.method.output_type;
438 let simple = output_type
439 .rfind('.')
440 .map(|i| &output_type[i + 1..])
441 .unwrap_or(output_type);
442 Some(simple.to_string())
443 } else {
444 None
445 }
446 }
447}
448
449/// Split body fields from a `MethodPlan` into required and optional subsets.
450///
451/// Delegates to [`BodyField::is_optional`] for the classification. Optional fields
452/// become `with_*` setter methods; required fields become constructor parameters.
453pub fn split_body_fields(plan: &MethodPlan) -> (Vec<&BodyField>, Vec<&BodyField>) {
454 let mut required = Vec::new();
455 let mut optional = Vec::new();
456 for field in plan.body_fields() {
457 if field.is_optional() {
458 optional.push(field);
459 } else {
460 required.push(field);
461 }
462 }
463 (required, optional)
464}
465
466/// Extract managed resources from service methods, deduplicating by type name.
467pub fn extract_managed_resources(
468 metadata: &CodeGenMetadata,
469 methods: &[MethodPlan],
470) -> Vec<ManagedResource> {
471 let mut resources = Vec::new();
472 let mut seen_types = HashSet::<String>::new();
473
474 for method in methods {
475 if let Some(ref resource_type) = method.output_resource_type {
476 if seen_types.contains(resource_type) {
477 continue;
478 }
479 if let Some(descriptor) = metadata.get_resource_descriptor(resource_type) {
480 resources.push(ManagedResource {
481 type_name: resource_type.clone(),
482 descriptor: descriptor.clone(),
483 });
484 seen_types.insert(resource_type.clone());
485 }
486 }
487 }
488
489 resources
490}