awsim-core 0.5.0

Core framework for AWSim — gateway, routing, protocol layer, state management
Documentation
use axum::http::{HeaderMap, StatusCode};
use bytes::Bytes;
use serde_json::Value;

use crate::error::AwsError;

use super::ParsedRequest;

/// Parse an awsQuery request.
///
/// Form body contains `Action=OperationName&Version=...&Param1=value1&...`
/// Complex types use dot-notation: `Tags.member.1.Key=Name&Tags.member.1.Value=foo`
pub fn parse_request(body: &Bytes) -> Result<ParsedRequest, AwsError> {
    let body_str = std::str::from_utf8(body)
        .map_err(|_| AwsError::bad_request("InvalidRequest", "Request body is not valid UTF-8"))?;

    let params: Vec<(String, String)> = serde_urlencoded::from_str(body_str)
        .map_err(|e| AwsError::bad_request("InvalidRequest", format!("Invalid form body: {e}")))?;

    let operation = params
        .iter()
        .find(|(k, _)| k == "Action")
        .map(|(_, v)| v.clone())
        .ok_or_else(|| AwsError::bad_request("MissingAction", "Missing 'Action' parameter"))?;

    // Convert flat dot-notation params into structured JSON
    let input = flatten_to_json(&params);

    Ok(ParsedRequest { operation, input })
}

/// Convert flat query params with dot-notation into a JSON value.
///
/// Example input:
///   `Tags.member.1.Key=Name`, `Tags.member.1.Value=foo`
/// Output:
///   `{"Tags": {"member": [{"Key": "Name", "Value": "foo"}]}}`
fn flatten_to_json(params: &[(String, String)]) -> Value {
    let mut map = serde_json::Map::new();
    for (key, value) in params {
        if key == "Action" || key == "Version" {
            continue;
        }
        set_nested(&mut map, key, value);
    }
    Value::Object(map)
}

fn set_nested(map: &mut serde_json::Map<String, Value>, key: &str, value: &str) {
    let parts: Vec<&str> = key.split('.').collect();
    set_nested_recursive(map, &parts, value);
}

fn set_nested_recursive(map: &mut serde_json::Map<String, Value>, parts: &[&str], value: &str) {
    if parts.is_empty() {
        return;
    }
    if parts.len() == 1 {
        map.insert(parts[0].to_string(), Value::String(value.to_string()));
        return;
    }

    let key = parts[0];
    let rest = &parts[1..];

    // Check if next part is a number (array index)
    if let Some(next) = rest.first()
        && next.parse::<usize>().is_ok()
    {
        // This is an array member pattern like "Tags.member.1.Key"
        let entry = map
            .entry(key.to_string())
            .or_insert_with(|| Value::Array(Vec::new()));
        if let Value::Array(arr) = entry {
            let idx: usize = next.parse::<usize>().unwrap() - 1; // 1-based → 0-based
            while arr.len() <= idx {
                arr.push(Value::Object(serde_json::Map::new()));
            }
            if rest.len() > 1 {
                if let Value::Object(ref mut inner) = arr[idx] {
                    set_nested_recursive(inner, &rest[1..], value);
                }
            } else {
                arr[idx] = Value::String(value.to_string());
            }
        }
        return;
    }

    let entry = map
        .entry(key.to_string())
        .or_insert_with(|| Value::Object(serde_json::Map::new()));
    if let Value::Object(inner) = entry {
        set_nested_recursive(inner, rest, value);
    }
}

/// Serialize a successful awsQuery XML response.
///
/// Format:
/// ```xml
/// <{Action}Response xmlns="...">
///   <{Action}Result>
///     {serialized fields}
///   </{Action}Result>
///   <ResponseMetadata>
///     <RequestId>{request_id}</RequestId>
///   </ResponseMetadata>
/// </{Action}Response>
/// ```
pub fn serialize_response(
    operation: &str,
    output: &Value,
    request_id: &str,
) -> (StatusCode, HeaderMap, Bytes) {
    let result_xml = json_to_xml_fields(output);

    let xml = format!(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <{operation}Response xmlns=\"https://iam.amazonaws.com/doc/2010-05-08/\">\n\
         <{operation}Result>\n\
         {result_xml}\
         </{operation}Result>\n\
         <ResponseMetadata>\n\
         <RequestId>{request_id}</RequestId>\n\
         </ResponseMetadata>\n\
         </{operation}Response>"
    );

    let mut headers = HeaderMap::new();
    headers.insert("content-type", "text/xml".parse().unwrap());
    headers.insert("x-amzn-requestid", request_id.parse().unwrap());
    (StatusCode::OK, headers, Bytes::from(xml))
}

/// Serialize an awsQuery/XML error response.
pub fn serialize_error(error: &AwsError, request_id: &str) -> (StatusCode, HeaderMap, Bytes) {
    let error_type = match error.error_type {
        crate::error::ErrorType::Sender => "Sender",
        crate::error::ErrorType::Receiver => "Receiver",
    };

    // Body-only extras: emit any extras that aren't header-promoted as
    // additional `<Field>value</Field>` elements inside `<Error>`. S3 uses
    // this for fields like `ActualObjectSize` and `RangeRequested` on 416,
    // and `Resource` / `BucketName` on various errors.
    let extras_xml = error
        .extras
        .as_deref()
        .map(|extras| {
            let mut buf = String::new();
            for (key, val) in extras.iter() {
                // Skip extras that are exclusively header-promoted.
                if matches!(key.as_str(), "DeleteMarker" | "VersionId") {
                    continue;
                }
                let s = match val {
                    Value::String(s) => s.clone(),
                    Value::Number(n) => n.to_string(),
                    Value::Bool(b) => b.to_string(),
                    _ => continue,
                };
                buf.push_str(&format!("<{key}>{s}</{key}>\n"));
            }
            buf
        })
        .unwrap_or_default();

    let xml = format!(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <ErrorResponse xmlns=\"http://iam.amazonaws.com/doc/2010-05-08/\">\n\
         <Error>\n\
         <Type>{error_type}</Type>\n\
         <Code>{code}</Code>\n\
         <Message>{message}</Message>\n\
         {extras_xml}</Error>\n\
         <RequestId>{request_id}</RequestId>\n\
         </ErrorResponse>",
        code = error.code,
        message = error.message,
    );

    let mut headers = HeaderMap::new();
    headers.insert("content-type", "text/xml".parse().unwrap());
    headers.insert("x-amzn-requestid", request_id.parse().unwrap());

    // Promote extras (e.g., DeleteMarker, VersionId) to response headers.
    // S3 relies on x-amz-delete-marker and x-amz-version-id headers.
    if let Some(extras) = &error.extras {
        if let Some(dm) = extras.get("DeleteMarker").and_then(Value::as_bool) {
            headers.insert("x-amz-delete-marker", dm.to_string().parse().unwrap());
        }
        if let Some(vid) = extras.get("VersionId").and_then(Value::as_str) {
            headers.insert("x-amz-version-id", vid.parse().unwrap());
        }
    }

    (error.status, headers, Bytes::from(xml))
}

/// Convert a JSON Value to XML elements.
pub fn json_to_xml_fields(value: &Value) -> String {
    match value {
        Value::Object(map) => {
            let mut xml = String::new();
            for (key, val) in map {
                match val {
                    Value::Array(arr) => {
                        for item in arr {
                            xml.push_str(&render_element(key, item));
                        }
                    }
                    _ => xml.push_str(&render_element(key, val)),
                }
            }
            xml
        }
        Value::String(s) => s.clone(),
        Value::Number(n) => n.to_string(),
        Value::Bool(b) => b.to_string(),
        _ => String::new(),
    }
}

/// Render a single `<key>...</key>` element. Pretty-prints structured
/// content (Object) onto its own lines so nested elements stay readable,
/// but writes scalars (String/Number/Bool) inline so we don't inject
/// whitespace into the value — some clients string-compare list members
/// and a leading newline shows up as part of the data.
fn render_element(key: &str, value: &Value) -> String {
    match value {
        Value::Object(_) => format!("<{key}>\n{}</{key}>\n", json_to_xml_fields(value)),
        Value::String(s) => format!("<{key}>{s}</{key}>\n"),
        Value::Number(n) => format!("<{key}>{n}</{key}>\n"),
        Value::Bool(b) => format!("<{key}>{b}</{key}>\n"),
        Value::Null => format!("<{key}/>\n"),
        Value::Array(_) => format!("<{key}>\n{}</{key}>\n", json_to_xml_fields(value)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_simple_query() {
        let body = Bytes::from("Action=GetCallerIdentity&Version=2011-06-15");
        let result = parse_request(&body).unwrap();
        assert_eq!(result.operation, "GetCallerIdentity");
        assert_eq!(result.input, Value::Object(serde_json::Map::new()));
    }

    #[test]
    fn test_parse_query_with_params() {
        let body = Bytes::from("Action=CreateUser&UserName=testuser&Path=/engineering/");
        let result = parse_request(&body).unwrap();
        assert_eq!(result.operation, "CreateUser");
        assert_eq!(result.input["UserName"], "testuser");
        assert_eq!(result.input["Path"], "/engineering/");
    }

    #[test]
    fn scalar_array_items_render_inline() {
        // Regression: previously the array branch wrapped every item with
        // `<member>\n…</member>`, leaving a literal newline inside the
        // string element when the item was a scalar. Clients that
        // round-trip ListRolePolicies → GetRolePolicy ended up with
        // names like "\nInline1".
        let value = serde_json::json!({
            "PolicyNames": { "member": ["Inline1", "Inline2"] },
        });
        let xml = json_to_xml_fields(&value);
        assert!(xml.contains("<member>Inline1</member>"));
        assert!(xml.contains("<member>Inline2</member>"));
        assert!(!xml.contains("<member>\n"));
    }

    #[test]
    fn nested_object_array_items_still_pretty_print() {
        // Object array items retain their newline so nested fields stay
        // readable.
        let value = serde_json::json!({
            "Tags": { "member": [{ "Key": "Env", "Value": "prod" }] },
        });
        let xml = json_to_xml_fields(&value);
        assert!(xml.contains("<Key>Env</Key>"));
        assert!(xml.contains("<Value>prod</Value>"));
        assert!(xml.starts_with("<Tags>\n<member>\n"));
    }

    #[test]
    fn test_flatten_dot_notation() {
        let params = vec![
            ("Action".to_string(), "TagResource".to_string()),
            ("Tags.member.1.Key".to_string(), "Env".to_string()),
            ("Tags.member.1.Value".to_string(), "prod".to_string()),
            ("Tags.member.2.Key".to_string(), "Team".to_string()),
            ("Tags.member.2.Value".to_string(), "eng".to_string()),
        ];
        let result = flatten_to_json(&params);
        let tags = result["Tags"]["member"].as_array().unwrap();
        assert_eq!(tags.len(), 2);
        assert_eq!(tags[0]["Key"], "Env");
        assert_eq!(tags[0]["Value"], "prod");
        assert_eq!(tags[1]["Key"], "Team");
        assert_eq!(tags[1]["Value"], "eng");
    }
}