From cb194739fae67adebb99f381ebf7cec9e9d0b845 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 15:35:48 +0200 Subject: [PATCH 01/11] [vercel only app-avatax] poc app router --- .../app-sdk-handlers/fetch-remote-jwks.ts | 14 ++ .../saleor-webhooks/process-saleor-webhook.ts | 198 +++++++++++++++++ .../saleor-webhooks/saleor-sync-webhook.ts | 37 ++++ .../next/saleor-webhooks/saleor-webhook.ts | 201 ++++++++++++++++++ .../sync-webhook-response-builder.ts | 171 +++++++++++++++ apps/avatax/app-sdk-handlers/utils.ts | 47 ++++ .../checkout-calculate-taxes-2/route.ts | 175 +++++++++++++++ apps/avatax/src/lib/app-metadata-cache.ts | 5 +- apps/avatax/src/pages/api/manifest.ts | 5 +- pnpm-lock.yaml | 4 +- 10 files changed, 852 insertions(+), 5 deletions(-) create mode 100644 apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts create mode 100644 apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts create mode 100644 apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts create mode 100644 apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts create mode 100644 apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts create mode 100644 apps/avatax/app-sdk-handlers/utils.ts create mode 100644 apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts diff --git a/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts b/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts new file mode 100644 index 000000000..d59a62a16 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts @@ -0,0 +1,14 @@ +export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string => + `${new URL(saleorApiUrl).origin}/.well-known/jwks.json`; + +export const fetchRemoteJwks = async (saleorApiUrl: string) => { + try { + const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl)); + + const jwksText = await jwksResponse.text(); + + return jwksText; + } catch (err) { + throw err; + } +}; diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts new file mode 100644 index 000000000..381570c22 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts @@ -0,0 +1,198 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { NextRequest } from "next/server"; +import { APL, AuthData } from "@saleor/app-sdk/APL"; +import { getOtelTracer } from "@saleor/apps-otel/src/otel-tracer"; +import { parseSchemaVersion } from "@saleor/webhook-utils/src/parse-schema-version"; +import { verifySignatureWithJwks } from "@saleor/app-sdk/verify-signature"; +import { getBaseUrl, getSaleorHeaders } from "../../utils"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; + +export type SaleorWebhookError = + | "OTHER" + | "MISSING_HOST_HEADER" + | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" + | "MISSING_EVENT_HEADER" + | "MISSING_PAYLOAD_HEADER" + | "MISSING_SIGNATURE_HEADER" + | "MISSING_REQUEST_BODY" + | "WRONG_EVENT" + | "NOT_REGISTERED" + | "SIGNATURE_VERIFICATION_FAILED" + | "WRONG_METHOD" + | "CANT_BE_PARSED" + | "CONFIGURATION_ERROR"; + +export class WebhookError extends Error { + errorType: SaleorWebhookError = "OTHER"; + + constructor(message: string, errorType: SaleorWebhookError) { + super(message); + if (errorType) { + this.errorType = errorType; + } + Object.setPrototypeOf(this, WebhookError.prototype); + } +} + +export type WebhookContext = { + baseUrl: string; + event: string; + payload: T; + authData: AuthData; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; +}; + +interface ProcessSaleorWebhookArgs { + req: NextRequest; + apl: APL; + allowedEvent: string; +} + +type ProcessSaleorWebhook = ( + props: ProcessSaleorWebhookArgs, +) => Promise>; + +/** + * Perform security checks on given request and return WebhookContext object. + * In case of validation issues, instance of the WebhookError will be thrown. + * + * @returns WebhookContext + */ +export const processSaleorWebhook: ProcessSaleorWebhook = async ({ + req, + apl, + allowedEvent, +}: ProcessSaleorWebhookArgs): Promise> => { + const tracer = getOtelTracer(); + + return tracer.startActiveSpan( + "processSaleorWebhook", + { + kind: SpanKind.INTERNAL, + attributes: { + allowedEvent, + }, + }, + async (span) => { + try { + if (req.method !== "POST") { + throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); + } + + const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); + const baseUrl = getBaseUrl(req.headers); + + if (!baseUrl) { + throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!event) { + throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); + } + + const expected = allowedEvent.toLowerCase(); + + if (event !== expected) { + throw new WebhookError( + `Wrong incoming request event: ${event}. Expected: ${expected}`, + "WRONG_EVENT", + ); + } + + if (!signature) { + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); + } + + const rawBody = await req.text(); + + if (!rawBody) { + throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); + } + + let parsedBody: unknown & { version?: string | null }; + + try { + parsedBody = JSON.parse(rawBody); + } catch { + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); + } + + let parsedSchemaVersion: number | null = null; + + try { + parsedSchemaVersion = parseSchemaVersion(parsedBody.version); + } catch {} + + /** + * Verify if the app is properly installed for given Saleor API URL + */ + const authData = await apl.get(saleorApiUrl); + + if (!authData) { + throw new WebhookError( + `Can't find auth data for ${saleorApiUrl}. Please register the application`, + "NOT_REGISTERED", + ); + } + + /** + * Verify payload signature + * + * TODO: Add test for repeat verification scenario + */ + try { + if (!authData.jwks) { + throw new Error("JWKS not found in AuthData"); + } + + await verifySignatureWithJwks(authData.jwks, signature, rawBody); + } catch { + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED"); + }); + + try { + await verifySignatureWithJwks(newJwks, signature, rawBody); + + await apl.set({ ...authData, jwks: newJwks }); + } catch { + throw new WebhookError( + "Request signature check failed", + "SIGNATURE_VERIFICATION_FAILED", + ); + } + } + + span.setStatus({ + code: SpanStatusCode.OK, + }); + + return { + baseUrl, + event, + payload: parsedBody as T, + authData, + schemaVersion: parsedSchemaVersion, + }; + } catch (err) { + const message = (err as Error)?.message ?? "Unknown error"; + + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + throw err; + } finally { + span.end(); + } + }, + ); +}; diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 000000000..bbf69de07 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,37 @@ +import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; +import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@saleor/app-sdk/types"; + +type InjectedContext = { + buildResponse: typeof buildSyncWebhookResponsePayload; +}; + +export class SaleorSyncWebhook< + TPayload = unknown, + TEvent extends SyncWebhookEventType = SyncWebhookEventType, +> extends SaleorWebhook> { + readonly event: TEvent; + + protected readonly eventType = "sync" as const; + + protected extraContext = { + buildResponse: buildSyncWebhookResponsePayload, + }; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler( + handlerFn: NextWebhookApiHandler< + TPayload, + { + buildResponse: typeof buildSyncWebhookResponsePayload; + } + >, + ) { + return super.createHandler(handlerFn); + } +} diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 000000000..f238f14e2 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,201 @@ +import { ASTNode } from "graphql"; + +import { + processSaleorWebhook, + SaleorWebhookError, + WebhookContext, + WebhookError, +} from "./process-saleor-webhook"; +import { APL } from "@saleor/app-sdk/APL"; +import { + AsyncWebhookEventType, + SyncWebhookEventType, + WebhookManifest, +} from "@saleor/app-sdk/types"; +import { gqlAstToString } from "../../utils"; +import { NextRequest, NextResponse } from "next/server"; + +export interface WebhookConfig { + name?: string; + webhookPath: string; + event: Event; + isActive?: boolean; + apl: APL; + onError?(error: WebhookError | Error, req: NextRequest, res: NextResponse): void; + formatErrorResponse?( + error: WebhookError | Error, + req: NextRequest, + res: NextResponse, + ): Promise<{ + code: number; + body: object | string; + }>; + query: string | ASTNode; + /** + * @deprecated will be removed in 0.35.0, use query field instead + */ + subscriptionQueryAst?: ASTNode; +} + +export const WebhookErrorCodeMap: Record = { + OTHER: 500, + MISSING_HOST_HEADER: 400, + MISSING_DOMAIN_HEADER: 400, + MISSING_API_URL_HEADER: 400, + MISSING_EVENT_HEADER: 400, + MISSING_PAYLOAD_HEADER: 400, + MISSING_SIGNATURE_HEADER: 400, + MISSING_REQUEST_BODY: 400, + WRONG_EVENT: 400, + NOT_REGISTERED: 401, + SIGNATURE_VERIFICATION_FAILED: 401, + WRONG_METHOD: 405, + CANT_BE_PARSED: 400, + CONFIGURATION_ERROR: 500, +}; + +export type NextWebhookApiHandler = ( + req: NextRequest, + res: NextResponse, + ctx: WebhookContext & TExtras, +) => unknown | Promise; + +export abstract class SaleorWebhook< + TPayload = unknown, + TExtras extends Record = {}, +> { + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + + name: string; + + webhookPath: string; + + query: string | ASTNode; + + event: AsyncWebhookEventType | SyncWebhookEventType; + + isActive?: boolean; + + apl: APL; + + onError: WebhookConfig["onError"]; + + formatErrorResponse: WebhookConfig["formatErrorResponse"]; + + protected constructor(configuration: WebhookConfig) { + const { + name, + webhookPath, + event, + query, + apl, + isActive = true, + subscriptionQueryAst, + } = configuration; + + this.name = name || `${event} webhook`; + /** + * Fallback subscriptionQueryAst to avoid breaking changes + * + * TODO Remove in 0.35.0 + */ + this.query = query ?? subscriptionQueryAst; + this.webhookPath = webhookPath; + this.event = event; + this.isActive = isActive; + this.apl = apl; + this.onError = configuration.onError; + this.formatErrorResponse = configuration.formatErrorResponse; + } + + private getTargetUrl(baseUrl: string) { + return new URL(this.webhookPath, baseUrl).href; + } + + /** + * Returns synchronous event manifest for this webhook. + * + * @param baseUrl Base URL used by your application + * @returns WebhookManifest + */ + getWebhookManifest(baseUrl: string): WebhookManifest { + const manifestBase: Omit = { + query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), + name: this.name, + targetUrl: this.getTargetUrl(baseUrl), + isActive: this.isActive, + }; + + switch (this.eventType) { + case "async": + return { + ...manifestBase, + asyncEvents: [this.event as AsyncWebhookEventType], + }; + case "sync": + return { + ...manifestBase, + syncEvents: [this.event as SyncWebhookEventType], + }; + default: { + throw new Error("Class extended incorrectly"); + } + } + } + + /** + * Wraps provided function, to ensure incoming request comes from registered Saleor instance. + * Also provides additional `context` object containing typed payload and request properties. + */ + createHandler(handlerFn: NextWebhookApiHandler) { + return async (req: NextRequest, res: NextResponse) => { + await processSaleorWebhook({ + req, + apl: this.apl, + allowedEvent: this.event, + }) + .then(async (context) => { + return handlerFn(req, res, { ...(this.extraContext ?? ({} as TExtras)), ...context }); + }) + .catch(async (e) => { + if (e instanceof WebhookError) { + if (this.onError) { + this.onError(e, req, res); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req, res); + + return NextResponse.json(body, { status: code }); + } + + return NextResponse.json( + { + error: { + type: e.errorType, + message: e.message, + }, + }, + { + status: WebhookErrorCodeMap[e.errorType] || 400, + }, + ); + } + + if (this.onError) { + this.onError(e, req, res); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req, res); + + return NextResponse.json(body, { status: code }); + } + + return new NextResponse(null, { status: 500 }); + }); + }; + } +} diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts new file mode 100644 index 000000000..50f83f370 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts @@ -0,0 +1,171 @@ +import { SyncWebhookEventType } from "@saleor/app-sdk/types"; + +export type SyncWebhookResponsesMap = { + CHECKOUT_CALCULATE_TAXES: { + shipping_price_gross_amount: number; + shipping_price_net_amount: number; + shipping_tax_rate: number; + lines: Array<{ + total_gross_amount: number; + total_net_amount: number; + tax_rate: number; + }>; + }; + CHECKOUT_FILTER_SHIPPING_METHODS: { + excluded_methods: Array<{ + id: string; + reason?: string; + }>; + }; + ORDER_CALCULATE_TAXES: SyncWebhookResponsesMap["CHECKOUT_CALCULATE_TAXES"]; + ORDER_FILTER_SHIPPING_METHODS: SyncWebhookResponsesMap["CHECKOUT_FILTER_SHIPPING_METHODS"]; + SHIPPING_LIST_METHODS_FOR_CHECKOUT: Array<{ + id: string; + name?: string; + amount: number; + currency: string; // or enum? + /** + * Integer + */ + maximum_delivery_days?: number; + }>; + TRANSACTION_CHARGE_REQUESTED: { + pspReference: string; + result?: "CHARGE_SUCCESS" | "CHARGE_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_REFUND_REQUESTED: { + pspReference: string; + result?: "REFUND_SUCCESS" | "REFUND_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_CANCELATION_REQUESTED: { + pspReference: string; + result?: "CANCEL_SUCCESS" | "CANCEL_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + PAYMENT_GATEWAY_INITIALIZE_SESSION: { + data: unknown; + }; + TRANSACTION_INITIALIZE_SESSION: { + pspReference?: string; + data?: unknown; + result: + | "CHARGE_SUCCESS" + | "CHARGE_FAILURE" + | "CHARGE_REQUESTED" + | "CHARGE_ACTION_REQUIRED" + | "AUTHORIZATION_SUCCESS" + | "AUTHORIZATION_FAILURE" + | "AUTHORIZATION_REQUESTED" + | "AUTHORIZATION_ACTION_REQUIRED"; + amount: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_PROCESS_SESSION: { + pspReference?: string; + data?: unknown; + result: + | "CHARGE_SUCCESS" + | "CHARGE_FAILURE" + | "CHARGE_REQUESTED" + | "CHARGE_ACTION_REQUIRED" + | "AUTHORIZATION_SUCCESS" + | "AUTHORIZATION_FAILURE" + | "AUTHORIZATION_REQUESTED" + | "AUTHORIZATION_ACTION_REQUIRED"; + amount: number; + time?: string; + externalUrl?: string; + message?: string; + }; + PAYMENT_METHOD_PROCESS_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_TOKENIZED"; + id: string; + data: unknown; + } + | { + result: "ADDITIONAL_ACTION_REQUIRED"; + id: string; + data: unknown; + } + | { + result: "PENDING"; + data: unknown; + } + | { + result: "FAILED_TO_TOKENIZE"; + error: string; + }; + PAYMENT_METHOD_INITIALIZE_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_TOKENIZED"; + id: string; + data: unknown; + } + | { + result: "ADDITIONAL_ACTION_REQUIRED"; + id: string; + data: unknown; + } + | { + result: "PENDING"; + data: unknown; + } + | { + result: "FAILED_TO_TOKENIZE"; + error: string; + }; + PAYMENT_GATEWAY_INITIALIZE_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_INITIALIZED"; + data: unknown; + } + | { + result: "FAILED_TO_INITIALIZE"; + error: string; + }; + STORED_PAYMENT_METHOD_DELETE_REQUESTED: + | { + result: "SUCCESSFULLY_DELETED"; + } + | { + result: "FAILED_TO_DELETE"; + error: string; + }; + LIST_STORED_PAYMENT_METHODS: { + paymentMethods: Array<{ + id: string; + supportedPaymentFlows: Array<"INTERACTIVE">; + type: string; + creditCardInfo?: { + brand: string; + lastDigits: string; + expMonth: string; + expYear: string; + firstDigits?: string; + }; + name?: string; + data?: unknown; + }>; + }; +}; + +/** + * Identity function, but it works on Typescript level to pick right payload based on first param + */ +export const buildSyncWebhookResponsePayload = ( + payload: SyncWebhookResponsesMap[E], +): SyncWebhookResponsesMap[E] => payload; diff --git a/apps/avatax/app-sdk-handlers/utils.ts b/apps/avatax/app-sdk-handlers/utils.ts new file mode 100644 index 000000000..071f9c7a2 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/utils.ts @@ -0,0 +1,47 @@ +import { ASTNode, print } from "graphql"; +import { + SALEOR_API_URL_HEADER, + SALEOR_AUTHORIZATION_BEARER_HEADER, + SALEOR_DOMAIN_HEADER, + SALEOR_EVENT_HEADER, + SALEOR_SCHEMA_VERSION, + SALEOR_SIGNATURE_HEADER, +} from "@saleor/app-sdk/const"; + +export const gqlAstToString = (ast: ASTNode) => + print(ast) // convert AST to string + .replaceAll(/\n*/g, "") // remove new lines + .replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces + .trim(); // remove whitespace from beginning and end + +export const getBaseUrl = (headers: Headers): string => { + const xForwardedProto = headers.get("x-forwarded-proto") ?? "http"; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + return `${protocol}://${headers.get("host")}`; +}; + +const toStringOrUndefined = (value: string | string[] | undefined | null) => + value ? value.toString() : undefined; + +const toFloatOrNull = (value: string | string[] | undefined | null) => + value ? parseFloat(value.toString()) : null; + +/** + * Extracts Saleor-specific headers from the response. + */ +export const getSaleorHeaders = (headers: Headers) => ({ + domain: toStringOrUndefined(headers.get(SALEOR_DOMAIN_HEADER)), + authorizationBearer: toStringOrUndefined(headers.get(SALEOR_AUTHORIZATION_BEARER_HEADER)), + signature: toStringOrUndefined(headers.get(SALEOR_SIGNATURE_HEADER)), + event: toStringOrUndefined(headers.get(SALEOR_EVENT_HEADER)), + saleorApiUrl: toStringOrUndefined(headers.get(SALEOR_API_URL_HEADER)), + schemaVersion: toFloatOrNull(headers.get(SALEOR_SCHEMA_VERSION)), +}); diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts new file mode 100644 index 000000000..ea21c6749 --- /dev/null +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -0,0 +1,175 @@ +import { wrapWithLoggerContext } from "@saleor/apps-logger/node"; +import { withOtel } from "@saleor/apps-otel"; +import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes"; +import * as Sentry from "@sentry/nextjs"; +import { captureException } from "@sentry/nextjs"; + +import { AppConfigExtractor } from "@/lib/app-config-extractor"; +import { AppConfigurationLogger } from "@/lib/app-configuration-logger"; +import { metadataCache, wrapWithMetadataCache } from "@/lib/app-metadata-cache"; +import { SubscriptionPayloadErrorChecker } from "@/lib/error-utils"; +import { createLogger } from "@/logger"; +import { loggerContext } from "@/logger-context"; +import { CalculateTaxesUseCase } from "@/modules/calculate-taxes/use-case/calculate-taxes.use-case"; +import { AvataxInvalidAddressError } from "@/modules/taxes/tax-error"; +import { CalculateTaxesPayload } from "@/modules/webhooks/payloads/calculate-taxes-payload"; +import { saleorApp } from "../../../../saleor-app"; +import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql"; +import { SaleorSyncWebhook } from "../../../../app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook"; + +export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook({ + name: "CheckoutCalculateTaxes2", + apl: saleorApp.apl, + event: "CHECKOUT_CALCULATE_TAXES", + query: UntypedCalculateTaxesDocument, + webhookPath: "/api/webhooks/checkout-calculate-taxes-2", +}); + +export const config = { + api: { + bodyParser: false, + }, +}; + +const logger = createLogger("checkoutCalculateTaxesSyncWebhook"); + +const withMetadataCache = wrapWithMetadataCache(metadataCache); + +const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException); +const useCase = new CalculateTaxesUseCase({ + configExtractor: new AppConfigExtractor(), +}); + +export const POST = withMetadataCache( + checkoutCalculateTaxesSyncWebhook2.createHandler(async (req, res, ctx) => { + try { + const { payload, authData } = ctx; + + subscriptionErrorChecker.checkPayload(payload); + + logger.info("Tax base payload for checkout calculate taxes", { + payload: payload.taxBase, + }); + + loggerContext.set("channelSlug", ctx.payload.taxBase.channel.slug); + loggerContext.set("checkoutId", ctx.payload.taxBase.sourceObject.id); + + if (payload.version) { + Sentry.setTag(ObservabilityAttributes.SALEOR_VERSION, payload.version); + loggerContext.set(ObservabilityAttributes.SALEOR_VERSION, payload.version); + } + + logger.info("Handler for CHECKOUT_CALCULATE_TAXES webhook called"); + + const appMetadata = payload.recipient?.privateMetadata ?? []; + const channelSlug = payload.taxBase.channel.slug; + + const configExtractor = new AppConfigExtractor(); + + const config = configExtractor + .extractAppConfigFromPrivateMetadata(appMetadata) + .map((config) => { + try { + new AppConfigurationLogger(logger).logConfiguration(config, channelSlug); + } catch (e) { + captureException( + new AppConfigExtractor.LogConfigurationMetricError( + "Failed to log configuration metric", + { + cause: e, + }, + ), + ); + } + + return config; + }); + + if (config.isErr()) { + logger.warn("Failed to extract app config from metadata", { error: config.error }); + + return Response.json( + { + message: `App configuration is broken for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + + metadataCache.setMetadata(appMetadata); + + return useCase.calculateTaxes(payload, authData).then((result) => { + return result.match( + (value) => { + return Response.json(ctx.buildResponse(value), { + status: 200, + }); + }, + (err) => { + logger.warn("Error calculating taxes", { error: err }); + + switch (err.constructor) { + case CalculateTaxesUseCase.FailedCalculatingTaxesError: { + return Response.json( + { + message: `Failed to calculate taxes for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { + status: 500, + }, + ); + } + case CalculateTaxesUseCase.ConfigBrokenError: { + return Response.json( + { + message: `Failed to calculate taxes due to invalid configuration for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + case CalculateTaxesUseCase.ExpectedIncompletePayloadError: { + return Response.json( + { + message: `Taxes can't be calculated due to incomplete payload for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + case CalculateTaxesUseCase.UnhandledError: { + captureException(err); + + return Response.json( + { + message: `Failed to calculate taxes (Unhandled error) for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { + status: 500, + }, + ); + } + } + }, + ); + }); + } catch (error) { + // todo this should be now available in usecase. Catch it from FailedCalculatingTaxesError + if (error instanceof AvataxInvalidAddressError) { + logger.warn( + "InvalidAppAddressError: App returns status 400 due to broken address configuration", + { error }, + ); + + return Response.json( + { + message: "InvalidAppAddressError: Check address in app configuration", + }, + { status: 400 }, + ); + } + + Sentry.captureException(error); + + return Response.json({ message: "Unhandled error" }, { status: 500 }); + } + }), +); diff --git a/apps/avatax/src/lib/app-metadata-cache.ts b/apps/avatax/src/lib/app-metadata-cache.ts index d8315f852..e3a5af2a9 100644 --- a/apps/avatax/src/lib/app-metadata-cache.ts +++ b/apps/avatax/src/lib/app-metadata-cache.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "async_hooks"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { MetadataItem } from "../../generated/graphql"; import { createLogger } from "../logger"; +import { NextRequest, NextResponse } from "next/server"; /** * Set global context that stores metadata from webhook payload. @@ -40,8 +41,8 @@ export class AppMetadataCache { } } -export const wrapWithMetadataCache = (cache: AppMetadataCache) => (handler: NextApiHandler) => { - return (req: NextApiRequest, res: NextApiResponse) => { +export const wrapWithMetadataCache = (cache: AppMetadataCache) => (handler: any) => { + return (req: any, res: any) => { return cache.wrap(() => { return handler(req, res); }); diff --git a/apps/avatax/src/pages/api/manifest.ts b/apps/avatax/src/pages/api/manifest.ts index 4a49a4049..65ebe7f5d 100644 --- a/apps/avatax/src/pages/api/manifest.ts +++ b/apps/avatax/src/pages/api/manifest.ts @@ -7,6 +7,7 @@ import packageJson from "../../../package.json"; import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app"; import { appWebhooks } from "../../../webhooks"; import { loggerContext } from "../../logger-context"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/app/webhooks/checkout-calculate-taxes-2/route"; export default wrapWithLoggerContext( withOtel( @@ -34,7 +35,9 @@ export default wrapWithLoggerContext( supportUrl: "https://github.com/saleor/apps/discussions", tokenTargetUrl: `${apiBaseURL}/api/register`, version: packageJson.version, - webhooks: appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)), + webhooks: [...appWebhooks, checkoutCalculateTaxesSyncWebhook2].map((w) => + w.getWebhookManifest(apiBaseURL), + ), }; return manifest; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37d9f9db9..1a7a33aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,7 +181,7 @@ importers: version: 10.43.1(@trpc/server@10.43.1) '@trpc/next': specifier: 10.43.1 - version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 10.43.1 version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -407,7 +407,7 @@ importers: version: 10.43.1(@trpc/server@10.43.1) '@trpc/next': specifier: 10.43.1 - version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 10.43.1 version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) From 5b1399755916b2b7507e9e26ed6e7676a0713e0c Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 15:54:16 +0200 Subject: [PATCH 02/11] remove config --- .../src/app/webhooks/checkout-calculate-taxes-2/route.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index ea21c6749..7e6d7942b 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -25,12 +25,6 @@ export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook Date: Thu, 11 Jul 2024 15:56:28 +0200 Subject: [PATCH 03/11] [vercel only app-avatax] poc app router --- apps/avatax/next-env.d.ts | 1 + .../checkout-calculate-taxes-2/route.ts | 15 +--------- apps/avatax/src/pages/api/manifest.ts | 2 +- apps/avatax/src/wh.ts | 12 ++++++++ apps/avatax/tsconfig.json | 29 +++++++++++++++---- 5 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 apps/avatax/src/wh.ts diff --git a/apps/avatax/next-env.d.ts b/apps/avatax/next-env.d.ts index 4f11a03dc..fd36f9494 100644 --- a/apps/avatax/next-env.d.ts +++ b/apps/avatax/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index 7e6d7942b..5ad7eb48a 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -1,5 +1,3 @@ -import { wrapWithLoggerContext } from "@saleor/apps-logger/node"; -import { withOtel } from "@saleor/apps-otel"; import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes"; import * as Sentry from "@sentry/nextjs"; import { captureException } from "@sentry/nextjs"; @@ -12,18 +10,7 @@ import { createLogger } from "@/logger"; import { loggerContext } from "@/logger-context"; import { CalculateTaxesUseCase } from "@/modules/calculate-taxes/use-case/calculate-taxes.use-case"; import { AvataxInvalidAddressError } from "@/modules/taxes/tax-error"; -import { CalculateTaxesPayload } from "@/modules/webhooks/payloads/calculate-taxes-payload"; -import { saleorApp } from "../../../../saleor-app"; -import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql"; -import { SaleorSyncWebhook } from "../../../../app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook"; - -export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook({ - name: "CheckoutCalculateTaxes2", - apl: saleorApp.apl, - event: "CHECKOUT_CALCULATE_TAXES", - query: UntypedCalculateTaxesDocument, - webhookPath: "/api/webhooks/checkout-calculate-taxes-2", -}); +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; const logger = createLogger("checkoutCalculateTaxesSyncWebhook"); diff --git a/apps/avatax/src/pages/api/manifest.ts b/apps/avatax/src/pages/api/manifest.ts index 65ebe7f5d..04b4ae905 100644 --- a/apps/avatax/src/pages/api/manifest.ts +++ b/apps/avatax/src/pages/api/manifest.ts @@ -7,7 +7,7 @@ import packageJson from "../../../package.json"; import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app"; import { appWebhooks } from "../../../webhooks"; import { loggerContext } from "../../logger-context"; -import { checkoutCalculateTaxesSyncWebhook2 } from "@/app/webhooks/checkout-calculate-taxes-2/route"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; export default wrapWithLoggerContext( withOtel( diff --git a/apps/avatax/src/wh.ts b/apps/avatax/src/wh.ts new file mode 100644 index 000000000..bd5b7fb09 --- /dev/null +++ b/apps/avatax/src/wh.ts @@ -0,0 +1,12 @@ +import { SaleorSyncWebhook } from "../app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook"; +import { CalculateTaxesPayload } from "@/modules/webhooks/payloads/calculate-taxes-payload"; +import { saleorApp } from "../saleor-app"; +import { UntypedCalculateTaxesDocument } from "../generated/graphql"; + +export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook({ + name: "CheckoutCalculateTaxes2", + apl: saleorApp.apl, + event: "CHECKOUT_CALCULATE_TAXES", + query: UntypedCalculateTaxesDocument, + webhookPath: "/api/webhooks/checkout-calculate-taxes-2", +}); diff --git a/apps/avatax/tsconfig.json b/apps/avatax/tsconfig.json index 687ae8c17..ff311c19f 100644 --- a/apps/avatax/tsconfig.json +++ b/apps/avatax/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -16,9 +20,24 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["src/*"] - } + "@/*": [ + "src/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "next.config.js", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From 25d890c60dc3e430e3caf39db91f8ebf3f4bf0e5 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 16:40:28 +0200 Subject: [PATCH 04/11] [vercel only app-avatax] poc app router --- .../next/saleor-webhooks/saleor-webhook.ts | 20 +++++------ .../checkout-calculate-taxes-2/route.ts | 36 +++++++++++-------- apps/avatax/src/lib/app-metadata-cache.ts | 14 +++++--- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts index f238f14e2..85dd221e5 100644 --- a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts @@ -21,11 +21,10 @@ export interface WebhookConfig = { export type NextWebhookApiHandler = ( req: NextRequest, - res: NextResponse, ctx: WebhookContext & TExtras, -) => unknown | Promise; +) => Promise; export abstract class SaleorWebhook< TPayload = unknown, @@ -150,23 +148,23 @@ export abstract class SaleorWebhook< * Also provides additional `context` object containing typed payload and request properties. */ createHandler(handlerFn: NextWebhookApiHandler) { - return async (req: NextRequest, res: NextResponse) => { + return async (req: NextRequest): Promise => { await processSaleorWebhook({ req, apl: this.apl, allowedEvent: this.event, }) .then(async (context) => { - return handlerFn(req, res, { ...(this.extraContext ?? ({} as TExtras)), ...context }); + return handlerFn(req, { ...(this.extraContext ?? ({} as TExtras)), ...context }); }) .catch(async (e) => { if (e instanceof WebhookError) { if (this.onError) { - this.onError(e, req, res); + this.onError(e, req); } if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); + const { code, body } = await this.formatErrorResponse(e, req); return NextResponse.json(body, { status: code }); } @@ -185,17 +183,19 @@ export abstract class SaleorWebhook< } if (this.onError) { - this.onError(e, req, res); + this.onError(e, req); } if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); + const { code, body } = await this.formatErrorResponse(e, req); return NextResponse.json(body, { status: code }); } return new NextResponse(null, { status: 500 }); }); + + return new NextResponse(null); }; } } diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index 5ad7eb48a..0bd3c6bcf 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -4,25 +4,26 @@ import { captureException } from "@sentry/nextjs"; import { AppConfigExtractor } from "@/lib/app-config-extractor"; import { AppConfigurationLogger } from "@/lib/app-configuration-logger"; -import { metadataCache, wrapWithMetadataCache } from "@/lib/app-metadata-cache"; +import { metadataCache, wrapWithMetadataCacheAppRouter } from "@/lib/app-metadata-cache"; import { SubscriptionPayloadErrorChecker } from "@/lib/error-utils"; import { createLogger } from "@/logger"; import { loggerContext } from "@/logger-context"; import { CalculateTaxesUseCase } from "@/modules/calculate-taxes/use-case/calculate-taxes.use-case"; import { AvataxInvalidAddressError } from "@/modules/taxes/tax-error"; import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; +import { NextRequest, NextResponse } from "next/server"; const logger = createLogger("checkoutCalculateTaxesSyncWebhook"); -const withMetadataCache = wrapWithMetadataCache(metadataCache); +const withMetadataCache = wrapWithMetadataCacheAppRouter(metadataCache); const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException); const useCase = new CalculateTaxesUseCase({ configExtractor: new AppConfigExtractor(), }); -export const POST = withMetadataCache( - checkoutCalculateTaxesSyncWebhook2.createHandler(async (req, res, ctx) => { +const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( + async (req, ctx): Promise => { try { const { payload, authData } = ctx; @@ -69,7 +70,7 @@ export const POST = withMetadataCache( if (config.isErr()) { logger.warn("Failed to extract app config from metadata", { error: config.error }); - return Response.json( + return NextResponse.json( { message: `App configuration is broken for checkout: ${payload.taxBase.sourceObject.id}`, }, @@ -79,10 +80,10 @@ export const POST = withMetadataCache( metadataCache.setMetadata(appMetadata); - return useCase.calculateTaxes(payload, authData).then((result) => { + const res = useCase.calculateTaxes(payload, authData).then((result) => { return result.match( (value) => { - return Response.json(ctx.buildResponse(value), { + return NextResponse.json(ctx.buildResponse(value), { status: 200, }); }, @@ -91,7 +92,7 @@ export const POST = withMetadataCache( switch (err.constructor) { case CalculateTaxesUseCase.FailedCalculatingTaxesError: { - return Response.json( + return NextResponse.json( { message: `Failed to calculate taxes for checkout: ${payload.taxBase.sourceObject.id}`, }, @@ -101,7 +102,7 @@ export const POST = withMetadataCache( ); } case CalculateTaxesUseCase.ConfigBrokenError: { - return Response.json( + return NextResponse.json( { message: `Failed to calculate taxes due to invalid configuration for checkout: ${payload.taxBase.sourceObject.id}`, }, @@ -109,7 +110,7 @@ export const POST = withMetadataCache( ); } case CalculateTaxesUseCase.ExpectedIncompletePayloadError: { - return Response.json( + return NextResponse.json( { message: `Taxes can't be calculated due to incomplete payload for checkout: ${payload.taxBase.sourceObject.id}`, }, @@ -119,7 +120,7 @@ export const POST = withMetadataCache( case CalculateTaxesUseCase.UnhandledError: { captureException(err); - return Response.json( + return NextResponse.json( { message: `Failed to calculate taxes (Unhandled error) for checkout: ${payload.taxBase.sourceObject.id}`, }, @@ -128,10 +129,15 @@ export const POST = withMetadataCache( }, ); } + default: { + return NextResponse.json({}); + } } }, ); }); + + return res; } catch (error) { // todo this should be now available in usecase. Catch it from FailedCalculatingTaxesError if (error instanceof AvataxInvalidAddressError) { @@ -140,7 +146,7 @@ export const POST = withMetadataCache( { error }, ); - return Response.json( + return NextResponse.json( { message: "InvalidAppAddressError: Check address in app configuration", }, @@ -150,7 +156,9 @@ export const POST = withMetadataCache( Sentry.captureException(error); - return Response.json({ message: "Unhandled error" }, { status: 500 }); + return NextResponse.json({ message: "Unhandled error" }, { status: 500 }); } - }), + }, ); + +export const POST = withMetadataCache(handler) as (req: NextRequest) => Promise; diff --git a/apps/avatax/src/lib/app-metadata-cache.ts b/apps/avatax/src/lib/app-metadata-cache.ts index e3a5af2a9..999341951 100644 --- a/apps/avatax/src/lib/app-metadata-cache.ts +++ b/apps/avatax/src/lib/app-metadata-cache.ts @@ -1,8 +1,10 @@ import { AsyncLocalStorage } from "async_hooks"; -import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { MetadataItem } from "../../generated/graphql"; import { createLogger } from "../logger"; import { NextRequest, NextResponse } from "next/server"; +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +type Handler = (req: NextRequest) => Promise; /** * Set global context that stores metadata from webhook payload. @@ -26,7 +28,7 @@ export class AppMetadataCache { return store.metadata; } - async wrap(fn: (...args: unknown[]) => unknown) { + async wrap(fn: any) { return this.als.run({ metadata: null }, fn); } @@ -41,12 +43,16 @@ export class AppMetadataCache { } } -export const wrapWithMetadataCache = (cache: AppMetadataCache) => (handler: any) => { - return (req: any, res: any) => { +export const wrapWithMetadataCache = (cache: AppMetadataCache) => (handler: NextApiHandler) => { + return (req: NextApiRequest, res: NextApiResponse) => { return cache.wrap(() => { return handler(req, res); }); }; }; +export const wrapWithMetadataCacheAppRouter = + (cache: AppMetadataCache) => (handler: Handler) => (req: NextRequest) => + cache.wrap(() => handler(req)); + export const metadataCache = new AppMetadataCache(); From 9aa19bee56fd6c876c587c5499aba586eee4f66b Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 17:10:53 +0200 Subject: [PATCH 05/11] [vercel only app-avatax] poc app router --- apps/avatax/webhooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/avatax/webhooks.ts b/apps/avatax/webhooks.ts index a8d797ef1..5f1eda18c 100644 --- a/apps/avatax/webhooks.ts +++ b/apps/avatax/webhooks.ts @@ -4,7 +4,7 @@ import { orderCancelledAsyncWebhook } from "./src/modules/webhooks/definitions/o import { orderConfirmedAsyncWebhook } from "./src/modules/webhooks/definitions/order-confirmed"; export const appWebhooks = [ - checkoutCalculateTaxesSyncWebhook, + // checkoutCalculateTaxesSyncWebhook, orderCalculateTaxesSyncWebhook, orderCancelledAsyncWebhook, orderConfirmedAsyncWebhook, From 167c0d5c7d369d9435cd404dd8714d0b76551812 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 17:28:51 +0200 Subject: [PATCH 06/11] [vercel only app-avatax] poc app router --- apps/avatax/src/wh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/avatax/src/wh.ts b/apps/avatax/src/wh.ts index bd5b7fb09..ce14ded13 100644 --- a/apps/avatax/src/wh.ts +++ b/apps/avatax/src/wh.ts @@ -8,5 +8,5 @@ export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook Date: Thu, 11 Jul 2024 17:46:12 +0200 Subject: [PATCH 07/11] [vercel only app-avatax] poc app router --- .../avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index 0bd3c6bcf..136a54c6e 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -130,7 +130,7 @@ const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( ); } default: { - return NextResponse.json({}); + return NextResponse.json({}, { status: 500 }); } } }, From 70311fa3560c1ebc0c06ababa7ff8343368bede6 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Jul 2024 18:04:42 +0200 Subject: [PATCH 08/11] [vercel only app-avatax] poc app router --- .../app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts index 85dd221e5..00e89fba2 100644 --- a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts @@ -177,7 +177,7 @@ export abstract class SaleorWebhook< }, }, { - status: WebhookErrorCodeMap[e.errorType] || 400, + status: 400, }, ); } @@ -189,7 +189,7 @@ export abstract class SaleorWebhook< if (this.formatErrorResponse) { const { code, body } = await this.formatErrorResponse(e, req); - return NextResponse.json(body, { status: code }); + return NextResponse.json(body, { status: 400 }); } return new NextResponse(null, { status: 500 }); From a41aecd2ccd4cb7cc21afa6a1e584ce8848cbb3c Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Fri, 12 Jul 2024 09:31:53 +0200 Subject: [PATCH 09/11] [vercel only app-avatax] poc app router --- .../checkout-calculate-taxes-2/route.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index 136a54c6e..f23d50534 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -1,22 +1,17 @@ -import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes"; import * as Sentry from "@sentry/nextjs"; import { captureException } from "@sentry/nextjs"; import { AppConfigExtractor } from "@/lib/app-config-extractor"; import { AppConfigurationLogger } from "@/lib/app-configuration-logger"; -import { metadataCache, wrapWithMetadataCacheAppRouter } from "@/lib/app-metadata-cache"; import { SubscriptionPayloadErrorChecker } from "@/lib/error-utils"; import { createLogger } from "@/logger"; -import { loggerContext } from "@/logger-context"; import { CalculateTaxesUseCase } from "@/modules/calculate-taxes/use-case/calculate-taxes.use-case"; import { AvataxInvalidAddressError } from "@/modules/taxes/tax-error"; import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; const logger = createLogger("checkoutCalculateTaxesSyncWebhook"); -const withMetadataCache = wrapWithMetadataCacheAppRouter(metadataCache); - const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException); const useCase = new CalculateTaxesUseCase({ configExtractor: new AppConfigExtractor(), @@ -33,14 +28,6 @@ const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( payload: payload.taxBase, }); - loggerContext.set("channelSlug", ctx.payload.taxBase.channel.slug); - loggerContext.set("checkoutId", ctx.payload.taxBase.sourceObject.id); - - if (payload.version) { - Sentry.setTag(ObservabilityAttributes.SALEOR_VERSION, payload.version); - loggerContext.set(ObservabilityAttributes.SALEOR_VERSION, payload.version); - } - logger.info("Handler for CHECKOUT_CALCULATE_TAXES webhook called"); const appMetadata = payload.recipient?.privateMetadata ?? []; @@ -78,8 +65,6 @@ const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( ); } - metadataCache.setMetadata(appMetadata); - const res = useCase.calculateTaxes(payload, authData).then((result) => { return result.match( (value) => { @@ -161,4 +146,4 @@ const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( }, ); -export const POST = withMetadataCache(handler) as (req: NextRequest) => Promise; +export const POST = handler; From d7dbdce95370d5804dc05d3fd09f11233930eafd Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Fri, 12 Jul 2024 09:53:36 +0200 Subject: [PATCH 10/11] [vercel only app-avatax] poc app router --- apps/avatax/next.config.js | 1 + .../src/app/webhooks/checkout-calculate-taxes-2/route.ts | 2 +- apps/avatax/src/pages/api/manifest.ts | 4 +--- apps/avatax/webhooks.ts | 2 ++ 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/avatax/next.config.js b/apps/avatax/next.config.js index 580cdcdc3..2599f1f87 100644 --- a/apps/avatax/next.config.js +++ b/apps/avatax/next.config.js @@ -14,6 +14,7 @@ const nextConfig = { "@saleor/react-hook-form-macaw", ], experimental: { + typedRoutes: true, optimizePackageImports: [ "@sentry/nextjs", "@sentry/node", diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts index f23d50534..185f94fed 100644 --- a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -65,7 +65,7 @@ const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( ); } - const res = useCase.calculateTaxes(payload, authData).then((result) => { + const res = await useCase.calculateTaxes(payload, authData).then((result) => { return result.match( (value) => { return NextResponse.json(ctx.buildResponse(value), { diff --git a/apps/avatax/src/pages/api/manifest.ts b/apps/avatax/src/pages/api/manifest.ts index 04b4ae905..0a55e0db6 100644 --- a/apps/avatax/src/pages/api/manifest.ts +++ b/apps/avatax/src/pages/api/manifest.ts @@ -35,9 +35,7 @@ export default wrapWithLoggerContext( supportUrl: "https://github.com/saleor/apps/discussions", tokenTargetUrl: `${apiBaseURL}/api/register`, version: packageJson.version, - webhooks: [...appWebhooks, checkoutCalculateTaxesSyncWebhook2].map((w) => - w.getWebhookManifest(apiBaseURL), - ), + webhooks: appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)), }; return manifest; diff --git a/apps/avatax/webhooks.ts b/apps/avatax/webhooks.ts index 5f1eda18c..e1e371ba9 100644 --- a/apps/avatax/webhooks.ts +++ b/apps/avatax/webhooks.ts @@ -2,10 +2,12 @@ import { checkoutCalculateTaxesSyncWebhook } from "./src/modules/webhooks/defini import { orderCalculateTaxesSyncWebhook } from "./src/modules/webhooks/definitions/order-calculate-taxes"; import { orderCancelledAsyncWebhook } from "./src/modules/webhooks/definitions/order-cancelled"; import { orderConfirmedAsyncWebhook } from "./src/modules/webhooks/definitions/order-confirmed"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; export const appWebhooks = [ // checkoutCalculateTaxesSyncWebhook, orderCalculateTaxesSyncWebhook, orderCancelledAsyncWebhook, orderConfirmedAsyncWebhook, + checkoutCalculateTaxesSyncWebhook2, ]; From 3c7638c0b8533b1d90ee519f0d0027dda338bb59 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Fri, 12 Jul 2024 10:18:34 +0200 Subject: [PATCH 11/11] [vercel only app-avatax] poc app router --- apps/avatax/next.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/avatax/next.config.js b/apps/avatax/next.config.js index 2599f1f87..580cdcdc3 100644 --- a/apps/avatax/next.config.js +++ b/apps/avatax/next.config.js @@ -14,7 +14,6 @@ const nextConfig = { "@saleor/react-hook-form-macaw", ], experimental: { - typedRoutes: true, optimizePackageImports: [ "@sentry/nextjs", "@sentry/node",