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