1use chrono::{DateTime, FixedOffset};
28use serde::{Deserialize, Serialize};
29use std::fmt;
30use std::io::Read;
31
32use crate::constants::{PaymentMethod, TransactionStatus};
33use crate::timefmt;
34
35pub const DEFAULT_MAX_BODY_SIZE: usize = 1 << 20;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum WebhookError {
41 NilReader,
47 EmptyBody,
49 BodyTooLarge {
51 limit: usize,
53 },
54 ReadBody(String),
58 DecodeBody(String),
61 InvalidOrderId,
63 InvalidAmount,
65}
66
67impl fmt::Display for WebhookError {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::NilReader => f.write_str("webhook: nil reader"),
71 Self::EmptyBody => f.write_str("webhook: empty body"),
72 Self::BodyTooLarge { limit } => {
73 write!(f, "webhook: body too large: exceeds {limit} bytes")
74 }
75 Self::ReadBody(message) => write!(f, "webhook: read body failed: {message}"),
76 Self::DecodeBody(message) => write!(f, "webhook: decode body failed: {message}"),
77 Self::InvalidOrderId => f.write_str("webhook: invalid order id"),
78 Self::InvalidAmount => f.write_str("webhook: invalid amount"),
79 }
80 }
81}
82
83impl std::error::Error for WebhookError {}
84
85#[derive(Debug, Clone)]
87pub struct Parser {
88 max_body_size: usize,
89}
90
91impl Default for Parser {
92 fn default() -> Self {
93 Self {
94 max_body_size: DEFAULT_MAX_BODY_SIZE,
95 }
96 }
97}
98
99impl Parser {
100 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn with_max_body_size(mut self, max_body_size: usize) -> Self {
108 if max_body_size > 0 {
109 self.max_body_size = max_body_size;
110 }
111 self
112 }
113
114 pub fn parse_reader<R>(&self, mut reader: R) -> Result<Event, WebhookError>
121 where
122 R: Read,
123 {
124 let mut limited = (&mut reader).take((self.max_body_size + 1) as u64);
125 let mut data = Vec::new();
126 limited
127 .read_to_end(&mut data)
128 .map_err(|err| WebhookError::ReadBody(err.to_string()))?;
129
130 if data.len() > self.max_body_size {
131 return Err(WebhookError::BodyTooLarge {
132 limit: self.max_body_size,
133 });
134 }
135 if data.is_empty() {
136 return Err(WebhookError::EmptyBody);
137 }
138
139 self.parse_bytes(&data)
140 }
141
142 pub fn parse_bytes(&self, data: &[u8]) -> Result<Event, WebhookError> {
145 if data.is_empty() {
146 return Err(WebhookError::EmptyBody);
147 }
148
149 serde_json::from_slice(data).map_err(|err| WebhookError::DecodeBody(err.to_string()))
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Event {
159 pub amount: i64,
161 pub order_id: String,
163 pub project: String,
165 pub status: TransactionStatus,
167 pub payment_method: PaymentMethod,
169 pub completed_at: String,
171 #[serde(default)]
178 pub is_sandbox: bool,
179}
180
181impl Event {
182 pub fn parse_time(&self) -> Result<DateTime<FixedOffset>, chrono::ParseError> {
184 timefmt::parse_rfc3339(&self.completed_at)
185 }
186
187 pub fn validate(&self) -> Result<(), WebhookError> {
193 if self.order_id.is_empty() {
194 return Err(WebhookError::InvalidOrderId);
195 }
196 if self.amount <= 0 {
197 return Err(WebhookError::InvalidAmount);
198 }
199 Ok(())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::io::{self, Read};
207
208 const VALID_PAYLOAD: &[u8] = br#"{"amount":22000,"order_id":"INV1","project":"p","status":"completed","payment_method":"qris","completed_at":"2024-09-10T08:07:02.819+07:00","is_sandbox":false}"#;
209
210 const DOC_PAYLOAD: &[u8] = br#"{"amount":22000,"order_id":"240910HDE7C9","project":"depodomain","status":"completed","payment_method":"qris","completed_at":"2024-09-10T08:07:02.819+07:00"}"#;
213
214 fn sample_event() -> Event {
215 Event {
216 amount: 22_000,
217 order_id: "INV1".into(),
218 project: "p".into(),
219 status: TransactionStatus::Completed,
220 payment_method: PaymentMethod::Qris,
221 completed_at: "2024-09-10T08:07:02.819+07:00".into(),
222 is_sandbox: false,
223 }
224 }
225
226 #[test]
227 fn webhook_error_display_covers_every_variant() {
228 assert_eq!(WebhookError::NilReader.to_string(), "webhook: nil reader");
229 assert_eq!(WebhookError::EmptyBody.to_string(), "webhook: empty body");
230 assert_eq!(
231 WebhookError::BodyTooLarge { limit: 1024 }.to_string(),
232 "webhook: body too large: exceeds 1024 bytes"
233 );
234 assert_eq!(
235 WebhookError::ReadBody("io broke".into()).to_string(),
236 "webhook: read body failed: io broke"
237 );
238 assert_eq!(
239 WebhookError::DecodeBody("bad json".into()).to_string(),
240 "webhook: decode body failed: bad json"
241 );
242 assert_eq!(
243 WebhookError::InvalidOrderId.to_string(),
244 "webhook: invalid order id"
245 );
246 assert_eq!(
247 WebhookError::InvalidAmount.to_string(),
248 "webhook: invalid amount"
249 );
250 }
251
252 #[test]
253 fn parser_default_uses_documented_limit() {
254 let parser = Parser::default();
255 assert_eq!(parser.max_body_size, DEFAULT_MAX_BODY_SIZE);
256 }
257
258 #[test]
259 fn parser_new_matches_default() {
260 let a = Parser::new();
261 let b = Parser::default();
262 assert_eq!(a.max_body_size, b.max_body_size);
263 }
264
265 #[test]
266 fn with_max_body_size_zero_is_a_no_op() {
267 let parser = Parser::new().with_max_body_size(0);
268 assert_eq!(parser.max_body_size, DEFAULT_MAX_BODY_SIZE);
269 }
270
271 #[test]
272 fn with_max_body_size_applies_positive_values() {
273 let parser = Parser::new().with_max_body_size(64);
274 assert_eq!(parser.max_body_size, 64);
275 }
276
277 #[test]
278 fn parse_bytes_decodes_a_valid_event() {
279 let event = Parser::new().parse_bytes(VALID_PAYLOAD).unwrap();
280 assert_eq!(event.order_id, "INV1");
281 assert_eq!(event.amount, 22_000);
282 assert_eq!(event.payment_method, PaymentMethod::Qris);
283 assert_eq!(event.status, TransactionStatus::Completed);
284 assert!(!event.is_sandbox);
285 }
286
287 #[test]
288 fn parse_bytes_accepts_doc_payload_without_is_sandbox() {
289 let event = Parser::new().parse_bytes(DOC_PAYLOAD).unwrap();
292 assert_eq!(event.order_id, "240910HDE7C9");
293 assert!(!event.is_sandbox);
294 }
295
296 #[test]
297 fn parse_bytes_rejects_empty_input() {
298 let err = Parser::new().parse_bytes(b"").unwrap_err();
299 assert_eq!(err, WebhookError::EmptyBody);
300 }
301
302 #[test]
303 fn parse_bytes_rejects_malformed_json() {
304 let err = Parser::new().parse_bytes(b"not json").unwrap_err();
305 assert!(
306 matches!(&err, WebhookError::DecodeBody(message) if !message.is_empty()),
307 "expected non-empty DecodeBody, got: {err:?}"
308 );
309 }
310
311 #[test]
312 fn parse_reader_decodes_a_valid_event() {
313 let event = Parser::new().parse_reader(VALID_PAYLOAD).unwrap();
314 assert_eq!(event.order_id, "INV1");
315 }
316
317 #[test]
318 fn parse_reader_rejects_empty_reader() {
319 let err = Parser::new().parse_reader(&b""[..]).unwrap_err();
320 assert_eq!(err, WebhookError::EmptyBody);
321 }
322
323 #[test]
324 fn parse_reader_rejects_oversize_body() {
325 let parser = Parser::new().with_max_body_size(8);
327 let err = parser.parse_reader(VALID_PAYLOAD).unwrap_err();
328 assert_eq!(err, WebhookError::BodyTooLarge { limit: 8 });
329 }
330
331 #[test]
332 fn parse_reader_surfaces_read_errors() {
333 struct FailingReader;
334 impl Read for FailingReader {
335 fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
336 Err(io::Error::other("reader broke"))
337 }
338 }
339
340 let err = Parser::new().parse_reader(FailingReader).unwrap_err();
341 assert!(
342 matches!(&err, WebhookError::ReadBody(message) if message.contains("reader broke")),
343 "expected ReadBody containing 'reader broke', got: {err:?}"
344 );
345 }
346
347 #[test]
348 fn event_validate_accepts_a_well_formed_event() {
349 sample_event().validate().unwrap();
350 }
351
352 #[test]
353 fn event_validate_rejects_empty_order_id() {
354 let mut event = sample_event();
355 event.order_id.clear();
356 assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidOrderId);
357 }
358
359 #[test]
360 fn event_validate_rejects_non_positive_amount() {
361 let mut event = sample_event();
362 event.amount = 0;
363 assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidAmount);
364
365 event.amount = -1;
366 assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidAmount);
367 }
368
369 #[test]
370 fn event_parse_time_round_trips_a_known_timestamp() {
371 let parsed = sample_event().parse_time().unwrap();
372 assert_eq!(parsed.to_rfc3339(), "2024-09-10T08:07:02.819+07:00");
373 }
374
375 #[test]
376 fn webhook_error_implements_std_error_trait() {
377 let err: &dyn std::error::Error = &WebhookError::EmptyBody;
379 assert_eq!(err.to_string(), "webhook: empty body");
380 }
381}