Returning JSON Responses
Standardizing your API output with a consistent structure.
What you'll learn
- Defining a standard ApiResponse struct
- Using Serde for serialization
- Handling different status codes and error messages
One thing that separates amateur APIs from professional ones is consistency. You don't want one endpoint returning a random string while another returns a complex object. Every response should follow a predictable format.
In this lesson, we're going to create a standard ApiResponse structure and update our handlers to use it.
Response structure
Let's decide on a format. A common one is:
{
"statusCode": 200,
"error": null,
"message": "Success",
"data": { ... }
}ApiResponse struct
In Rust, we use serde to handle JSON. We'll define a generic struct so it can hold any type of data.
Add this to your src/bin/hello.rs (we'll move it to a shared file in the next lesson):
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiResponse<T> {
pub status_code: u16,
pub error: Option<String>,
pub message: String,
pub data: Option<T>,
}Why generic <T>?
The T is a placeholder for whatever data you want to return. For a "Hello" API, T might just be a String. For a user profile API, T might be a User struct. This makes our response wrapper reusable.
Why #[serde(rename_all = "camelCase")]?
In Rust, we use snake_case (e.g., status_code), but in JSON/JavaScript, it's standard to use camelCase (e.g., statusCode). This macro handles the conversion automatically so we can follow Rust's style while providing a standard API.
Using the structure
Now, let's see how we would use this in a handler. We'll create a helper function to make it even cleaner.
fn create_response<T: Serialize>(status: u16, message: &str, data: Option<T>) -> ApiGatewayProxyResponse {
let response_body = ApiResponse {
status_code: status,
error: if status >= 400 { Some("Error".into()) } else { None },
message: message.to_string(),
data,
};
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
// Adding CORS headers is also a good idea here!
headers.insert("Access-Control-Allow-Origin", "*".parse().unwrap());
ApiGatewayProxyResponse {
status_code: status as i64,
headers,
multi_value_headers: HeaderMap::new(),
body: Some(Body::Text(serde_json::to_string(&response_body).unwrap())),
is_base64_encoded: false,
}
}Now our handler logic becomes much simpler:
async fn handler(
_event: LambdaEvent<ApiGatewayProxyRequest>,
) -> Result<ApiGatewayProxyResponse, Error> {
Ok(create_response(200, "Hello from Rust!", Some("Happy coding!")))
}Handling Errors
What if something goes wrong? Our structure handles that too:
if name.is_empty() {
return Ok(create_response::<()>(400, "Name is required", None));
}Pro Tip
The :: <()> syntax tells Rust that the data field is empty (a "unit type").
Why bother with this?
By using a consistent structure:
- Frontend developers love you: They can write one error-handling function for all API calls.
- Debugging is easier: You always know exactly where to look for the message or status.
- Refactoring is safer: If you change your data structure, the outer "shell" remains the same.
We've started adding some really useful code, but if we have 10 Lambda functions, we don't want to copy-paste this ApiResponse struct into every file. In the next post, we'll learn how to use a lib.rs file to share code across all our Lambdas.