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> {
|
async fn main() -> Result<(), plunk_rs::Error> {
|
||||||
let client = Client::new("sk_your_plunk_api_key")?;
|
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" }))?;
|
.with_data(json!({ "first_name": "Ada" }))?;
|
||||||
|
|
||||||
client.send(&email).await?;
|
client.send(&email).await?;
|
||||||
|
|||||||
+21
-7
@@ -1,5 +1,8 @@
|
|||||||
use crate::error::{Error, Result};
|
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::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -45,6 +48,8 @@ impl Email {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a template email for a single recipient.
|
/// 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>
|
pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
|
||||||
where
|
where
|
||||||
A: TryInto<EmailAddress>,
|
A: TryInto<EmailAddress>,
|
||||||
@@ -54,6 +59,8 @@ impl Email {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a template email for multiple recipients.
|
/// 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>
|
pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = EmailAddress>,
|
I: IntoIterator<Item = EmailAddress>,
|
||||||
@@ -61,7 +68,7 @@ impl Email {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
recipients: Recipients::many(to)?,
|
recipients: Recipients::many(to)?,
|
||||||
content: EmailContent::Template {
|
content: EmailContent::Template {
|
||||||
template_id: normalize_non_empty(template_id.into(), Error::InvalidTemplateId)?,
|
template_id: normalize_template_id(template_id.into())?,
|
||||||
},
|
},
|
||||||
from: None,
|
from: None,
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
@@ -282,7 +289,7 @@ mod tests {
|
|||||||
EmailAddress::new("one@example.com").unwrap(),
|
EmailAddress::new("one@example.com").unwrap(),
|
||||||
EmailAddress::new("two@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();
|
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
|
||||||
|
|
||||||
@@ -290,14 +297,14 @@ mod tests {
|
|||||||
json,
|
json,
|
||||||
json!({
|
json!({
|
||||||
"to": ["one@example.com", "two@example.com"],
|
"to": ["one@example.com", "two@example.com"],
|
||||||
"template": "tpl_123"
|
"template": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn template_data_must_be_an_object() {
|
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()
|
.unwrap()
|
||||||
.with_data(vec!["not", "an", "object"])
|
.with_data(vec!["not", "an", "object"])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
@@ -307,7 +314,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typed_template_data_serializes_from_struct() {
|
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()
|
.unwrap()
|
||||||
.with_data(WelcomeData { first_name: "Ada" })
|
.with_data(WelcomeData { first_name: "Ada" })
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -318,7 +325,7 @@ mod tests {
|
|||||||
json,
|
json,
|
||||||
json!({
|
json!({
|
||||||
"to": "user@example.com",
|
"to": "user@example.com",
|
||||||
"template": "tpl_123",
|
"template": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"data": {
|
"data": {
|
||||||
"first_name": "Ada"
|
"first_name": "Ada"
|
||||||
}
|
}
|
||||||
@@ -347,6 +354,13 @@ mod tests {
|
|||||||
assert!(matches!(error, Error::InvalidTemplateId));
|
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]
|
#[test]
|
||||||
fn email_accessors_are_consistent() {
|
fn email_accessors_are_consistent() {
|
||||||
let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
|
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>;
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct ApiError {
|
pub struct ApiError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
code: Option<i64>,
|
error_code: Option<String>,
|
||||||
kind: Option<String>,
|
|
||||||
message: String,
|
message: String,
|
||||||
|
field_errors: Vec<ApiFieldError>,
|
||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,16 +39,25 @@ impl ApiError {
|
|||||||
match serde_json::from_str::<crate::wire::WireApiError>(&body) {
|
match serde_json::from_str::<crate::wire::WireApiError>(&body) {
|
||||||
Ok(payload) => Self {
|
Ok(payload) => Self {
|
||||||
status,
|
status,
|
||||||
code: Some(payload.code),
|
error_code: Some(payload.error.code),
|
||||||
kind: Some(payload.error),
|
message: payload.error.message,
|
||||||
message: payload.message,
|
field_errors: payload
|
||||||
|
.error
|
||||||
|
.errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|error| ApiFieldError {
|
||||||
|
field: error.field,
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
body,
|
body,
|
||||||
},
|
},
|
||||||
Err(_) => Self {
|
Err(_) => Self {
|
||||||
status,
|
status,
|
||||||
code: None,
|
error_code: None,
|
||||||
kind: None,
|
|
||||||
message: "unparseable API error response".to_string(),
|
message: "unparseable API error response".to_string(),
|
||||||
|
field_errors: Vec::new(),
|
||||||
body,
|
body,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -37,18 +67,18 @@ impl ApiError {
|
|||||||
self.status
|
self.status
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn code(&self) -> Option<i64> {
|
pub fn error_code(&self) -> Option<&str> {
|
||||||
self.code
|
self.error_code.as_deref()
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kind(&self) -> Option<&str> {
|
|
||||||
self.kind.as_deref()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn message(&self) -> &str {
|
pub fn message(&self) -> &str {
|
||||||
&self.message
|
&self.message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn field_errors(&self) -> &[ApiFieldError] {
|
||||||
|
&self.field_errors
|
||||||
|
}
|
||||||
|
|
||||||
pub fn body(&self) -> &str {
|
pub fn body(&self) -> &str {
|
||||||
&self.body
|
&self.body
|
||||||
}
|
}
|
||||||
@@ -56,8 +86,8 @@ impl ApiError {
|
|||||||
|
|
||||||
impl fmt::Display for ApiError {
|
impl fmt::Display for ApiError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self.kind() {
|
match self.error_code() {
|
||||||
Some(kind) => write!(f, "{} {}: {}", self.status, kind, self.message),
|
Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
|
||||||
None => write!(f, "{}: {}", self.status, self.message),
|
None => write!(f, "{}: {}", self.status, self.message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +107,8 @@ pub enum Error {
|
|||||||
InvalidBody,
|
InvalidBody,
|
||||||
#[error("template id cannot be empty")]
|
#[error("template id cannot be empty")]
|
||||||
InvalidTemplateId,
|
InvalidTemplateId,
|
||||||
|
#[error("template id must be a UUID")]
|
||||||
|
InvalidTemplateIdFormat,
|
||||||
#[error("display name cannot be empty")]
|
#[error("display name cannot be empty")]
|
||||||
InvalidDisplayName,
|
InvalidDisplayName,
|
||||||
#[error("header name cannot be empty")]
|
#[error("header name cannot be empty")]
|
||||||
@@ -107,7 +139,23 @@ mod tests {
|
|||||||
ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
|
ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
|
||||||
|
|
||||||
assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
|
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>");
|
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 client::{Client, ClientBuilder};
|
||||||
pub use email::{Email, EmailAddress, Recipients};
|
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};
|
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())
|
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> {
|
pub(crate) fn normalize_header_key(value: String) -> Result<String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -64,3 +97,20 @@ pub(crate) fn normalize_header_key(value: String) -> Result<String> {
|
|||||||
}
|
}
|
||||||
Ok(trimmed.to_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)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub(crate) struct WireApiError {
|
pub(crate) struct WireApiError {
|
||||||
pub(crate) code: i64,
|
pub(crate) error: WireApiErrorDetails,
|
||||||
pub(crate) error: String,
|
}
|
||||||
pub(crate) message: String,
|
|
||||||
|
#[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