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