1use lambda_http::Context;
2use or_panic::prelude::*;
3
4use crate::Error;
5
6#[derive(Debug, Clone)]
7pub struct EndpointMetadata {
9 pub product: String,
11
12 pub component: String,
14
15 pub lambda_context: Context,
21
22 pub stage: Option<String>,
24
25 pub allowed_methods: String,
27
28 pub response_content_type: String,
30}
31impl EndpointMetadata {
32 pub fn builder() -> EndpointMetadataBuilder {
33 EndpointMetadataBuilder::default()
34 }
35
36 pub fn lambda_url(&self) -> Result<String, Error> {
38 let fn_name = &self.lambda_context.env_config.function_name;
39 let fn_region = std::env::var("AWS_REGION")
40 .or_panic("AWS Lambda should always have the AWS_REGION environment variable set.");
41
42 let mut url = format!(
43 "https://{}.console.aws.amazon.com/lambda/home?region={}#/functions/{}?tab=monitoring",
44 fn_region, fn_region, fn_name
45 );
46
47 if let Some(stage) = &self.stage {
48 url.push_str(&format!("&qualifier={stage}"));
49 }
50
51 Ok(url)
52 }
53
54 pub fn cloudwatch_log(&self, request_id: &str) -> Result<String, Error> {
56 let fn_name = &self.lambda_context.env_config.function_name;
57 let fn_region = std::env::var("AWS_REGION")
58 .or_panic("AWS Lambda should always have the AWS_REGION environment variable set.");
59
60 let url = format!(
61 concat!(
62 "https://{fn_region}.console.aws.amazon.com/",
63 "cloudwatch/home?region={fn_region}#logsV2:log-groups/log-group/",
64 "$252Faws$252Flambda$252F{fn_name}/log-events",
65 "$3FfilterPattern$3D$2522{request_id}$2522"
66 ),
67 fn_region = fn_region,
68 fn_name = fn_name,
69 request_id = request_id,
70 );
71
72 Ok(url)
73 }
74}
75
76#[derive(Default)]
77pub struct EndpointMetadataBuilder {
78 pub product: Option<String>,
79 pub component: Option<String>,
80 pub lambda_context: Option<Context>,
81 pub stage: Option<String>,
82 pub allowed_methods: Option<String>,
83 pub response_content_type: Option<String>,
84}
85impl EndpointMetadataBuilder {
86 pub fn new() -> Self {
87 Self::default()
88 }
89
90 pub fn context(mut self, ctx: Context) -> Self {
91 let parts: Vec<&str> = ctx.invoked_function_arn.split(':').collect();
92 let alias = parts.get(7).map(|s| (*s).to_owned());
93
94 self.stage = alias;
95 self.lambda_context = Some(ctx);
96 self
97 }
98
99 pub fn product(mut self, v: impl Into<String>) -> Self {
100 self.product = Some(v.into());
101 self
102 }
103
104 pub fn component(mut self, v: impl Into<String>) -> Self {
105 self.component = Some(v.into());
106 self
107 }
108
109 pub fn stage(mut self, v: impl Into<String>) -> Self {
110 self.stage = Some(v.into());
111 self
112 }
113
114 pub fn allowed_methods(mut self, v: impl Into<String>) -> Self {
115 self.allowed_methods = Some(v.into());
116 self
117 }
118
119 pub fn response_content_type(mut self, v: impl Into<String>) -> Self {
120 self.response_content_type = Some(v.into());
121 self
122 }
123
124 pub fn build(self) -> Result<EndpointMetadata, Error> {
125 let aws_lambda_context = self.lambda_context.ok_or(Error::MissingLambdaContext)?;
126
127 Ok(EndpointMetadata {
128 product: self.product.unwrap_or("N/A".to_owned()),
129 component: self.component.unwrap_or("N/A".to_owned()),
130 lambda_context: aws_lambda_context,
131 stage: self.stage,
132 allowed_methods: self
133 .allowed_methods
134 .unwrap_or("GET, POST, PUT, DELETE".to_owned()),
135 response_content_type: self
136 .response_content_type
137 .unwrap_or("application/json".to_owned()),
138 })
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use lambda_http::lambda_runtime::Config;
146 use std::sync::{Arc, Mutex, OnceLock};
147
148 fn env_lock() -> &'static Mutex<()> {
149 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
150 LOCK.get_or_init(|| Mutex::new(()))
151 }
152
153 fn with_aws_region<T>(region: &str, f: impl FnOnce() -> T) -> T {
154 let _g = env_lock().lock().unwrap();
155
156 let prev = std::env::var("AWS_REGION").ok();
157 unsafe { std::env::set_var("AWS_REGION", region) };
158
159 let out = f();
160
161 match prev {
162 Some(v) => unsafe { std::env::set_var("AWS_REGION", v) },
163 None => unsafe { std::env::remove_var("AWS_REGION") },
164 }
165 out
166 }
167
168 fn mk_ctx(function_name: &str, invoked_arn: &str) -> Context {
169 let mut ctx = Context::default();
170 ctx.invoked_function_arn = invoked_arn.to_string();
171
172 let cfg = Config {
173 function_name: function_name.to_string(),
174 ..Default::default()
175 };
176 ctx.env_config = Arc::new(cfg);
177
178 ctx
179 }
180
181 #[test]
182 fn builder_build_requires_context() {
183 let err = EndpointMetadata::builder().build().unwrap_err();
184 match err {
185 Error::MissingLambdaContext => {}
186 other => panic!("expected MissingLambdaContext, got: {other:?}"),
187 }
188 }
189
190 #[test]
191 fn builder_defaults_when_fields_missing() {
192 let ctx = mk_ctx(
193 "my-fn",
194 "arn:aws:lambda:us-east-1:123456789012:function:my-fn",
195 );
196 let md = EndpointMetadata::builder().context(ctx).build().unwrap();
197
198 assert_eq!(md.product, "N/A");
199 assert_eq!(md.component, "N/A");
200 assert_eq!(md.allowed_methods, "GET, POST, PUT, DELETE");
201 assert_eq!(md.response_content_type, "application/json");
202 assert_eq!(md.stage, None);
203 }
204
205 #[test]
206 fn builder_context_extracts_stage_from_invoked_arn_alias() {
207 let arn = "arn:aws:lambda:us-east-1:123456789012:function:my-fn:dev";
208 let ctx = mk_ctx("my-fn", arn);
209
210 let md = EndpointMetadata::builder().context(ctx).build().unwrap();
211 assert_eq!(md.stage.as_deref(), Some("dev"));
212 }
213
214 #[test]
215 fn builder_context_no_alias_keeps_stage_none() {
216 let arn = "arn:aws:lambda:us-east-1:123456789012:function:my-fn";
217 let ctx = mk_ctx("my-fn", arn);
218
219 let md = EndpointMetadata::builder().context(ctx).build().unwrap();
220 assert_eq!(md.stage, None);
221 }
222
223 #[test]
224 fn lambda_url_without_stage_has_no_qualifier() {
225 let ctx = mk_ctx("my-fn", "arn:aws:lambda:us-east-1:123:function:my-fn");
226 let md = EndpointMetadata::builder().context(ctx).build().unwrap();
227
228 let url = with_aws_region("us-east-1", || md.lambda_url().unwrap());
229
230 assert_eq!(
231 url,
232 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-fn?tab=monitoring"
233 );
234 assert!(!url.contains("qualifier="));
235 }
236
237 #[test]
238 fn lambda_url_with_stage_appends_qualifier() {
239 let ctx = mk_ctx("my-fn", "arn:aws:lambda:us-east-1:123:function:my-fn");
240 let md = EndpointMetadata::builder()
241 .context(ctx)
242 .stage("prod")
243 .build()
244 .unwrap();
245
246 let url = with_aws_region("us-east-1", || md.lambda_url().unwrap());
247
248 assert_eq!(
249 url,
250 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-fn?tab=monitoring&qualifier=prod"
251 );
252 }
253
254 #[test]
255 fn cloudwatch_log_formats_expected_url() {
256 let ctx = mk_ctx("my-fn", "arn:aws:lambda:us-east-1:123:function:my-fn");
257 let md = EndpointMetadata::builder().context(ctx).build().unwrap();
258
259 let url = with_aws_region("us-west-2", || md.cloudwatch_log("req-123").unwrap());
260
261 assert_eq!(
262 url,
263 "https://us-west-2.console.aws.amazon.com/cloudwatch/home?region=us-west-2#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fmy-fn/log-events$3FfilterPattern$3D$2522req-123$2522"
264 );
265 }
266
267 #[test]
268 fn builder_setters_override_defaults() {
269 let ctx = mk_ctx("my-fn", "arn:aws:lambda:us-east-1:123:function:my-fn");
270
271 let md = EndpointMetadata::builder()
272 .context(ctx)
273 .product("Couplet")
274 .component("Auth")
275 .allowed_methods("GET")
276 .response_content_type("text/plain")
277 .build()
278 .unwrap();
279
280 assert_eq!(md.product, "Couplet");
281 assert_eq!(md.component, "Auth");
282 assert_eq!(md.allowed_methods, "GET");
283 assert_eq!(md.response_content_type, "text/plain");
284 }
285}