#[cfg(test)]
mod tests {
use beckon::beckon;
use reqwest::{header::HeaderMap, Url};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::time::Duration;
use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
beckon!(
HttpProvider,
{
{
path: "/users",
method: GET,
res: MyResponse,
},
{
path: "/users/{id}",
method: GET,
path_params: PathParams,
res: MyResponse,
},
{
path: "/search",
method: GET,
query_params: QueryParams,
res: MyResponse,
},
{
path: "/data",
method: GET,
headers: HeaderMap,
res: MyResponse,
},
{
method: GET,
res: MyResponse,
},
{
path: "/users",
method: POST,
req: MyRequest,
res: MyResponse,
},
}
);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct MyResponse {
value: String,
}
#[derive(Serialize, Deserialize)]
pub struct MyRequest {
data: String,
}
#[derive(Serialize, Deserialize)]
pub struct PathParams {
id: String,
}
#[derive(Serialize, Deserialize)]
pub struct QueryParams {
q: String,
}
fn create_success_response(value: &str) -> MyResponse {
MyResponse {
value: value.to_string(),
}
}
#[tokio::test]
async fn test_get_with_path() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("users");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_users().await?;
assert_eq!(result.value, "users");
Ok(())
}
#[tokio::test]
async fn test_get_with_path_params() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("user-123");
Mock::given(method("GET"))
.and(wiremock::matchers::path_regex(r"^/users/\w+$"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.get_users_by_id(&PathParams {
id: "123".to_string(),
})
.await?;
assert_eq!(result.value, "user-123");
Ok(())
}
#[tokio::test]
async fn test_get_with_query_params() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("search-result");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/search"))
.and(wiremock::matchers::query_param("q", "test"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.get_search(&QueryParams {
q: "test".to_string(),
})
.await?;
assert_eq!(result.value, "search-result");
Ok(())
}
#[tokio::test]
async fn test_get_with_headers() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("with-headers");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/data"))
.and(wiremock::matchers::header("x-api-key", "secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "secret".parse()?);
let result = provider.get_data(headers).await?;
assert_eq!(result.value, "with-headers");
Ok(())
}
#[tokio::test]
async fn test_get_without_path() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("no-path");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get().await?;
assert_eq!(result.value, "no-path");
Ok(())
}
#[tokio::test]
async fn test_post_with_body() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("created");
Mock::given(method("POST"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.post_users(&MyRequest {
data: "test".to_string(),
})
.await?;
assert_eq!(result.value, "created");
Ok(())
}
#[tokio::test]
async fn test_new_with_none_timeout() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(
ResponseTemplate::new(200).set_body_json(create_success_response("default")),
)
.mount(&mock_server)
.await;
let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, None);
let result = provider.get_users().await?;
assert_eq!(result.value, "default");
Ok(())
}
#[tokio::test]
async fn test_trait_mock_provider() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
SimpleProvider,
{
{
path: "/items",
method: GET,
res: MyResponse,
},
{
path: "/items/{id}",
method: GET,
path_params: PathParams,
res: MyResponse,
},
}
);
pub struct MockProvider;
impl SimpleProviderTrait for MockProvider {
async fn get_items(&self) -> Result<MyResponse, SimpleProviderError> {
Ok(create_success_response("mock-items"))
}
async fn get_items_by_id(
&self,
_path_params: &PathParams,
) -> Result<MyResponse, SimpleProviderError> {
Ok(create_success_response("mock-item-123"))
}
}
let mock = MockProvider;
assert_eq!(mock.get_items().await?.value, "mock-items");
assert_eq!(
mock.get_items_by_id(&PathParams {
id: "123".to_string()
})
.await?
.value,
"mock-item-123"
);
Ok(())
}
#[tokio::test]
async fn test_patch_with_body() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
PatchProvider,
{
{
path: "/users/{id}",
method: PATCH,
path_params: PathParams,
req: MyRequest,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("patched");
Mock::given(method("PATCH"))
.and(wiremock::matchers::path_regex(r"^/users/\w+$"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
PatchProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.patch_users_by_id(
&PathParams {
id: "42".to_string(),
},
&MyRequest {
data: "partial".to_string(),
},
)
.await?;
assert_eq!(result.value, "patched");
Ok(())
}
#[tokio::test]
async fn test_http_error_response() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_users().await;
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert!(
err_msg.contains("404"),
"Expected 404 in error: {}",
err_msg
);
Ok(())
}
#[tokio::test]
async fn test_http_error_captures_body() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(
ResponseTemplate::new(422)
.set_body_string(r#"{"error":"email already taken","code":"user_exists"}"#),
)
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let err = provider.get_users().await.unwrap_err();
match err {
HttpProviderError::Http { status, body, .. } => {
assert_eq!(status, 422);
assert!(body.contains("user_exists"), "body was: {body}");
}
other => panic!("expected Http error, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn test_http_error_empty_body() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let err = provider.get_users().await.unwrap_err();
match &err {
HttpProviderError::Http { status, body, .. } => {
assert_eq!(*status, 404);
assert_eq!(body, "");
}
other => panic!("expected Http error, got {other:?}"),
}
assert_eq!(format!("{err}"), "HTTP 404 Not Found");
Ok(())
}
#[tokio::test]
async fn test_http_error_plain_text_body() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(500).set_body_string("upstream exploded"))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let err = provider.get_users().await.unwrap_err();
match &err {
HttpProviderError::Http { status, body, .. } => {
assert_eq!(*status, 500);
assert_eq!(body, "upstream exploded");
}
other => panic!("expected Http error, got {other:?}"),
}
assert_eq!(
format!("{err}"),
"HTTP 500 Internal Server Error: upstream exploded"
);
Ok(())
}
#[tokio::test]
async fn test_http_error_body_on_retry_path() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
RetryBodyProvider,
retries: 2,
{
{
path: "/always-500",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/always-500"))
.respond_with(
ResponseTemplate::new(503).set_body_string(r#"{"error":"service unavailable"}"#),
)
.mount(&mock_server)
.await;
let provider =
RetryBodyProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let err = provider.get_always_500().await.unwrap_err();
match err {
RetryBodyProviderError::Http { status, body, .. } => {
assert_eq!(status, 503);
assert!(body.contains("service unavailable"), "body was: {body}");
}
other => panic!("expected Http error, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn test_multiple_path_params() -> Result<(), Box<dyn std::error::Error>> {
#[derive(Serialize, Deserialize)]
pub struct MultiPathParams {
user_id: String,
post_id: String,
}
beckon!(
MultiParamProvider,
{
{
path: "/users/{user_id}/posts/{post_id}",
method: GET,
path_params: MultiPathParams,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("nested");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users/1/posts/2"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
MultiParamProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.get_users_posts_by_user_id_and_post_id(&MultiPathParams {
user_id: "1".to_string(),
post_id: "2".to_string(),
})
.await?;
assert_eq!(result.value, "nested");
Ok(())
}
#[tokio::test]
async fn test_retry_on_5xx_succeeds_after_transient_failure(
) -> Result<(), Box<dyn std::error::Error>> {
beckon!(
RetryProvider,
retries: 3,
{
{
path: "/flaky",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("recovered");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/flaky"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/flaky"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(2)
.mount(&mock_server)
.await;
let provider =
RetryProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_flaky().await?;
assert_eq!(result.value, "recovered");
Ok(())
}
#[tokio::test]
async fn test_no_retry_on_4xx() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
Retry4xxProvider,
retries: 3,
{
{
path: "/not-found",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/not-found"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&mock_server)
.await;
let provider =
Retry4xxProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_not_found().await;
assert!(result.is_err());
Ok(())
}
#[tokio::test]
async fn test_retry_endpoint_override() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
RetryOverrideProvider,
retries: 3,
{
{
path: "/no-retry",
method: GET,
res: MyResponse,
retries: 0,
},
}
);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/no-retry"))
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&mock_server)
.await;
let provider =
RetryOverrideProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_no_retry().await;
assert!(result.is_err());
Ok(())
}
#[tokio::test]
async fn test_backward_compat_no_retries_field() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("compat");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider.get_users().await?;
assert_eq!(result.value, "compat");
Ok(())
}
#[tokio::test]
async fn test_optional_response() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
NoResponseProvider,
{
{
path: "/delete",
method: DELETE,
},
{
path: "/update",
method: PUT,
req: MyRequest,
},
}
);
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(wiremock::matchers::path("/delete"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(wiremock::matchers::path("/update"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let provider =
NoResponseProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result: Result<(), _> = provider.delete_delete().await;
assert!(result.is_ok());
let result: Result<(), _> = provider
.put_update(&MyRequest {
data: "test".to_string(),
})
.await;
assert!(result.is_ok());
Ok(())
}
#[tokio::test]
async fn test_bearer_auth() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
BearerApi,
auth: Bearer,
{
{
path: "/protected",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("bearer-ok");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/protected"))
.and(wiremock::matchers::header(
"Authorization",
"Bearer my-token",
))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider = BearerApi::new(
Url::from_str(&mock_server.uri())?,
"my-token",
Duration::from_secs(5),
);
let result = provider.get_protected().await?;
assert_eq!(result.value, "bearer-ok");
Ok(())
}
#[tokio::test]
async fn test_basic_auth() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
BasicApi,
auth: Basic,
{
{
path: "/secure",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("basic-ok");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/secure"))
.and(wiremock::matchers::header_exists("Authorization"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider = BasicApi::new(
Url::from_str(&mock_server.uri())?,
"user",
"pass",
Duration::from_secs(5),
);
let result = provider.get_secure().await?;
assert_eq!(result.value, "basic-ok");
Ok(())
}
#[tokio::test]
async fn test_api_key_auth() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
ApiKeyApi,
auth: ApiKey("X-Api-Key"),
{
{
path: "/data",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("apikey-ok");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/data"))
.and(wiremock::matchers::header("X-Api-Key", "sk_live_123"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider = ApiKeyApi::new(
Url::from_str(&mock_server.uri())?,
"sk_live_123",
Duration::from_secs(5),
);
let result = provider.get_data().await?;
assert_eq!(result.value, "apikey-ok");
Ok(())
}
#[tokio::test]
async fn test_auth_with_retries() -> Result<(), Box<dyn std::error::Error>> {
beckon!(
AuthRetryApi,
auth: Bearer,
retries: 2,
{
{
path: "/flaky-auth",
method: GET,
res: MyResponse,
},
}
);
let mock_server = MockServer::start().await;
let response = create_success_response("auth-retry-ok");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/flaky-auth"))
.and(wiremock::matchers::header("Authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/flaky-auth"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let provider = AuthRetryApi::new(
Url::from_str(&mock_server.uri())?,
"tok",
Duration::from_secs(5),
);
let result = provider.get_flaky_auth().await?;
assert_eq!(result.value, "auth-retry-ok");
Ok(())
}
#[tokio::test]
async fn test_path_params_are_percent_encoded() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("encoded");
Mock::given(method("GET"))
.and(wiremock::matchers::path_regex(r"^/users/[^/]+$"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let result = provider
.get_users_by_id(&PathParams {
id: "1/2".to_string(),
})
.await?;
assert_eq!(result.value, "encoded");
Ok(())
}
#[tokio::test]
async fn test_with_client_constructor() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("shared-client");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let provider = HttpProvider::with_client(
Url::from_str(&mock_server.uri())?,
client,
Duration::from_secs(5),
);
let result = provider.get_users().await?;
assert_eq!(result.value, "shared-client");
Ok(())
}
#[tokio::test]
async fn test_with_client_uses_caller_config() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
let response = create_success_response("configured");
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.and(wiremock::matchers::header("x-app", "beckon"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&mock_server)
.await;
let mut default_headers = HeaderMap::new();
default_headers.insert("x-app", "beckon".parse()?);
let http = reqwest::Client::builder()
.default_headers(default_headers)
.build()?;
let provider = HttpProvider::with_client(
Url::from_str(&mock_server.uri())?,
http,
Duration::from_secs(5),
);
let result = provider.get_users().await?;
assert_eq!(result.value, "configured");
Ok(())
}
#[tokio::test]
async fn test_cloned_client_still_works() -> Result<(), Box<dyn std::error::Error>> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wiremock::matchers::path("/users"))
.respond_with(
ResponseTemplate::new(200).set_body_json(create_success_response("cloned")),
)
.mount(&mock_server)
.await;
let provider =
HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5));
let cloned = provider.clone();
let from_clone = cloned.get_users().await?;
let from_original = provider.get_users().await?;
assert_eq!(from_clone.value, "cloned");
assert_eq!(from_original.value, "cloned");
Ok(())
}
}