hyperlite 0.1.0

Lightweight HTTP framework built on hyper, tokio, and tower
Documentation
use chrono::DateTime;
use hyper::StatusCode;
use serde::Serialize;
use serde_json::{json, Value};

use hyperlite::response::with_correlation_id;
use hyperlite::{empty, failure, not_found, success, ApiError};

mod test_helpers;
use test_helpers::*;

#[derive(Serialize)]
struct TestData {
    value: String,
}

#[tokio::test]
async fn test_success_response_status() {
    let response = success(StatusCode::CREATED, TestData { value: "ok".into() });
    assert_status(&response, StatusCode::CREATED);
}

#[tokio::test]
async fn test_success_response_content_type() {
    let response = success(StatusCode::OK, TestData { value: "ok".into() });
    assert_content_type_json(&response);
}

#[tokio::test]
async fn test_success_response_envelope() {
    let response = success(
        StatusCode::OK,
        TestData {
            value: "data".into(),
        },
    );
    let json = read_body_json(response).await;
    assert_json_envelope(&json, true);
}

#[tokio::test]
async fn test_success_response_data() {
    let response = success(
        StatusCode::OK,
        TestData {
            value: "hello".into(),
        },
    );
    let json = read_body_json(response).await;
    assert_eq!(json["data"]["value"], "hello");
}

#[tokio::test]
async fn test_success_response_timestamp() {
    let response = success(
        StatusCode::OK,
        TestData {
            value: "hello".into(),
        },
    );
    let json = read_body_json(response).await;
    let timestamp = json["meta"]["timestamp"]
        .as_str()
        .expect("timestamp missing");
    assert!(DateTime::parse_from_rfc3339(timestamp).is_ok());
}

#[tokio::test]
async fn test_success_response_no_errors() {
    let response = success(
        StatusCode::OK,
        TestData {
            value: "hello".into(),
        },
    );
    let json = read_body_json(response).await;
    assert!(json.get("errors").is_none());
}

#[tokio::test]
async fn test_failure_response_status() {
    let response = failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("VALIDATION", "invalid")],
    );
    assert_status(&response, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_failure_response_content_type() {
    let response = failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("VALIDATION", "invalid")],
    );
    assert_content_type_json(&response);
}

#[tokio::test]
async fn test_failure_response_envelope() {
    let response = failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("VALIDATION", "invalid")],
    );
    let json = read_body_json(response).await;
    assert_json_envelope(&json, false);
}

#[tokio::test]
async fn test_failure_response_errors() {
    let response = failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("VALIDATION", "invalid")],
    );
    let json = read_body_json(response).await;
    assert_eq!(json["errors"].as_array().unwrap().len(), 1);
}

#[tokio::test]
async fn test_failure_response_multiple_errors() {
    let response = failure(
        StatusCode::UNPROCESSABLE_ENTITY,
        vec![
            ApiError::new("VALIDATION", "email"),
            ApiError::new("VALIDATION", "password"),
        ],
    );
    let json = read_body_json(response).await;
    assert_eq!(json["errors"].as_array().unwrap().len(), 2);
}

#[tokio::test]
async fn test_failure_response_no_data() {
    let response = failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("VALIDATION", "invalid")],
    );
    let json = read_body_json(response).await;
    assert!(json.get("data").is_none());
}

#[tokio::test]
async fn test_not_found_response_status() {
    let response = not_found("/missing".to_owned());
    assert_status(&response, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_not_found_response_envelope() {
    let response = not_found("/missing".to_owned());
    let json = read_body_json(response).await;
    assert_json_envelope(&json, false);
}

#[tokio::test]
async fn test_not_found_response_path() {
    let response = not_found("/users/123".to_owned());
    let json = read_body_json(response).await;
    assert_eq!(json["data"]["path"], "/users/123");
}

#[tokio::test]
async fn test_not_found_response_message() {
    let response = not_found("/users/123".to_owned());
    let json = read_body_json(response).await;
    assert_eq!(json["message"], "Resource not found");
}

#[tokio::test]
async fn test_not_found_response_error_code() {
    let response = not_found("/users/123".to_owned());
    let json = read_body_json(response).await;
    assert_eq!(json["errors"][0]["code"], "NOT_FOUND");
}

#[tokio::test]
async fn test_empty_response_status() {
    let response = empty(StatusCode::ACCEPTED);
    assert_status(&response, StatusCode::ACCEPTED);
}

#[tokio::test]
async fn test_empty_response_no_body() {
    let bytes = read_body_bytes(empty(StatusCode::NO_CONTENT)).await;
    assert_eq!(bytes.len(), 0);
}

#[tokio::test]
async fn test_empty_response_no_content_type() {
    let response = empty(StatusCode::NO_CONTENT);
    assert!(response.headers().get("content-type").is_none());
}

#[tokio::test]
async fn test_empty_response_204() {
    let response = empty(StatusCode::NO_CONTENT);
    assert_status(&response, StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn test_with_correlation_id_in_meta() {
    let response = with_correlation_id(
        StatusCode::OK,
        true,
        Some(json!({"status": "ok"})),
        None,
        None,
        Some("req-123".into()),
    );
    let json = read_body_json(response).await;
    assert_eq!(json["meta"]["correlationId"], "req-123");
}

#[tokio::test]
async fn test_with_correlation_id_in_header() {
    let response = with_correlation_id(
        StatusCode::OK,
        true,
        Some(json!({"status": "ok"})),
        None,
        None,
        Some("req-456".into()),
    );
    let header = response
        .headers()
        .get("x-request-id")
        .expect("missing x-request-id header");
    assert_eq!(header, "req-456");
}

#[tokio::test]
async fn test_with_correlation_id_matches() {
    let response = with_correlation_id(
        StatusCode::OK,
        true,
        Some(json!({"status": "ok"})),
        None,
        None,
        Some("req-789".into()),
    );
    let header = response
        .headers()
        .get("x-request-id")
        .expect("missing header")
        .to_str()
        .unwrap()
        .to_owned();
    let json = read_body_json(response).await;
    assert_eq!(json["meta"]["correlationId"], header);
}

#[tokio::test]
async fn test_with_correlation_id_none() {
    let response = with_correlation_id::<Value>(StatusCode::OK, true, None, None, None, None);
    assert!(response.headers().get("x-request-id").is_none());
    let json = read_body_json(response).await;
    assert!(json["meta"].get("correlationId").is_none());
}

#[test]
fn test_api_error_new() {
    let error = ApiError::new("CODE", "message");
    assert_eq!(error.code, "CODE");
    assert_eq!(error.message.as_deref(), Some("message"));
}

#[test]
fn test_api_error_with_code() {
    let error = ApiError::with_code("CODE");
    assert_eq!(error.code, "CODE");
    assert!(error.message.is_none());
}

#[test]
fn test_api_error_with_optional_message() {
    let error = ApiError::with_optional_message("CODE", Some("message".into()));
    assert_eq!(error.message.as_deref(), Some("message"));
}

#[test]
fn test_api_error_serialization() {
    let error = ApiError::new("CODE", "message");
    let value = serde_json::to_value(&error).unwrap();
    assert_eq!(value["code"], "CODE");
    assert_eq!(value["message"], "message");
}

#[test]
fn test_api_error_message_omitted_when_none() {
    let error = ApiError::with_code("CODE");
    let value = serde_json::to_value(&error).unwrap();
    assert!(value.get("message").is_none());
}

#[tokio::test]
async fn test_envelope_success_fields() {
    let response = success(StatusCode::OK, TestData { value: "ok".into() });
    let json = read_body_json(response).await;
    assert!(json.get("data").is_some());
    assert!(json.get("errors").is_none());
}

#[tokio::test]
async fn test_envelope_failure_fields() {
    let response = failure(StatusCode::BAD_REQUEST, vec![ApiError::new("CODE", "oops")]);
    let json = read_body_json(response).await;
    assert!(json.get("data").is_none());
    assert!(json.get("errors").is_some());
}

#[tokio::test]
async fn test_envelope_meta_always_present() {
    let success_json =
        read_body_json(success(StatusCode::OK, TestData { value: "ok".into() })).await;
    let failure_json = read_body_json(failure(
        StatusCode::BAD_REQUEST,
        vec![ApiError::new("CODE", "oops")],
    ))
    .await;
    assert!(success_json.get("meta").is_some());
    assert!(failure_json.get("meta").is_some());
}

#[tokio::test]
async fn test_envelope_timestamp_format() {
    let response = success(StatusCode::OK, TestData { value: "ok".into() });
    let json = read_body_json(response).await;
    let timestamp = json["meta"]["timestamp"].as_str().unwrap();
    assert!(DateTime::parse_from_rfc3339(timestamp).is_ok());
}

#[derive(Serialize)]
struct EmptyStruct {}

#[tokio::test]
async fn test_success_with_empty_struct() {
    let response = success(StatusCode::OK, EmptyStruct {});
    let json = read_body_json(response).await;
    assert!(json["data"].is_object());
}

#[derive(Serialize)]
struct NestedData {
    nested: InnerData,
}

#[derive(Serialize)]
struct InnerData {
    value: i32,
}

#[tokio::test]
async fn test_success_with_nested_data() {
    let response = success(
        StatusCode::OK,
        NestedData {
            nested: InnerData { value: 10 },
        },
    );
    let json = read_body_json(response).await;
    assert_eq!(json["data"]["nested"]["value"], 10);
}

#[tokio::test]
async fn test_failure_with_empty_errors() {
    let response = failure(StatusCode::BAD_REQUEST, vec![]);
    let json = read_body_json(response).await;
    assert!(json["errors"].is_array());
    assert_eq!(json["errors"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn test_not_found_with_special_chars() {
    let response = not_found("/naïve path".to_owned());
    let json = read_body_json(response).await;
    assert_eq!(json["data"]["path"], "/naïve path");
}