Skip to main content

platform_core/automation/
routing.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! `rest.yaml` parsing and URL matching — Rust port of the Java `RoutingEntry`
18//! (`org.platformlambda.automation.config.RoutingEntry`), following the
19//! authoritative grammar in the Java project's
20//! `docs/guides/rest-automation/rest-grammar.md`.
21//!
22//! Increment-6 scope is **function binding**: `service` is a composable
23//! function route; increment E-3 added the **flow binding** (`flow:` → the
24//! x-flow-id header for `http.flow.adapter`). Deferred (see the design doc
25//! §5e/§7): HTTP(S) relay (`url_rewrite`/`trust_all_cert`), A/B dual
26//! service, multipart upload.
27
28use std::collections::HashMap;
29use std::time::Duration;
30
31use crate::function::AppError;
32use crate::util::config_reader::ConfigReader;
33use crate::util::multi_level_map::ConfigValue;
34
35const ALLOWED_METHODS: [&str; 6] = ["GET", "PUT", "POST", "DELETE", "HEAD", "PATCH"];
36const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37const MIN_TIMEOUT: Duration = Duration::from_secs(1);
38const MAX_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
39
40/// One `rest:` endpoint entry (Java `RouteInfo`).
41#[derive(Clone, Debug)]
42pub struct RouteInfo {
43    pub service: String,
44    pub methods: Vec<String>,
45    /// URL segments as authored; `{param}` captures and a trailing `*` wildcard.
46    pub url: String,
47    pub timeout: Duration,
48    pub cors: Option<CorsInfo>,
49    pub headers: Option<HeaderInfo>,
50    /// Simple form only: a function route that authorizes the request.
51    pub authentication: Option<String>,
52    pub tracing: bool,
53    /// Per-endpoint impedance overrides (grammar: `trace.id.header`,
54    /// `correlation.id.header`, `traceparent.header`).
55    pub trace_id_header: Option<String>,
56    pub correlation_id_header: Option<String>,
57    pub traceparent_header: Option<String>,
58    /// Event-script flow binding (grammar `flow:`): the flow id injected as
59    /// the `x-flow-id` request header for `http.flow.adapter` (increment E-3).
60    pub flow: Option<String>,
61    /// `stream: true` — the response is a multi-shot event stream: the request
62    /// checks out a dedicated ordered reply lane for its lifetime and the edge
63    /// renders segments progressively (Java `RouteInfo.streamResponse`).
64    pub stream_response: bool,
65    segments: Vec<Segment>,
66}
67
68#[derive(Clone, Debug)]
69enum Segment {
70    /// Case-insensitive literal (stored lowercase).
71    Literal(String),
72    /// `{param}` capture with its name.
73    Param(String),
74    /// A bare `*` segment — matches any single segment; in trailing position
75    /// it also lets the URL run longer (Java `matchRoute` wildcard rule).
76    Any,
77    /// `foo*` — case-insensitive prefix match on one segment (Java
78    /// `notMatched` `endsWith("*")`); trailing position also lets the URL
79    /// run longer.
80    Prefix(String),
81}
82
83/// A reusable `cors:` block (Java `CorsInfo`).
84#[derive(Clone, Debug, Default)]
85pub struct CorsInfo {
86    pub id: String,
87    /// headers for the preflight (OPTIONS) response
88    pub options: Vec<(String, String)>,
89    /// headers added to normal responses
90    pub headers: Vec<(String, String)>,
91}
92
93/// A reusable `headers:` block (Java `HeaderInfo`): request/response
94/// add / drop / keep transforms.
95#[derive(Clone, Debug, Default)]
96pub struct HeaderInfo {
97    pub id: String,
98    pub request: HeaderTransform,
99    pub response: HeaderTransform,
100}
101
102#[derive(Clone, Debug, Default)]
103pub struct HeaderTransform {
104    pub add: Vec<(String, String)>,
105    pub drop: Vec<String>,
106    pub keep: Vec<String>,
107}
108
109impl HeaderTransform {
110    /// Apply this transform to a header map (names matched case-insensitively;
111    /// grammar: `keep` retains only the listed headers, then `drop` removes,
112    /// then `add` appends).
113    pub fn apply(&self, headers: &mut HashMap<String, String>) {
114        if !self.keep.is_empty() {
115            let keep: Vec<String> = self.keep.iter().map(|k| k.to_lowercase()).collect();
116            headers.retain(|name, _| keep.contains(&name.to_lowercase()));
117        }
118        for name in &self.drop {
119            let name = name.to_lowercase();
120            headers.retain(|existing, _| existing.to_lowercase() != name);
121        }
122        for (name, value) in &self.add {
123            headers.insert(name.clone(), value.clone());
124        }
125    }
126}
127
128/// The `static-content` block (Java `RoutingEntry` + `SimpleHttpFilter`):
129/// no-cache pages and an optional request filter for static assets.
130#[derive(Clone, Debug)]
131pub struct StaticContent {
132    /// Pages served with no-cache headers instead of the etag protocol
133    /// (default `["/", "/index.html"]` — entry pages must never be cached,
134    /// e.g. for SSO redirection).
135    pub no_cache_pages: Vec<String>,
136    pub filter: Option<SimpleHttpFilter>,
137}
138
139impl Default for StaticContent {
140    fn default() -> Self {
141        StaticContent {
142            no_cache_pages: vec!["/".to_string(), "/index.html".to_string()],
143            filter: None,
144        }
145    }
146}
147
148/// A composable function that inspects HTTP requests for configured static
149/// paths (Java `SimpleHttpFilter`) — e.g. a backend handling SSO redirection
150/// for a UI bundle. Patterns: exact, `prefix*`, or `*suffix`.
151#[derive(Clone, Debug)]
152pub struct SimpleHttpFilter {
153    pub path_list: Vec<String>,
154    pub exclusion_list: Vec<String>,
155    pub service: String,
156}
157
158/// Match a path against exact / `prefix*` / `*suffix` patterns
159/// (Java `HttpRouter.matchedElement`).
160pub fn matched_element(elements: &[String], path: &str) -> bool {
161    elements.iter().any(|pattern| {
162        if let Some(prefix) = pattern.strip_suffix('*') {
163            path.starts_with(prefix)
164        } else if let Some(suffix) = pattern.strip_prefix('*') {
165            path.ends_with(suffix)
166        } else {
167            path == pattern
168        }
169    })
170}
171
172/// A valid filter pattern has at most one `*`, and only at the start or end
173/// (Java `invalidFilterParameters`, inverted).
174fn valid_patterns(patterns: &[String]) -> bool {
175    patterns.iter().all(|p| {
176        !p.is_empty()
177            && match p.matches('*').count() {
178                0 => true,
179                1 => p.starts_with('*') || p.ends_with('*'),
180                _ => false,
181            }
182    })
183}
184
185/// The parsed routing table (Java `RoutingEntry`).
186pub struct RoutingTable {
187    routes: Vec<RouteInfo>,
188    static_content: StaticContent,
189}
190
191/// A matched route with its extracted `{param}` path variables.
192pub struct AssignedRoute<'a> {
193    pub info: &'a RouteInfo,
194    pub path_params: HashMap<String, String>,
195}
196
197impl RoutingTable {
198    /// Load and validate a `rest.yaml` (Java `RoutingEntry.load`). Invalid
199    /// entries fail the load — the grammar's parser invariants.
200    pub fn load(reader: &ConfigReader) -> Result<Self, AppError> {
201        let map = reader.get_map();
202        // reusable blocks first, so entry references can be validated
203        let cors_blocks = parse_cors_blocks(map.get_element("cors"))?;
204        let header_blocks = parse_header_blocks(map.get_element("headers"))?;
205        let Some(ConfigValue::List(entries)) = map.get_element("rest") else {
206            return Err(AppError::new(400, "rest.yaml has no 'rest' section"));
207        };
208        let mut routes = Vec::new();
209        for (index, entry) in entries.iter().enumerate() {
210            let ConfigValue::Map(entry) = entry else {
211                return Err(AppError::new(400, format!("rest[{index}] is not a map")));
212            };
213            routes.push(parse_route(index, entry, &cors_blocks, &header_blocks)?);
214        }
215        let static_content = parse_static_content(reader)?;
216        Ok(RoutingTable {
217            routes,
218            static_content,
219        })
220    }
221
222    /// The `static-content` configuration (defaults applied when absent).
223    pub fn static_content(&self) -> &StaticContent {
224        &self.static_content
225    }
226
227    /// Build a routing table from YAML text (used for the built-in default
228    /// endpoints — same parser, same invariants).
229    pub fn from_yaml_text(yaml: &str) -> Result<Self, AppError> {
230        let value: serde_yaml::Value =
231            serde_yaml::from_str(yaml).map_err(|e| AppError::new(400, e.to_string()))?;
232        match ConfigValue::from_yaml(&value) {
233            ConfigValue::Map(map) => {
234                let reader = ConfigReader::from_map(map);
235                Self::load(&reader)
236            }
237            _ => Err(AppError::new(400, "rest.yaml text must be a YAML mapping")),
238        }
239    }
240
241    pub fn routes(&self) -> &[RouteInfo] {
242        &self.routes
243    }
244
245    /// Whether any entry already claims this URL (used by the default-endpoint
246    /// merge: user entries always win — Java `default-rest.yaml` semantics).
247    pub fn has_url(&self, url: &str) -> bool {
248        self.routes.iter().any(|r| r.url.eq_ignore_ascii_case(url))
249    }
250
251    pub(crate) fn add_route(&mut self, route: RouteInfo) {
252        self.routes.push(route);
253    }
254
255    /// Match a request (Java `getRouteInfo`): candidates must match every
256    /// segment; precedence is more literal segments first, wildcard last.
257    /// Method filtering happens here too — `OPTIONS` matches any entry (CORS
258    /// preflight, auto-added per the grammar).
259    pub fn find(&self, method: &str, path: &str) -> Option<AssignedRoute<'_>> {
260        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
261        let mut best: Option<(usize, bool, AssignedRoute)> = None;
262        for info in &self.routes {
263            if method != "OPTIONS" && !info.methods.iter().any(|m| m == method) {
264                continue;
265            }
266            let Some(params) = info.match_path(&segments) else {
267                continue;
268            };
269            let literals = info
270                .segments
271                .iter()
272                .filter(|s| matches!(s, Segment::Literal(_)))
273                .count();
274            let wildcard = info.is_open_ended();
275            let better = match &best {
276                None => true,
277                // prefer non-wildcard, then more literal segments
278                Some((best_literals, best_wildcard, _)) => {
279                    (!wildcard && *best_wildcard)
280                        || (wildcard == *best_wildcard && literals > *best_literals)
281                }
282            };
283            if better {
284                best = Some((
285                    literals,
286                    wildcard,
287                    AssignedRoute {
288                        info,
289                        path_params: params,
290                    },
291                ));
292            }
293        }
294        best.map(|(_, _, assigned)| assigned)
295    }
296
297    /// True when the path matches SOME entry regardless of method — the Java
298    /// `getSimilarRoute` marker (increment 56, parity F14c): a known path
299    /// with a wrong method answers 405 "Method not allowed", never 404.
300    pub fn path_matches_any_method(&self, path: &str) -> bool {
301        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
302        self.routes
303            .iter()
304            .any(|info| info.match_path(&segments).is_some())
305    }
306}
307
308impl RouteInfo {
309    /// True when the pattern ends with a `*`-suffixed segment (bare `*` or
310    /// `foo*`) — Java's `configured.endsWith("*")` wildcard flag: the URL may
311    /// then run LONGER than the pattern, but never shorter (`/api/files/*`
312    /// does NOT match `/api/files` — the increment-56 fix of the Rust-only
313    /// empty-remainder match).
314    fn is_open_ended(&self) -> bool {
315        matches!(
316            self.segments.last(),
317            Some(Segment::Any) | Some(Segment::Prefix(_))
318        )
319    }
320
321    /// Match URL segments against this entry — the exact Java `matchRoute` +
322    /// `notMatched` rules (case-insensitive literals and prefixes),
323    /// returning the extracted `{param}` values on success.
324    fn match_path(&self, request_segments: &[&str]) -> Option<HashMap<String, String>> {
325        if self.is_open_ended() {
326            if self.segments.len() > request_segments.len() {
327                return None;
328            }
329        } else if self.segments.len() != request_segments.len() {
330            return None;
331        }
332        let mut params = HashMap::new();
333        for (i, segment) in self.segments.iter().enumerate() {
334            let actual = request_segments.get(i)?;
335            match segment {
336                Segment::Any => {}
337                Segment::Literal(expected) => {
338                    if actual.to_lowercase() != *expected {
339                        return None;
340                    }
341                }
342                Segment::Prefix(prefix) => {
343                    if !actual.to_lowercase().starts_with(prefix.as_str()) {
344                        return None;
345                    }
346                }
347                Segment::Param(name) => {
348                    params.insert(name.clone(), actual.to_string());
349                }
350            }
351        }
352        Some(params)
353    }
354}
355
356/// Navigate a possibly-dotted key: config normalization nests dotted YAML keys
357/// (`trace.id.header` → `trace: {id: {header: …}}`), exactly as the Java
358/// ConfigReader does — so look up the direct key first, then walk the dots.
359fn lookup<'a>(
360    map: &'a std::collections::BTreeMap<String, ConfigValue>,
361    key: &str,
362) -> Option<&'a ConfigValue> {
363    if let Some(value) = map.get(key) {
364        return Some(value);
365    }
366    let mut parts = key.split('.');
367    let mut current = map.get(parts.next()?)?;
368    for part in parts {
369        match current {
370            ConfigValue::Map(nested) => current = nested.get(part)?,
371            _ => return None,
372        }
373    }
374    Some(current)
375}
376
377fn parse_route(
378    index: usize,
379    entry: &std::collections::BTreeMap<String, ConfigValue>,
380    cors_blocks: &HashMap<String, CorsInfo>,
381    header_blocks: &HashMap<String, HeaderInfo>,
382) -> Result<RouteInfo, AppError> {
383    let text = |key: &str| {
384        lookup(entry, key)
385            .and_then(|v| v.as_text())
386            .map(str::to_string)
387    };
388    // invariant 1: service, methods, url are required
389    let service = text("service")
390        .ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'service'")))?;
391    if service.starts_with("http://") || service.starts_with("https://") {
392        return Err(AppError::new(
393            400,
394            format!("rest[{index}] HTTP relay is not yet ported (service '{service}')"),
395        ));
396    }
397    let url =
398        text("url").ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'url'")))?;
399    let Some(ConfigValue::List(raw_methods)) = entry.get("methods") else {
400        return Err(AppError::new(
401            400,
402            format!("rest[{index}] missing 'methods' list"),
403        ));
404    };
405    // invariant 2: methods from the allowed set; OPTIONS is auto-added
406    let mut methods = Vec::new();
407    for method in raw_methods {
408        let method = method
409            .as_text()
410            .map(str::to_uppercase)
411            .ok_or_else(|| AppError::new(400, format!("rest[{index}] method must be text")))?;
412        if !ALLOWED_METHODS.contains(&method.as_str()) {
413            return Err(AppError::new(
414                400,
415                format!("rest[{index}] invalid method '{method}' (allowed: {ALLOWED_METHODS:?})"),
416            ));
417        }
418        methods.push(method);
419    }
420    // segments: literal / {param} / bare `*` (any one segment) / `foo*`
421    // (prefix) — the full Java RoutingEntry grammar (increment 56, parity
422    // F14b: mid-path `*` and segment prefixes were previously rejected or
423    // treated as literals)
424    let mut segments = Vec::new();
425    let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
426    for part in &parts {
427        if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
428            segments.push(Segment::Param(name.to_string()));
429        } else if *part == "*" {
430            segments.push(Segment::Any);
431        } else if let Some(prefix) = part.strip_suffix('*') {
432            segments.push(Segment::Prefix(prefix.to_lowercase()));
433        } else {
434            segments.push(Segment::Literal(part.to_lowercase()));
435        }
436    }
437    // invariant 3: cors/headers references must exist
438    let cors =
439        match text("cors") {
440            Some(id) => Some(cors_blocks.get(&id).cloned().ok_or_else(|| {
441                AppError::new(400, format!("rest[{index}] unknown cors id '{id}'"))
442            })?),
443            None => None,
444        };
445    let headers = match text("headers") {
446        Some(id) => Some(header_blocks.get(&id).cloned().ok_or_else(|| {
447            AppError::new(400, format!("rest[{index}] unknown headers id '{id}'"))
448        })?),
449        None => None,
450    };
451    let tracing = matches!(entry.get("tracing"), Some(ConfigValue::Bool(true)));
452    let stream_response = matches!(entry.get("stream"), Some(ConfigValue::Bool(true)));
453    Ok(RouteInfo {
454        service,
455        methods,
456        url,
457        timeout: parse_timeout(text("timeout").as_deref()),
458        cors,
459        headers,
460        authentication: text("authentication"),
461        tracing,
462        trace_id_header: text("trace.id.header"),
463        correlation_id_header: text("correlation.id.header"),
464        traceparent_header: text("traceparent.header"),
465        flow: text("flow"),
466        stream_response,
467        segments,
468    })
469}
470
471/// Parse a duration like `30s`, `500ms`, `2m` (default 30 s; clamped 1 s–5 m,
472/// Java parity). Shared with the HTTP boundary for `event.stream.keep.alive`.
473pub(crate) fn parse_timeout(value: Option<&str>) -> Duration {
474    let parsed = value.and_then(|text| {
475        let text = text.trim().to_lowercase();
476        if let Some(ms) = text.strip_suffix("ms") {
477            ms.trim().parse::<u64>().ok().map(Duration::from_millis)
478        } else if let Some(minutes) = text.strip_suffix('m') {
479            minutes
480                .trim()
481                .parse::<u64>()
482                .ok()
483                .map(|m| Duration::from_secs(m * 60))
484        } else if let Some(seconds) = text.strip_suffix('s') {
485            seconds.trim().parse::<u64>().ok().map(Duration::from_secs)
486        } else {
487            text.parse::<u64>().ok().map(Duration::from_secs)
488        }
489    });
490    parsed
491        .unwrap_or(DEFAULT_TIMEOUT)
492        .clamp(MIN_TIMEOUT, MAX_TIMEOUT)
493}
494
495fn parse_cors_blocks(section: Option<&ConfigValue>) -> Result<HashMap<String, CorsInfo>, AppError> {
496    let mut blocks = HashMap::new();
497    let Some(ConfigValue::List(entries)) = section else {
498        return Ok(blocks);
499    };
500    for entry in entries {
501        let ConfigValue::Map(map) = entry else {
502            continue;
503        };
504        let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
505            return Err(AppError::new(400, "cors block missing 'id'"));
506        };
507        let mut info = CorsInfo {
508            id: id.to_string(),
509            ..CorsInfo::default()
510        };
511        info.options = parse_header_lines(map.get("options"), id, "options")?;
512        info.headers = parse_header_lines(map.get("headers"), id, "headers")?;
513        blocks.insert(info.id.clone(), info);
514    }
515    Ok(blocks)
516}
517
518/// Each cors line must be `Access-Control-*: value` (grammar invariant).
519fn parse_header_lines(
520    list: Option<&ConfigValue>,
521    id: &str,
522    kind: &str,
523) -> Result<Vec<(String, String)>, AppError> {
524    let mut out = Vec::new();
525    if let Some(ConfigValue::List(lines)) = list {
526        for line in lines {
527            let Some(line) = line.as_text() else { continue };
528            let Some((name, value)) = line.split_once(':') else {
529                return Err(AppError::new(
530                    400,
531                    format!("cors '{id}' {kind} line '{line}' is not 'name: value'"),
532                ));
533            };
534            let name = name.trim();
535            if !name.to_lowercase().starts_with("access-control-") {
536                return Err(AppError::new(
537                    400,
538                    format!("cors '{id}' {kind} line '{name}' must be an Access-Control-* header"),
539                ));
540            }
541            out.push((name.to_string(), value.trim().to_string()));
542        }
543    }
544    Ok(out)
545}
546
547fn parse_header_blocks(
548    section: Option<&ConfigValue>,
549) -> Result<HashMap<String, HeaderInfo>, AppError> {
550    let mut blocks = HashMap::new();
551    let Some(ConfigValue::List(entries)) = section else {
552        return Ok(blocks);
553    };
554    for entry in entries {
555        let ConfigValue::Map(map) = entry else {
556            continue;
557        };
558        let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
559            return Err(AppError::new(400, "headers block missing 'id'"));
560        };
561        blocks.insert(
562            id.to_string(),
563            HeaderInfo {
564                id: id.to_string(),
565                request: parse_transform(map.get("request")),
566                response: parse_transform(map.get("response")),
567            },
568        );
569    }
570    Ok(blocks)
571}
572
573fn parse_transform(section: Option<&ConfigValue>) -> HeaderTransform {
574    let mut transform = HeaderTransform::default();
575    let Some(ConfigValue::Map(map)) = section else {
576        return transform;
577    };
578    if let Some(ConfigValue::List(add)) = map.get("add") {
579        for line in add {
580            if let Some((name, value)) = line.as_text().and_then(|l| l.split_once(':')) {
581                transform
582                    .add
583                    .push((name.trim().to_string(), value.trim().to_string()));
584            }
585        }
586    }
587    for (key, target) in [("drop", &mut transform.drop), ("keep", &mut transform.keep)] {
588        if let Some(ConfigValue::List(names)) = map.get(key) {
589            for name in names {
590                if let Some(name) = name.as_text() {
591                    target.push(name.to_string());
592                }
593            }
594        }
595    }
596    transform
597}
598
599/// Parse the optional `static-content` block (Java `getNoCacheConfig` +
600/// `getStaticContentFilter`). Missing block → defaults; an invalid filter is
601/// an error (the grammar's fail-on-invalid discipline).
602fn parse_static_content(reader: &ConfigReader) -> Result<StaticContent, AppError> {
603    let mut result = StaticContent::default();
604    if let Some(ConfigValue::List(pages)) = reader
605        .get_map()
606        .get_element("static-content.no-cache-pages")
607    {
608        let list: Vec<String> = pages
609            .iter()
610            .filter_map(|v| v.as_text().map(str::to_string))
611            .collect();
612        if valid_patterns(&list) && !list.is_empty() {
613            result.no_cache_pages = list;
614        } else {
615            return Err(AppError::new(
616                400,
617                "static-content.no-cache-pages has invalid syntax",
618            ));
619        }
620    }
621    let map = reader.get_map();
622    if map.key_exists("static-content.filter") {
623        let Some(ConfigValue::List(paths)) = map.get_element("static-content.filter.path") else {
624            return Err(AppError::new(
625                400,
626                "static-content.filter.path must be a list",
627            ));
628        };
629        let path_list: Vec<String> = paths
630            .iter()
631            .filter_map(|v| v.as_text().map(str::to_string))
632            .collect();
633        let service = map
634            .get_element("static-content.filter.service")
635            .and_then(|v| v.as_text())
636            .map(str::to_string)
637            .ok_or_else(|| AppError::new(400, "static-content.filter.service is required"))?;
638        let exclusion_list: Vec<String> = match map.get_element("static-content.filter.exclusion") {
639            Some(ConfigValue::List(items)) => items
640                .iter()
641                .filter_map(|v| v.as_text().map(str::to_string))
642                .collect(),
643            _ => Vec::new(),
644        };
645        if path_list.is_empty() || !valid_patterns(&path_list) || !valid_patterns(&exclusion_list) {
646            return Err(AppError::new(
647                400,
648                "static-content.filter path/exclusion has invalid syntax",
649            ));
650        }
651        log::info!("static-content.filter loaded: {path_list:?} -> {service}, exclusion {exclusion_list:?}");
652        result.filter = Some(SimpleHttpFilter {
653            path_list,
654            exclusion_list,
655            service,
656        });
657    }
658    Ok(result)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    fn table(yaml: &str) -> Result<RoutingTable, AppError> {
666        let dir = std::env::temp_dir().join(format!("pc-rest-{}", uuid::Uuid::new_v4().simple()));
667        std::fs::create_dir_all(&dir).unwrap();
668        let file = dir.join("rest.yaml");
669        std::fs::write(&file, yaml).unwrap();
670        let reader = ConfigReader::load(&format!("file:{}", file.display()))
671            .map_err(|e| AppError::new(400, e.to_string()))?;
672        let result = RoutingTable::load(&reader);
673        std::fs::remove_dir_all(&dir).ok();
674        result
675    }
676
677    const VALID: &str = r#"
678rest:
679  - service: "greeting.api"
680    methods: ['GET']
681    url: "/api/greeting/{user}"
682    timeout: 10s
683    cors: cors_1
684    headers: header_1
685    tracing: true
686  - service: "catch.all"
687    methods: ['GET', 'POST']
688    url: "/api/files/*"
689  - service: "exact.match"
690    methods: ['GET']
691    url: "/api/greeting/system"
692cors:
693  - id: cors_1
694    options:
695      - "Access-Control-Allow-Origin: *"
696      - "Access-Control-Allow-Methods: GET, POST, OPTIONS"
697    headers:
698      - "Access-Control-Allow-Origin: *"
699headers:
700  - id: header_1
701    request:
702      drop: ['x-secret']
703    response:
704      add: ["x-served-by: mercury"]
705"#;
706
707    #[test]
708    fn parses_valid_rest_yaml() {
709        let table = table(VALID).unwrap();
710        assert_eq!(table.routes().len(), 3);
711        let route = &table.routes()[0];
712        assert_eq!(route.service, "greeting.api");
713        assert_eq!(route.timeout, Duration::from_secs(10));
714        assert!(route.tracing);
715        assert!(route.cors.is_some());
716        assert!(route.headers.is_some());
717    }
718
719    #[test]
720    fn match_precedence_exact_then_param_then_wildcard() {
721        let table = table(VALID).unwrap();
722        // exact literal wins over {param}
723        let hit = table.find("GET", "/api/greeting/system").unwrap();
724        assert_eq!(hit.info.service, "exact.match");
725        // {param} extraction (value case preserved; literals case-insensitive)
726        let hit = table.find("GET", "/API/Greeting/Eric").unwrap();
727        assert_eq!(hit.info.service, "greeting.api");
728        assert_eq!(hit.path_params["user"], "Eric");
729        // wildcard consumes the remainder
730        let hit = table.find("POST", "/api/files/a/b/c").unwrap();
731        assert_eq!(hit.info.service, "catch.all");
732        // method filtering
733        assert!(table.find("DELETE", "/api/greeting/eric").is_none());
734        // OPTIONS matches any entry (CORS preflight)
735        assert!(table.find("OPTIONS", "/api/greeting/eric").is_some());
736        // no match
737        assert!(table.find("GET", "/api/unknown").is_none());
738    }
739
740    #[test]
741    fn parser_invariants_are_enforced() {
742        // invalid method
743        assert!(table("rest:\n  - service: x.y\n    methods: ['FETCH']\n    url: /a\n").is_err());
744        // missing cors reference
745        assert!(table(
746            "rest:\n  - service: x.y\n    methods: ['GET']\n    url: /a\n    cors: nope\n"
747        )
748        .is_err());
749        // relay not yet ported
750        assert!(table(
751            "rest:\n  - service: 'https://example.com'\n    methods: ['GET']\n    url: /a\n"
752        )
753        .is_err());
754        // mid-path `*` is VALID since increment 56 (Java RoutingEntry parity:
755        // it matches exactly one segment)
756        assert!(
757            table("rest:\n  - service: x.y\n    methods: ['GET']\n    url: '/a/*/b'\n").is_ok()
758        );
759        // cors line must be Access-Control-*
760        assert!(table(
761            "rest:\n  - service: x.y\n    methods: ['GET']\n    url: /a\ncors:\n  - id: c1\n    options:\n      - 'X-Other: 1'\n"
762        )
763        .is_err());
764    }
765
766    #[test]
767    fn timeout_parse_and_clamp() {
768        assert_eq!(parse_timeout(Some("10s")), Duration::from_secs(10));
769        assert_eq!(parse_timeout(Some("2m")), Duration::from_secs(120));
770        assert_eq!(parse_timeout(Some("1500ms")), Duration::from_millis(1500));
771        assert_eq!(parse_timeout(Some("500ms")), MIN_TIMEOUT); // clamped up to 1s
772        assert_eq!(parse_timeout(None), DEFAULT_TIMEOUT);
773        assert_eq!(parse_timeout(Some("0s")), MIN_TIMEOUT); // clamp low
774        assert_eq!(parse_timeout(Some("30m")), MAX_TIMEOUT); // clamp high
775        assert_eq!(parse_timeout(Some("garbage")), DEFAULT_TIMEOUT);
776    }
777
778    #[test]
779    fn header_transform_keep_drop_add() {
780        let transform = HeaderTransform {
781            add: vec![("x-served-by".into(), "mercury".into())],
782            drop: vec!["X-Secret".into()],
783            keep: vec![],
784        };
785        let mut headers: HashMap<String, String> = HashMap::from([
786            ("x-secret".into(), "shh".into()),
787            ("accept".into(), "*/*".into()),
788        ]);
789        transform.apply(&mut headers);
790        assert!(!headers.contains_key("x-secret"));
791        assert_eq!(headers["x-served-by"], "mercury");
792        assert_eq!(headers["accept"], "*/*");
793        let keep_only = HeaderTransform {
794            keep: vec!["Accept".into()],
795            ..HeaderTransform::default()
796        };
797        let mut headers: HashMap<String, String> = HashMap::from([
798            ("accept".into(), "*/*".into()),
799            ("x-noise".into(), "1".into()),
800        ]);
801        keep_only.apply(&mut headers);
802        assert_eq!(headers.len(), 1);
803        assert!(headers.contains_key("accept"));
804    }
805}