use crate::application::service::sms_ports::{
SmsApiPort, SmsSendFailure, SmsSendOutcome, SmsSendRequest,
};
#[derive(serde::Deserialize)]
struct ProviderResponse {
#[serde(default)]
state: Option<String>,
#[serde(default, alias = "error_code")]
code: Option<String>,
#[serde(default)]
message: Option<String>,
}
pub struct HttpSmsApi {
endpoint: String,
token: String,
client: reqwest::Client,
}
impl HttpSmsApi {
pub fn new(endpoint: String, token: String) -> Self {
Self {
endpoint,
token,
client: reqwest::Client::new(),
}
}
}
fn map_state(state: &str) -> Option<SmsSendOutcome> {
match state {
"processing" => Some(SmsSendOutcome::Processing),
"success" | "sent" => Some(SmsSendOutcome::Accepted),
"delivered" => Some(SmsSendOutcome::Delivered),
_ => None,
}
}
pub fn map_error_code(code: &str) -> &'static str {
match code {
"insufficient_credit" => "sms_credit",
"wrong_number_format" => "sms_number_format",
"country_not_supported" => "sms_country_not_supported",
"unregistered" => "sms_acc",
_ => "sms_server",
}
}
#[async_trait::async_trait]
impl SmsApiPort for HttpSmsApi {
async fn send(&self, req: &SmsSendRequest) -> Result<SmsSendOutcome, SmsSendFailure> {
let body = serde_json::json!({
"content": req.body,
"numbers": [{ "uuid": req.uuid, "number": req.number }],
});
let resp = self
.client
.post(&self.endpoint)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| SmsSendFailure {
failure_type: "sms_server".into(),
message: format!("provider request failed: {e}"),
iap_status_code: None,
})?;
let status = resp.status();
let payload: ProviderResponse = resp.json().await.map_err(|e| SmsSendFailure {
failure_type: "sms_server".into(),
message: format!("provider returned a non-JSON body (HTTP {status}): {e}"),
iap_status_code: None,
})?;
if let Some(state) = payload.state.as_deref().and_then(map_state) {
return Ok(state);
}
Err(SmsSendFailure {
failure_type: map_error_code(payload.code.as_deref().unwrap_or("")).into(),
message: payload.message.unwrap_or_else(|| {
format!("provider rejected the send (HTTP {status}, no detail)")
}),
iap_status_code: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iap_states_map_onto_port_outcomes() {
assert_eq!(map_state("processing"), Some(SmsSendOutcome::Processing));
assert_eq!(map_state("success"), Some(SmsSendOutcome::Accepted));
assert_eq!(map_state("sent"), Some(SmsSendOutcome::Accepted));
assert_eq!(map_state("delivered"), Some(SmsSendOutcome::Delivered));
assert_eq!(map_state("mystery"), None);
}
#[test]
fn iap_error_codes_map_onto_failure_vocabulary() {
assert_eq!(map_error_code("insufficient_credit"), "sms_credit");
assert_eq!(map_error_code("wrong_number_format"), "sms_number_format");
assert_eq!(map_error_code("country_not_supported"), "sms_country_not_supported");
assert_eq!(map_error_code("unregistered"), "sms_acc");
assert_eq!(map_error_code("server_error"), "sms_server");
assert_eq!(map_error_code("never-seen-before"), "sms_server");
}
}