From f563f7ac27f2841b18865ad313763321ec1f7707 Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Mon, 3 Feb 2025 15:11:17 +0100 Subject: [PATCH 1/2] Add lambda and web api handlers Add exports Update JSDocs Add tests for register handler Remove `saleorDomain` from type Rename aws-lambda file Throw errors in adapters, instead of resolving to null Add AWS lambda tests Fix lambda types Fix lambda headers Add lambda manifest test, fix headers resolution Fetch API: add tests for protected handler, fix types Add tests for lambda protected handler, fix types Simplify Fetch API adapter logic, add tests for adapter Add lambda tests Refactor sync webhook names, refactor types Add web api sync webhook tests Add async webhook tests Add lambda sync webhook test Fix types Remove aws-lambda handlers (separate PR) Remove lambda Removed unused functions --- package.json | 10 + .../actions/register-action-handler.ts | 69 +----- .../create-app-register-handler.test.ts | 184 ++++++++++++++ .../fetch-api/create-app-register-handler.ts | 36 +++ .../fetch-api/create-manifest-handler.test.ts | 51 ++++ .../fetch-api/create-manifest-handler.ts | 34 +++ .../create-protected-handler.test.ts | 105 ++++++++ .../fetch-api/create-protected-handler.ts | 39 +++ src/handlers/platforms/fetch-api/index.ts | 6 + .../fetch-api/platform-adapter.test.ts | 232 ++++++++++++++++++ .../platforms/fetch-api/platform-adapter.ts | 75 ++++++ .../saleor-async-webhook.test.ts | 116 +++++++++ .../saleor-webhooks/saleor-async-webhook.ts | 20 ++ .../saleor-sync-webhook.test.ts | 130 ++++++++++ .../saleor-webhooks/saleor-sync-webhook.ts | 34 +++ .../saleor-webhooks/saleor-webhook.ts | 42 ++++ .../next/create-app-register-handler.test.ts | 24 +- .../next/create-manifest-handler.test.ts | 48 ++-- .../platforms/next/create-manifest-handler.ts | 28 ++- .../next/create-protected-handler.test.ts | 7 +- .../platforms/next/platform-adapter.ts | 18 +- .../saleor-async-webhook.test.ts | 43 ++-- .../saleor-webhooks/saleor-sync-webhook.ts | 19 +- .../create-app-register-handler-types.ts | 1 - src/handlers/shared/generic-saleor-webhook.ts | 6 +- src/handlers/shared/saleor-webhook.ts | 12 + tsup.config.ts | 4 + 27 files changed, 1246 insertions(+), 147 deletions(-) create mode 100644 src/handlers/platforms/fetch-api/create-app-register-handler.test.ts create mode 100644 src/handlers/platforms/fetch-api/create-app-register-handler.ts create mode 100644 src/handlers/platforms/fetch-api/create-manifest-handler.test.ts create mode 100644 src/handlers/platforms/fetch-api/create-manifest-handler.ts create mode 100644 src/handlers/platforms/fetch-api/create-protected-handler.test.ts create mode 100644 src/handlers/platforms/fetch-api/create-protected-handler.ts create mode 100644 src/handlers/platforms/fetch-api/index.ts create mode 100644 src/handlers/platforms/fetch-api/platform-adapter.test.ts create mode 100644 src/handlers/platforms/fetch-api/platform-adapter.ts create mode 100644 src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.test.ts create mode 100644 src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts create mode 100644 src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.test.ts create mode 100644 src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts create mode 100644 src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts diff --git a/package.json b/package.json index 9f35f1ff..b4a2a092 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,16 @@ "import": "./handlers/next/index.mjs", "require": "./handlers/next/index.js" }, + "./handlers/fetch-api": { + "types": "./handlers/fetch-api/index.d.ts", + "import": "./handlers/fetch-api/index.mjs", + "require": "./handlers/fetch-api/index.js" + }, + "./handlers/next-app-router": { + "types": "./handlers/fetch-api/index.d.ts", + "import": "./handlers/fetch-api/index.mjs", + "require": "./handlers/fetch-api/index.js" + }, "./handlers/shared": { "types": "./handlers/shared/index.d.ts", "import": "./handlers/shared/index.mjs", diff --git a/src/handlers/actions/register-action-handler.ts b/src/handlers/actions/register-action-handler.ts index cfb69cce..f7eff63c 100644 --- a/src/handlers/actions/register-action-handler.ts +++ b/src/handlers/actions/register-action-handler.ts @@ -4,8 +4,8 @@ import { SALEOR_API_URL_HEADER } from "@/const"; import { createDebug } from "@/debug"; import { fetchRemoteJwks } from "@/fetch-remote-jwks"; import { getAppId } from "@/get-app-id"; -import { HasAPL } from "@/saleor-app"; +import { GenericCreateAppRegisterHandlerOptions } from "../shared"; import { ActionHandlerInterface, ActionHandlerResult, @@ -66,59 +66,6 @@ export type HookCallbackErrorParams = { export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never; -export type AppRegisterHandlerOptions = HasAPL & { - /** - * Protect app from being registered in Saleor other than specific. - * By default, allow everything. - * - * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) - * or a function that receives a full Saleor API URL ad returns true/false. - */ - allowedSaleorUrls?: Array boolean)>; - /** - * Run right after Saleor calls this endpoint - */ - onRequestStart?( - request: Request, - context: { - authToken?: string; - saleorApiUrl?: string; - respondWithError: CallbackErrorHandler; - } - ): Promise; - /** - * Run after all security checks - */ - onRequestVerified?( - request: Request, - context: { - authData: AuthData; - respondWithError: CallbackErrorHandler; - } - ): Promise; - /** - * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error - */ - onAuthAplSaved?( - request: Request, - context: { - authData: AuthData; - respondWithError: CallbackErrorHandler; - } - ): Promise; - /** - * Run after APL fails to set AuthData - */ - onAplSetFailed?( - request: Request, - context: { - authData: AuthData; - error: unknown; - respondWithError: CallbackErrorHandler; - } - ): Promise; -}; - export class RegisterActionHandler implements ActionHandlerInterface { @@ -142,7 +89,7 @@ export class RegisterActionHandler } async handleAction( - config: AppRegisterHandlerOptions + config: GenericCreateAppRegisterHandlerOptions ): Promise> { debug("Request received"); @@ -278,7 +225,7 @@ export class RegisterActionHandler } private async handleOnRequestStartCallback( - onRequestStart: AppRegisterHandlerOptions["onRequestStart"], + onRequestStart: GenericCreateAppRegisterHandlerOptions["onRequestStart"], { authToken, saleorApiUrl }: { authToken: string; saleorApiUrl: string } ) { if (onRequestStart) { @@ -305,7 +252,7 @@ export class RegisterActionHandler allowedSaleorUrls, }: { saleorApiUrl: string; - allowedSaleorUrls: AppRegisterHandlerOptions["allowedSaleorUrls"]; + allowedSaleorUrls: GenericCreateAppRegisterHandlerOptions["allowedSaleorUrls"]; }) { if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { debug( @@ -326,7 +273,7 @@ export class RegisterActionHandler return null; } - private async checkAplIsConfigured(apl: AppRegisterHandlerOptions["apl"]) { + private async checkAplIsConfigured(apl: GenericCreateAppRegisterHandlerOptions["apl"]) { const { configured: aplConfigured } = await apl.isConfigured(); if (!aplConfigured) { @@ -408,7 +355,7 @@ export class RegisterActionHandler } private async handleOnRequestVerifiedCallback( - onRequestVerified: AppRegisterHandlerOptions["onRequestVerified"], + onRequestVerified: GenericCreateAppRegisterHandlerOptions["onRequestVerified"], authData: AuthData ) { if (onRequestVerified) { @@ -436,8 +383,8 @@ export class RegisterActionHandler authData, }: { apl: APL; - onAplSetFailed: AppRegisterHandlerOptions["onAplSetFailed"]; - onAuthAplSaved: AppRegisterHandlerOptions["onAuthAplSaved"]; + onAplSetFailed: GenericCreateAppRegisterHandlerOptions["onAplSetFailed"]; + onAuthAplSaved: GenericCreateAppRegisterHandlerOptions["onAuthAplSaved"]; authData: AuthData; }) { try { diff --git a/src/handlers/platforms/fetch-api/create-app-register-handler.test.ts b/src/handlers/platforms/fetch-api/create-app-register-handler.test.ts new file mode 100644 index 00000000..b3cf49cf --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-app-register-handler.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AuthData } from "@/APL"; +import { SALEOR_API_URL_HEADER } from "@/const"; +import * as fetchRemoteJwksModule from "@/fetch-remote-jwks"; +import * as getAppIdModule from "@/get-app-id"; +import { MockAPL } from "@/test-utils/mock-apl"; + +import { + createAppRegisterHandler, + CreateAppRegisterHandlerOptions, +} from "./create-app-register-handler"; + +describe("Fetch API createAppRegisterHandler", () => { + const mockJwksValue = "{}"; + const mockAppId = "42"; + const saleorApiUrl = "https://mock-saleor-domain.saleor.cloud/graphql/"; + const authToken = "mock-auth-token"; + + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue(mockJwksValue); + vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue(mockAppId); + let mockApl: MockAPL; + let request: Request; + + beforeEach(() => { + mockApl = new MockAPL(); + request = new Request("https://example.com", { + method: "POST", + headers: { + "Content-Type": "application/json", + Host: "mock-slaeor-domain.saleor.cloud", + "X-Forwarded-Proto": "https", + [SALEOR_API_URL_HEADER]: saleorApiUrl, + }, + body: JSON.stringify({ auth_token: authToken }), + }); + }); + + it("Sets auth data for correct request", async () => { + const handler = createAppRegisterHandler({ apl: mockApl }); + const response = await handler(request); + + expect(response.status).toBe(200); + expect(mockApl.set).toHaveBeenCalledWith({ + saleorApiUrl, + token: authToken, + appId: mockAppId, + jwks: mockJwksValue, + }); + }); + + it("Returns 403 for prohibited Saleor URLs", async () => { + request.headers.set(SALEOR_API_URL_HEADER, "https://wrong-domain.saleor.cloud/graphql/"); + const handler = createAppRegisterHandler({ + apl: mockApl, + allowedSaleorUrls: [saleorApiUrl], + }); + + const response = await handler(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + }); + + it("Handles invalid JSON bodies", async () => { + const brokenRequest = new Request("https://example.com", { + method: "POST", + headers: { + "Content-Type": "application/json", + Host: "mock-slaeor-domain.saleor.cloud", + "X-Forwarded-Proto": "https", + [SALEOR_API_URL_HEADER]: saleorApiUrl, + }, + body: "{ ", + }); + const handler = createAppRegisterHandler({ + apl: mockApl, + allowedSaleorUrls: [saleorApiUrl], + }); + + const response = await handler(brokenRequest); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Invalid request json."); + }); + + describe("Callback hooks", () => { + const expectedAuthData: AuthData = { + token: authToken, + saleorApiUrl, + jwks: mockJwksValue, + appId: mockAppId, + }; + + it("Triggers success callbacks when APL save succeeds", async () => { + const mockOnRequestStart = vi.fn(); + const mockOnRequestVerified = vi.fn(); + const mockOnAuthAplFailed = vi.fn(); + const mockOnAuthAplSaved = vi.fn(); + + const handler = createAppRegisterHandler({ + apl: mockApl, + onRequestStart: mockOnRequestStart, + onRequestVerified: mockOnRequestVerified, + onAplSetFailed: mockOnAuthAplFailed, + onAuthAplSaved: mockOnAuthAplSaved, + }); + + await handler(request); + + expect(mockOnRequestStart).toHaveBeenCalledWith( + request, + expect.objectContaining({ + authToken, + saleorApiUrl, + }) + ); + expect(mockOnRequestVerified).toHaveBeenCalledWith( + request, + expect.objectContaining({ + authData: expectedAuthData, + }) + ); + expect(mockOnAuthAplSaved).toHaveBeenCalledWith( + request, + expect.objectContaining({ + authData: expectedAuthData, + }) + ); + expect(mockOnAuthAplFailed).not.toHaveBeenCalled(); + }); + + it("Triggers failure callback when APL save fails", async () => { + const mockOnAuthAplFailed = vi.fn(); + const mockOnAuthAplSaved = vi.fn(); + + mockApl.set.mockRejectedValueOnce(new Error("Save failed")); + + const handler = createAppRegisterHandler({ + apl: mockApl, + onAplSetFailed: mockOnAuthAplFailed, + onAuthAplSaved: mockOnAuthAplSaved, + }); + + await handler(request); + + expect(mockOnAuthAplFailed).toHaveBeenCalledWith( + request, + expect.objectContaining({ + error: expect.any(Error), + authData: expectedAuthData, + }) + ); + }); + + it("Allows custom error responses via hooks", async () => { + const mockOnRequestStart = vi + .fn>() + .mockImplementation((_req, context) => + context.respondWithError({ + status: 401, + message: "test message", + }) + ); + const handler = createAppRegisterHandler({ + apl: mockApl, + onRequestStart: mockOnRequestStart, + }); + + const response = await handler(request); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: "test message", + }, + success: false, + }); + expect(mockOnRequestStart).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/handlers/platforms/fetch-api/create-app-register-handler.ts b/src/handlers/platforms/fetch-api/create-app-register-handler.ts new file mode 100644 index 00000000..a3625977 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-app-register-handler.ts @@ -0,0 +1,36 @@ +import { RegisterActionHandler } from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; + +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; + +/** + * Returns API route handler for Web API compatible request handlers + * (examples: Next.js app router, hono, deno, etc.) + * that use signature: (req: Request) => Response + * where Request and Response are Fetch API objects + * + * Handler is for register endpoint that is called by Saleor when installing the app + * + * It verifies the request and stores `app_token` from Saleor + * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) + * + * **Recommended path**: `/api/register` + * (configured in manifest handler) + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): WebApiHandler => + async (req) => { + const adapter = new WebApiAdapter(req); + const useCase = new RegisterActionHandler(adapter); + const result = await useCase.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/fetch-api/create-manifest-handler.test.ts b/src/handlers/platforms/fetch-api/create-manifest-handler.test.ts new file mode 100644 index 00000000..a3dee3d9 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-manifest-handler.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SALEOR_SCHEMA_VERSION } from "@/const"; + +import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; + +describe("Fetch API createManifestHandler", () => { + it("Creates a handler that responds with manifest, includes a request and baseUrl in factory method", async () => { + const baseUrl = "https://some-app-host.cloud"; + const request = new Request(baseUrl, { + headers: { + host: "some-app-host.cloud", + "x-forwarded-proto": "https", + [SALEOR_SCHEMA_VERSION]: "3.20", + }, + method: "GET", + }); + + const mockManifestFactory = vi + .fn() + .mockImplementation(({ appBaseUrl }) => ({ + name: "Test app", + tokenTargetUrl: `${appBaseUrl}/api/register`, + appUrl: appBaseUrl, + permissions: [], + id: "app-id", + version: "1", + })); + + const handler = createManifestHandler({ + manifestFactory: mockManifestFactory, + }); + + const response = await handler(request); + + expect(mockManifestFactory).toHaveBeenCalledWith({ + appBaseUrl: baseUrl, + request, + schemaVersion: 3.2, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + appUrl: "https://some-app-host.cloud", + id: "app-id", + name: "Test app", + permissions: [], + tokenTargetUrl: "https://some-app-host.cloud/api/register", + version: "1", + }); + }); +}); diff --git a/src/handlers/platforms/fetch-api/create-manifest-handler.ts b/src/handlers/platforms/fetch-api/create-manifest-handler.ts new file mode 100644 index 00000000..828157e6 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-manifest-handler.ts @@ -0,0 +1,34 @@ +import { + CreateManifestHandlerOptions as GenericHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; + +export type CreateManifestHandlerOptions = GenericHandlerOptions; + +/** Returns app manifest API route handler for Web API compatible request handlers + * (examples: Next.js app router, hono, deno, etc.) + * that use signature: (req: Request) => Response + * where Request and Response are Fetch API objects + * + * App manifest is an endpoint that Saleor will call your App metadata. + * It has the App's name and description, as well as all the necessary information to + * register webhooks, permissions, and extensions. + * + * **Recommended path**: `/api/manifest` + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * */ +export const createManifestHandler = + (config: CreateManifestHandlerOptions): WebApiHandler => + async (request: Request) => { + const adapter = new WebApiAdapter(request); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/fetch-api/create-protected-handler.test.ts b/src/handlers/platforms/fetch-api/create-protected-handler.test.ts new file mode 100644 index 00000000..ffd26ee3 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-protected-handler.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; +import { Permission } from "@/types"; + +import { createProtectedHandler } from "./create-protected-handler"; + +describe("Web API createProtectedHandler", () => { + const mockAPL = new MockAPL(); + const mockHandlerFn = vi.fn(); + + const mockHandlerContext: ProtectedHandlerContext = { + baseUrl: "https://example.com", + authData: { + token: mockAPL.mockToken, + saleorApiUrl: "https://example.saleor.cloud/graphql/", + appId: mockAPL.mockAppId, + jwks: mockAPL.mockJwks, + }, + user: { + email: "test@example.com", + userPermissions: [], + }, + }; + + let request: Request; + + beforeEach(() => { + request = new Request("https://example.com", { + headers: { + host: "some-saleor-host.cloud", + "x-forwarded-proto": "https", + }, + method: "GET", + }); + }); + + describe("validation", () => { + it("sends error when request validation fails", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "failure", + value: { + status: 401, + body: "Unauthorized", + bodyType: "string", + }, + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + const response = await handler(request); + + expect(mockHandlerFn).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + }); + + it("calls handler function when validation succeeds", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "ok", + value: mockHandlerContext, + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + await handler(request); + + expect(mockHandlerFn).toHaveBeenCalledWith(request, mockHandlerContext); + }); + }); + + describe("permissions handling", () => { + it("passes required permissions from factory input to validator", async () => { + const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); + const requiredPermissions: Permission[] = ["MANAGE_APPS"]; + + const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); + await handler(request); + + expect(validateRequestSpy).toHaveBeenCalledWith({ + apl: mockAPL, + requiredPermissions, + }); + }); + }); + + describe("error handling", () => { + it("returns 500 status when user handler function throws error", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "ok", + value: mockHandlerContext, + }); + + mockHandlerFn.mockImplementationOnce(() => { + throw new Error("Test error"); + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + const response = await handler(request); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/handlers/platforms/fetch-api/create-protected-handler.ts b/src/handlers/platforms/fetch-api/create-protected-handler.ts new file mode 100644 index 00000000..e360250a --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-protected-handler.ts @@ -0,0 +1,39 @@ +import { APL } from "@/APL"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; + +import { WebApiAdapter, WebApiHandler } from "./platform-adapter"; + +export type WebApiProtectedHandler = ( + request: Request, + ctx: ProtectedHandlerContext +) => Response | Promise; + +export const createProtectedHandler = + ( + handlerFn: WebApiProtectedHandler, + apl: APL, + requiredPermissions?: Permission[] + ): WebApiHandler => + async (request) => { + const adapter = new WebApiAdapter(request); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ + apl, + requiredPermissions, + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } + + const context = validationResult.value; + try { + return await handlerFn(request, context); + } catch (err) { + return new Response("Unexpected server error", { status: 500 }); + } + }; diff --git a/src/handlers/platforms/fetch-api/index.ts b/src/handlers/platforms/fetch-api/index.ts new file mode 100644 index 00000000..b1ad0cd4 --- /dev/null +++ b/src/handlers/platforms/fetch-api/index.ts @@ -0,0 +1,6 @@ +export * from "./create-app-register-handler"; +export * from "./create-manifest-handler"; +export * from "./create-protected-handler"; +export * from "./platform-adapter"; +export * from "./saleor-webhooks/saleor-async-webhook"; +export * from "./saleor-webhooks/saleor-sync-webhook"; diff --git a/src/handlers/platforms/fetch-api/platform-adapter.test.ts b/src/handlers/platforms/fetch-api/platform-adapter.test.ts new file mode 100644 index 00000000..e144b3f8 --- /dev/null +++ b/src/handlers/platforms/fetch-api/platform-adapter.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import { WebApiAdapter } from "./platform-adapter"; + +describe("WebApiAdapter", () => { + const headers = new Headers({ + "Content-Type": "application/json", + host: "example.com", + "x-forwarded-proto": "https, http", + }); + describe("getHeader", () => { + it("should return the corresponding header value or null", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers, + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + + expect(adapter.getHeader("host")).toBe("example.com"); + expect(adapter.getHeader("non-existent")).toBeNull(); + }); + }); + + describe("getBody", () => { + it("should return the parsed JSON body", async () => { + const sampleJson = { key: "value" }; + const request = new Request("https://example.com/api", { + method: "POST", + headers, + body: JSON.stringify(sampleJson), + }); + const adapter = new WebApiAdapter(request); + const body = await adapter.getBody(); + expect(body).toEqual(sampleJson); + }); + + it("should throw exception when JSON cannot be parsed", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers, + body: "{ ", // invalid JSON + }); + const adapter = new WebApiAdapter(request); + await expect(adapter.getBody()).rejects.toThrowError(); + }); + + it("should allow reading original request stream, not disturbing it", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers, + body: "{}", + }); + const adapter = new WebApiAdapter(request); + await adapter.getBody(); + expect(request.bodyUsed).toBe(false); + + // Reading should work because stream wasn't disturbed + await expect(request.text()).resolves.toBe("{}"); + }); + }); + + describe("getRawBody", () => { + it("should return the text body", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + "Content-Type": "text/plain", + host: "example.com", + }), + body: "plain text", + }); + const adapter = new WebApiAdapter(request); + const body = await adapter.getRawBody(); + expect(body).toBe("plain text"); + }); + + it("should allow reading original request stream, not disturbing it", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + "Content-Type": "text/plain", + host: "example.com", + }), + body: "plain text", + }); + const adapter = new WebApiAdapter(request); + await adapter.getRawBody(); + expect(request.bodyUsed).toBe(false); + + // Reading should work because stream wasn't disturbed + await expect(request.text()).resolves.toBe("plain text"); + }); + }); + + describe("getBaseUrl", () => { + it("should return base url with protocol from x-forwarded-proto", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "x-forwarded-proto": "https", + }), + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer https when x-forwarded-proto has multiple values", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "x-forwarded-proto": "https,http,wss", + }), + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer http when x-forwarded-proto has multiple values and https is not present", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "x-forwarded-proto": "wss,http", + }), + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.getBaseUrl()).toBe("http://example.com"); + }); + + it("should use first protocol when https is not present in x-forwarded-proto", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "x-forwarded-proto": "wss,ftp", + }), + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.getBaseUrl()).toBe("wss://example.com"); + }); + + it("should use protocol from Request URL when `x-forwarded-proto` header is not present", () => { + const request = new Request("http://example.com/api", { + method: "GET", + headers: new Headers({ + host: "example.org", + }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.getBaseUrl()).toBe("http://example.org"); + }); + + describe("method getter", () => { + it("should return POST method when used in request", () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + }), + body: JSON.stringify({ foo: "bar" }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.method).toBe("POST"); + }); + + it("should return GET method when used in request", () => { + const request = new Request("https://example.com/api", { + method: "GET", + headers: new Headers({ + host: "example.com", + }), + }); + const adapter = new WebApiAdapter(request); + expect(adapter.method).toBe("GET"); + }); + }); + + describe("send", () => { + it("should return a Response with a JSON body and appropriate headers", async () => { + const sampleJson = { foo: "bar" }; + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "Content-Type": "application/json", + }), + body: JSON.stringify(sampleJson), + }); + const adapter = new WebApiAdapter(request); + const response = await adapter.send({ + bodyType: "json" as const, + body: sampleJson, + status: 201, + }); + expect(response.status).toBe(201); + expect(response.headers.get("Content-Type")).toBe("application/json"); + + const responseBody = await response.json(); + expect(responseBody).toEqual(sampleJson); + }); + + it("should return a Response with plain text body and appropriate headers", async () => { + const request = new Request("https://example.com/api", { + method: "POST", + headers: new Headers({ + host: "example.com", + "Content-Type": "text/plain", + }), + body: "plain text", + }); + const adapter = new WebApiAdapter(request); + const response = await adapter.send({ + status: 200, + body: "Some text", + bodyType: "string", + }); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + + const responseBody = await response.text(); + expect(responseBody).toBe("Some text"); + }); + }); + }); +}); diff --git a/src/handlers/platforms/fetch-api/platform-adapter.ts b/src/handlers/platforms/fetch-api/platform-adapter.ts new file mode 100644 index 00000000..692dd82a --- /dev/null +++ b/src/handlers/platforms/fetch-api/platform-adapter.ts @@ -0,0 +1,75 @@ +import { + ActionHandlerResult, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type WebApiHandlerInput = Request; +export type WebApiHandler = (req: Request) => Response | Promise; + +/** PlatformAdapter for Web API (Fetch API: Request and Response) + * + * Platform adapters are used in Actions to handle generic request logic + * like getting body, headers, etc. + * + * Thanks to this Actions logic can be re-used for each platform + + * @see {PlatformAdapterInterface} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * + * */ +export class WebApiAdapter implements PlatformAdapterInterface { + constructor(public request: Request) {} + + getHeader(name: string) { + return this.request.headers.get(name); + } + + async getBody() { + const request = this.request.clone(); + + return request.json(); + } + + async getRawBody() { + const request = this.request.clone(); + return request.text(); + } + + getBaseUrl(): string { + const url = new URL(this.request.url); // This is safe, URL in Request object must be valid + const host = this.request.headers.get("host"); + const xForwardedProto = this.request.headers.get("x-forwarded-proto"); + + let protocol: string; + if (xForwardedProto) { + const xForwardedForProtocols = xForwardedProto.split(",").map((value) => value.trimStart()); + protocol = + xForwardedForProtocols.find((el) => el === "https") || + xForwardedForProtocols.find((el) => el === "http") || + xForwardedForProtocols[0]; + } else { + // Some providers (e.g. Deno Deploy) + // do not set x-forwarded-for header when handling request + // try to get it from URL + protocol = url.protocol.replace(":", ""); + } + + return `${protocol}://${host}`; + } + + get method() { + return this.request.method as "POST" | "GET"; + } + + async send(result: ActionHandlerResult): Promise { + const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; + + return new Response(body, { + status: result.status, + headers: { + "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", + }, + }); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.test.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.test.ts new file mode 100644 index 00000000..ac6c0f6e --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormatWebhookErrorResult } from "@/handlers/shared"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; +import { AsyncWebhookEventType } from "@/types"; + +import { SaleorAsyncWebhook } from "./saleor-async-webhook"; +import { WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; + +const webhookPath = "api/webhooks/product-updated"; +const baseUrl = "http://example.com"; + +describe("Web API SaleorAsyncWebhook", () => { + const mockAPL = new MockAPL(); + + const validConfig: WebhookConfig = { + apl: mockAPL, + event: "PRODUCT_UPDATED", + webhookPath, + query: "subscription { event { ... on ProductUpdated { product { id }}}}", + } as const; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("createHandler", () => { + it("validates request before passing it to provided handler function with context", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "product_updated", + payload: { data: "test_payload" }, + schemaVersion: 3.2, + authData: { + saleorApiUrl: mockAPL.workingSaleorApiUrl, + token: mockAPL.mockToken, + jwks: mockAPL.mockJwks, + appId: mockAPL.mockAppId, + }, + }, + }); + + const handler = vi + .fn() + .mockImplementation(() => new Response("OK", { status: 200 })); + + const webhook = new SaleorAsyncWebhook(validConfig); + const request = new Request(`${baseUrl}/webhook`); + + const wrappedHandler = webhook.createHandler(handler); + const response = await wrappedHandler(request); + + expect(response.status).toBe(200); + expect(handler).toBeCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + request, + expect.objectContaining({ + payload: { data: "test_payload" }, + authData: expect.objectContaining({ + saleorApiUrl: mockAPL.workingSaleorApiUrl, + }), + }) + ); + }); + + it("prevents handler execution when validation fails and returns error", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); + + const webhook = new SaleorAsyncWebhook(validConfig); + const handler = vi.fn(); + const request = new Request(`${baseUrl}/webhook`); + + const wrappedHandler = webhook.createHandler(handler); + const response = await wrappedHandler(request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe("Unexpected error while handling request"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should allow overriding error responses using formatErrorResponse", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); + + const mockFormatErrorResponse = vi.fn().mockResolvedValue({ + body: "Custom error", + code: 418, + } as FormatWebhookErrorResult); + const webhook = new SaleorAsyncWebhook({ + ...validConfig, + formatErrorResponse: mockFormatErrorResponse, + }); + + const request = new Request(`${baseUrl}/webhook`, { + method: "POST", + headers: { "saleor-event": "invalid_event" }, + }); + + const handler = vi.fn(); + const wrappedHandler = webhook.createHandler(handler); + const response = await wrappedHandler(request); + + expect(response.status).toBe(418); + await expect(response.text()).resolves.toBe("Custom error"); + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 00000000..027d2452 --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,20 @@ +import { AsyncWebhookEventType } from "@/types"; + +import { WebApiHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; + +export class SaleorAsyncWebhook extends SaleorWebApiWebhook { + readonly event: AsyncWebhookEventType; + + protected readonly eventType = "async" as const; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler(handlerFn: WebApiWebhookHandler): WebApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.test.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.test.ts new file mode 100644 index 00000000..83dbea60 --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormatWebhookErrorResult } from "@/handlers/shared"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; + +import { SaleorSyncWebhook, WebApiSyncWebhookHandler } from "./saleor-sync-webhook"; + +describe("Web API SaleorSyncWebhook", () => { + const mockAPL = new MockAPL(); + const baseUrl = "http://saleor-app.com"; + const webhookConfiguration = { + apl: mockAPL, + webhookPath: "api/webhooks/checkout-calculate-taxes", + event: "CHECKOUT_CALCULATE_TAXES", + query: "subscription { event { ... on CheckoutCalculateTaxes { payload } } }", + name: "Webhook test name", + isActive: true, + } as const; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should validate request and return Response", async () => { + type Payload = { data: "test_payload" }; + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "checkout_calculate_taxes", + payload: { data: "test_payload" }, + schemaVersion: 3.19, + authData: { + token: webhookConfiguration.apl.mockToken, + jwks: webhookConfiguration.apl.mockJwks, + saleorApiUrl: webhookConfiguration.apl.workingSaleorApiUrl, + appId: webhookConfiguration.apl.mockAppId, + }, + }, + }); + + const handler = vi + .fn>() + .mockImplementation((_request, ctx) => { + const responsePayload = ctx.buildResponse({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 1.08 }], + shipping_price_gross_amount: 2, + shipping_tax_rate: 8, + shipping_price_net_amount: 1, + }); + return new Response(JSON.stringify(responsePayload), { status: 200 }); + }); + + const saleorSyncWebhook = new SaleorSyncWebhook(webhookConfiguration); + + // Note: Requests are not representative of a real one, + // we mock resolved value from webhook validator, which parses request + const request = new Request(`${baseUrl}/webhook`); + + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + const response = await wrappedHandler(request); + + expect(response.status).toBe(200); + expect(handler).toBeCalledTimes(1); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 1.08 }], + shipping_price_gross_amount: 2, + shipping_tax_rate: 8, + shipping_price_net_amount: 1, + }) + ); + }); + + it("should return error when request is not valid", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); + + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...webhookConfiguration, + }); + + const handler = vi.fn(); + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + + // Note: Requests are not representative of a real one, + // we mock resolved value from webhook validator, which parses request + const request = new Request(`${baseUrl}/webhook`); + const response = await wrappedHandler(request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe("Unexpected error while handling request"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should allow overriding error responses using formatErrorResponse", async () => { + const error = new Error("Test error"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error, + }); + + const mockFormatErrorResponse = vi.fn().mockResolvedValue({ + body: "Custom error", + code: 418, + } as FormatWebhookErrorResult); + + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...webhookConfiguration, + formatErrorResponse: mockFormatErrorResponse, + }); + + const handler = vi.fn(); + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + + // Note: Requests are not representative of a real one, + // we mock resolved value from webhook validator, which parses request + const request = new Request(`${baseUrl}/webhook`); + const response = await wrappedHandler(request); + + expect(mockFormatErrorResponse).toHaveBeenCalledWith(error, request); + expect(response.status).toBe(418); + await expect(response.text()).resolves.toBe("Custom error"); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 00000000..af5e8a89 --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,34 @@ +import { SyncWebhookInjectedContext } from "@/handlers/shared"; +import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@/types"; + +import { WebApiHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; + +export type WebApiSyncWebhookHandler< + TPayload, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> = WebApiWebhookHandler>; + +export class SaleorSyncWebhook< + TPayload = unknown, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> extends SaleorWebApiWebhook> { + readonly event: TEvent; + + protected readonly eventType = "sync" as const; + + protected extraContext = { + buildResponse: buildSyncWebhookResponsePayload, + }; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler(handlerFn: WebApiSyncWebhookHandler): WebApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..f1d9ac5a --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,42 @@ +import { createDebug } from "@/debug"; +import { + GenericSaleorWebhook, + GenericWebhookConfig, +} from "@/handlers/shared/generic-saleor-webhook"; +import { WebhookContext } from "@/handlers/shared/saleor-webhook"; +import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "../platform-adapter"; + +const debug = createDebug("SaleorWebhook"); + +export type WebhookConfig = + GenericWebhookConfig; + +/** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ +export type WebApiWebhookHandler = ( + req: Request, + ctx: WebhookContext & TExtras +) => Response | Promise; + +export abstract class SaleorWebApiWebhook< + TPayload = unknown, + TExtras extends Record = {} +> extends GenericSaleorWebhook { + createHandler(handlerFn: WebApiWebhookHandler): WebApiHandler { + return async (req) => { + const adapter = new WebApiAdapter(req); + const prepareRequestResult = await super.prepareRequest(adapter); + + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } + + debug("Incoming request validated. Call handlerFn"); + return handlerFn(req, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); + }; + } +} diff --git a/src/handlers/platforms/next/create-app-register-handler.test.ts b/src/handlers/platforms/next/create-app-register-handler.test.ts index 5b62bddd..45d3f42b 100644 --- a/src/handlers/platforms/next/create-app-register-handler.test.ts +++ b/src/handlers/platforms/next/create-app-register-handler.test.ts @@ -2,11 +2,15 @@ import { createMocks } from "node-mocks-http"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { APL, AuthData } from "@/APL"; +import { SALEOR_API_URL_HEADER } from "@/const"; import * as fetchRemoteJwksModule from "@/fetch-remote-jwks"; import * as getAppIdModule from "@/get-app-id"; import { MockAPL } from "@/test-utils/mock-apl"; -import { createAppRegisterHandler } from "./create-app-register-handler"; +import { + createAppRegisterHandler, + CreateAppRegisterHandlerOptions, +} from "./create-app-register-handler"; const mockJwksValue = "{}"; const mockAppId = "42"; @@ -16,7 +20,7 @@ const mockAppId = "42"; vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("{}"); vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue("42"); -describe("create-app-register-handler", () => { +describe("Next.js createAppRegisterHandler", () => { let mockApl: APL; beforeEach(() => { @@ -35,7 +39,7 @@ describe("create-app-register-handler", () => { headers: { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", - "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", + [SALEOR_API_URL_HEADER]: "https://mock-saleor-domain.saleor.cloud/graphql/", }, method: "POST", }); @@ -199,19 +203,14 @@ describe("create-app-register-handler", () => { }); it("Allows to send custom error response via callback hook", async () => { - const mockOnRequestStart = vi.fn().mockImplementation( - ( - req, - context: { - respondWithError(params: { status: number; body: string; message: string }): Error; - } - ) => + const mockOnRequestStart = vi + .fn>() + .mockImplementation((_req, context) => context.respondWithError({ status: 401, - body: "test", message: "test message", }) - ); + ); const { res, req } = createMocks({ /** @@ -244,6 +243,7 @@ describe("create-app-register-handler", () => { message: "test message", }, }); + expect(mockOnRequestStart).toHaveBeenCalled(); }); }); }); diff --git a/src/handlers/platforms/next/create-manifest-handler.test.ts b/src/handlers/platforms/next/create-manifest-handler.test.ts index 7924feaf..5af517c6 100644 --- a/src/handlers/platforms/next/create-manifest-handler.test.ts +++ b/src/handlers/platforms/next/create-manifest-handler.test.ts @@ -1,50 +1,54 @@ import { createMocks } from "node-mocks-http"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SALEOR_SCHEMA_VERSION } from "@/const"; -import { AppManifest } from "@/types"; -import { createManifestHandler } from "./create-manifest-handler"; +import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; -describe("createManifestHandler", () => { +describe("Next.js createManifestHandler", () => { it("Creates a handler that responds with Manifest. Includes request in context", async () => { - expect.assertions(4); + const baseUrl = "https://some-app-host.cloud"; const { res, req } = createMocks({ headers: { - host: "some-saleor-host.cloud", + host: "some-app-host.cloud", "x-forwarded-proto": "https", [SALEOR_SCHEMA_VERSION]: "3.20", }, method: "GET", }); + const mockManifestFactory = vi + .fn() + .mockImplementation(({ appBaseUrl }) => ({ + name: "Test app", + tokenTargetUrl: `${appBaseUrl}/api/register`, + appUrl: appBaseUrl, + permissions: [], + id: "app-id", + version: "1", + })); + const handler = createManifestHandler({ - manifestFactory({ appBaseUrl, request }): AppManifest { - expect(request).toBeDefined(); - expect(request.headers.host).toBe("some-saleor-host.cloud"); - - return { - name: "Mock name", - tokenTargetUrl: `${appBaseUrl}/api/register`, - appUrl: appBaseUrl, - permissions: [], - id: "app-id", - version: "1", - }; - }, + manifestFactory: mockManifestFactory, }); await handler(req, res); + expect(mockManifestFactory).toHaveBeenCalledWith({ + appBaseUrl: baseUrl, + request: req, + schemaVersion: 3.2, + }); + expect(res.statusCode).toBe(200); expect(res._getJSONData()).toEqual({ - appUrl: "https://some-saleor-host.cloud", + appUrl: "https://some-app-host.cloud", id: "app-id", - name: "Mock name", + name: "Test app", permissions: [], - tokenTargetUrl: "https://some-saleor-host.cloud/api/register", + tokenTargetUrl: "https://some-app-host.cloud/api/register", version: "1", }); }); diff --git a/src/handlers/platforms/next/create-manifest-handler.ts b/src/handlers/platforms/next/create-manifest-handler.ts index cce8341e..66534e02 100644 --- a/src/handlers/platforms/next/create-manifest-handler.ts +++ b/src/handlers/platforms/next/create-manifest-handler.ts @@ -7,17 +7,23 @@ import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-ada export type CreateManifestHandlerOptions = GenericCreateManifestHandlerOptions; -/** - * Creates API handler for Next.js page router. +/** Returns app manifest API route handler for Next.js pages router * - * elps with Manifest creation, hides - * implementation details if possible - */ + * App manifest is an endpoint that Saleor will call your App metadata. + * It has the App's name and description, as well as all the necessary information to + * register webhooks, permissions, and extensions. + * + * **Recommended path**: `/api/manifest` + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} + * @see {@link https://nextjs.org/docs/pages/building-your-application/routing/api-routes} + * */ export const createManifestHandler = (options: CreateManifestHandlerOptions): NextJsHandler => - async (req, res) => { - const adapter = new NextJsAdapter(req, res); - const actionHandler = new ManifestActionHandler(adapter); - const result = await actionHandler.handleAction(options); - return adapter.send(result); - }; + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(options); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/next/create-protected-handler.test.ts b/src/handlers/platforms/next/create-protected-handler.test.ts index 98071e9d..acaa5f2f 100644 --- a/src/handlers/platforms/next/create-protected-handler.test.ts +++ b/src/handlers/platforms/next/create-protected-handler.test.ts @@ -1,13 +1,16 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, it, vi } from "vitest"; -import { ProtectedActionValidator, ProtectedHandlerContext } from "@/handlers/shared/protected-action-validator"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; import { MockAPL } from "@/test-utils/mock-apl"; import { Permission } from "@/types"; import { createProtectedHandler } from "./create-protected-handler"; -describe("createProtectedHandler", () => { +describe("Next.js createProtectedHandler", () => { const mockAPL = new MockAPL(); const mockHandlerFn = vi.fn(); const { req, res } = createMocks({ diff --git a/src/handlers/platforms/next/platform-adapter.ts b/src/handlers/platforms/next/platform-adapter.ts index 7b76fb96..9973aea8 100644 --- a/src/handlers/platforms/next/platform-adapter.ts +++ b/src/handlers/platforms/next/platform-adapter.ts @@ -9,10 +9,21 @@ import { export type NextJsHandlerInput = NextApiRequest; export type NextJsHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; +/** PlatformAdapter for Next.js /pages router API routes + * + * Platform adapters are used in Actions to handle generic request logic + * like getting body, headers, etc. + * + * Thanks to this Actions logic can be re-used for each platform + + * @see {PlatformAdapterInterface} + * @see {@link https://nextjs.org/docs/pages/building-your-application/routing/api-routes} + * + * */ export class NextJsAdapter implements PlatformAdapterInterface { readonly type = "next" as const; - constructor(public request: NextApiRequest, private res: NextApiResponse) { } + constructor(public request: NextApiRequest, private res: NextApiResponse) {} getHeader(name: string) { const header = this.request.headers[name]; @@ -39,7 +50,10 @@ export class NextJsAdapter implements PlatformAdapterInterface el === "https") || protocols.find((el => el === "http")) || protocols[0]; + const protocol = + protocols.find((el) => el === "https") || + protocols.find((el) => el === "http") || + protocols[0]; return `${protocol}://${host}`; } diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts index 763ac6b6..637f3f35 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts @@ -19,14 +19,14 @@ describe("Next.js SaleorAsyncWebhook", () => { vi.restoreAllMocks(); }); - const validAsyncWebhookConfiguration: WebhookConfig = { + const webhookConfig: WebhookConfig = { apl: mockAPL, event: "PRODUCT_UPDATED", webhookPath, query: "subscription { event { ... on ProductUpdated { product { id }}}}", } as const; - const saleorAsyncWebhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); + const saleorAsyncWebhook = new SaleorAsyncWebhook(webhookConfig); describe("getWebhookManifest", () => { it("should return full path to the webhook route based on given baseUrl", async () => { @@ -56,17 +56,17 @@ describe("Next.js SaleorAsyncWebhook", () => { baseUrl: "example.com", event: "product_updated", payload: { data: "test_payload" }, - schemaVersion: 3.19, + schemaVersion: 3.2, authData: { - token: "token", - jwks: "", - saleorApiUrl: "https://example.com/graphql/", - appId: "12345", + saleorApiUrl: mockAPL.workingSaleorApiUrl, + token: mockAPL.mockToken, + jwks: mockAPL.mockJwks, + appId: mockAPL.mockAppId, }, }, }); - const testHandler: NextJsWebhookHandler = vi.fn().mockImplementation((_req, res, context) => { + const handler: NextJsWebhookHandler = vi.fn().mockImplementation((_req, res, context) => { if (context.payload.data === "test_payload") { res.status(200).end(); return; @@ -75,25 +75,28 @@ describe("Next.js SaleorAsyncWebhook", () => { }); const { req, res } = createMocks(); - const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler); + const wrappedHandler = saleorAsyncWebhook.createHandler(handler); await wrappedHandler(req, res); expect(res.statusCode).toBe(200); - expect(testHandler).toBeCalledTimes(1); + expect(handler).toBeCalledTimes(1); }); - it("prevents handler execution when validation fails", async () => { - const handler = vi.fn(); - const webhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); - + it("prevents handler execution when validation fails and returns error", async () => { vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ result: "failure", error: new Error("Test error"), }); + const handler = vi.fn(); + const webhook = new SaleorAsyncWebhook(webhookConfig); const { req, res } = createMocks(); - await webhook.createHandler(handler)(req, res); + const wrappedHandler = webhook.createHandler(handler); + await wrappedHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res._getData()).toBe("Unexpected error while handling request"); expect(handler).not.toHaveBeenCalled(); }); @@ -106,7 +109,7 @@ describe("Next.js SaleorAsyncWebhook", () => { }); const webhook = new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, + ...webhookConfig, onError: vi.fn(), formatErrorResponse, }); @@ -128,7 +131,7 @@ describe("Next.js SaleorAsyncWebhook", () => { it("calls onError and uses default JSON response when formatErrorResponse not provided", async () => { const webhookError = new WebhookError("Test error", "OTHER"); const webhook = new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, + ...webhookConfig, onError: vi.fn(), }); @@ -148,7 +151,7 @@ describe("Next.js SaleorAsyncWebhook", () => { }); describe("WebhookError code mapping", () => { - const webhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); + const webhook = new SaleorAsyncWebhook(webhookConfig); it("should map OTHER error to 500 status code", async () => { const webhookError = new WebhookError("Internal server error", "OTHER"); @@ -238,7 +241,7 @@ describe("Next.js SaleorAsyncWebhook", () => { }); const webhook = new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, + ...webhookConfig, onError: vi.fn(), formatErrorResponse, }); @@ -259,7 +262,7 @@ describe("Next.js SaleorAsyncWebhook", () => { it("calls onError and uses default text response when formatErrorResponse not provided", async () => { const webhook = new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, + ...webhookConfig, onError: vi.fn(), }); diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts index 6ac8b003..74b75710 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts @@ -1,18 +1,20 @@ import { NextApiHandler } from "next"; +import { SyncWebhookInjectedContext } from "@/handlers/shared"; import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; import { SyncWebhookEventType } from "@/types"; import { NextJsWebhookHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; -type InjectedContext = { - buildResponse: typeof buildSyncWebhookResponsePayload; -}; +export type NextJsSyncWebhookHandler< + TPayload, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> = NextJsWebhookHandler>; export class SaleorSyncWebhook< TPayload = unknown, TEvent extends SyncWebhookEventType = SyncWebhookEventType -> extends SaleorWebhook> { +> extends SaleorWebhook> { readonly event: TEvent; protected readonly eventType = "sync" as const; @@ -27,14 +29,7 @@ export class SaleorSyncWebhook< this.event = configuration.event; } - createHandler( - handlerFn: NextJsWebhookHandler< - TPayload, - { - buildResponse: typeof buildSyncWebhookResponsePayload; - } - > - ): NextApiHandler { + createHandler(handlerFn: NextJsSyncWebhookHandler): NextApiHandler { return super.createHandler(handlerFn); } } diff --git a/src/handlers/shared/create-app-register-handler-types.ts b/src/handlers/shared/create-app-register-handler-types.ts index a35d369d..edfe2e17 100644 --- a/src/handlers/shared/create-app-register-handler-types.ts +++ b/src/handlers/shared/create-app-register-handler-types.ts @@ -24,7 +24,6 @@ export type GenericCreateAppRegisterHandlerOptions = HasAPL & { request: RequestType, context: { authToken?: string; - saleorDomain?: string; saleorApiUrl?: string; respondWithError: CallbackErrorHandler; } diff --git a/src/handlers/shared/generic-saleor-webhook.ts b/src/handlers/shared/generic-saleor-webhook.ts index 82f15312..63b9b80a 100644 --- a/src/handlers/shared/generic-saleor-webhook.ts +++ b/src/handlers/shared/generic-saleor-webhook.ts @@ -4,6 +4,7 @@ import { APL } from "@/APL"; import { createDebug } from "@/debug"; import { gqlAstToString } from "@/gql-ast-to-string"; import { + FormatWebhookErrorResult, WebhookContext, WebhookError, WebhookErrorCodeMap, @@ -29,10 +30,7 @@ export interface GenericWebhookConfig< formatErrorResponse?( error: WebhookError | Error, request: RequestType - ): Promise<{ - code: number; - body: string; - }>; + ): Promise; query: string | ASTNode; } diff --git a/src/handlers/shared/saleor-webhook.ts b/src/handlers/shared/saleor-webhook.ts index fe524908..e207daa9 100644 --- a/src/handlers/shared/saleor-webhook.ts +++ b/src/handlers/shared/saleor-webhook.ts @@ -1,4 +1,7 @@ +import { SyncWebhookEventType } from "@/types"; + import { AuthData } from "../../APL"; +import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; export const WebhookErrorCodeMap: Record = { OTHER: 500, @@ -54,3 +57,12 @@ export type WebhookContext = { /** Added in Saleor 3.15 */ schemaVersion: number | null; }; + +export type SyncWebhookInjectedContext = { + buildResponse: typeof buildSyncWebhookResponsePayload; +}; + +export type FormatWebhookErrorResult = { + code: number; + body: string; +}; diff --git a/tsup.config.ts b/tsup.config.ts index fc399ee9..09c4652a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ // Mapped exports "handlers/next/index": "src/handlers/platforms/next/index.ts", + "handlers/fetch-api/index": "src/handlers/platforms/fetch-api/index.ts", + + // Virtual export + "handlers/next-app-router/index": "src/handlers/platforms/fetch-api/index.ts", }, dts: true, clean: true, From 6f4bd163dd9a22145d032a75ec126c46bcc0e92c Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 5 Feb 2025 17:19:37 +0100 Subject: [PATCH 2/2] Add changeset --- .changeset/rare-tools-change.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rare-tools-change.md diff --git a/.changeset/rare-tools-change.md b/.changeset/rare-tools-change.md new file mode 100644 index 00000000..d07d1c18 --- /dev/null +++ b/.changeset/rare-tools-change.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Added handlers for Web API: Request and Response