Skip to main content

olai_codegen/parsing/
http.rs

1/// Represents a segment in a URL template
2#[derive(Debug, Clone, PartialEq)]
3pub enum UrlSegment {
4    /// A static literal segment like "catalogs" or "metadata"
5    Static(String),
6    /// A path parameter like "{name}" or "{catalog_name}"
7    Parameter(String),
8}
9
10/// Parsed representation of an HTTP rule pattern
11///
12/// The [`Default`] is an empty pattern (no template, no segments). It exists for client protocols
13/// that carry no HTTP routing (e.g. ConnectRPC), where a `MethodPlan` still needs an `http_pattern`
14/// field but the REST emitters that read it are never invoked.
15#[derive(Debug, Clone, Default)]
16pub struct HttpPattern {
17    /// The original template string
18    pub template: String,
19    /// Parsed segments in order
20    pub segments: Vec<UrlSegment>,
21    /// Just the parameter names in order (for backward compatibility)
22    pub parameters: Vec<String>,
23    /// Static prefix (everything before the first parameter)
24    pub static_prefix: String,
25    /// Static suffix (everything after the last parameter)
26    pub static_suffix: Option<String>,
27}
28
29impl HttpPattern {
30    /// Parse an HTTP rule pattern template
31    pub fn parse(template: &str) -> Self {
32        let segments = parse_url_segments(template);
33        let parameters = segments
34            .iter()
35            .filter_map(|seg| match seg {
36                UrlSegment::Parameter(name) => Some(name.clone()),
37                UrlSegment::Static(_) => None,
38            })
39            .collect();
40
41        let static_prefix = extract_static_prefix(&segments);
42        let static_suffix = extract_static_suffix(&segments);
43
44        HttpPattern {
45            template: template.to_string(),
46            segments,
47            parameters,
48            static_prefix,
49            static_suffix,
50        }
51    }
52
53    pub fn ends_with_static(&self) -> bool {
54        self.segments
55            .last()
56            .is_some_and(|seg| matches!(seg, UrlSegment::Static(_)))
57    }
58
59    pub fn ends_with_parameter(&self) -> bool {
60        self.segments
61            .last()
62            .is_some_and(|seg| matches!(seg, UrlSegment::Parameter(_)))
63    }
64
65    /// Get the base path (static prefix without leading slash)
66    pub fn base_path(&self) -> String {
67        self.static_prefix
68            .trim_start_matches('/')
69            .trim_end_matches('/')
70            .to_string()
71    }
72
73    /// Get parameter names in the order they appear in the URL
74    pub fn parameter_names(&self) -> &[String] {
75        &self.parameters
76    }
77
78    /// Generate a format string for URL construction
79    /// Returns ("/catalogs/{}", ["name"]) for "/catalogs/{name}"
80    pub fn to_format_string(&self) -> (String, Vec<String>) {
81        let mut format_parts = Vec::new();
82        let mut format_args = Vec::new();
83
84        for segment in &self.segments {
85            match segment {
86                UrlSegment::Static(literal) => {
87                    format_parts.push(literal.clone());
88                }
89                UrlSegment::Parameter(name) => {
90                    format_parts.push("{}".to_string());
91                    format_args.push(name.clone());
92                }
93            }
94        }
95
96        (
97            format_parts.join("").trim_start_matches('/').to_string(),
98            format_args,
99        )
100    }
101}
102
103/// Normalize the raw contents of a `{…}` placeholder into the single request-field name it binds.
104///
105/// gRPC transcoding (`google.api.http`) templates use richer placeholder syntax than a bare
106/// field name:
107/// - **Segment binding**: `{name=catalogs/*}` binds the `name` field to a multi-segment pattern.
108///   The pattern after `=` is matched at runtime by the literal URL segments and does not change
109///   which field is bound, so we keep only the field name before `=`. **Supported.**
110/// - **Dotted field path**: `{table.catalog_name}` binds a *nested* request field. Supporting this
111///   correctly would ripple through analysis (nested field resolution), the Rust client URL
112///   builder (`request.table.catalog_name` access), and binding signatures. **Not yet supported** —
113///   returns `None` so the caller preserves the placeholder verbatim instead of emitting broken
114///   code. Tracked as a known limitation.
115///
116/// Returns the normalized flat field name, or `None` when the placeholder is degenerate
117/// (e.g. `{=foo}`) or uses an unsupported shape (dotted path).
118fn normalize_placeholder(raw: &str) -> Option<String> {
119    let name = raw.split('=').next().unwrap_or("").trim();
120    if name.is_empty() || name.contains('.') {
121        return None;
122    }
123    Some(name.to_string())
124}
125
126/// Parse URL template into segments
127fn parse_url_segments(template: &str) -> Vec<UrlSegment> {
128    let mut segments = Vec::new();
129    let mut chars = template.chars().peekable();
130    let mut current_static = String::new();
131
132    while let Some(ch) = chars.next() {
133        if ch == '{' {
134            // Save any accumulated static content
135            if !current_static.is_empty() {
136                segments.push(UrlSegment::Static(current_static.clone()));
137                current_static.clear();
138            }
139
140            // Parse the raw placeholder contents (everything up to the closing `}`).
141            let mut raw = String::new();
142            let mut closed = false;
143            for next_ch in chars.by_ref() {
144                if next_ch == '}' {
145                    closed = true;
146                    break;
147                }
148                raw.push(next_ch);
149            }
150
151            // An unclosed brace (e.g. `{name` with no `}`) is malformed — skip it rather
152            // than emitting a parameter with a truncated or empty name.
153            match (!raw.is_empty() && closed)
154                .then(|| normalize_placeholder(&raw))
155                .flatten()
156            {
157                Some(param_name) => segments.push(UrlSegment::Parameter(param_name)),
158                // Unsupported placeholder shape (e.g. a dotted nested path like
159                // `{table.catalog_name}`). Preserve it verbatim as a literal so the URL template
160                // stays intact and no invalid Rust identifier is ever generated from it. A flat
161                // binding for the nested field is a known gap (see `normalize_placeholder`).
162                None if closed && !raw.is_empty() => {
163                    segments.push(UrlSegment::Static(format!("{{{raw}}}")));
164                }
165                None => {}
166            }
167        } else {
168            current_static.push(ch);
169        }
170    }
171
172    // Add any remaining static content
173    if !current_static.is_empty() {
174        segments.push(UrlSegment::Static(current_static));
175    }
176
177    segments
178}
179
180/// Extract static prefix (everything before first parameter)
181fn extract_static_prefix(segments: &[UrlSegment]) -> String {
182    let mut prefix = String::new();
183    for segment in segments {
184        match segment {
185            UrlSegment::Static(literal) => prefix.push_str(literal),
186            UrlSegment::Parameter(_) => break,
187        }
188    }
189    prefix
190}
191
192/// Extract static suffix (everything after last parameter)
193fn extract_static_suffix(segments: &[UrlSegment]) -> Option<String> {
194    let mut suffix = String::new();
195    let mut found_last_param_index = None;
196
197    // Find the last parameter index
198    for (i, segment) in segments.iter().enumerate() {
199        if matches!(segment, UrlSegment::Parameter(_)) {
200            found_last_param_index = Some(i);
201        }
202    }
203
204    // If we found a parameter, collect everything after it
205    if let Some(last_param_index) = found_last_param_index {
206        for segment in segments.iter().skip(last_param_index + 1) {
207            if let UrlSegment::Static(literal) = segment {
208                suffix.push_str(literal);
209            }
210        }
211    }
212
213    (!suffix.is_empty()).then_some(suffix)
214}
215
216/// Extract path parameter names from URL template like "/catalogs/{name}"
217/// (Kept for backward compatibility)
218pub fn extract_path_parameters(path_template: &str) -> Vec<String> {
219    HttpPattern::parse(path_template).parameters
220}
221
222/// Extract pattern information from an HttpRule
223pub fn extract_http_rule_pattern(http_rule: &crate::google::api::HttpRule) -> Option<HttpPattern> {
224    use crate::google::api::http_rule::Pattern;
225
226    let template = match &http_rule.pattern {
227        Some(Pattern::Get(path)) => path,
228        Some(Pattern::Post(path)) => path,
229        Some(Pattern::Put(path)) => path,
230        Some(Pattern::Delete(path)) => path,
231        Some(Pattern::Patch(path)) => path,
232        Some(Pattern::Custom(custom)) => &custom.path,
233        None => return None,
234    };
235
236    Some(HttpPattern::parse(template))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_extract_path_parameters() {
245        assert_eq!(extract_path_parameters("/catalogs/{name}"), vec!["name"]);
246        assert_eq!(
247            extract_path_parameters("/shares/{share}/schemas/{schema}/tables/{name}"),
248            vec!["share", "schema", "name"]
249        );
250        assert_eq!(extract_path_parameters("/catalogs"), Vec::<String>::new());
251    }
252
253    #[test]
254    fn test_http_pattern_parsing() {
255        // Test simple static path
256        let pattern = HttpPattern::parse("/catalogs");
257        assert_eq!(pattern.parameters, Vec::<String>::new());
258        assert_eq!(pattern.static_prefix, "/catalogs");
259        assert_eq!(pattern.static_suffix, None);
260
261        // Test single parameter
262        let pattern = HttpPattern::parse("/catalogs/{name}");
263        assert_eq!(pattern.parameters, vec!["name"]);
264        assert_eq!(pattern.static_prefix, "/catalogs/");
265        assert_eq!(pattern.static_suffix, None);
266
267        // Test multiple parameters
268        let pattern = HttpPattern::parse("/shares/{share}/schemas/{schema}/tables/{name}");
269        assert_eq!(pattern.parameters, vec!["share", "schema", "name"]);
270        assert_eq!(pattern.static_prefix, "/shares/");
271        assert_eq!(pattern.static_suffix, None);
272
273        // Test parameter with suffix
274        let pattern = HttpPattern::parse("/catalogs/{name}/metadata");
275        assert_eq!(pattern.parameters, vec!["name"]);
276        assert_eq!(pattern.static_prefix, "/catalogs/");
277        assert_eq!(pattern.static_suffix.as_deref(), Some("/metadata"));
278    }
279
280    #[test]
281    fn test_http_pattern_segments() {
282        let pattern = HttpPattern::parse("/shares/{share}/schemas/{schema}");
283
284        use UrlSegment;
285        assert_eq!(
286            pattern.segments,
287            vec![
288                UrlSegment::Static("/shares/".to_string()),
289                UrlSegment::Parameter("share".to_string()),
290                UrlSegment::Static("/schemas/".to_string()),
291                UrlSegment::Parameter("schema".to_string()),
292            ]
293        );
294    }
295
296    #[test]
297    fn test_http_pattern_to_format_string() {
298        let pattern = HttpPattern::parse("/catalogs/{name}");
299        let (format_str, args) = pattern.to_format_string();
300        assert_eq!(format_str, "catalogs/{}");
301        assert_eq!(args, vec!["name"]);
302
303        let pattern = HttpPattern::parse("/shares/{share}/schemas/{schema}");
304        let (format_str, args) = pattern.to_format_string();
305        assert_eq!(format_str, "shares/{}/schemas/{}");
306        assert_eq!(args, vec!["share", "schema"]);
307    }
308
309    #[test]
310    fn test_http_pattern_base_path() {
311        let pattern = HttpPattern::parse("/catalogs/{name}");
312        assert_eq!(pattern.base_path(), "catalogs");
313
314        let pattern = HttpPattern::parse("/shares/{share}/schemas");
315        assert_eq!(pattern.base_path(), "shares");
316    }
317
318    #[test]
319    fn test_unclosed_brace_is_ignored() {
320        // A malformed template like `/catalogs/{name` (missing closing brace)
321        // should not produce a parameter — the unclosed segment is discarded.
322        let pattern = HttpPattern::parse("/catalogs/{name");
323        assert!(
324            pattern.parameters.is_empty(),
325            "unclosed brace must not produce a parameter"
326        );
327        assert_eq!(pattern.static_prefix, "/catalogs/");
328    }
329
330    #[test]
331    fn test_segment_binding_placeholder() {
332        // `{name=catalogs/*}` binds the `name` field; the `=pattern` suffix is stripped.
333        let pattern = HttpPattern::parse("/v1/{name=catalogs/*}");
334        assert_eq!(pattern.parameters, vec!["name"]);
335
336        let (format_str, args) = pattern.to_format_string();
337        assert_eq!(args, vec!["name"]);
338        // The format string must not contain the `=catalogs/*` pattern text.
339        assert!(!format_str.contains('='), "got: {format_str}");
340    }
341
342    #[test]
343    fn test_dotted_placeholder_is_preserved_not_a_param() {
344        // Dotted nested paths are not yet supported: the placeholder is kept verbatim as a literal
345        // (so the URL stays intact) and is NOT surfaced as a parameter (so no invalid ident is
346        // generated downstream).
347        let pattern = HttpPattern::parse("/v1/tables/{table.catalog_name}/x");
348        assert!(
349            pattern.parameters.is_empty(),
350            "dotted placeholder must not become a parameter: {:?}",
351            pattern.parameters
352        );
353        assert!(
354            pattern.template.contains("{table.catalog_name}"),
355            "original template text is retained"
356        );
357    }
358
359    #[test]
360    fn test_degenerate_placeholder_is_ignored() {
361        // `{=foo}` has no field name before `=` — not a parameter.
362        let pattern = HttpPattern::parse("/v1/{=foo}");
363        assert!(pattern.parameters.is_empty());
364    }
365
366    #[test]
367    fn test_extract_http_rule_pattern() {
368        use crate::google::api::{HttpRule, http_rule::Pattern};
369
370        // Test GET pattern
371        let http_rule = HttpRule {
372            pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
373            ..Default::default()
374        };
375
376        let pattern = extract_http_rule_pattern(&http_rule).unwrap();
377        assert_eq!(pattern.parameters, vec!["name"]);
378        assert_eq!(pattern.template, "/catalogs/{name}");
379
380        // Test POST pattern
381        let http_rule = HttpRule {
382            pattern: Some(Pattern::Post("/catalogs".to_string())),
383            ..Default::default()
384        };
385
386        let pattern = extract_http_rule_pattern(&http_rule).unwrap();
387        assert_eq!(pattern.parameters, Vec::<String>::new());
388        assert_eq!(pattern.template, "/catalogs");
389
390        // Test None pattern
391        let http_rule = HttpRule {
392            pattern: None,
393            ..Default::default()
394        };
395
396        assert!(extract_http_rule_pattern(&http_rule).is_none());
397    }
398}