use http::Method;
use quote::ToTokens;
use reqwest::Url;
use crate::generator::{
ast::{
ClientRootNode, ContentCategory, Documentation, EnumToken, FieldDef, FieldNameToken, MultipartFieldInfo,
OperationBody, OperationInfo, OperationKind, ParameterLocation, ParsedPath, PathSegment, ResponseMediaType,
StructToken, TypeRef,
},
codegen::{
Visibility,
client::{ClientFragment, ClientMethodFragment, MultipartFormFragment},
},
};
#[derive(Default)]
struct TestOperation {
summary: Option<String>,
description: Option<String>,
response_media_types: Option<Vec<ResponseMediaType>>,
response_enum: Option<String>,
}
impl TestOperation {
fn build(self) -> OperationInfo {
let method = Method::GET;
let path_template = "/test";
OperationInfo::builder()
.stable_id("test_operation")
.operation_id("testOperation")
.method(method.clone())
.path(ParsedPath {
segments: vec![PathSegment::Literal("test".to_string())],
query_string: None,
})
.kind(OperationKind::Http)
.request_type(StructToken::new("TestRequest"))
.response_type("TestResponse".to_string())
.maybe_response_enum(self.response_enum.as_deref().map(EnumToken::new))
.response_media_types(
self
.response_media_types
.unwrap_or_else(|| vec![ResponseMediaType::new("application/json")]),
)
.documentation(
Documentation::documentation()
.maybe_summary(self.summary.as_deref())
.maybe_description(self.description.as_deref())
.method(&method)
.path(path_template)
.call(),
)
.build()
}
}
#[test]
fn test_build_doc_attributes() {
struct Case {
summary: Option<&'static str>,
description: Option<&'static str>,
expected_contains: Vec<&'static str>,
expected_missing: Vec<&'static str>,
}
let cases = [
Case {
summary: Some("Test summary"),
description: None,
expected_contains: vec!["Test summary", "GET /test"],
expected_missing: vec!["Test description"],
},
Case {
summary: None,
description: Some("Test description"),
expected_contains: vec!["Test description", "GET /test"],
expected_missing: vec![],
},
Case {
summary: Some("Test summary"),
description: Some("Test description"),
expected_contains: vec!["Test summary", "Test description", "GET /test"],
expected_missing: vec![],
},
Case {
summary: Some("Test summary"),
description: Some("Line 1\nLine 2\nLine 3"),
expected_contains: vec!["Line 1", "Line 2", "Line 3"],
expected_missing: vec![],
},
Case {
summary: None,
description: None,
expected_contains: vec!["GET /test"],
expected_missing: vec![],
},
];
for case in cases {
let label = format!("summary={:?}, description={:?}", case.summary, case.description);
let operation = TestOperation {
summary: case.summary.map(String::from),
description: case.description.map(String::from),
..Default::default()
}
.build();
let output = operation.documentation.to_string();
for expected in case.expected_contains {
assert!(output.contains(expected), "{label}: expected to contain '{expected}'");
}
for missing in case.expected_missing {
assert!(
!output.contains(missing),
"{label}: expected NOT to contain '{missing}'"
);
}
}
let operation = TestOperation {
summary: Some("Test summary".to_string()),
description: Some("Test description".to_string()),
..Default::default()
}
.build();
let output = operation.documentation.to_string();
let summary_pos = output.find("Test summary").unwrap();
let description_pos = output.find("Test description").unwrap();
let signature_pos = output.find("GET /test").unwrap();
assert!(summary_pos < description_pos, "summary should come before description");
assert!(
description_pos < signature_pos,
"description should come before signature"
);
}
#[test]
fn test_response_handling_content_categories() {
struct Case {
category: ContentCategory,
expected_return_ty: &'static str,
expected_contains: Vec<&'static str>,
}
let cases = [
Case {
category: ContentCategory::Json,
expected_return_ty: "TestResponse",
expected_contains: vec!["json", "TestResponse"],
},
Case {
category: ContentCategory::Text,
expected_return_ty: "String",
expected_contains: vec!["text"],
},
Case {
category: ContentCategory::Binary,
expected_return_ty: "reqwest :: Response",
expected_contains: vec!["Ok (response)"],
},
Case {
category: ContentCategory::Xml,
expected_return_ty: "reqwest :: Response",
expected_contains: vec!["Ok (response)"],
},
Case {
category: ContentCategory::FormUrlEncoded,
expected_return_ty: "reqwest :: Response",
expected_contains: vec!["Ok (response)"],
},
Case {
category: ContentCategory::Multipart,
expected_return_ty: "reqwest :: Response",
expected_contains: vec!["Ok (response)"],
},
Case {
category: ContentCategory::EventStream,
expected_return_ty: "oas3_gen_support :: EventStream < TestResponse >",
expected_contains: vec!["EventStream :: from_response", "response"],
},
];
for case in cases {
let label = format!("category={:?}", case.category);
let content_type = match case.category {
ContentCategory::Json => "application/json",
ContentCategory::Text => "text/plain",
ContentCategory::Binary => "application/octet-stream",
ContentCategory::EventStream => "text/event-stream",
ContentCategory::Xml => "application/xml",
ContentCategory::FormUrlEncoded => "application/x-www-form-urlencoded",
ContentCategory::Multipart => "multipart/form-data",
};
let operation = TestOperation {
response_media_types: Some(vec![ResponseMediaType::new(content_type)]),
..Default::default()
}
.build();
let method = ClientMethodFragment::new(operation.clone(), Visibility::Public)
.generate()
.unwrap()
.to_string();
let expected_return = format!("-> anyhow :: Result < {} >", case.expected_return_ty);
assert!(
method.contains(&expected_return),
"{label}: return type mismatch. Got code: {method}"
);
for expected in case.expected_contains {
assert!(
method.contains(expected),
"{label}: expected response to contain '{expected}'"
);
}
}
}
#[test]
fn test_response_handling_with_response_enum() {
let operation = TestOperation {
response_enum: Some("TestResponseEnum".to_string()),
..Default::default()
}
.build();
let method = ClientMethodFragment::new(operation.clone(), Visibility::Public)
.generate()
.unwrap()
.to_string();
assert!(
method.contains("-> anyhow :: Result < TestResponseEnum >"),
"success type should contain TestResponseEnum"
);
assert!(
method.contains("parse_response"),
"parse_body should use parse_response"
);
}
#[test]
fn test_event_stream_response_handling() {
let operation = TestOperation {
response_media_types: Some(vec![ResponseMediaType::new("text/event-stream")]),
..Default::default()
}
.build();
let method = ClientMethodFragment::new(operation.clone(), Visibility::Public)
.generate()
.unwrap()
.to_string();
assert!(method.contains("EventStream"), "return type should contain EventStream");
assert!(
method.contains("TestResponse"),
"return type should contain the response type"
);
assert!(
method.contains("EventStream :: from_response"),
"parse_body should create EventStream from response"
);
}
#[test]
fn test_multipart_generation() {
let strict_body = OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.content_category(ContentCategory::Multipart)
.multipart_fields(vec![
MultipartFieldInfo::builder()
.name(FieldNameToken::new("file"))
.is_bytes(true)
.build(),
MultipartFieldInfo::builder()
.name(FieldNameToken::new("description"))
.build(),
])
.build();
let strict_code = MultipartFormFragment::new(strict_body.clone())
.into_token_stream()
.to_string();
assert!(
strict_code.contains("Part :: bytes"),
"strict: should use Part::bytes for binary"
);
assert!(
strict_code.contains("Part :: text"),
"strict: should use Part::text for text"
);
assert!(
strict_code.contains("form . part (\"file\""),
"strict: should have file part"
);
assert!(
strict_code.contains("form . part (\"description\""),
"strict: should have description part"
);
assert!(
!strict_code.contains("serde_json :: to_value"),
"strict: should NOT use fallback"
);
let fallback_body = OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.content_category(ContentCategory::Multipart)
.build();
let fallback_code = MultipartFormFragment::new(fallback_body.clone())
.into_token_stream()
.to_string();
assert!(
fallback_code.contains("serde_json :: to_value"),
"fallback: should use serde_json"
);
assert!(fallback_code.contains("form . text"), "fallback: should use form.text");
assert!(
!fallback_code.contains("Part :: bytes"),
"fallback: should NOT use Part::bytes"
);
}
#[test]
fn test_client_filters_webhook_operations() {
let http_operation = {
let method = Method::GET;
let path_template = "/pets";
let documentation = Documentation::documentation()
.summary("List all pets")
.method(&method)
.path(path_template)
.call();
OperationInfo::builder()
.stable_id("list_pets")
.operation_id("listPets")
.method(method)
.path(ParsedPath {
segments: vec![PathSegment::Literal("pets".to_string())],
query_string: None,
})
.kind(OperationKind::Http)
.request_type(StructToken::new("ListPetsRequest"))
.response_type("Vec<Pet>".to_string())
.response_media_types(vec![ResponseMediaType::new("application/json")])
.parameters(vec![
FieldDef::builder()
.name(FieldNameToken::new("x_custom_header"))
.rust_type(TypeRef::new("String"))
.parameter_location(ParameterLocation::Header)
.original_name("X-Custom-Header".to_string())
.build(),
])
.documentation(documentation)
.build()
};
let webhook_operation = {
let method = Method::POST;
let path_template = "webhooks/petAdded";
let documentation = Documentation::documentation()
.summary("Pet added webhook")
.method(&method)
.path(path_template)
.call();
OperationInfo::builder()
.stable_id("pet_added_hook")
.operation_id("petAddedHook")
.method(method)
.path(ParsedPath {
segments: vec![
PathSegment::Literal("webhooks".to_string()),
PathSegment::Literal("petAdded".to_string()),
],
query_string: None,
})
.kind(OperationKind::Webhook)
.request_type(StructToken::new("PetAddedHookRequest"))
.response_type("WebhookResponse".to_string())
.response_media_types(vec![ResponseMediaType::new("application/json")])
.parameters(vec![
FieldDef::builder()
.name(FieldNameToken::new("x_webhook_secret"))
.rust_type(TypeRef::new("String"))
.parameter_location(ParameterLocation::Header)
.original_name("X-Webhook-Secret".to_string())
.build(),
])
.documentation(documentation)
.build()
};
let operations = vec![http_operation, webhook_operation];
let metadata = ClientRootNode {
name: StructToken::new("PetStoreClient"),
title: "PetStore".to_string(),
base_url: "https://api.example.com".to_string(),
version: "1.0.0".to_string(),
description: None,
};
let generator = ClientFragment::new(&metadata, &operations, Visibility::Public);
let output = generator.to_token_stream().to_string();
assert!(
output.contains("list_pets"),
"HTTP operation method should be generated"
);
assert!(
!output.contains("pet_added_hook"),
"Webhook operation method should NOT be generated"
);
assert!(output.contains("headers"), "HTTP operation should use headers");
}
#[test]
fn test_path_segments_static_path() {
let parsed_path = ParsedPath {
segments: vec![PathSegment::Literal("pets".to_string())],
query_string: None,
};
let output = parsed_path
.segments
.iter()
.map(|s| s.to_token_stream().to_string())
.collect::<String>();
assert!(
output.contains("push") && output.contains("pets"),
"should push 'pets' segment: {output}"
);
assert_eq!(parsed_path.segments.len(), 1, "should have exactly one segment");
}
#[test]
fn test_path_segments_single_param() {
let parsed_path = ParsedPath {
segments: vec![
PathSegment::Literal("pets".to_string()),
PathSegment::Param(FieldNameToken::new("pet_id")),
],
query_string: None,
};
let output = parsed_path
.segments
.iter()
.map(|s| s.to_token_stream().to_string())
.collect::<String>();
assert!(output.contains("pets"), "should push 'pets' literal: {output}");
assert!(output.contains("pet_id"), "should reference path param: {output}");
assert_eq!(parsed_path.segments.len(), 2, "should have two segments");
}
#[test]
fn test_path_segments_multiple_params() {
let parsed_path = ParsedPath {
segments: vec![
PathSegment::Literal("users".to_string()),
PathSegment::Param(FieldNameToken::new("user_id")),
PathSegment::Literal("posts".to_string()),
PathSegment::Param(FieldNameToken::new("post_id")),
],
query_string: None,
};
let output = parsed_path
.segments
.iter()
.map(|s| s.to_token_stream().to_string())
.collect::<String>();
assert!(output.contains("users"), "should push 'users': {output}");
assert!(output.contains("posts"), "should push 'posts': {output}");
assert!(output.contains("user_id"), "should reference user_id: {output}");
assert!(output.contains("post_id"), "should reference post_id: {output}");
assert_eq!(parsed_path.segments.len(), 4, "should have four segments");
}
#[test]
fn test_path_segments_mixed_segment() {
let parsed_path = ParsedPath {
segments: vec![
PathSegment::Literal("api".to_string()),
PathSegment::Mixed {
format: "v{}.json".to_string(),
params: vec![FieldNameToken::new("version")],
},
],
query_string: None,
};
let output = parsed_path
.segments
.iter()
.map(|s| s.to_token_stream().to_string())
.collect::<String>();
assert!(output.contains("api"), "should push 'api': {output}");
assert!(
output.contains("format"),
"should use format! for mixed segment: {output}"
);
assert!(
output.contains("v{}.json"),
"should have correct format string: {output}"
);
assert_eq!(parsed_path.segments.len(), 2, "should have two segments");
}
#[test]
fn test_multipart_method_generation_with_path_params() {
let method = Method::POST;
let path_template = "/pets/{petId}/upload";
let documentation = Documentation::documentation()
.summary("Upload pet image")
.description("Updates the pet's image and name")
.method(&method)
.path(path_template)
.call();
let operation = OperationInfo::builder()
.stable_id("upload_pet_image")
.operation_id("uploadPetImage")
.method(method)
.path(ParsedPath {
segments: vec![
PathSegment::Literal("pets".to_string()),
PathSegment::Param(FieldNameToken::new("pet_id")),
PathSegment::Literal("upload".to_string()),
],
query_string: None,
})
.kind(OperationKind::Http)
.request_type(StructToken::new("UploadPetImageRequest"))
.response_type("Pet".to_string())
.response_media_types(vec![ResponseMediaType::new("application/json")])
.parameters(vec![
FieldDef::builder()
.name(FieldNameToken::new("pet_id"))
.rust_type(TypeRef::new("String"))
.parameter_location(ParameterLocation::Path)
.build(),
])
.body(
OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.content_category(ContentCategory::Multipart)
.multipart_fields(vec![
MultipartFieldInfo::builder()
.name(FieldNameToken::new("image"))
.is_bytes(true)
.build(),
MultipartFieldInfo::builder().name(FieldNameToken::new("name")).build(),
])
.build(),
)
.documentation(documentation)
.build();
let generated = ClientMethodFragment::new(operation.clone(), Visibility::Public)
.generate()
.unwrap()
.to_string();
assert!(
generated.contains("upload_pet_image"),
"method name should be upload_pet_image"
);
assert!(
generated.contains("UploadPetImageRequest"),
"should use UploadPetImageRequest type"
);
assert!(generated.contains("post (url)"), "should use POST method");
assert!(generated.contains("pet_id"), "should include path parameter pet_id");
assert!(
generated.contains("multipart :: Form :: new"),
"should create multipart form"
);
assert!(
generated.contains("Part :: bytes"),
"should use Part::bytes for image field"
);
assert!(
generated.contains("Part :: text"),
"should use Part::text for name field"
);
assert!(
generated.contains("req_builder . multipart (form)"),
"should attach multipart form to request"
);
assert!(
generated.contains("-> anyhow :: Result < Pet >"),
"should return Pet type"
);
}
#[test]
fn test_multipart_with_nullable_fields() {
let body = OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.content_category(ContentCategory::Multipart)
.multipart_fields(vec![
MultipartFieldInfo::builder()
.name(FieldNameToken::new("file"))
.is_bytes(true)
.build(),
MultipartFieldInfo::builder()
.name(FieldNameToken::new("description"))
.nullable(true)
.build(),
])
.build();
let code = MultipartFormFragment::new(body.clone()).into_token_stream().to_string();
assert!(
code.contains("if let Some (val)"),
"nullable field should use if let Some pattern"
);
assert!(
code.contains("form . part (\"file\""),
"non-nullable file field should be added unconditionally"
);
assert!(
code.contains("form . part (\"description\""),
"nullable description field should be added conditionally"
);
}
#[test]
fn test_multipart_with_json_serialization() {
let body = OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.content_category(ContentCategory::Multipart)
.multipart_fields(vec![
MultipartFieldInfo::builder()
.name(FieldNameToken::new("metadata"))
.requires_json(true)
.build(),
MultipartFieldInfo::builder()
.name(FieldNameToken::new("simple_text"))
.build(),
])
.build();
let code = MultipartFormFragment::new(body.clone()).into_token_stream().to_string();
assert!(
code.contains("serde_json :: to_string"),
"JSON-required field should use serde_json::to_string"
);
assert!(code.contains("form . part (\"metadata\""), "should have metadata part");
assert!(
code.contains("form . part (\"simple_text\""),
"should have simple_text part"
);
}
#[test]
fn test_multipart_fallback_uses_type_inference() {
let body = OperationBody::builder()
.field_name(FieldNameToken::new("body"))
.body_type(TypeRef::new("UploadRequest"))
.content_category(ContentCategory::Multipart)
.build();
let code = MultipartFormFragment::new(body.clone()).into_token_stream().to_string();
assert!(
code.contains("serde_json :: to_value (body)"),
"fallback should use type inference without explicit annotation: {code}"
);
assert!(
!code.contains("to_value :: <"),
"fallback should not include explicit type annotation: {code}"
);
}
#[test]
fn test_url_path_segments_encoding() {
let mut url = Url::parse("http://example.com").unwrap();
url
.path_segments_mut()
.expect("valid URL")
.clear()
.push("pets")
.push("cat/dog");
assert_eq!(url.path(), "/pets/cat%2Fdog", "slash should be encoded as %2F");
url
.path_segments_mut()
.expect("valid URL")
.clear()
.push("pets")
.push("hello world");
assert_eq!(url.path(), "/pets/hello%20world", "space should be encoded as %20");
url
.path_segments_mut()
.expect("valid URL")
.clear()
.push("pets")
.push("100%");
assert_eq!(url.path(), "/pets/100%25", "percent should be encoded as %25");
url
.path_segments_mut()
.expect("valid URL")
.clear()
.push("pets")
.push("a?b#c");
assert_eq!(url.path(), "/pets/a%3Fb%23c", "query/fragment chars should be encoded");
url
.path_segments_mut()
.expect("valid URL")
.clear()
.push("pets")
.push("cafe");
assert_eq!(url.path(), "/pets/cafe", "unicode should be percent-encoded");
}
#[test]
fn test_url_construction_with_embedded_query_string() {
use crate::generator::codegen::client::UrlConstructionFragment;
let path_with_query = ParsedPath {
segments: vec![
PathSegment::Literal("v1".to_string()),
PathSegment::Literal("messages".to_string()),
],
query_string: Some("beta=true".to_string()),
};
let fragment = UrlConstructionFragment::new(path_with_query);
let code = fragment.to_token_stream().to_string();
assert!(code.contains("push (\"v1\")"), "should push v1 segment: {code}");
assert!(
code.contains("push (\"messages\")"),
"should push messages segment: {code}"
);
assert!(
code.contains("set_query (Some (\"beta=true\"))"),
"should set query string: {code}"
);
}
#[test]
fn test_url_construction_without_query_string() {
use crate::generator::codegen::client::UrlConstructionFragment;
let path_without_query = ParsedPath {
segments: vec![
PathSegment::Literal("v1".to_string()),
PathSegment::Literal("messages".to_string()),
],
query_string: None,
};
let fragment = UrlConstructionFragment::new(path_without_query);
let code = fragment.to_token_stream().to_string();
assert!(code.contains("push (\"v1\")"), "should push v1 segment: {code}");
assert!(
code.contains("push (\"messages\")"),
"should push messages segment: {code}"
);
assert!(
!code.contains("set_query"),
"should NOT call set_query when there's no query string: {code}"
);
}
#[test]
fn test_demonstrates_query_string_fix() {
let mut url = Url::parse("https://api.example.com").unwrap();
url.path_segments_mut().unwrap().push("v1").push("messages");
url.set_query(Some("beta=true"));
assert_eq!(
url.as_str(),
"https://api.example.com/v1/messages?beta=true",
"correct URL with query param"
);
}