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