Skip to main content

mockforge_core/
odata_rewrite.rs

1//! OData function call URI rewrite layer
2//!
3//! Rewrites incoming OData function call syntax in request URIs so they match
4//! the Axum routes registered by `axum_path()`.
5//!
6//! Example: `GET /me/getEffectivePermissions(scope='read')` is rewritten to
7//! `GET /me/getEffectivePermissions/read` which matches the registered route
8//! `/me/getEffectivePermissions/{scope}`.
9//!
10//! Uses a tower `Layer` that transforms the request URI BEFORE Axum's routing,
11//! ensuring the rewritten path is used for route matching.
12
13use axum::http::{Request, Uri};
14use std::task::{Context, Poll};
15
16/// Tower layer that rewrites OData function call syntax in request URIs.
17///
18/// Apply this as a layer on an Axum Router to rewrite OData paths before routing.
19///
20/// # Example
21/// ```rust,ignore
22/// use mockforge_core::odata_rewrite::ODataRewriteLayer;
23///
24/// let app = Router::new()
25///     .route("/func/{param}", get(handler))
26///     .layer(ODataRewriteLayer);
27/// ```
28#[derive(Debug, Clone, Copy)]
29pub struct ODataRewriteLayer;
30
31impl<S> tower::Layer<S> for ODataRewriteLayer {
32    type Service = ODataRewriteService<S>;
33
34    fn layer(&self, inner: S) -> Self::Service {
35        ODataRewriteService { inner }
36    }
37}
38
39/// Tower service that rewrites OData URIs before forwarding to the inner service.
40#[derive(Debug, Clone)]
41pub struct ODataRewriteService<S> {
42    inner: S,
43}
44
45impl<S, B> tower::Service<Request<B>> for ODataRewriteService<S>
46where
47    S: tower::Service<Request<B>>,
48{
49    type Response = <S as tower::Service<Request<B>>>::Response;
50    type Error = <S as tower::Service<Request<B>>>::Error;
51    type Future = <S as tower::Service<Request<B>>>::Future;
52
53    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
54        self.inner.poll_ready(cx)
55    }
56
57    fn call(&mut self, mut req: Request<B>) -> Self::Future {
58        let path = req.uri().path();
59
60        // Fast path: no parentheses means no OData syntax
61        if path.contains('(') {
62            let rewritten = rewrite_odata_path(path);
63
64            if rewritten != path {
65                tracing::debug!("OData rewrite: '{}' -> '{}'", path, rewritten);
66
67                // Rebuild the URI preserving query string
68                let new_uri = if let Some(query) = req.uri().query() {
69                    format!("{}?{}", rewritten, query)
70                } else {
71                    rewritten
72                };
73
74                if let Ok(uri) = new_uri.parse::<Uri>() {
75                    *req.uri_mut() = uri;
76                }
77            }
78        }
79
80        self.inner.call(req)
81    }
82}
83
84/// Rewrite OData function call syntax in a path.
85///
86/// Mirrors the logic in `OpenApiRoute::axum_path()` but operates on concrete
87/// parameter values instead of `{param}` placeholders.
88pub fn rewrite_odata_path(path: &str) -> String {
89    let mut result = String::with_capacity(path.len());
90    let mut chars = path.chars().peekable();
91
92    while let Some(ch) = chars.next() {
93        if ch == '(' {
94            // Collect content inside parentheses
95            let mut paren_content = String::new();
96            for c in chars.by_ref() {
97                if c == ')' {
98                    break;
99                }
100                paren_content.push(c);
101            }
102
103            if paren_content.is_empty() {
104                // Empty parens: functionName() → functionName (strip parens)
105                continue;
106            }
107
108            if paren_content.contains('=') {
109                // key='value' or key=value pairs → /value segments
110                for part in paren_content.split(',') {
111                    if let Some((_key, value)) = part.split_once('=') {
112                        let param = value.trim_matches(|c| c == '\'' || c == '"');
113                        result.push('/');
114                        result.push_str(param);
115                    }
116                }
117            } else {
118                // Parentheses without key=value — preserve as-is
119                result.push('(');
120                result.push_str(&paren_content);
121                result.push(')');
122            }
123        } else {
124            result.push(ch);
125        }
126    }
127
128    result
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_fast_path_normal_paths() {
137        assert_eq!(rewrite_odata_path("/users"), "/users");
138        assert_eq!(rewrite_odata_path("/users/123"), "/users/123");
139        assert_eq!(rewrite_odata_path("/api/v1/items"), "/api/v1/items");
140    }
141
142    #[test]
143    fn test_single_param_odata_rewrite() {
144        assert_eq!(
145            rewrite_odata_path("/me/getEffectivePermissions(scope='read')"),
146            "/me/getEffectivePermissions/read"
147        );
148        assert_eq!(
149            rewrite_odata_path("/reports/getTeamsUserActivityCounts(period='D7')"),
150            "/reports/getTeamsUserActivityCounts/D7"
151        );
152    }
153
154    #[test]
155    fn test_multi_param_odata_rewrite() {
156        assert_eq!(rewrite_odata_path("/func(key1='val1',key2='val2')"), "/func/val1/val2");
157    }
158
159    #[test]
160    fn test_empty_parens_stripped() {
161        assert_eq!(rewrite_odata_path("/func()"), "/func");
162        assert_eq!(rewrite_odata_path("/a/func()/b"), "/a/func/b");
163    }
164
165    #[test]
166    fn test_nested_odata_in_middle_of_path() {
167        assert_eq!(
168            rewrite_odata_path("/drives/abc/items/xyz/delta(token='foo')"),
169            "/drives/abc/items/xyz/delta/foo"
170        );
171    }
172
173    #[test]
174    fn test_unquoted_values() {
175        assert_eq!(rewrite_odata_path("/func(key=value)"), "/func/value");
176    }
177
178    #[test]
179    fn test_value_without_equals_preserved() {
180        // Parentheses without key=value syntax should be preserved
181        assert_eq!(rewrite_odata_path("/func(something)"), "/func(something)");
182    }
183
184    #[test]
185    fn test_query_string_not_in_path() {
186        // rewrite_odata_path only handles the path portion;
187        // query string preservation is handled by the service itself.
188        assert_eq!(rewrite_odata_path("/func(key='val')"), "/func/val");
189    }
190
191    #[test]
192    fn test_microsoft_graph_odata_paths() {
193        // Real Microsoft Graph OData function call patterns
194        assert_eq!(
195            rewrite_odata_path("/reports/microsoft.graph.getTeamsUserActivityCounts(period='D7')"),
196            "/reports/microsoft.graph.getTeamsUserActivityCounts/D7"
197        );
198        assert_eq!(
199            rewrite_odata_path(
200                "/users/abc/calendar/microsoft.graph.allowedCalendarSharingRoles(User='admin')"
201            ),
202            "/users/abc/calendar/microsoft.graph.allowedCalendarSharingRoles/admin"
203        );
204    }
205
206    #[test]
207    fn test_microsoft_graph_multi_param() {
208        assert_eq!(
209            rewrite_odata_path(
210                "/groups/abc/team/primaryChannel/microsoft.graph.doesUserHaveAccess(userId='u1',tenantId='t1',userPrincipalName='user@example.com')"
211            ),
212            "/groups/abc/team/primaryChannel/microsoft.graph.doesUserHaveAccess/u1/t1/user@example.com"
213        );
214    }
215}