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 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,