-
-
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).
// In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues. | ||
console.error('Unhandled event type: ', event.type); | ||
if (err instanceof UnhandledWebhookEventError) { | ||
return response.status(200).json({ received: true }); |
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.
Same thing as in https://github.com/wasp-lang/open-saas/pull/377/files#r1963148873
throw new Error('Invalid Stripe Event'); | ||
}); | ||
switch (event.type) { | ||
case 'checkout.session.completed': |
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 tried doing stuff like this to reduce the code duplication, but I couldn't get the exact types (discriminate unions):
const eventToSchema = {
'checkout.session.completed': sessionCompletedDataSchema,
'invoice.paid': invoicePaidDataSchema,
'customer.subscription.updated': subscriptionUpdatedDataSchema,
'customer.subscription.deleted': subscriptionDeletedDataSchema,
} as const;
function isSchemaDefinedForEvent(eventType: string): eventType is keyof typeof eventToSchema {
return eventType in eventToSchema;
}
if (!isSchemaDefinedForEvent(event.type)) {
throw new UnhandledWebhookEventError(event.type);
}
const schema = eventToSchema[event.type];
const data = await schema.parseAsync(event.data.object).catch(handleParsingError);
return { eventName: event.type, data };
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 is starting to look more complicated than it needs to be.
But wouldn't using a Record
type be clearer, where the key type is Stripe.Event
? That way you wouldn't have to use eventType is keyof typeof eventToSchema
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 is starting to look more complicated than it needs to be.
You mean the code in the PR or the code in this comment?
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.
@infomiho Yeah, this is a classic problem. Once you get down to a union type, you can't deunionize it (if it's unknown at compile time).
You could make it work by passing the event.type
into a function, and then querying for the correct schema inside. At least I think this would work, I could try tomorrow. On second thought, maybe not. I'd have to play around.
But, I think I agree with @vincanger - this is a lot for an average OpenSaas user.
@vincanger Btw, to answer your question, he can't use Record<Stripe.Event, ...>
because Stripe.Event
is an alias for a string (they underspecified the type).
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.
For reducing duplication, we could focus on each case's body and perhaps extract a function:
switch (event.type) {
// ...
case 'payment_intent.succeeded':
const paymentIntent = await parseStripeEventData(paymentIntentSucceededDataSchema, event.data.object);
return { eventName: event.type, data: paymentIntent };
// option 1
case 'customer.subscription.updated':
return {
eventName: event.type,
data: parseStripeEventData(subscriptionDeletedDataSchema, event.data.object),
};
// option 2
case 'customer.subscription.deleted':
const deletedSubscription = await parseStripeEventData(
subscriptionDeletedDataSchema,
event.data.object
);
return { eventName: event.type, data: deletedSubscription };
}
function parseStripeEventData<Z extends z.AnyZodObject>(
schema: Z,
rawStripeEvent: unknown
): Promise<z.infer<Z>> {
return schema.parseAsync(rawStripeEvent).catch((e) => {
console.error(e);
throw new Error('Error parsing Stripe event object');
});
}
But again, since this is a template, and the types here are pretty scary, I'd probably keep the duplication.
I am even on the edge of suggesting we hold off with introducing Zod to webhooks, but I can't judge is it to HC or not. I'll leave that 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.
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?
case 'customer.subscription.deleted': | ||
await handleCustomerSubscriptionDeleted(payload.data, prismaUserDelegate); | ||
break; | ||
default: |
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're missing payment_intent.succeeded
but I think that's still awaiting to be merged in another PR...
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 added the handling of the payment_intent.succeeded
hook 👍
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.
Just to double check. How come this was missing and is it possible we missed something else?
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.
It was a parallel PR of introducing payment_intent.succeeded
event #375
} 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
throw new Error('Invalid Stripe Event'); | ||
}); | ||
switch (event.type) { | ||
case 'checkout.session.completed': |
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.
@infomiho Yeah, this is a classic problem. Once you get down to a union type, you can't deunionize it (if it's unknown at compile time).
You could make it work by passing the event.type
into a function, and then querying for the correct schema inside. At least I think this would work, I could try tomorrow. On second thought, maybe not. I'd have to play around.
But, I think I agree with @vincanger - this is a lot for an average OpenSaas user.
@vincanger Btw, to answer your question, he can't use Record<Stripe.Event, ...>
because Stripe.Event
is an alias for a string (they underspecified the type).
throw new Error('Invalid Stripe Event'); | ||
}); | ||
switch (event.type) { | ||
case 'checkout.session.completed': |
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.
For reducing duplication, we could focus on each case's body and perhaps extract a function:
switch (event.type) {
// ...
case 'payment_intent.succeeded':
const paymentIntent = await parseStripeEventData(paymentIntentSucceededDataSchema, event.data.object);
return { eventName: event.type, data: paymentIntent };
// option 1
case 'customer.subscription.updated':
return {
eventName: event.type,
data: parseStripeEventData(subscriptionDeletedDataSchema, event.data.object),
};
// option 2
case 'customer.subscription.deleted':
const deletedSubscription = await parseStripeEventData(
subscriptionDeletedDataSchema,
event.data.object
);
return { eventName: event.type, data: deletedSubscription };
}
function parseStripeEventData<Z extends z.AnyZodObject>(
schema: Z,
rawStripeEvent: unknown
): Promise<z.infer<Z>> {
return schema.parseAsync(rawStripeEvent).catch((e) => {
console.error(e);
throw new Error('Error parsing Stripe event object');
});
}
But again, since this is a template, and the types here are pretty scary, I'd probably keep the duplication.
I am even on the edge of suggesting we hold off with introducing Zod to webhooks, but I can't judge is it to HC or not. I'll leave that 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.
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.
} | ||
} | ||
}; | ||
|
||
function parseRequestBody(request: express.Request): string { |
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 function and parseWebhookPayload
seem very similar to Stripe's constructStripeEvent
. Should we make the naming consistent?
// This is a subtype of Order type from "@lemonsqueezy/lemonsqueezy.js" | ||
// specifically Order['data'] |
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.
If it's the type of Order['data']
, then it's not a subtype. Same applies to subscriptionData
. This confused me at first because I only read "subtype" and didn't understand why we're renaming them from X
to XData
.
|
||
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?
} 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.
} | ||
if (result.data.data.length > 1) { | ||
function extractPriceId(items: SubscsriptionItems): string { | ||
if (items.data.length > 1) { |
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.
Do we also need a check for an empty array?
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