Learning Rust: Local API Gateway

Adrian Macal
Level Up Coding
Published in
3 min readFeb 5, 2024

--

Do you, like me, expect the local development cycle to be as fast as possible?

When observing the development process of the front-end, you might be impressed that a single changed line in React code is incredibly quickly reflected in the browser. Front-end developers expect this process to take less than a second. So, why should building an HTTP API on AWS Lambda be any different? In this story, I am building a local HTTP server that proxies all requests to the local AWS Lambda runtime. The purpose of this tool is to enable rapid iterations when developing an API. Imagine you just modified the endpoint, cargo catches it, the binary is recompiled, and your React App is ready to fetch the updated data. And all this happens as quickly as you can switch to your browser.

Today, I am going to show the outcome first. The final product is written in Rust as a command-line tool connecting to the local Lambda Runtime. First, we need to install it and start it. It blocks, so the Lambda Runtime has to be started in Terminal B, which also blocks. The final check is done with curl in Terminal C.

# Terminal A
vscode ➜ /workspaces/my-api $ cargo install aws-local
...

vscode ➜ /workspaces/my-api $ aws-local --function-name my-api -vvv
2024-02-05 10:30:37.499 [INFO] aws_local: Binding server on 127.0.0.1:3000
2024-02-05 10:30:48.322 [INFO] aws_local: Handling POST /echo
2024-02-05 10:30:48.322 [DEBUG] reqwest: starting new connection: http://127.0.0.1:9000/
2024-02-05 10:30:48.384 [INFO] aws_local: Handling POST /echo completed

# Terminal B
vscode ➜ /workspaces/my-api $ cargo lambda watch -a 127.0.0.1 -v
INFO invoke server listening on 127.0.0.1:9000
DEBUG request stack initialized in first request function_name="my-api"
INFO starting lambda function function="my-api" manifest="Cargo.toml"
DEBUG processing request req_id="0f185edf-51e1-4933-be59-192ef7194650" function="my-api"
DEBUG request stack increased function_name="my-api"
DEBUG processing request req_id="c7193c59-8106-418b-8e83-9b27bc27b6b9" function="my-api"

# Terminal C
vscode ➜ /workspaces/my-api $ curl --silent --location 'localhost:3000/echo' --header 'Content-Type: application/json' --data '{ "hello": "rust" }' | jq
{
"body": {
"hello": "rust"
},
"headers": {
"accept": "*/*",
"content-length": "19",
"content-type": "application/json",
"host": "localhost:3000",
"user-agent": "curl/7.88.1",
"x-amzn-trace-id": "Root=1-65c0ba73-3c867d43f073de7768b8ad1c;Parent=13c8a18c215b2dc1;Sampled=1"
}
}

The idea is silly simple. The HTTP server must catch all calls to any endpoint. With axum, it can be done with only a few lines:

// server.rs

pub async fn start(self) -> Result<(), ServerError> {
...

let app = Router::new()
.route("/", any(call_proxy))
.route("/*path", any(call_proxy))
.layer(Extension(self.lambda));

match axum::serve(listener, app).await {
Ok(()) => info!("Server started on {address}"),
Err(error) => return ServerError::raise_serving(address, error),
};

...
}

It also requires defining a call_proxy handler. The handler is responsible for translating incoming axum requests to API Gateway-like payload so that the Lambda Runtime can handle it. The return path is analogous.

// handler.rs

pub async fn call_proxy(
Extension(lambda): Extension<LambdaFunction>,
method: Method,
uri: OriginalUri,
headers: HeaderMap,
query: Query<HashMap<String, Vec<String>>>,
body: Bytes,
) -> impl IntoResponse {
...

let info = RequestInfo::new(method.clone(), uri.clone().into(), headers, query.0, body);
let request = info.into_http_request();

let response = match lambda.call_http(request).await {
Ok(response) => ResponseInfo::from_http_response(response),
Err(error) => return ResponseInfo::internal_server_error(error.to_string()),
};

let response = match response {
Ok(value) => value,
Err(error) => return ResponseInfo::internal_server_error(error.to_string()),
};

...
}

The actual component to communicate with lambda uses the reqwest crate.

// lambda.rs

pub async fn call_http(
&self,
request: ApiGatewayV2httpRequest,
) -> Result<ApiGatewayV2httpResponse, reqwest::Error> {

let response = self
.gateway
.post(&self.endpoint)
.json(&request)
.send()
.await;

let response = match response {
Ok(response) => response,
Err(error) => {
warn!("Calling lambda failed: {}", error.to_string());
return Err(error);
}
};

let json = match response.json::<ApiGatewayV2httpResponse>().await {
Ok(data) => data,
Err(error) => {
warn!("Parsing lambda response failed: {}", error.to_string());
return Err(error);
}
};

Ok(json)
}

Rust is already at such a stage where you can pick up any package and be productive from the very first minute. The crafted tool is very simple, and building it took only an hour. Saving a lot of hours of mine and perhaps yours.

GitHub: https://github.com/amacal/aws-local
Crates: https://crates.io/crates/aws-local

--

--

Software Developer, Data Engineer with solid knowledge of Business Intelligence. Passionate about programming.