-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Adds Zod validation for webhook payloads #377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
06458e3
to
4dc0c4b
Compare
7760552
to
4cc1b39
Compare
Signed-off-by: Mihovil Ilakovac <[email protected]>
5750330
to
a8f52f2
Compare
} catch (err) { | ||
if (err instanceof UnhandledWebhookEventError) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't sure if we wanted to return something other than 200
if we receive a request for a webhook event we don't handle.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we could, but we shouldn't be receiving any webhooks we don't explicitly request from the Stripe dashboard settings. Maybe the console.error is enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we shouldn't be receiving any webhooks we don't explicitly request from the Stripe
Yes, I understand but I kept seeing errors for some of the hooks in the e2e tests so I implemented this bit - this way we are just "ignoring" the extra webhook calls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should probably return an error code, right? This way, we explicitly tell Stripe (hey, we couldn't handle this). It probably makes things easier for people requesting refunds etc.
Yes, I understand but I kept seeing errors for some of the hooks in the e2e tests so I implemented this bit - this way we are just "ignoring" the extra webhook calls.
I didn't get this part. Why would they be sending events we didn't request? If that's the case, all the more reason to return 400 or something similar (e.g., 422 - unprocessable content).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've went with explicitly returning a 422 for the unhandled events. I don't have much experience with Stripe or Lemon Squeezy web hook events so I thought they might not like a non-200 response.
I'll see what happens in the e2e tests in the CI and post a screenshot just to be extra clear on what I meant earlier.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we log them and find out? I'll leave it to @vincanger
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on latest CI run: https://github.com/wasp-lang/open-saas/actions/runs/14514923688/job/40721696044
The unhandled events are:
- customer.created
- charge.succeeded
- payment_method.attached
- customer.updated
- customer.subscription.created
- payment_intent.created
- invoice.created
- invoice.finalized
- invoice.updated
- invoice.payment_succeeded
I think that's fine that we don't handle all the events Stripe send to us. If it's okay to have the 422 status code with Stripe, then I think we are okay with the current state of things. @vincanger
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. I'd say we merge the payment_intent.succeeded
webhook addition and add that first, before merging though.
@@ -10,47 +10,75 @@ import { emailSender } from 'wasp/server/email'; | |||
import { assertUnreachable } from '../../shared/utils'; | |||
import { requireNodeEnvVar } from '../../server/utils'; | |||
import { z } from 'zod'; | |||
import { | |||
InvoicePaidData, | |||
parseWebhookPayload, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we separate and import types at the type of the file, as we've been doing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean import the types at the top of the file? I don't see that we did that in this file e.g.
import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
is on top.
I've added the import type
bit for the types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah true, this won't apply for zod types as they're runtime specific, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this won't apply for zod types as they're runtime specific, right?
I'm not following sorry :) What do mean exactly that won't apply to Zod types?
} catch (err) { | ||
if (err instanceof UnhandledWebhookEventError) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we could, but we shouldn't be receiving any webhooks we don't explicitly request from the Stripe dashboard settings. Maybe the console.error is enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tested it out and looking good.
@@ -10,47 +10,75 @@ import { emailSender } from 'wasp/server/email'; | |||
import { assertUnreachable } from '../../shared/utils'; | |||
import { requireNodeEnvVar } from '../../server/utils'; | |||
import { z } from 'zod'; | |||
import { | |||
InvoicePaidData, | |||
parseWebhookPayload, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah true, this won't apply for zod types as they're runtime specific, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work!
I left some comments. Don't think I found anything wrong, but I had some questions.
Note
Btw, in the future, I recommend doing refactors/non-functional changes in a separate PR. Git often doesn't realize something was moved and important changes can slip through unnoticed.
I've only started doing this very recently after reading this great article: https://mtlynch.io/code-review-love. It's a must-read. Although I was and still am guilty of some of the things he mentions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work, left some more comments but most big stufff is resolved.
@vincanger Please come weigh in on the unresolved threads that require your expertise.
|
||
export type OrderData = z.infer<typeof orderDataSchema>; | ||
|
||
const genericEventSchema = z.object({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we document where this type comes from as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we link the type like we did for the others?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type is not exported unfortunately, so I get a Typescript error when I try to link to the type.
} catch (err) { | ||
if (err instanceof UnhandledWebhookEventError) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should probably return an error code, right? This way, we explicitly tell Stripe (hey, we couldn't handle this). It probably makes things easier for people requesting refunds etc.
Yes, I understand but I kept seeing errors for some of the hooks in the e2e tests so I implemented this bit - this way we are just "ignoring" the extra webhook calls.
I didn't get this part. Why would they be sending events we didn't request? If that's the case, all the more reason to return 400 or something similar (e.g., 422 - unprocessable content).
const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw); | ||
|
||
return lineItems; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I advise just returning it directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have to await
for the error handling to kick in. If I return directly, the code looks like:
return await subscriptionItemsSchema.parseAsync(lineItemsRaw);
which is a common footgun - maybe somebody removes the await
(thinking, I've heard I can just return the promise directly) and then the error handling changes. I'd like to keep the current impl for better clarity around what's going on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it. Didn't notice the catch block.
maybe somebody removes the await (thinking, I've heard I can just return the promise directly)
Hm, you could say the same thing about removing the extra variable 😄
Inlining a expression into the return statement is arguably even a bigger footgun than return await
because more people understand it.
For example, if I saw return await
, I would think it's weird, probably break out of autopilot, and notice the catch
block. With the current code, I thought it was an oversight.
The best solution, IMO, is picking whichever option you prefer and leaving a comment warning the reader about the error handling. Like we did here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer the current option because it looks dead simple to me. It might not look that clean to some, but to me it's still an okay way of writing that logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work, all good on my side.
I left two unresolved threads but you won't need me for that so I'm approving.
Please have @vincanger check the other stuff.
Adds runtime validation for all webhook payloads instead of relying on type assertions.
The idea was:
{ eventName, data }
which helps when you check foreventName
you are sure of the type of thedata
object