Skip to main content

lambda_forge/
endpoint.rs

1use lambda_http::Context;
2use or_panic::prelude::*;
3
4use crate::Error;
5
6#[derive(Debug, Clone)]
7/// Metadata for a specific endpoint.
8pub struct EndpointMetadata {
9    /// Product name (Application name).
10    pub product: String,
11
12    /// Component or section name.
13    pub component: String,
14
15    /// The AWS Lambda function execution context.
16    ///
17    /// NOTE: The values in this struct are populated using
18    /// the Lambda environment variables and the headers
19    /// returned by the poll request to the Runtime APIs.
20    pub lambda_context: Context,
21
22    /// Current stage it's running within.
23    pub stage: Option<String>,
24
25    /// Allowed HTTP methods for the endpoint.
26    pub allowed_methods: String,
27
28    /// Response content type for the endpoint.
29    pub response_content_type: String,
30}
31impl EndpointMetadata {
32    pub fn builder() -> EndpointMetadataBuilder {
33        EndpointMetadataBuilder::default()
34    }
35
36    /// Get the URL to the lambda function currently running.
37    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    /// Get the URL to the cloudwatch log of the invocation currently running.
55    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}