Skip to main content

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) => &param.name,
143            RequestParam::Query(param) => &param.name,
144            RequestParam::Body(param) => &param.name,
145        }
146    }
147
148    pub fn field_type(&self) -> &UnifiedType {
149        match self {
150            RequestParam::Path(param) => &param.field_type,
151            RequestParam::Query(param) => &param.field_type,
152            RequestParam::Body(param) => &param.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}