1#![forbid(unsafe_code)]
3
4use anyhow::{Context, Result};
5use axum::{Router, extract::Request, middleware::Next, response::Response};
6use http::Uri;
7use lambda_http::{RequestExt, request::RequestContext};
8use minco_http::Principal;
9use std::collections::{BTreeMap, BTreeSet};
10use tower::ServiceExt;
11
12pub async fn run_router(router: Router) -> Result<()> {
13 let service = lambda_http::service_fn(move |request: lambda_http::Request| {
14 route_request(router.clone(), request)
15 });
16 lambda_http::run(service)
17 .await
18 .map_err(|error| anyhow::anyhow!("Lambda HTTP runtime failed: {error}"))
19}
20
21async fn route_request(
22 router: Router,
23 mut request: lambda_http::Request,
24) -> std::result::Result<Response, std::convert::Infallible> {
25 strip_api_gateway_stage_from_uri(&mut request);
26 router.oneshot(request).await
27}
28
29fn strip_api_gateway_stage_from_uri(request: &mut lambda_http::Request) -> bool {
30 let Some(RequestContext::ApiGatewayV2(context)) = request.request_context_ref() else {
31 return false;
32 };
33 let Some(stage) = context.stage.as_deref() else {
34 return false;
35 };
36 if stage.is_empty() || stage == "$default" {
37 return false;
38 }
39
40 let raw_path = request.raw_http_path().to_owned();
44 let source_path = if raw_path.is_empty() {
45 request.uri().path()
46 } else {
47 &raw_path
48 };
49 let prefix = format!("/{stage}");
50 let normalized_path = if source_path == prefix {
51 "/"
52 } else if let Some(suffix) = source_path.strip_prefix(&prefix) {
53 if suffix.starts_with('/') {
54 suffix
55 } else {
56 source_path
57 }
58 } else {
59 source_path
60 };
61
62 if normalized_path == request.uri().path() {
63 return false;
64 }
65 let normalized_path_and_query = match request.uri().query() {
66 Some(query) => format!("{normalized_path}?{query}"),
67 None => normalized_path.to_owned(),
68 };
69 let Ok(path_and_query) = normalized_path_and_query.parse() else {
70 return false;
71 };
72 let mut parts = request.uri().clone().into_parts();
73 parts.path_and_query = Some(path_and_query);
74 let Ok(uri) = Uri::from_parts(parts) else {
75 return false;
76 };
77 *request.uri_mut() = uri;
78 true
79}
80
81pub async fn inject_api_gateway_principal(mut request: Request, next: Next) -> Response {
82 if let Some(principal) = principal_from_request_context(request.request_context_ref()) {
83 request.extensions_mut().insert(principal);
84 }
85 next.run(request).await
86}
87
88#[must_use]
89pub fn principal_from_request_context(context: Option<&RequestContext>) -> Option<Principal> {
90 let RequestContext::ApiGatewayV2(context) = context? else {
91 return None;
92 };
93 let authorizer = context.authorizer.as_ref()?;
94 let value = serde_json::to_value(authorizer).ok()?;
95 let claims = value
96 .pointer("/jwt/claims")
97 .or_else(|| value.get("claims"))?
98 .as_object()?;
99 principal_from_claims(claims)
100}
101
102fn principal_from_claims(claims: &serde_json::Map<String, serde_json::Value>) -> Option<Principal> {
103 let subject = claims.get("sub")?.as_str()?.trim();
104 if subject.is_empty() {
105 return None;
106 }
107 let claims = claims
108 .iter()
109 .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_owned())))
110 .collect::<BTreeMap<_, _>>();
111 let mut permissions = BTreeSet::new();
112 for claim in ["scope", "permissions", "custom:permissions"] {
113 if let Some(value) = claims.get(claim) {
114 permissions.extend(
115 value
116 .split([',', ' '])
117 .map(str::trim)
118 .filter(|value| !value.is_empty())
119 .map(str::to_owned),
120 );
121 }
122 }
123 Some(Principal {
124 subject: subject.to_owned(),
125 permissions,
126 claims,
127 })
128}
129
130pub async fn load_secure_parameter(name: &str) -> Result<String> {
131 if name.trim().is_empty() {
132 anyhow::bail!("SSM parameter name is empty");
133 }
134 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
135 let response = aws_sdk_ssm::Client::new(&config)
136 .get_parameter()
137 .name(name)
138 .with_decryption(true)
139 .send()
140 .await
141 .with_context(|| format!("failed to load SSM parameter {name}"))?;
142 response
143 .parameter
144 .and_then(|parameter| parameter.value)
145 .filter(|value| !value.is_empty())
146 .with_context(|| format!("SSM parameter {name} has no value"))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use axum::{
153 http::{HeaderValue, StatusCode, header},
154 routing::get,
155 };
156
157 fn gateway_request(uri: &str, stage: Option<&str>) -> lambda_http::Request {
158 let uri = uri.parse::<Uri>().expect("request URI is valid");
159 let path = uri.path();
160 let query = uri.query().unwrap_or_default();
161 let event = serde_json::json!({
162 "version": "2.0",
163 "routeKey": "GET /health/live",
164 "rawPath": path,
165 "rawQueryString": query,
166 "headers": {
167 "host": "example.execute-api.invalid"
168 },
169 "requestContext": {
170 "accountId": "123456789012",
171 "apiId": "api-id",
172 "domainName": "example.execute-api.invalid",
173 "domainPrefix": "example",
174 "http": {
175 "method": "GET",
176 "path": path,
177 "protocol": "HTTP/1.1",
178 "sourceIp": "127.0.0.1",
179 "userAgent": "minco-test"
180 },
181 "requestId": "request-id",
182 "routeKey": "GET /health/live",
183 "stage": stage,
184 "time": "30/Jul/2026:09:06:25 +0000",
185 "timeEpoch": 1_785_402_385_000_u64
186 },
187 "isBase64Encoded": false
188 });
189 lambda_http::request::from_str(&event.to_string()).expect("API Gateway v2 event is valid")
190 }
191
192 #[test]
193 fn absent_gateway_context_is_anonymous() {
194 assert!(principal_from_request_context(None).is_none());
195 }
196
197 #[test]
198 fn non_gateway_requests_are_not_rewritten() {
199 let mut request = http::Request::builder()
200 .uri("/candidate/health/live")
201 .body(lambda_http::Body::Empty)
202 .expect("request is valid");
203
204 assert!(!strip_api_gateway_stage_from_uri(&mut request));
205 assert_eq!(request.uri().path(), "/candidate/health/live");
206 }
207
208 #[test]
209 fn maps_locked_cognito_permission_attributes() {
210 let claims = serde_json::json!({
211 "sub": "smoke-user",
212 "custom:permissions": "orders.create orders.read",
213 "aud": "client-id"
214 });
215 let principal =
216 principal_from_claims(claims.as_object().expect("claims")).expect("principal");
217 assert_eq!(principal.subject, "smoke-user");
218 assert!(principal.permissions.contains("orders.create"));
219 assert!(principal.permissions.contains("orders.read"));
220 }
221
222 #[test]
223 fn strips_the_exact_named_stage_before_axum_routing() {
224 let mut request = gateway_request(
225 "https://example.execute-api.invalid/candidate/health/live?probe=1",
226 Some("candidate"),
227 );
228
229 assert!(strip_api_gateway_stage_from_uri(&mut request));
230 assert_eq!(request.uri().path(), "/health/live");
231 assert_eq!(request.uri().query(), Some("probe=1"));
232 assert_eq!(
233 request.uri().authority().map(http::uri::Authority::as_str),
234 Some("example.execute-api.invalid")
235 );
236 }
237
238 #[test]
239 fn named_stage_normalization_is_boundary_safe() {
240 let mut root = gateway_request("/candidate?probe=1", Some("candidate"));
241 assert!(strip_api_gateway_stage_from_uri(&mut root));
242 assert_eq!(
243 root.uri()
244 .path_and_query()
245 .map(http::uri::PathAndQuery::as_str),
246 Some("/?probe=1")
247 );
248
249 let mut different_prefix = gateway_request("/candidate-v2/health/live", Some("candidate"));
250 assert_eq!(
251 different_prefix.uri().path(),
252 "/candidate/candidate-v2/health/live"
253 );
254 assert!(strip_api_gateway_stage_from_uri(&mut different_prefix));
255 assert_eq!(different_prefix.uri().path(), "/candidate-v2/health/live");
256
257 let mut unprefixed = gateway_request("/health/live", Some("candidate"));
258 assert_eq!(unprefixed.uri().path(), "/candidate/health/live");
259 assert!(strip_api_gateway_stage_from_uri(&mut unprefixed));
260 assert_eq!(unprefixed.uri().path(), "/health/live");
261
262 let mut default_stage = gateway_request("/health/live", Some("$default"));
263 assert!(!strip_api_gateway_stage_from_uri(&mut default_stage));
264 assert_eq!(default_stage.uri().path(), "/health/live");
265 }
266
267 #[tokio::test]
268 async fn named_stage_is_removed_before_axum_route_matching() {
269 let router = Router::new().route("/health/live", get(|| async { StatusCode::NO_CONTENT }));
270 let request = gateway_request("/candidate/health/live", Some("candidate"));
271 assert_eq!(request.uri().path(), "/candidate/health/live");
272
273 let response = route_request(router, request)
274 .await
275 .expect("router service is infallible");
276
277 assert_eq!(response.status(), StatusCode::NO_CONTENT);
278 }
279
280 #[tokio::test]
281 async fn compressed_response_uses_lambda_binary_transport() {
282 let router = minco_http::apply_standard_middleware(
283 Router::new().route(
284 "/payload",
285 get(|| async { "minco-lambda-compression-".repeat(128) }),
286 ),
287 &minco_http::HttpRuntimeConfig::default(),
288 )
289 .expect("standard HTTP middleware is valid");
290 let mut request = gateway_request("/payload", Some("$default"));
291 request
292 .headers_mut()
293 .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip"));
294
295 let response = route_request(router, request)
296 .await
297 .expect("router service is infallible");
298 assert_eq!(
299 response.headers().get(header::CONTENT_ENCODING),
300 Some(&HeaderValue::from_static("gzip"))
301 );
302
303 let response = lambda_http::IntoResponse::into_response(response).await;
304 match response.body() {
305 lambda_http::Body::Binary(bytes) => {
306 assert!(bytes.starts_with(&[0x1f, 0x8b]));
307 }
308 body => panic!("compressed Lambda response was not binary: {body:?}"),
309 }
310 }
311}