satay-codegen 0.4.1

Generate Rust client code from OpenAPI 3.1 documents
Documentation
use std::fs;

use crate::ast::*;
use crate::common::*;

#[test]
fn generated_simple_fixture_compiles_and_behaves() {
    let files = satay_codegen::generate(SIMPLE).expect("generate simple fixture");
    let temp = tempfile::tempdir().expect("create temp crate");
    let crate_dir = temp.path();
    let generated_dir = crate_dir.join("src/generated");

    let runtime_path = runtime_path_toml();

    write_manifest(crate_dir, &runtime_path, false, false);
    write_generated_files(&generated_dir, &files);
    let lib_contents = r##"pub mod generated;

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

    #[test]
    fn constructs_request_parts_without_io() {
        let parts = get_user_parts(GetUserInput::new("user/42").include_details(true))
        .expect("request parts");

        assert_eq!(parts.method, http::Method::GET);
        assert_eq!(parts.uri, "/users/user%2F42?includeDetails=true");
        assert_eq!(parts.headers.len(), 0);
        assert_eq!(parts.body, ());
    }

    #[test]
    fn action_builder_constructs_json_request_without_io() {
        let request = Api::new()
            .get_user("user/42")
            .include_details(true)
            .request()
            .expect("action request");

        assert_eq!(request.method(), http::Method::GET);
        assert_eq!(request.uri(), "/users/user%2F42?includeDetails=true");
        assert!(request.body().is_empty());
    }

    #[test]
    fn encodes_json_request_body() {
        let request = encode_update_user(
            UpdateUserInput::new("42")
            .notify(false)
            .body(UpdateUserRequest {
                age: None,
                name: "Ada".to_owned(),
            }),
        )
        .expect("encoded request");

        assert_eq!(request.method(), http::Method::PUT);
        assert_eq!(request.uri(), "/users/42?notify=false");
        assert_eq!(
            request.headers().get(http::header::CONTENT_TYPE).unwrap(),
            "application/vnd.satay.user+json"
        );

        let body: serde_json::Value = serde_json::from_slice(request.body()).unwrap();
        assert_eq!(body, serde_json::json!({ "name": "Ada" }));

        let empty_request = encode_update_user(UpdateUserInput::new("42"))
        .expect("encoded request without body");
        assert_eq!(empty_request.uri(), "/users/42");
        assert!(empty_request
            .headers()
            .get(http::header::CONTENT_TYPE)
            .is_none());
        assert!(empty_request.body().is_empty());
    }

    #[test]
    fn decodes_json_response_enums() {
        let response = satay_runtime::ResponseParts {
            status: http::StatusCode::OK,
            headers: http::HeaderMap::new(),
            body: br#"{"id":"42","name":"Ada","status":"active","age":36,"tags":["admin"]}"#.to_vec(),
        };
        let decoded = GetUserAction::decode(response).expect("decoded response");

        match decoded {
            GetUserResponse::Ok(user) => {
                assert_eq!(user.id, "42");
                assert_eq!(user.name, "Ada");
                assert_eq!(user.status, UserStatus::Active);
                assert_eq!(user.age, Some(36));
                assert_eq!(user.tags, Some(vec!["admin".to_owned()]));
            }
            other => panic!("unexpected response: {other:?}"),
        }
    }

    #[test]
    fn preserves_unexpected_response_body() {
        let response = satay_runtime::ResponseParts {
            status: http::StatusCode::INTERNAL_SERVER_ERROR,
            headers: http::HeaderMap::new(),
            body: b"server exploded".to_vec(),
        };
        let decoded = decode_get_user_response(response).expect("decoded response");

        match decoded {
            GetUserResponse::UnexpectedStatus(status, body) => {
                assert_eq!(status, http::StatusCode::INTERNAL_SERVER_ERROR);
                assert_eq!(body, b"server exploded");
            }
            other => panic!("unexpected response: {other:?}"),
        }
    }
}
"##;
    fs::write(crate_dir.join("src/lib.rs"), lib_contents).expect("write lib");

    run_temp_cargo(crate_dir, "test", &[], "generated crate tests");
}

#[test]
fn generated_response_name_collision_compiles_and_decodes() {
    let files =
        satay_codegen::generate(RESPONSE_NAME_COLLISION).expect("generate collision fixture");

    let parts = parse_rust(find_file(&files, "psi/parts.rs"));
    let response = find_enum(&parts, "PsiOperationResponse");
    assert_eq!(
        norm(&variant(response, "Ok").fields),
        norm_str("(PsiResponse)")
    );
    assert!(!has_enum(&parts, "PsiResponse"));

    let temp = tempfile::tempdir().expect("create temp crate");
    let crate_dir = temp.path();
    let generated_dir = crate_dir.join("src/generated");

    let runtime_path = runtime_path_toml();

    write_manifest(crate_dir, &runtime_path, false, false);
    write_generated_files(&generated_dir, &files);
    let lib_contents = r##"pub mod generated;

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

    #[test]
    fn decodes_nonrecursive_collision_response() {
        let expected = PsiResponse { value: 42 };
        let manual = PsiOperationResponse::Ok(expected.clone());
        assert_eq!(manual, PsiOperationResponse::Ok(expected));

        let response = satay_runtime::ResponseParts {
            status: http::StatusCode::OK,
            headers: http::HeaderMap::new(),
            body: br#"{"value":42}"#.to_vec(),
        };
        let decoded = PsiAction::decode(response).expect("decoded response");

        assert_eq!(decoded, PsiOperationResponse::Ok(PsiResponse { value: 42 }));
    }
}
"##;
    fs::write(crate_dir.join("src/lib.rs"), lib_contents).expect("write lib");

    run_temp_cargo(
        crate_dir,
        "test",
        &[],
        "response collision generated crate tests",
    );
}

#[test]
fn generated_constrained_fixture_enforces_openapi_bounds() {
    let files = satay_codegen::generate(CONSTRAINED).expect("generate constrained fixture");

    let types_rs = parse_rust(find_file(&files, "types.rs"));
    assert_tuple_struct(&types_rs, "Age", "u8");
    assert_attr_contains(
        &find_struct(&types_rs, "Age").attrs,
        "nutype::nutype",
        "validate(less_or_equal = 130)",
    );
    assert_attr_contains(
        &find_struct(&types_rs, "UserName").attrs,
        "nutype::nutype",
        "validate(len_char_min = 1, len_char_max = 80)",
    );
    assert_attr_contains(
        &find_struct(&types_rs, "UserNickname").attrs,
        "nutype::nutype",
        "validate(len_char_min = 1, len_char_max = 40)",
    );
    assert_attr_contains(
        &find_struct(&types_rs, "UserScore").attrs,
        "nutype::nutype",
        "validate(finite, greater = 0.0, less = 1.0)",
    );
    assert_field(
        find_struct(&types_rs, "User"),
        "nickname",
        "Option<UserNickname>",
    );
    assert_attr_contains(
        &find_struct(&types_rs, "GetUserUserIdParameter").attrs,
        "nutype::nutype",
        r#"regex = "^[a-zA-Z0-9-]+$""#,
    );

    let temp = tempfile::tempdir().expect("create temp crate");
    let crate_dir = temp.path();
    let generated_dir = crate_dir.join("src/generated");

    let runtime_path = runtime_path_toml();

    write_manifest(crate_dir, &runtime_path, true, false);
    write_generated_files(&generated_dir, &files);
    let lib_contents = r##"pub mod generated;

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

    #[test]
    fn rejects_invalid_values_at_construction() {
        assert!(Age::try_new(131).is_err());
        assert!(GetUserLimitParameter::try_new(0).is_err());
        assert!(UserName::try_new(String::new()).is_err());
        assert!(UserName::try_new("a".repeat(81)).is_err());
        assert!(UserNickname::try_new(String::new()).is_err());
        assert!(UserNickname::try_new("a".repeat(41)).is_err());
        assert!(UserScore::try_new(0.0).is_err());
        assert!(UserScore::try_new(1.0).is_err());
        assert!(UserScore::try_new(0.5).is_ok());
        assert!(GetUserTagsParameter::try_new(Vec::new()).is_err());
    }

    #[test]
    fn regex_validation_rejects_invalid_patterns() {
        assert!(Email::try_new("not-an-email".to_owned()).is_err());
        assert!(Email::try_new("user@domain.com".to_owned()).is_ok());
        assert!(Slug::try_new("hello world".to_owned()).is_err());
        assert!(Slug::try_new("hello-world".to_owned()).is_ok());
    }

    #[test]
    fn request_parts_use_validated_values() {
        let user_id = GetUserUserIdParameter::try_new("user-42".to_owned()).unwrap();
        let limit = GetUserLimitParameter::try_new(10).unwrap();
        let tag = GetUserTagsParameterItem::try_new("rs".to_owned()).unwrap();
        let tags = GetUserTagsParameter::try_new(vec![tag]).unwrap();

        let parts = get_user_parts(GetUserInput {
            user_id,
            limit: Some(limit),
            tags: Some(tags),
        })
        .expect("request parts");

        assert_eq!(parts.uri, "/users/user-42?limit=10&tags=rs");
    }


    #[test]
    fn response_deserialization_accepts_31_nullable_type_arrays() {
        let response = satay_runtime::ResponseParts {
            status: http::StatusCode::OK,
            headers: http::HeaderMap::new(),
            body: br#"{"id":"42","name":"Ada","nickname":null,"age":36,"score":0.5}"#.to_vec(),
        };

        let decoded = decode_get_user_response(response).expect("nullable nickname accepted");
        match decoded {
            GetUserResponse::Ok(user) => assert!(user.nickname.is_none()),
            other => panic!("unexpected response: {other:?}"),
        }
    }
    #[test]
    fn response_deserialization_enforces_bounds() {
        let response = satay_runtime::ResponseParts {
            status: http::StatusCode::OK,
            headers: http::HeaderMap::new(),
            body: br#"{"id":"42","name":"Ada","nickname":null,"age":131,"score":0.5}"#.to_vec(),
        };

        let err = decode_get_user_response(response).expect_err("invalid age rejected");
        assert!(err.to_string().contains("JSON error"));
    }
}
"##;
    fs::write(crate_dir.join("src/lib.rs"), lib_contents).expect("write lib");

    run_temp_cargo(crate_dir, "test", &[], "constrained generated crate tests");
    run_temp_cargo(
        crate_dir,
        "check",
        &["--no-default-features"],
        "constrained generated crate no-default check",
    );
}