TutorialbeginnerPart 11 of 18

Returning JSON Responses

Standardizing your API output with a consistent structure.

April 22, 20263 min read

What you'll learn

  • Defining a standard ApiResponse struct
  • Using Serde for serialization
  • Handling different status codes and error messages
Prerequisites:Previous post completed (Path Parameters and Query Strings)

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:

json
Source File
{
  "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):

src/bin/hello.rs
rust
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.

src/bin/hello.rs
rust
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:

src/bin/hello.rs
rust
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:

rust
Source File
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:

  1. Frontend developers love you: They can write one error-handling function for all API calls.
  2. Debugging is easier: You always know exactly where to look for the message or status.
  3. 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.