Skip to main content

rustack_apigatewayv2_http/
router.rs

1//! API Gateway v2 URL router.
2//!
3//! Matches incoming HTTP requests against the route table defined in the model
4//! crate and extracts path parameters.
5
6use std::collections::HashMap;
7
8use rustack_apigatewayv2_model::{
9    error::ApiGatewayV2Error,
10    operations::{APIGATEWAYV2_ROUTES, ApiGatewayV2Operation},
11};
12
13/// Extracted path parameters from a matched route.
14#[derive(Debug, Clone, Default)]
15pub struct PathParams {
16    params: HashMap<String, String>,
17}
18
19impl PathParams {
20    /// Get a path parameter by name.
21    #[must_use]
22    pub fn get(&self, name: &str) -> Option<&str> {
23        self.params.get(name).map(String::as_str)
24    }
25
26    /// Insert a path parameter.
27    pub fn insert(&mut self, name: String, value: String) {
28        self.params.insert(name, value);
29    }
30
31    /// Returns the number of extracted parameters.
32    #[must_use]
33    pub fn len(&self) -> usize {
34        self.params.len()
35    }
36
37    /// Returns `true` if no parameters were extracted.
38    #[must_use]
39    pub fn is_empty(&self) -> bool {
40        self.params.is_empty()
41    }
42
43    /// Iterate over all extracted parameters.
44    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
45        self.params.iter().map(|(k, v)| (k.as_str(), v.as_str()))
46    }
47}
48
49/// Match an HTTP request path against a route pattern, extracting parameters.
50fn match_path(path: &str, pattern: &str) -> Option<PathParams> {
51    let path_segments: Vec<&str> = path.split('/').collect();
52    let pattern_segments: Vec<&str> = pattern.split('/').collect();
53
54    if path_segments.len() != pattern_segments.len() {
55        return None;
56    }
57
58    let mut params = PathParams::default();
59    for (ps, pp) in path_segments.iter().zip(pattern_segments.iter()) {
60        if pp.starts_with('{') && pp.ends_with('}') {
61            let name = &pp[1..pp.len() - 1];
62            params.insert(name.to_owned(), (*ps).to_owned());
63        } else if ps != pp {
64            return None;
65        }
66    }
67    Some(params)
68}
69
70/// Resolve an HTTP request to an API Gateway v2 operation.
71///
72/// Returns the matched operation, extracted path parameters, and success status code.
73///
74/// # Errors
75///
76/// Returns `ApiGatewayV2Error` if no route matches.
77pub fn resolve_operation(
78    method: &http::Method,
79    path: &str,
80) -> Result<(ApiGatewayV2Operation, PathParams, u16), ApiGatewayV2Error> {
81    for route in APIGATEWAYV2_ROUTES {
82        if route.method == *method {
83            if let Some(params) = match_path(path, route.path_pattern) {
84                return Ok((route.operation, params, route.success_status));
85            }
86        }
87    }
88    Err(ApiGatewayV2Error::unknown_operation(method, path))
89}
90
91/// Simple percent-decoding for path segments.
92///
93/// Handles `%XX` sequences commonly found in ARN-encoded path parameters.
94#[must_use]
95pub fn percent_decode(input: &str) -> String {
96    let mut result = String::with_capacity(input.len());
97    let mut chars = input.chars();
98    while let Some(ch) = chars.next() {
99        if ch == '%' {
100            let hex: String = chars.by_ref().take(2).collect();
101            if hex.len() == 2 {
102                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
103                    result.push(byte as char);
104                    continue;
105                }
106            }
107            // Malformed percent-encoding, keep literal.
108            result.push('%');
109            result.push_str(&hex);
110        } else {
111            result.push(ch);
112        }
113    }
114    result
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    // ---- match_path tests ----
122
123    #[test]
124    fn test_should_match_exact_path() {
125        let params = match_path("/v2/apis", "/v2/apis").expect("should match");
126        assert!(params.params.is_empty());
127    }
128
129    #[test]
130    fn test_should_match_path_with_single_param() {
131        let params = match_path("/v2/apis/abc123", "/v2/apis/{apiId}").expect("should match");
132        assert_eq!(params.get("apiId"), Some("abc123"));
133    }
134
135    #[test]
136    fn test_should_match_path_with_multiple_params() {
137        let params = match_path(
138            "/v2/apis/abc123/routes/rt-456",
139            "/v2/apis/{apiId}/routes/{routeId}",
140        )
141        .expect("should match");
142        assert_eq!(params.get("apiId"), Some("abc123"));
143        assert_eq!(params.get("routeId"), Some("rt-456"));
144    }
145
146    #[test]
147    fn test_should_match_deep_nested_path() {
148        let params = match_path(
149            "/v2/apis/abc123/routes/rt-456/routeresponses/rr-789",
150            "/v2/apis/{apiId}/routes/{routeId}/routeresponses/{routeResponseId}",
151        )
152        .expect("should match");
153        assert_eq!(params.get("apiId"), Some("abc123"));
154        assert_eq!(params.get("routeId"), Some("rt-456"));
155        assert_eq!(params.get("routeResponseId"), Some("rr-789"));
156    }
157
158    #[test]
159    fn test_should_not_match_shorter_path() {
160        assert!(match_path("/v2/apis", "/v2/apis/{apiId}").is_none());
161    }
162
163    #[test]
164    fn test_should_not_match_longer_path() {
165        assert!(match_path("/v2/apis/abc123/extra", "/v2/apis/{apiId}").is_none());
166    }
167
168    #[test]
169    fn test_should_not_match_wrong_literal_segment() {
170        assert!(match_path("/v2/wrong/abc123", "/v2/apis/{apiId}").is_none());
171    }
172
173    // ---- resolve_operation tests ----
174
175    #[test]
176    fn test_should_resolve_create_api() {
177        let (op, params, status) =
178            resolve_operation(&http::Method::POST, "/v2/apis").expect("should resolve");
179        assert_eq!(op, ApiGatewayV2Operation::CreateApi);
180        assert!(params.params.is_empty());
181        assert_eq!(status, 201);
182    }
183
184    #[test]
185    fn test_should_resolve_get_apis() {
186        let (op, _, status) =
187            resolve_operation(&http::Method::GET, "/v2/apis").expect("should resolve");
188        assert_eq!(op, ApiGatewayV2Operation::GetApis);
189        assert_eq!(status, 200);
190    }
191
192    #[test]
193    fn test_should_resolve_get_api() {
194        let (op, params, status) =
195            resolve_operation(&http::Method::GET, "/v2/apis/abc123").expect("should resolve");
196        assert_eq!(op, ApiGatewayV2Operation::GetApi);
197        assert_eq!(params.get("apiId"), Some("abc123"));
198        assert_eq!(status, 200);
199    }
200
201    #[test]
202    fn test_should_resolve_update_api() {
203        let (op, params, _) =
204            resolve_operation(&http::Method::PATCH, "/v2/apis/abc123").expect("should resolve");
205        assert_eq!(op, ApiGatewayV2Operation::UpdateApi);
206        assert_eq!(params.get("apiId"), Some("abc123"));
207    }
208
209    #[test]
210    fn test_should_resolve_delete_api() {
211        let (op, _, status) =
212            resolve_operation(&http::Method::DELETE, "/v2/apis/abc123").expect("should resolve");
213        assert_eq!(op, ApiGatewayV2Operation::DeleteApi);
214        assert_eq!(status, 204);
215    }
216
217    #[test]
218    fn test_should_resolve_create_route() {
219        let (op, params, status) = resolve_operation(&http::Method::POST, "/v2/apis/abc123/routes")
220            .expect("should resolve");
221        assert_eq!(op, ApiGatewayV2Operation::CreateRoute);
222        assert_eq!(params.get("apiId"), Some("abc123"));
223        assert_eq!(status, 201);
224    }
225
226    #[test]
227    fn test_should_resolve_get_route() {
228        let (op, params, _) =
229            resolve_operation(&http::Method::GET, "/v2/apis/abc123/routes/rt-456")
230                .expect("should resolve");
231        assert_eq!(op, ApiGatewayV2Operation::GetRoute);
232        assert_eq!(params.get("apiId"), Some("abc123"));
233        assert_eq!(params.get("routeId"), Some("rt-456"));
234    }
235
236    #[test]
237    fn test_should_resolve_delete_route() {
238        let (op, _, status) =
239            resolve_operation(&http::Method::DELETE, "/v2/apis/abc123/routes/rt-456")
240                .expect("should resolve");
241        assert_eq!(op, ApiGatewayV2Operation::DeleteRoute);
242        assert_eq!(status, 204);
243    }
244
245    #[test]
246    fn test_should_resolve_create_integration() {
247        let (op, _, status) =
248            resolve_operation(&http::Method::POST, "/v2/apis/abc123/integrations")
249                .expect("should resolve");
250        assert_eq!(op, ApiGatewayV2Operation::CreateIntegration);
251        assert_eq!(status, 201);
252    }
253
254    #[test]
255    fn test_should_resolve_get_integration() {
256        let (op, params, _) =
257            resolve_operation(&http::Method::GET, "/v2/apis/abc123/integrations/int-789")
258                .expect("should resolve");
259        assert_eq!(op, ApiGatewayV2Operation::GetIntegration);
260        assert_eq!(params.get("integrationId"), Some("int-789"));
261    }
262
263    #[test]
264    fn test_should_resolve_create_stage() {
265        let (op, _, status) = resolve_operation(&http::Method::POST, "/v2/apis/abc123/stages")
266            .expect("should resolve");
267        assert_eq!(op, ApiGatewayV2Operation::CreateStage);
268        assert_eq!(status, 201);
269    }
270
271    #[test]
272    fn test_should_resolve_get_stage() {
273        let (op, params, _) = resolve_operation(&http::Method::GET, "/v2/apis/abc123/stages/prod")
274            .expect("should resolve");
275        assert_eq!(op, ApiGatewayV2Operation::GetStage);
276        assert_eq!(params.get("stageName"), Some("prod"));
277    }
278
279    #[test]
280    fn test_should_resolve_create_deployment() {
281        let (op, _, status) = resolve_operation(&http::Method::POST, "/v2/apis/abc123/deployments")
282            .expect("should resolve");
283        assert_eq!(op, ApiGatewayV2Operation::CreateDeployment);
284        assert_eq!(status, 201);
285    }
286
287    #[test]
288    fn test_should_resolve_get_route_response() {
289        let (op, params, status) = resolve_operation(
290            &http::Method::GET,
291            "/v2/apis/abc123/routes/rt-456/routeresponses/rr-789",
292        )
293        .expect("should resolve");
294        assert_eq!(op, ApiGatewayV2Operation::GetRouteResponse);
295        assert_eq!(params.get("apiId"), Some("abc123"));
296        assert_eq!(params.get("routeId"), Some("rt-456"));
297        assert_eq!(params.get("routeResponseId"), Some("rr-789"));
298        assert_eq!(status, 200);
299    }
300
301    #[test]
302    fn test_should_resolve_create_route_response() {
303        let (op, _, status) = resolve_operation(
304            &http::Method::POST,
305            "/v2/apis/abc123/routes/rt-456/routeresponses",
306        )
307        .expect("should resolve");
308        assert_eq!(op, ApiGatewayV2Operation::CreateRouteResponse);
309        assert_eq!(status, 201);
310    }
311
312    #[test]
313    fn test_should_resolve_get_model_template() {
314        let (op, params, _) =
315            resolve_operation(&http::Method::GET, "/v2/apis/abc123/models/mod-1/template")
316                .expect("should resolve");
317        assert_eq!(op, ApiGatewayV2Operation::GetModelTemplate);
318        assert_eq!(params.get("modelId"), Some("mod-1"));
319    }
320
321    #[test]
322    fn test_should_resolve_create_authorizer() {
323        let (op, _, status) = resolve_operation(&http::Method::POST, "/v2/apis/abc123/authorizers")
324            .expect("should resolve");
325        assert_eq!(op, ApiGatewayV2Operation::CreateAuthorizer);
326        assert_eq!(status, 201);
327    }
328
329    #[test]
330    fn test_should_resolve_create_domain_name() {
331        let (op, _, status) =
332            resolve_operation(&http::Method::POST, "/v2/domainnames").expect("should resolve");
333        assert_eq!(op, ApiGatewayV2Operation::CreateDomainName);
334        assert_eq!(status, 201);
335    }
336
337    #[test]
338    fn test_should_resolve_get_domain_name() {
339        let (op, params, _) = resolve_operation(&http::Method::GET, "/v2/domainnames/example.com")
340            .expect("should resolve");
341        assert_eq!(op, ApiGatewayV2Operation::GetDomainName);
342        assert_eq!(params.get("domainName"), Some("example.com"));
343    }
344
345    #[test]
346    fn test_should_resolve_create_vpc_link() {
347        let (op, _, status) =
348            resolve_operation(&http::Method::POST, "/v2/vpclinks").expect("should resolve");
349        assert_eq!(op, ApiGatewayV2Operation::CreateVpcLink);
350        assert_eq!(status, 201);
351    }
352
353    #[test]
354    fn test_should_resolve_delete_vpc_link() {
355        let (op, _, status) =
356            resolve_operation(&http::Method::DELETE, "/v2/vpclinks/vpc-1").expect("should resolve");
357        assert_eq!(op, ApiGatewayV2Operation::DeleteVpcLink);
358        assert_eq!(status, 202);
359    }
360
361    #[test]
362    fn test_should_resolve_tag_resource() {
363        let (op, params, status) =
364            resolve_operation(&http::Method::POST, "/v2/tags/some-arn").expect("should resolve");
365        assert_eq!(op, ApiGatewayV2Operation::TagResource);
366        assert_eq!(params.get("resource-arn"), Some("some-arn"));
367        assert_eq!(status, 201);
368    }
369
370    #[test]
371    fn test_should_resolve_get_tags() {
372        let (op, _, _) =
373            resolve_operation(&http::Method::GET, "/v2/tags/some-arn").expect("should resolve");
374        assert_eq!(op, ApiGatewayV2Operation::GetTags);
375    }
376
377    #[test]
378    fn test_should_resolve_untag_resource() {
379        let (op, _, status) =
380            resolve_operation(&http::Method::DELETE, "/v2/tags/some-arn").expect("should resolve");
381        assert_eq!(op, ApiGatewayV2Operation::UntagResource);
382        assert_eq!(status, 204);
383    }
384
385    #[test]
386    fn test_should_resolve_get_api_mapping() {
387        let (op, params, _) = resolve_operation(
388            &http::Method::GET,
389            "/v2/domainnames/example.com/apimappings/map-1",
390        )
391        .expect("should resolve");
392        assert_eq!(op, ApiGatewayV2Operation::GetApiMapping);
393        assert_eq!(params.get("domainName"), Some("example.com"));
394        assert_eq!(params.get("apiMappingId"), Some("map-1"));
395    }
396
397    #[test]
398    fn test_should_resolve_create_api_mapping() {
399        let (op, _, status) = resolve_operation(
400            &http::Method::POST,
401            "/v2/domainnames/example.com/apimappings",
402        )
403        .expect("should resolve");
404        assert_eq!(op, ApiGatewayV2Operation::CreateApiMapping);
405        assert_eq!(status, 201);
406    }
407
408    #[test]
409    fn test_should_error_on_unknown_route() {
410        let err =
411            resolve_operation(&http::Method::GET, "/v2/nonexistent").expect_err("should error");
412        assert_eq!(
413            err.code,
414            rustack_apigatewayv2_model::error::ApiGatewayV2ErrorCode::UnknownOperation
415        );
416    }
417
418    #[test]
419    fn test_should_error_on_wrong_method() {
420        let err = resolve_operation(&http::Method::PATCH, "/v2/apis").expect_err("should error");
421        assert_eq!(
422            err.code,
423            rustack_apigatewayv2_model::error::ApiGatewayV2ErrorCode::UnknownOperation
424        );
425    }
426
427    // ---- percent_decode tests ----
428
429    #[test]
430    fn test_should_decode_percent_encoded_colons() {
431        let decoded = percent_decode("arn%3Aaws%3Aapigateway");
432        assert_eq!(decoded, "arn:aws:apigateway");
433    }
434
435    #[test]
436    fn test_should_pass_through_plain_text() {
437        let decoded = percent_decode("my-api-id");
438        assert_eq!(decoded, "my-api-id");
439    }
440
441    #[test]
442    fn test_should_handle_malformed_percent_encoding() {
443        let decoded = percent_decode("bad%ZZstuff");
444        assert_eq!(decoded, "bad%ZZstuff");
445    }
446}