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    /// Drop every entry whose target is a function route that is not
223    /// registered, the way Java's `RoutingEntry.resolveServices` does at load
224    /// (`po.exists(service)`, else "Skip [methods] url - Service x not
225    /// available" as a warning). An entry whose backing function was left out,
226    /// such as an `#[optional_service]` whose condition is false or a typo,
227    /// must not become a live URL that answers "Route ... not found". HTTP(S)
228    /// targets are not routes and are never checked. Returns the skipped
229    /// entries' `("[methods]", url, service)` for the caller to log.
230    pub fn retain_available(
231        &mut self,
232        is_registered: impl Fn(&str) -> bool,
233    ) -> Vec<(String, String, String)> {
234        let mut skipped = Vec::new();
235        self.routes.retain(|route| {
236            let target = route.service.to_ascii_lowercase();
237            if target.starts_with("http://") || target.starts_with("https://") {
238                return true;
239            }
240            if is_registered(&route.service) {
241                return true;
242            }
243            // Java logs the method list as "[GET, POST]"
244            skipped.push((
245                format!("[{}]", route.methods.join(", ")),
246                route.url.clone(),
247                route.service.clone(),
248            ));
249            false
250        });
251        skipped
252    }
253
254    /// The `static-content` configuration (defaults applied when absent).
255    pub fn static_content(&self) -> &StaticContent {
256        &self.static_content
257    }
258
259    /// Build a routing table from YAML text (used for the built-in default
260    /// endpoints — same parser, same invariants).
261    pub fn from_yaml_text(yaml: &str) -> Result<Self, AppError> {
262        let value: serde_yaml::Value =
263            serde_yaml::from_str(yaml).map_err(|e| AppError::new(400, e.to_string()))?;
264        match ConfigValue::from_yaml(&value) {
265            ConfigValue::Map(map) => {
266                let reader = ConfigReader::from_map(map);
267                Self::load(&reader)
268            }
269            _ => Err(AppError::new(400, "rest.yaml text must be a YAML mapping")),
270        }
271    }
272
273    pub fn routes(&self) -> &[RouteInfo] {
274        &self.routes
275    }
276
277    /// Whether any entry already claims this URL (used by the default-endpoint
278    /// merge: user entries always win — Java `default-rest.yaml` semantics).
279    pub fn has_url(&self, url: &str) -> bool {
280        self.routes.iter().any(|r| r.url.eq_ignore_ascii_case(url))
281    }
282
283    pub(crate) fn add_route(&mut self, route: RouteInfo) {
284        self.routes.push(route);
285    }
286
287    /// Match a request (Java `getRouteInfo`): candidates must match every
288    /// segment; precedence is more literal segments first, wildcard last.
289    /// Method filtering happens here too — `OPTIONS` matches any entry (CORS
290    /// preflight, auto-added per the grammar).
291    pub fn find(&self, method: &str, path: &str) -> Option<AssignedRoute<'_>> {
292        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
293        let mut best: Option<(usize, bool, AssignedRoute)> = None;
294        for info in &self.routes {
295            if method != "OPTIONS" && !info.methods.iter().any(|m| m == method) {
296                continue;
297            }
298            let Some(params) = info.match_path(&segments) else {
299                continue;
300            };
301            let literals = info
302                .segments
303                .iter()
304                .filter(|s| matches!(s, Segment::Literal(_)))
305                .count();
306            let wildcard = info.is_open_ended();
307            let better = match &best {
308                None => true,
309                // prefer non-wildcard, then more literal segments
310                Some((best_literals, best_wildcard, _)) => {
311                    (!wildcard && *best_wildcard)
312                        || (wildcard == *best_wildcard && literals > *best_literals)
313                }
314            };
315            if better {
316                best = Some((
317                    literals,
318                    wildcard,
319                    AssignedRoute {
320                        info,
321                        path_params: params,
322                    },
323                ));
324            }
325        }
326        best.map(|(_, _, assigned)| assigned)
327    }
328
329    /// True when the path matches SOME entry regardless of method — the Java
330    /// `getSimilarRoute` marker (increment 56, parity F14c): a known path
331    /// with a wrong method answers 405 "Method not allowed", never 404.
332    pub fn path_matches_any_method(&self, path: &str) -> bool {
333        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
334        self.routes
335            .iter()
336            .any(|info| info.match_path(&segments).is_some())
337    }
338}
339
340impl RouteInfo {
341    /// True when the pattern ends with a `*`-suffixed segment (bare `*` or
342    /// `foo*`) — Java's `configured.endsWith("*")` wildcard flag: the URL may
343    /// then run LONGER than the pattern, but never shorter (`/api/files/*`
344    /// does NOT match `/api/files` — the increment-56 fix of the Rust-only
345    /// empty-remainder match).
346    fn is_open_ended(&self) -> bool {
347        matches!(
348            self.segments.last(),
349            Some(Segment::Any) | Some(Segment::Prefix(_))
350        )
351    }
352
353    /// Match URL segments against this entry — the exact Java `matchRoute` +
354    /// `notMatched` rules (case-insensitive literals and prefixes),
355    /// returning the extracted `{param}` values on success.
356    fn match_path(&self, request_segments: &[&str]) -> Option<HashMap<String, String>> {
357        if self.is_open_ended() {
358            if self.segments.len() > request_segments.len() {
359                return None;
360            }
361        } else if self.segments.len() != request_segments.len() {
362            return None;
363        }
364        let mut params = HashMap::new();
365        for (i, segment) in self.segments.iter().enumerate() {
366            let actual = request_segments.get(i)?;
367            match segment {
368                Segment::Any => {}
369                Segment::Literal(expected) => {
370                    if actual.to_lowercase() != *expected {
371                        return None;
372                    }
373                }
374                Segment::Prefix(prefix) => {
375                    if !actual.to_lowercase().starts_with(prefix.as_str()) {
376                        return None;
377                    }
378                }
379                Segment::Param(name) => {
380                    params.insert(name.clone(), actual.to_string());
381                }
382            }
383        }
384        Some(params)
385    }
386}
387
388/// Navigate a possibly-dotted key: config normalization nests dotted YAML keys
389/// (`trace.id.header` → `trace: {id: {header: …}}`), exactly as the Java
390/// ConfigReader does — so look up the direct key first, then walk the dots.
391fn lookup<'a>(
392    map: &'a std::collections::BTreeMap<String, ConfigValue>,
393    key: &str,
394) -> Option<&'a ConfigValue> {
395    if let Some(value) = map.get(key) {
396        return Some(value);
397    }
398    let mut parts = key.split('.');
399    let mut current = map.get(parts.next()?)?;
400    for part in parts {
401        match current {
402            ConfigValue::Map(nested) => current = nested.get(part)?,
403            _ => return None,
404        }
405    }
406    Some(current)
407}
408
409fn parse_route(
410    index: usize,
411    entry: &std::collections::BTreeMap<String, ConfigValue>,
412    cors_blocks: &HashMap<String, CorsInfo>,
413    header_blocks: &HashMap<String, HeaderInfo>,
414) -> Result<RouteInfo, AppError> {
415    let text = |key: &str| {
416        lookup(entry, key)
417            .and_then(|v| v.as_text())
418            .map(str::to_string)
419    };
420    // invariant 1: service, methods, url are required
421    let service = text("service")
422        .ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'service'")))?;
423    if service.starts_with("http://") || service.starts_with("https://") {
424        return Err(AppError::new(
425            400,
426            format!("rest[{index}] HTTP relay is not yet ported (service '{service}')"),
427        ));
428    }
429    let url =
430        text("url").ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'url'")))?;
431    let Some(ConfigValue::List(raw_methods)) = entry.get("methods") else {
432        return Err(AppError::new(
433            400,
434            format!("rest[{index}] missing 'methods' list"),
435        ));
436    };
437    // invariant 2: methods from the allowed set; OPTIONS is auto-added
438    let mut methods = Vec::new();
439    for method in raw_methods {
440        let method = method
441            .as_text()
442            .map(str::to_uppercase)
443            .ok_or_else(|| AppError::new(400, format!("rest[{index}] method must be text")))?;
444        if !ALLOWED_METHODS.contains(&method.as_str()) {
445            return Err(AppError::new(
446                400,
447                format!("rest[{index}] invalid method '{method}' (allowed: {ALLOWED_METHODS:?})"),
448            ));
449        }
450        methods.push(method);
451    }
452    // segments: literal / {param} / bare `*` (any one segment) / `foo*`
453    // (prefix) — the full Java RoutingEntry grammar (increment 56, parity
454    // F14b: mid-path `*` and segment prefixes were previously rejected or
455    // treated as literals)
456    let mut segments = Vec::new();
457    let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
458    for part in &parts {
459        if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
460            segments.push(Segment::Param(name.to_string()));
461        } else if *part == "*" {
462            segments.push(Segment::Any);
463        } else if let Some(prefix) = part.strip_suffix('*') {
464            segments.push(Segment::Prefix(prefix.to_lowercase()));
465        } else {
466            segments.push(Segment::Literal(part.to_lowercase()));
467        }
468    }
469    // invariant 3: cors/headers references must exist
470    let cors =
471        match text("cors") {
472            Some(id) => Some(cors_blocks.get(&id).cloned().ok_or_else(|| {
473                AppError::new(400, format!("rest[{index}] unknown cors id '{id}'"))
474            })?),
475            None => None,
476        };
477    let headers = match text("headers") {
478        Some(id) => Some(header_blocks.get(&id).cloned().ok_or_else(|| {
479            AppError::new(400, format!("rest[{index}] unknown headers id '{id}'"))
480        })?),
481        None => None,
482    };
483    let tracing = matches!(entry.get("tracing"), Some(ConfigValue::Bool(true)));
484    let stream_response = matches!(entry.get("stream"), Some(ConfigValue::Bool(true)));
485    Ok(RouteInfo {
486        service,
487        methods,
488        url,
489        timeout: parse_timeout(text("timeout").as_deref()),
490        cors,
491        headers,
492        authentication: text("authentication"),
493        tracing,
494        trace_id_header: text("trace.id.header"),
495        correlation_id_header: text("correlation.id.header"),
496        traceparent_header: text("traceparent.header"),
497        flow: text("flow"),
498        stream_response,
499        segments,
500    })
501}
502
503/// Parse a duration like `30s`, `500ms`, `2m` (default 30 s; clamped 1 s–5 m,
504/// Java parity). Shared with the HTTP boundary for `event.stream.keep.alive`.
505pub(crate) fn parse_timeout(value: Option<&str>) -> Duration {
506    let parsed = value.and_then(|text| {
507        let text = text.trim().to_lowercase();
508        if let Some(ms) = text.strip_suffix("ms") {
509            ms.trim().parse::<u64>().ok().map(Duration::from_millis)
510        } else if let Some(minutes) = text.strip_suffix('m') {
511            minutes
512                .trim()
513                .parse::<u64>()
514                .ok()
515                .map(|m| Duration::from_secs(m * 60))
516        } else if let Some(seconds) = text.strip_suffix('s') {
517            seconds.trim().parse::<u64>().ok().map(Duration::from_secs)
518        } else {
519            text.parse::<u64>().ok().map(Duration::from_secs)
520        }
521    });
522    parsed
523        .unwrap_or(DEFAULT_TIMEOUT)
524        .clamp(MIN_TIMEOUT, MAX_TIMEOUT)
525}
526
527fn parse_cors_blocks(section: Option<&ConfigValue>) -> Result<HashMap<String, CorsInfo>, AppError> {
528    let mut blocks = HashMap::new();
529    let Some(ConfigValue::List(entries)) = section else {
530        return Ok(blocks);
531    };
532    for entry in entries {
533        let ConfigValue::Map(map) = entry else {
534            continue;
535        };
536        let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
537            return Err(AppError::new(400, "cors block missing 'id'"));
538        };
539        let mut info = CorsInfo {
540            id: id.to_string(),
541            ..CorsInfo::default()
542        };
543        info.options = parse_header_lines(map.get("options"), id, "options")?;
544        info.headers = parse_header_lines(map.get("headers"), id, "headers")?;
545        blocks.insert(info.id.clone(), info);
546    }
547    Ok(blocks)
548}
549
550/// Each cors line must be `Access-Control-*: value` (grammar invariant).
551fn parse_header_lines(
552    list: Option<&ConfigValue>,
553    id: &str,
554    kind: &str,
555) -> Result<Vec<(String, String)>, AppError> {
556    let mut out = Vec::new();
557    if let Some(ConfigValue::List(lines)) = list {
558        for line in lines {
559            let Some(line) = line.as_text() else { continue };
560            let Some((name, value)) = line.split_once(':') else {
561                return Err(AppError::new(
562                    400,
563                    format!("cors '{id}' {kind} line '{line}' is not 'name: value'"),
564                ));
565            };
566            let name = name.trim();
567            if !name.to_lowercase().starts_with("access-control-") {
568                return Err(AppError::new(
569                    400,
570                    format!("cors '{id}' {kind} line '{name}' must be an Access-Control-* header"),
571                ));
572            }
573            out.push((name.to_string(), value.trim().to_string()));
574        }
575    }
576    Ok(out)
577}
578
579fn parse_header_blocks(
580    section: Option<&ConfigValue>,
581) -> Result<HashMap<String, HeaderInfo>, AppError> {
582    let mut blocks = HashMap::new();
583    let Some(ConfigValue::List(entries)) = section else {
584        return Ok(blocks);
585    };
586    for entry in entries {
587        let ConfigValue::Map(map) = entry else {
588            continue;
589        };
590        let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
591            return Err(AppError::new(400, "headers block missing 'id'"));
592        };
593        blocks.insert(
594            id.to_string(),
595            HeaderInfo {
596                id: id.to_string(),
597                request: parse_transform(map.get("request")),
598                response: parse_transform(map.get("response")),
599            },
600        );
601    }
602    Ok(blocks)
603}
604
605fn parse_transform(section: Option<&ConfigValue>) -> HeaderTransform {
606    let mut transform = HeaderTransform::default();
607    let Some(ConfigValue::Map(map)) = section else {
608        return transform;
609    };
610    if let Some(ConfigValue::List(add)) = map.get("add") {
611        for line in add {
612            if let Some((name, value)) = line.as_text().and_then(|l| l.split_once(':')) {
613                transform
614                    .add
615                    .push((name.trim().to_string(), value.trim().to_string()));
616            }
617        }
618    }
619    for (key, target) in [("drop", &mut transform.drop), ("keep", &mut transform.keep)] {
620        if let Some(ConfigValue::List(names)) = map.get(key) {
621            for name in names {
622                if let Some(name) = name.as_text() {
623                    target.push(name.to_string());
624                }
625            }
626        }
627    }
628    transform
629}
630
631/// Parse the optional `static-content` block (Java `getNoCacheConfig` +
632/// `getStaticContentFilter`). Missing block → defaults; an invalid filter is
633/// an error (the grammar's fail-on-invalid discipline).
634fn parse_static_content(reader: &ConfigReader) -> Result<StaticContent, AppError> {
635    let mut result = StaticContent::default();
636    if let Some(ConfigValue::List(pages)) = reader
637        .get_map()
638        .get_element("static-content.no-cache-pages")
639    {
640        let list: Vec<String> = pages
641            .iter()
642            .filter_map(|v| v.as_text().map(str::to_string))
643            .collect();
644        if valid_patterns(&list) && !list.is_empty() {
645            result.no_cache_pages = list;
646        } else {
647            return Err(AppError::new(
648                400,
649                "static-content.no-cache-pages has invalid syntax",
650            ));
651        }
652    }
653    let map = reader.get_map();
654    if map.key_exists("static-content.filter") {
655        let Some(ConfigValue::List(paths)) = map.get_element("static-content.filter.path") else {
656            return Err(AppError::new(
657                400,
658                "static-content.filter.path must be a list",
659            ));
660        };
661        let path_list: Vec<String> = paths
662            .iter()
663            .filter_map(|v| v.as_text().map(str::to_string))
664            .collect();
665        let service = map
666            .get_element("static-content.filter.service")
667            .and_then(|v| v.as_text())
668            .map(str::to_string)
669            .ok_or_else(|| AppError::new(400, "static-content.filter.service is required"))?;
670        let exclusion_list: Vec<String> = match map.get_element("static-content.filter.exclusion") {
671            Some(ConfigValue::List(items)) => items
672                .iter()
673                .filter_map(|v| v.as_text().map(str::to_string))
674                .collect(),
675            _ => Vec::new(),
676        };
677        if path_list.is_empty() || !valid_patterns(&path_list) || !valid_patterns(&exclusion_list) {
678            return Err(AppError::new(
679                400,
680                "static-content.filter path/exclusion has invalid syntax",
681            ));
682        }
683        log::info!("static-content.filter loaded: {path_list:?} -> {service}, exclusion {exclusion_list:?}");
684        result.filter = Some(SimpleHttpFilter {
685            path_list,
686            exclusion_list,
687            service,
688        });
689    }
690    Ok(result)
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    fn table(yaml: &str) -> Result<RoutingTable, AppError> {
698        let dir = std::env::temp_dir().join(format!("pc-rest-{}", uuid::Uuid::new_v4().simple()));
699        std::fs::create_dir_all(&dir).unwrap();
700        let file = dir.join("rest.yaml");
701        std::fs::write(&file, yaml).unwrap();
702        let reader = ConfigReader::load(&format!("file:{}", file.display()))
703            .map_err(|e| AppError::new(400, e.to_string()))?;
704        let result = RoutingTable::load(&reader);
705        std::fs::remove_dir_all(&dir).ok();
706        result
707    }
708
709    const VALID: &str = r#"
710rest:
711  - service: "greeting.api"
712    methods: ['GET']
713    url: "/api/greeting/{user}"
714    timeout: 10s
715    cors: cors_1
716    headers: header_1
717    tracing: true
718  - service: "catch.all"
719    methods: ['GET', 'POST']
720    url: "/api/files/*"
721  - service: "exact.match"
722    methods: ['GET']
723    url: "/api/greeting/system"
724cors:
725  - id: cors_1
726    options:
727      - "Access-Control-Allow-Origin: *"
728      - "Access-Control-Allow-Methods: GET, POST, OPTIONS"
729    headers:
730      - "Access-Control-Allow-Origin: *"
731headers:
732  - id: header_1
733    request:
734      drop: ['x-secret']
735    response:
736      add: ["x-served-by: mercury"]
737"#;
738
739    #[test]
740    fn parses_valid_rest_yaml() {
741        let table = table(VALID).unwrap();
742        assert_eq!(table.routes().len(), 3);
743        let route = &table.routes()[0];
744        assert_eq!(route.service, "greeting.api");
745        assert_eq!(route.timeout, Duration::from_secs(10));
746        assert!(route.tracing);
747        assert!(route.cors.is_some());
748        assert!(route.headers.is_some());
749    }
750
751    #[test]
752    fn retain_available_drops_only_unregistered_function_routes() {
753        let mut t = table(
754            "rest:\n  - service: keep.me\n    methods: ['GET']\n    url: /a\n  - service: drop.me\n    methods: ['GET', 'POST']\n    url: /b\n",
755        )
756        .unwrap();
757        let skipped = t.retain_available(|service| service == "keep.me");
758        assert_eq!(
759            skipped,
760            vec![(
761                "[GET, POST]".to_string(),
762                "/b".to_string(),
763                "drop.me".to_string()
764            )]
765        );
766        let left: Vec<&str> = t.routes().iter().map(|r| r.service.as_str()).collect();
767        assert_eq!(left, vec!["keep.me"]);
768    }
769
770    #[test]
771    fn match_precedence_exact_then_param_then_wildcard() {
772        let table = table(VALID).unwrap();
773        // exact literal wins over {param}
774        let hit = table.find("GET", "/api/greeting/system").unwrap();
775        assert_eq!(hit.info.service, "exact.match");
776        // {param} extraction (value case preserved; literals case-insensitive)
777        let hit = table.find("GET", "/API/Greeting/Eric").unwrap();
778        assert_eq!(hit.info.service, "greeting.api");
779        assert_eq!(hit.path_params["user"], "Eric");
780        // wildcard consumes the remainder
781        let hit = table.find("POST", "/api/files/a/b/c").unwrap();
782        assert_eq!(hit.info.service, "catch.all");
783        // method filtering
784        assert!(table.find("DELETE", "/api/greeting/eric").is_none());
785        // OPTIONS matches any entry (CORS preflight)
786        assert!(table.find("OPTIONS", "/api/greeting/eric").is_some());
787        // no match
788        assert!(table.find("GET", "/api/unknown").is_none());
789    }
790
791    #[test]
792    fn parser_invariants_are_enforced() {
793        // invalid method
794        assert!(table("rest:\n  - service: x.y\n    methods: ['FETCH']\n    url: /a\n").is_err());
795        // missing cors reference
796        assert!(table(
797            "rest:\n  - service: x.y\n    methods: ['GET']\n    url: /a\n    cors: nope\n"
798        )
799        .is_err());
800        // relay not yet ported
801        assert!(table(
802            "rest:\n  - service: 'https://example.com'\n    methods: ['GET']\n    url: /a\n"
803        )
804        .is_err());
805        // mid-path `*` is VALID since increment 56 (Java RoutingEntry parity:
806        // it matches exactly one segment)
807        assert!(
808            table("rest:\n  - service: x.y\n    methods: ['GET']\n    url: '/a/*/b'\n").is_ok()
809        );
810        // cors line must be Access-Control-*
811        assert!(table(
812            "rest:\n  - service: x.y\n    methods: ['GET']\n    url: /a\ncors:\n  - id: c1\n    options:\n      - 'X-Other: 1'\n"
813        )
814        .is_err());
815    }
816
817    #[test]
818    fn timeout_parse_and_clamp() {
819        assert_eq!(parse_timeout(Some("10s")), Duration::from_secs(10));
820        assert_eq!(parse_timeout(Some("2m")), Duration::from_secs(120));
821        assert_eq!(parse_timeout(Some("1500ms")), Duration::from_millis(1500));
822        assert_eq!(parse_timeout(Some("500ms")), MIN_TIMEOUT); // clamped up to 1s
823        assert_eq!(parse_timeout(None), DEFAULT_TIMEOUT);
824        assert_eq!(parse_timeout(Some("0s")), MIN_TIMEOUT); // clamp low
825        assert_eq!(parse_timeout(Some("30m")), MAX_TIMEOUT); // clamp high
826        assert_eq!(parse_timeout(Some("garbage")), DEFAULT_TIMEOUT);
827    }
828
829    #[test]
830    fn header_transform_keep_drop_add() {
831        let transform = HeaderTransform {
832            add: vec![("x-served-by".into(), "mercury".into())],
833            drop: vec!["X-Secret".into()],
834            keep: vec![],
835        };
836        let mut headers: HashMap<String, String> = HashMap::from([
837            ("x-secret".into(), "shh".into()),
838            ("accept".into(), "*/*".into()),
839        ]);
840        transform.apply(&mut headers);
841        assert!(!headers.contains_key("x-secret"));
842        assert_eq!(headers["x-served-by"], "mercury");
843        assert_eq!(headers["accept"], "*/*");
844        let keep_only = HeaderTransform {
845            keep: vec!["Accept".into()],
846            ..HeaderTransform::default()
847        };
848        let mut headers: HashMap<String, String> = HashMap::from([
849            ("accept".into(), "*/*".into()),
850            ("x-noise".into(), "1".into()),
851        ]);
852        keep_only.apply(&mut headers);
853        assert_eq!(headers.len(), 1);
854        assert!(headers.contains_key("accept"));
855    }
856}