fix: validate template UUIDs and parse Plunk validation errors
This commit is contained in:
@@ -74,7 +74,7 @@ use serde_json::json;
|
||||
async fn main() -> Result<(), plunk_rs::Error> {
|
||||
let client = Client::new("sk_your_plunk_api_key")?;
|
||||
|
||||
let email = Email::template("user@example.com", "tpl_xxxxxxxx")?
|
||||
let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")?
|
||||
.with_data(json!({ "first_name": "Ada" }))?;
|
||||
|
||||
client.send(&email).await?;
|
||||
|
||||
+21
-7
@@ -1,5 +1,8 @@
|
||||
use crate::error::{Error, Result};
|
||||
use crate::util::{normalize_email, normalize_header_key, normalize_non_empty, serialize_data_map};
|
||||
use crate::util::{
|
||||
normalize_email, normalize_header_key, normalize_non_empty, normalize_template_id,
|
||||
serialize_data_map,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -45,6 +48,8 @@ impl Email {
|
||||
}
|
||||
|
||||
/// Creates a template email for a single recipient.
|
||||
///
|
||||
/// `template_id` must be the Plunk template UUID, not the template name.
|
||||
pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
|
||||
where
|
||||
A: TryInto<EmailAddress>,
|
||||
@@ -54,6 +59,8 @@ impl Email {
|
||||
}
|
||||
|
||||
/// Creates a template email for multiple recipients.
|
||||
///
|
||||
/// `template_id` must be the Plunk template UUID, not the template name.
|
||||
pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
|
||||
where
|
||||
I: IntoIterator<Item = EmailAddress>,
|
||||
@@ -61,7 +68,7 @@ impl Email {
|
||||
Ok(Self {
|
||||
recipients: Recipients::many(to)?,
|
||||
content: EmailContent::Template {
|
||||
template_id: normalize_non_empty(template_id.into(), Error::InvalidTemplateId)?,
|
||||
template_id: normalize_template_id(template_id.into())?,
|
||||
},
|
||||
from: None,
|
||||
reply_to: None,
|
||||
@@ -282,7 +289,7 @@ mod tests {
|
||||
EmailAddress::new("one@example.com").unwrap(),
|
||||
EmailAddress::new("two@example.com").unwrap(),
|
||||
];
|
||||
let email = Email::template_many(recipients, "tpl_123").unwrap();
|
||||
let email = Email::template_many(recipients, "550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
|
||||
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
|
||||
|
||||
@@ -290,14 +297,14 @@ mod tests {
|
||||
json,
|
||||
json!({
|
||||
"to": ["one@example.com", "two@example.com"],
|
||||
"template": "tpl_123"
|
||||
"template": "550e8400-e29b-41d4-a716-446655440000"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_data_must_be_an_object() {
|
||||
let error = Email::template("user@example.com", "tpl_123")
|
||||
let error = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
|
||||
.unwrap()
|
||||
.with_data(vec!["not", "an", "object"])
|
||||
.unwrap_err();
|
||||
@@ -307,7 +314,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn typed_template_data_serializes_from_struct() {
|
||||
let email = Email::template("user@example.com", "tpl_123")
|
||||
let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
|
||||
.unwrap()
|
||||
.with_data(WelcomeData { first_name: "Ada" })
|
||||
.unwrap();
|
||||
@@ -318,7 +325,7 @@ mod tests {
|
||||
json,
|
||||
json!({
|
||||
"to": "user@example.com",
|
||||
"template": "tpl_123",
|
||||
"template": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"data": {
|
||||
"first_name": "Ada"
|
||||
}
|
||||
@@ -347,6 +354,13 @@ mod tests {
|
||||
assert!(matches!(error, Error::InvalidTemplateId));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_uuid_template_id_at_construction_time() {
|
||||
let error = Email::template("user@example.com", "free-trial").unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::InvalidTemplateIdFormat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_accessors_are_consistent() {
|
||||
let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
|
||||
|
||||
+64
-16
@@ -4,12 +4,33 @@ use thiserror::Error as ThisError;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ApiFieldError {
|
||||
field: String,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiFieldError {
|
||||
pub fn field(&self) -> &str {
|
||||
&self.field
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn error_code(&self) -> Option<&str> {
|
||||
self.code.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
code: Option<i64>,
|
||||
kind: Option<String>,
|
||||
error_code: Option<String>,
|
||||
message: String,
|
||||
field_errors: Vec<ApiFieldError>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
@@ -18,16 +39,25 @@ impl ApiError {
|
||||
match serde_json::from_str::<crate::wire::WireApiError>(&body) {
|
||||
Ok(payload) => Self {
|
||||
status,
|
||||
code: Some(payload.code),
|
||||
kind: Some(payload.error),
|
||||
message: payload.message,
|
||||
error_code: Some(payload.error.code),
|
||||
message: payload.error.message,
|
||||
field_errors: payload
|
||||
.error
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|error| ApiFieldError {
|
||||
field: error.field,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
.collect(),
|
||||
body,
|
||||
},
|
||||
Err(_) => Self {
|
||||
status,
|
||||
code: None,
|
||||
kind: None,
|
||||
error_code: None,
|
||||
message: "unparseable API error response".to_string(),
|
||||
field_errors: Vec::new(),
|
||||
body,
|
||||
},
|
||||
}
|
||||
@@ -37,18 +67,18 @@ impl ApiError {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn code(&self) -> Option<i64> {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
pub fn error_code(&self) -> Option<&str> {
|
||||
self.error_code.as_deref()
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn field_errors(&self) -> &[ApiFieldError] {
|
||||
&self.field_errors
|
||||
}
|
||||
|
||||
pub fn body(&self) -> &str {
|
||||
&self.body
|
||||
}
|
||||
@@ -56,8 +86,8 @@ impl ApiError {
|
||||
|
||||
impl fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.kind() {
|
||||
Some(kind) => write!(f, "{} {}: {}", self.status, kind, self.message),
|
||||
match self.error_code() {
|
||||
Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
|
||||
None => write!(f, "{}: {}", self.status, self.message),
|
||||
}
|
||||
}
|
||||
@@ -77,6 +107,8 @@ pub enum Error {
|
||||
InvalidBody,
|
||||
#[error("template id cannot be empty")]
|
||||
InvalidTemplateId,
|
||||
#[error("template id must be a UUID")]
|
||||
InvalidTemplateIdFormat,
|
||||
#[error("display name cannot be empty")]
|
||||
InvalidDisplayName,
|
||||
#[error("header name cannot be empty")]
|
||||
@@ -107,7 +139,23 @@ mod tests {
|
||||
ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
|
||||
|
||||
assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(error.code(), None);
|
||||
assert_eq!(error.error_code(), None);
|
||||
assert!(error.field_errors().is_empty());
|
||||
assert_eq!(error.body(), "<html>bad gateway</html>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_parses_nested_plunk_error_shape() {
|
||||
let body = r#"{"success":false,"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","statusCode":422,"requestId":"babb9a40-0826-4246-998d-35f6b3265612","errors":[{"field":"template","message":"Invalid uuid","code":"invalid_string"}],"suggestion":"Please check the API documentation for the correct request format."},"timestamp":"2026-04-30T02:47:06.773Z"}"#;
|
||||
|
||||
let error = ApiError::from_response(StatusCode::UNPROCESSABLE_ENTITY, body.into());
|
||||
|
||||
assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(error.error_code(), Some("VALIDATION_ERROR"));
|
||||
assert_eq!(error.message(), "Request validation failed");
|
||||
assert_eq!(error.field_errors().len(), 1);
|
||||
assert_eq!(error.field_errors()[0].field(), "template");
|
||||
assert_eq!(error.field_errors()[0].message(), "Invalid uuid");
|
||||
assert_eq!(error.field_errors()[0].error_code(), Some("invalid_string"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,5 +7,5 @@ mod wire;
|
||||
|
||||
pub use client::{Client, ClientBuilder};
|
||||
pub use email::{Email, EmailAddress, Recipients};
|
||||
pub use error::{ApiError, Error, Result};
|
||||
pub use error::{ApiError, ApiFieldError, Error, Result};
|
||||
pub use response::{Contact, Delivery, SendResponse};
|
||||
|
||||
+50
@@ -54,6 +54,39 @@ pub(crate) fn normalize_non_empty(value: String, error: Error) -> Result<String>
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_template_id(value: String) -> Result<String> {
|
||||
let trimmed = normalize_non_empty(value, Error::InvalidTemplateId)?;
|
||||
if is_uuid_like(&trimmed) {
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
Err(Error::InvalidTemplateIdFormat)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_uuid_like(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() != 36 {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (idx, byte) in bytes.iter().enumerate() {
|
||||
match idx {
|
||||
8 | 13 | 18 | 23 => {
|
||||
if *byte != b'-' {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !byte.is_ascii_hexdigit() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_header_key(value: String) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -64,3 +97,20 @@ pub(crate) fn normalize_header_key(value: String) -> Result<String> {
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_template_id_accepts_uuid() {
|
||||
let id = normalize_template_id("550e8400-e29b-41d4-a716-446655440000".to_string()).unwrap();
|
||||
assert_eq!(id, "550e8400-e29b-41d4-a716-446655440000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_template_id_rejects_name() {
|
||||
let error = normalize_template_id("free-trial".to_string()).unwrap_err();
|
||||
assert!(matches!(error, Error::InvalidTemplateIdFormat));
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -105,7 +105,21 @@ pub(crate) struct WireContact {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct WireApiError {
|
||||
pub(crate) code: i64,
|
||||
pub(crate) error: String,
|
||||
pub(crate) message: String,
|
||||
pub(crate) error: WireApiErrorDetails,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct WireApiErrorDetails {
|
||||
pub(crate) code: String,
|
||||
pub(crate) message: String,
|
||||
#[serde(default)]
|
||||
pub(crate) errors: Vec<WireApiFieldError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct WireApiFieldError {
|
||||
pub(crate) field: String,
|
||||
pub(crate) message: String,
|
||||
#[serde(default)]
|
||||
pub(crate) code: Option<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user