Skip to main content

gingr/
webhook.rs

1//! Fixture-safe Gingr webhook parsing and acknowledgement contracts.
2//!
3//! Webhooks are parsed into a quarantined envelope first. Verification is explicit,
4//! uses a caller-provided secret, and failure maps to an acknowledgement without
5//! mutating provider state or sending customer messages.
6//!
7//! ```rust
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! use gingr::{response, webhook};
10//!
11//! let raw = r#"{
12//!   "webhook_type": "animal_edited",
13//!   "entity_id": 812,
14//!   "entity_type": "animal",
15//!   "entity_data": {"name": "Miso"}
16//! }"#;
17//! let envelope = webhook::Envelope::from_json(raw)?;
18//! assert_eq!(envelope.event_type_input(), Some("animal_edited"));
19//!
20//! let missing_signature = envelope.verify(&webhook::SignatureKey::from_secret("fixture-only"));
21//! assert!(matches!(
22//!     missing_signature,
23//!     Err(webhook::VerificationError::MissingField { field: "signature" })
24//! ));
25//! assert_eq!(
26//!     webhook::Ack::RejectedPermanently.http_status(),
27//!     response::HttpStatus::FORBIDDEN
28//! );
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::response;
34use hmac::{Hmac, Mac};
35use secrecy::SecretString;
36use sha2::Sha256;
37use std::fmt;
38use subtle::ConstantTimeEq;
39
40/// Shared verification result type used across the webhook boundary.
41pub type VerificationResult<T> = core::result::Result<T, VerificationError>;
42/// Shared parse result type used across the webhook boundary.
43pub type ParseResult<T> = core::result::Result<T, ParseError>;
44
45type HmacSha256 = Hmac<Sha256>;
46
47#[derive(Clone)]
48/// Secret used to validate Gingr webhook signatures before payloads cross the provider boundary.
49pub struct SignatureKey(SecretString);
50
51impl SignatureKey {
52    /// Wraps the shared Gingr webhook secret without exposing it in debug output.
53    pub fn from_secret(raw: impl Into<String>) -> Self {
54        Self(SecretString::new(raw.into()))
55    }
56
57    fn expose_for_verification(&self) -> &str {
58        use secrecy::ExposeSecret;
59        self.0.expose_secret()
60    }
61}
62
63impl fmt::Debug for SignatureKey {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        formatter.write_str("SignatureKey(<redacted>)")
66    }
67}
68
69impl fmt::Display for SignatureKey {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter.write_str("<redacted>")
72    }
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76/// Gingr webhook event names normalized from provider strings while retaining unknown events.
77pub enum EventType {
78    /// Gingr event fired when a reservation checks in.
79    CheckIn,
80    /// Gingr event fired when a reservation checks out.
81    CheckOut,
82    /// Gingr in-progress check-in event.
83    CheckingIn,
84    /// Gingr in-progress check-out event.
85    CheckingOut,
86    /// Gingr event for outbound email activity.
87    EmailSent,
88    /// Gingr event for a newly created owner record.
89    OwnerCreated,
90    /// Gingr event for changes to an owner record.
91    OwnerEdited,
92    /// Gingr event for a newly created animal record.
93    AnimalCreated,
94    /// Gingr event for changes to an animal record.
95    AnimalEdited,
96    /// Gingr event for a newly created incident.
97    IncidentCreated,
98    /// Gingr event for changes to an incident.
99    IncidentEdited,
100    /// Gingr event for a newly created lead.
101    LeadCreated,
102    /// Provider supplied an unrecognized value; preserve it for audit instead of failing closed.
103    Unknown(String),
104}
105
106impl EventType {
107    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
108    pub fn parse(raw: impl AsRef<str>) -> Self {
109        match raw.as_ref() {
110            "check_in" => Self::CheckIn,
111            "check_out" => Self::CheckOut,
112            "checking_in" => Self::CheckingIn,
113            "checking_out" => Self::CheckingOut,
114            "email_sent" => Self::EmailSent,
115            "owner_created" => Self::OwnerCreated,
116            "owner_edited" => Self::OwnerEdited,
117            "animal_created" => Self::AnimalCreated,
118            "animal_edited" => Self::AnimalEdited,
119            "incident_created" => Self::IncidentCreated,
120            "incident_edited" => Self::IncidentEdited,
121            "lead_created" => Self::LeadCreated,
122            other => Self::Unknown(other.to_owned()),
123        }
124    }
125
126    /// Returns the exact Gingr token used when acknowledging or auditing provider events.
127    pub fn as_provider_str(&self) -> &str {
128        match self {
129            Self::CheckIn => "check_in",
130            Self::CheckOut => "check_out",
131            Self::CheckingIn => "checking_in",
132            Self::CheckingOut => "checking_out",
133            Self::EmailSent => "email_sent",
134            Self::OwnerCreated => "owner_created",
135            Self::OwnerEdited => "owner_edited",
136            Self::AnimalCreated => "animal_created",
137            Self::AnimalEdited => "animal_edited",
138            Self::IncidentCreated => "incident_created",
139            Self::IncidentEdited => "incident_edited",
140            Self::LeadCreated => "lead_created",
141            Self::Unknown(raw) => raw,
142        }
143    }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147/// Gingr webhook entity classes normalized from provider strings while retaining unknown entities.
148pub enum EntityType {
149    /// Webhook entity is a Gingr reservation.
150    Reservation,
151    /// Provider value refers to a Gingr owner/customer.
152    Owner,
153    /// Provider value refers to a Gingr animal/pet.
154    Animal,
155    /// Provider value refers to a Gingr incident.
156    Incident,
157    /// Provider value refers to a Gingr lead.
158    Lead,
159    /// Provider supplied an unrecognized value; preserve it for audit instead of failing closed.
160    Unknown(String),
161}
162
163impl EntityType {
164    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
165    pub fn parse(raw: impl AsRef<str>) -> Self {
166        match raw.as_ref() {
167            "reservation" => Self::Reservation,
168            "owner" => Self::Owner,
169            "animal" => Self::Animal,
170            "incident" => Self::Incident,
171            "lead" => Self::Lead,
172            other => Self::Unknown(other.to_owned()),
173        }
174    }
175
176    /// Returns the exact Gingr token used when acknowledging or auditing provider events.
177    pub fn as_provider_str(&self) -> &str {
178        match self {
179            Self::Reservation => "reservation",
180            Self::Owner => "owner",
181            Self::Animal => "animal",
182            Self::Incident => "incident",
183            Self::Lead => "lead",
184            Self::Unknown(raw) => raw,
185        }
186    }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
190/// Normalized Gingr entity identifier preserved as text across numeric and string webhook inputs.
191pub struct EntityId(String);
192
193impl EntityId {
194    fn normalize(value: &serde_json::Value) -> VerificationResult<Self> {
195        match value {
196            serde_json::Value::String(raw) if !raw.is_empty() => Ok(Self(raw.clone())),
197            serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => {
198                Ok(Self(number.to_string()))
199            }
200            other => Err(VerificationError::UnsupportedEntityId {
201                observed_type: json_type_name(other).to_owned(),
202            }),
203        }
204    }
205
206    /// Returns the normalized provider or storage string slice.
207    pub fn as_str(&self) -> &str {
208        &self.0
209    }
210}
211
212impl fmt::Display for EntityId {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter.write_str(&self.0)
215    }
216}
217
218#[derive(Clone, PartialEq)]
219/// Raw Gingr webhook envelope before signature verification and required-field promotion.
220pub struct Envelope {
221    wire: WireEnvelope,
222}
223
224impl fmt::Debug for Envelope {
225    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226        formatter
227            .debug_struct("Envelope")
228            .field("webhook_type", &self.wire.webhook_type)
229            .field(
230                "entity_id_type",
231                &self.wire.entity_id.as_ref().map(json_type_name),
232            )
233            .field("entity_type", &self.wire.entity_type)
234            .field("signature_present", &self.wire.signature.is_some())
235            .finish_non_exhaustive()
236    }
237}
238
239impl Envelope {
240    /// Parses a raw Gingr webhook request body into an unverified envelope.
241    pub fn from_json(raw: impl AsRef<str>) -> ParseResult<Self> {
242        let wire = serde_json::from_str(raw.as_ref())?;
243        Ok(Self { wire })
244    }
245
246    /// Returns the raw event type field supplied by Gingr, if present.
247    pub fn event_type_input(&self) -> Option<&str> {
248        self.wire.webhook_type.as_deref()
249    }
250
251    /// Returns the raw entity type field supplied by Gingr, if present.
252    pub fn entity_type_input(&self) -> Option<&str> {
253        self.wire.entity_type.as_deref()
254    }
255
256    /// Returns the signature value supplied with the webhook payload, if present.
257    pub fn signature_input(&self) -> Option<&str> {
258        self.wire.signature.as_deref()
259    }
260
261    /// Validates the webhook signature and promotes required provider fields into typed values.
262    pub fn verify(self, key: &SignatureKey) -> VerificationResult<Verified> {
263        let webhook_type =
264            self.wire
265                .webhook_type
266                .as_deref()
267                .ok_or(VerificationError::MissingField {
268                    field: "webhook_type",
269                })?;
270        let entity_id_value = self
271            .wire
272            .entity_id
273            .as_ref()
274            .ok_or(VerificationError::MissingField { field: "entity_id" })?;
275        let entity_id = EntityId::normalize(entity_id_value)?;
276        let entity_type =
277            self.wire
278                .entity_type
279                .as_deref()
280                .ok_or(VerificationError::MissingField {
281                    field: "entity_type",
282                })?;
283        let supplied_signature = self
284            .wire
285            .signature
286            .as_deref()
287            .ok_or(VerificationError::MissingField { field: "signature" })?;
288
289        verify_signature(
290            key,
291            webhook_type,
292            entity_id.as_str(),
293            entity_type,
294            supplied_signature,
295        )?;
296
297        Ok(Verified {
298            event_type: EventType::parse(webhook_type),
299            entity_id,
300            entity_type: EntityType::parse(entity_type),
301            payload: Payload { wire: self.wire },
302        })
303    }
304}
305
306#[derive(Clone, Debug, PartialEq)]
307/// Webhook payload that passed signature validation and required entity/event checks.
308pub struct Verified {
309    event_type: EventType,
310    entity_id: EntityId,
311    entity_type: EntityType,
312    payload: Payload,
313}
314
315impl Verified {
316    /// Returns the normalized Gingr event type for a verified webhook.
317    pub fn event_type(&self) -> EventType {
318        self.event_type.clone()
319    }
320
321    /// Returns the provider entity identifier from the verified webhook.
322    pub fn entity_id(&self) -> &EntityId {
323        &self.entity_id
324    }
325
326    /// Returns the normalized Gingr entity type for a verified webhook.
327    pub fn entity_type(&self) -> EntityType {
328        self.entity_type.clone()
329    }
330
331    /// Returns the verified provider payload for downstream mapping.
332    pub fn payload(&self) -> &Payload {
333        &self.payload
334    }
335}
336
337#[derive(Clone, PartialEq)]
338/// Provider-specific webhook payload body retained for downstream DTO mapping.
339pub struct Payload {
340    wire: WireEnvelope,
341}
342
343impl fmt::Debug for Payload {
344    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345        formatter
346            .debug_struct("Payload")
347            .field("provider_url", &self.wire.webhook_url)
348            .field("has_entity_data", &!self.wire.entity_data.is_null())
349            .field("has_email_data", &self.wire.email_data.is_some())
350            .field(
351                "recipient_count",
352                &self.wire.recipients.as_ref().map(Vec::len),
353            )
354            .finish_non_exhaustive()
355    }
356}
357
358impl Payload {
359    /// Returns the Gingr entity payload object carried by the webhook.
360    pub fn entity_data(&self) -> &serde_json::Value {
361        &self.wire.entity_data
362    }
363
364    /// Returns Gingr email metadata when the event includes it.
365    pub fn email_data(&self) -> Option<&serde_json::Value> {
366        self.wire.email_data.as_ref()
367    }
368
369    /// Returns the provider recipient list for email-related webhook events.
370    pub fn recipients(&self) -> Option<&Vec<serde_json::Value>> {
371        self.wire.recipients.as_ref()
372    }
373
374    /// Returns Gingr URL metadata included in the payload, if supplied.
375    pub fn provider_url(&self) -> Option<&str> {
376        self.wire.webhook_url.as_deref()
377    }
378}
379
380#[derive(Clone, PartialEq, serde::Deserialize)]
381struct WireEnvelope {
382    webhook_url: Option<String>,
383    webhook_type: Option<String>,
384    entity_id: Option<serde_json::Value>,
385    entity_type: Option<String>,
386    signature: Option<String>,
387    #[serde(default)]
388    entity_data: serde_json::Value,
389    email_data: Option<serde_json::Value>,
390    recipients: Option<Vec<serde_json::Value>>,
391    #[serde(flatten)]
392    unknown: serde_json::Map<String, serde_json::Value>,
393}
394
395#[derive(Debug, thiserror::Error)]
396/// Failures while reading the raw Gingr webhook JSON envelope.
397pub enum ParseError {
398    #[error("invalid Gingr webhook JSON: {0}")]
399    /// Raw webhook body could not be parsed as Gingr JSON.
400    Json(#[from] serde_json::Error),
401}
402
403#[derive(Debug, thiserror::Error, PartialEq, Eq)]
404/// Reasons a Gingr webhook cannot be trusted or promoted.
405pub enum VerificationError {
406    #[error("Gingr webhook is missing required field {field}")]
407    /// Webhook omitted a required provider field.
408    MissingField {
409        /// Provider field required before this mapping can create a source-backed candidate.
410        field: &'static str,
411    },
412    #[error("unsupported Gingr webhook entity_id representation: {observed_type}")]
413    /// Webhook used an entity_id shape this integration cannot normalize.
414    UnsupportedEntityId {
415        /// Observed type attached to this Gingr error or DTO.
416        observed_type: String,
417    },
418    #[error("malformed Gingr webhook signature: {reason}")]
419    /// Webhook signature could not be parsed before comparison.
420    MalformedSignature {
421        /// Boundary-level reason explaining why this provider request or parse step was rejected.
422        reason: String,
423    },
424    #[error("Gingr webhook signature mismatch")]
425    /// Computed signature did not match the value supplied by Gingr.
426    SignatureMismatch,
427}
428
429#[derive(Clone, Debug, PartialEq, Eq)]
430/// HTTP acknowledgement categories returned to Gingr after webhook handling.
431pub enum Ack {
432    /// Webhook was accepted and no retry is requested.
433    Processed,
434    /// Webhook failed validation in a way Gingr should not retry.
435    RejectedPermanently,
436    /// Webhook processing failed transiently and may be retried.
437    RetryableFailure,
438    /// Propagates a retryable downstream HTTP status while acknowledging Gingr semantics.
439    RetryableStatus(response::HttpStatus),
440}
441
442impl Ack {
443    /// Classifies a failed dependency response as retryable for Gingr acknowledgement.
444    pub fn retryable_status(status: response::HttpStatus) -> Self {
445        if status.is_gingr_retry_override_allowed() {
446            Self::RetryableStatus(status)
447        } else {
448            Self::RetryableFailure
449        }
450    }
451
452    /// Maps the acknowledgement category to the HTTP status returned to Gingr.
453    pub fn http_status(&self) -> response::HttpStatus {
454        match self {
455            Self::Processed => response::HttpStatus::OK,
456            Self::RejectedPermanently => response::HttpStatus::FORBIDDEN,
457            Self::RetryableFailure => response::HttpStatus::INTERNAL_SERVER_ERROR,
458            Self::RetryableStatus(status) => *status,
459        }
460    }
461}
462
463fn verify_signature(
464    key: &SignatureKey,
465    webhook_type: &str,
466    entity_id: &str,
467    entity_type: &str,
468    supplied_signature: &str,
469) -> VerificationResult<()> {
470    let supplied = decode_lower_hex_sha256(supplied_signature)?;
471    let mut mac = HmacSha256::new_from_slice(key.expose_for_verification().as_bytes())
472        .expect("HMAC accepts keys of any size");
473    mac.update(webhook_type.as_bytes());
474    mac.update(entity_id.as_bytes());
475    mac.update(entity_type.as_bytes());
476    let expected = mac.finalize().into_bytes();
477
478    if expected.as_slice().ct_eq(&supplied).into() {
479        Ok(())
480    } else {
481        Err(VerificationError::SignatureMismatch)
482    }
483}
484
485fn decode_lower_hex_sha256(raw: &str) -> VerificationResult<[u8; 32]> {
486    if raw.len() != 64 {
487        return Err(VerificationError::MalformedSignature {
488            reason: "expected 64 lowercase hex characters".to_owned(),
489        });
490    }
491
492    let mut decoded = [0_u8; 32];
493    for (index, pair) in raw.as_bytes().chunks_exact(2).enumerate() {
494        let high = decode_lower_hex_nibble(pair[0])?;
495        let low = decode_lower_hex_nibble(pair[1])?;
496        decoded[index] = (high << 4) | low;
497    }
498    Ok(decoded)
499}
500
501fn decode_lower_hex_nibble(byte: u8) -> VerificationResult<u8> {
502    match byte {
503        b'0'..=b'9' => Ok(byte - b'0'),
504        b'a'..=b'f' => Ok(byte - b'a' + 10),
505        _ => Err(VerificationError::MalformedSignature {
506            reason: "signature must be lowercase hex".to_owned(),
507        }),
508    }
509}
510
511fn json_type_name(value: &serde_json::Value) -> &'static str {
512    match value {
513        serde_json::Value::Null => "null",
514        serde_json::Value::Bool(_) => "boolean",
515        serde_json::Value::Number(number) if number.is_f64() => "decimal number",
516        serde_json::Value::Number(_) => "number",
517        serde_json::Value::String(_) => "string",
518        serde_json::Value::Array(_) => "array",
519        serde_json::Value::Object(_) => "object",
520    }
521}