diff --git a/.changeset/serious-lamps-rhyme.md b/.changeset/serious-lamps-rhyme.md new file mode 100644 index 00000000..d967a1d3 --- /dev/null +++ b/.changeset/serious-lamps-rhyme.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": patch +--- + +Added AWS Lambda platform handlers diff --git a/package.json b/package.json index b4a2a092..3eaf1c0a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@changesets/cli": "2.27.1", "@testing-library/dom": "^8.17.1", "@testing-library/react": "^13.4.0", + "@types/aws-lambda": "^8.10.147", "@types/debug": "^4.1.7", "@types/node": "^18.7.15", "@types/react": "18.0.21", @@ -155,6 +156,11 @@ "import": "./handlers/fetch-api/index.mjs", "require": "./handlers/fetch-api/index.js" }, + "./handlers/aws-lambda": { + "types": "./handlers/aws-lambda/index.d.ts", + "import": "./handlers/aws-lambda/index.mjs", + "require": "./handlers/aws-lambda/index.js" + }, "./handlers/shared": { "types": "./handlers/shared/index.d.ts", "import": "./handlers/shared/index.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94412890..8d1c963e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@testing-library/react': specifier: ^13.4.0 version: 13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/aws-lambda': + specifier: ^8.10.147 + version: 8.10.147 '@types/debug': specifier: ^4.1.7 version: 4.1.7 @@ -818,6 +821,9 @@ packages: '@types/aria-query@4.2.2': resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==} + '@types/aws-lambda@8.10.147': + resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4318,6 +4324,8 @@ snapshots: '@types/aria-query@4.2.2': {} + '@types/aws-lambda@8.10.147': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.20.7 diff --git a/src/handlers/platforms/aws-lambda/create-app-register-handler.test.ts b/src/handlers/platforms/aws-lambda/create-app-register-handler.test.ts new file mode 100644 index 00000000..0d73b977 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-app-register-handler.test.ts @@ -0,0 +1,173 @@ +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +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"; +import { createLambdaEvent, mockLambdaContext } from "./test-utils"; + +describe("AWS Lambda 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 event: APIGatewayProxyEventV2; + beforeEach(() => { + mockApl = new MockAPL(); + event = createLambdaEvent({ + body: JSON.stringify({ auth_token: authToken }), + headers: { + "content-type": "application/json", + host: "mock-slaeor-domain.saleor.cloud", + "x-forwarded-proto": "https", + [SALEOR_API_URL_HEADER]: saleorApiUrl, + }, + }); + }); + + it("Sets auth data for correct Lambda event", async () => { + const handler = createAppRegisterHandler({ apl: mockApl }); + const response = await handler(event, mockLambdaContext); + + expect(response.statusCode).toBe(200); + expect(mockApl.set).toHaveBeenCalledWith({ + saleorApiUrl, + token: authToken, + appId: mockAppId, + jwks: mockJwksValue, + }); + }); + + it("Returns 403 for prohibited Saleor URLs in Lambda event", async () => { + event.headers[SALEOR_API_URL_HEADER] = "https://wrong-domain.saleor.cloud/graphql/"; + + const handler = createAppRegisterHandler({ + apl: mockApl, + allowedSaleorUrls: [(url) => url === "https://correct-domain.saleor.cloud"], + }); + + const response = await handler(event, mockLambdaContext); + const body = JSON.parse(response.body!); + + expect(response.statusCode).toBe(403); + expect(body.success).toBe(false); + }); + + it("Handles invalid JSON bodies in Lambda event", async () => { + event.body = "{ "; + const handler = createAppRegisterHandler({ apl: mockApl }); + const response = await handler(event, mockLambdaContext); + + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Invalid request json."); + }); + + describe("Lambda callback hooks", () => { + const expectedAuthData: AuthData = { + token: authToken, + saleorApiUrl, + jwks: mockJwksValue, + appId: mockAppId, + }; + + it("Triggers success callbacks with Lambda event context", 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(event, mockLambdaContext); + + expect(mockOnRequestStart).toHaveBeenCalledWith( + event, + expect.objectContaining({ + authToken, + saleorApiUrl, + }) + ); + expect(mockOnRequestVerified).toHaveBeenCalledWith( + event, + expect.objectContaining({ + authData: expectedAuthData, + }) + ); + expect(mockOnAuthAplSaved).toHaveBeenCalledWith( + event, + 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(event, mockLambdaContext); + + expect(mockOnAuthAplFailed).toHaveBeenCalledWith( + event, + 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(event, mockLambdaContext); + + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.body!)).toStrictEqual({ + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: "test message", + }, + success: false, + }); + expect(mockOnRequestStart).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/handlers/platforms/aws-lambda/create-app-register-handler.ts b/src/handlers/platforms/aws-lambda/create-app-register-handler.ts new file mode 100644 index 00000000..2313b445 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-app-register-handler.ts @@ -0,0 +1,33 @@ +import { RegisterActionHandler } from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; + +import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; + +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; + +/** + * Returns API route handler for AWS Lambda HTTP triggered events + * (created by Amazon API Gateway, Lambda Function URL) + * that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2 + * + * 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://www.npmjs.com/package/@types/aws-lambda} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const useCase = new RegisterActionHandler(adapter); + const result = await useCase.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/aws-lambda/create-manifest-handler.test.ts b/src/handlers/platforms/aws-lambda/create-manifest-handler.test.ts new file mode 100644 index 00000000..e5173d4a --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-manifest-handler.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SALEOR_SCHEMA_VERSION } from "@/const"; + +import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; +import { createLambdaEvent, mockLambdaContext } from "./test-utils"; + +describe("AWS Lambda createManifestHandler", () => { + it("Creates a handler that responds with manifest, includes a request and baseUrl in factory method", async () => { + // Note: This event uses $default stage which means it's not included in the URL + // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + // also see platform-adapter + const event = createLambdaEvent({ + method: "GET", + path: "/manifest", + headers: { + "content-type": "application/json", + host: "some-app-host.cloud", + "x-forwarded-proto": "https", + [SALEOR_SCHEMA_VERSION]: "3.20", + }, + }); + const expectedBaseUrl = "https://some-app-host.cloud"; + + 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(event, mockLambdaContext); + + expect(mockManifestFactory).toHaveBeenCalledWith( + expect.objectContaining({ + appBaseUrl: expectedBaseUrl, + request: event, + schemaVersion: 3.2, + }) + ); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body!)).toStrictEqual({ + appUrl: expectedBaseUrl, + id: "app-id", + name: "Test app", + permissions: [], + tokenTargetUrl: `${expectedBaseUrl}/api/register`, + version: "1", + }); + }); + + it("Works with event that has AWS Lambda stage", async () => { + // Note: AWS lambda uses stages which are passed in lambda request context + // Contexts are appended to the lambda base URL, like so: / + // In this case we're simulating test stage, which results in /test + // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + const event = createLambdaEvent({ + method: "GET", + path: "/manifest", + headers: { + "content-type": "application/json", + host: "some-app-host.cloud", + "x-forwarded-proto": "https", + [SALEOR_SCHEMA_VERSION]: "3.20", + }, + requestContext: { + stage: "test", + }, + }); + const expectedBaseUrl = "https://some-app-host.cloud/test"; + + 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(event, mockLambdaContext); + + expect(mockManifestFactory).toHaveBeenCalledWith( + expect.objectContaining({ + appBaseUrl: expectedBaseUrl, + request: event, + schemaVersion: 3.2, + }) + ); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body!)).toStrictEqual({ + appUrl: expectedBaseUrl, + id: "app-id", + name: "Test app", + permissions: [], + tokenTargetUrl: `${expectedBaseUrl}/api/register`, + version: "1", + }); + }); +}); diff --git a/src/handlers/platforms/aws-lambda/create-manifest-handler.ts b/src/handlers/platforms/aws-lambda/create-manifest-handler.ts new file mode 100644 index 00000000..457acbad --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-manifest-handler.ts @@ -0,0 +1,30 @@ +import { + CreateManifestHandlerOptions as GenericHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; + +import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; + +export type CreateManifestHandlerOptions = GenericHandlerOptions; + +/** Returns app manifest API route handler for AWS Lambda HTTP triggered events + * (created by Amazon API Gateway, Lambda Function URL) + * that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2 + * + * 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} + * */ +export const createManifestHandler = + (config: CreateManifestHandlerOptions): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/aws-lambda/create-protected-handler.test.ts b/src/handlers/platforms/aws-lambda/create-protected-handler.test.ts new file mode 100644 index 00000000..ac686159 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-protected-handler.test.ts @@ -0,0 +1,105 @@ +import { 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 { AwsLambdaProtectedHandler, createProtectedHandler } from "./create-protected-handler"; +import { createLambdaEvent, mockLambdaContext } from "./test-utils"; + +describe("AWS Lambda createProtectedHandler", () => { + const mockAPL = new MockAPL(); + const mockHandlerFn = vi.fn(() => ({ + statusCode: 200, + body: "success", + })); + + 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: [], + }, + }; + + const event = createLambdaEvent({ + 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(event, mockLambdaContext); + + expect(mockHandlerFn).not.toHaveBeenCalled(); + expect(response.statusCode).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(event, mockLambdaContext); + + expect(mockHandlerFn).toHaveBeenCalledWith(event, mockLambdaContext, mockHandlerContext); + }); + }); + + describe("permissions handling", () => { + it("checks if required permissions are satisfies using validator", async () => { + const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); + const requiredPermissions: Permission[] = ["MANAGE_APPS"]; + + const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); + await handler(event, mockLambdaContext); + + 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(event, mockLambdaContext); + + expect(response.statusCode).toBe(500); + }); + }); +}); diff --git a/src/handlers/platforms/aws-lambda/create-protected-handler.ts b/src/handlers/platforms/aws-lambda/create-protected-handler.ts new file mode 100644 index 00000000..c4298a0d --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-protected-handler.ts @@ -0,0 +1,49 @@ +import { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2, Context } from "aws-lambda"; + +import { APL } from "@/APL"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; + +import { AwsLambdaAdapter, AWSLambdaHandler } from "./platform-adapter"; + +export type AwsLambdaProtectedHandler = ( + event: APIGatewayProxyEventV2, + context: Context, + saleorContext: ProtectedHandlerContext +) => Promise | APIGatewayProxyStructuredResultV2; + +/** + * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. + * Also provides additional `saleorContext` object containing request properties. + */ +export const createProtectedHandler = + ( + handlerFn: AwsLambdaProtectedHandler, + apl: APL, + requiredPermissions?: Permission[] + ): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ + apl, + requiredPermissions, + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } + + const saleorContext = validationResult.value; + try { + return await handlerFn(event, context, saleorContext); + } catch (err) { + return { + statusCode: 500, + body: "Unexpected Server Error", + }; + } + }; diff --git a/src/handlers/platforms/aws-lambda/index.ts b/src/handlers/platforms/aws-lambda/index.ts new file mode 100644 index 00000000..b1ad0cd4 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/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/aws-lambda/platform-adapter.test.ts b/src/handlers/platforms/aws-lambda/platform-adapter.test.ts new file mode 100644 index 00000000..da5e548e --- /dev/null +++ b/src/handlers/platforms/aws-lambda/platform-adapter.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; + +import { AwsLambdaAdapter } from "./platform-adapter"; +import { createLambdaEvent, mockLambdaContext } from "./test-utils"; + +describe("AwsLambdaAdapter", () => { + describe("getHeader", () => { + it("should return the corresponding header value or null", () => { + const event = createLambdaEvent({ + headers: { + "content-type": "application/json", + host: "example.com", + "x-forwarded-proto": "https, http", + }, + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + // Works with all lowercase + expect(adapter.getHeader("host")).toBe("example.com"); + // Works with Pascal-case + expect(adapter.getHeader("Host")).toBe("example.com"); + expect(adapter.getHeader("X-Forwarded-Proto")).toBe("https, http"); + // Returns null for non existent headers + expect(adapter.getHeader("non-existent")).toBeNull(); + }); + }); + + describe("getBody", () => { + it("should return the parsed JSON body", async () => { + const sampleJson = { key: "value" }; + const event = createLambdaEvent({ + body: JSON.stringify(sampleJson), + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + const body = await adapter.getBody(); + expect(body).toEqual(sampleJson); + }); + + it("should throw exception when JSON cannot be parsed", async () => { + const event = createLambdaEvent({ + body: "{ ", // invalid JSON + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + await expect(adapter.getBody()).rejects.toThrowError(); + }); + + it("should return null when body is empty", async () => { + const event = createLambdaEvent({ body: undefined }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + const body = await adapter.getBody(); + expect(body).toBeNull(); + }); + }); + + describe("getRawBody", () => { + it("should return the text body", async () => { + const event = createLambdaEvent({ + body: "plain text", + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + const body = await adapter.getRawBody(); + expect(body).toBe("plain text"); + }); + + it("should return null when body is empty", async () => { + const event = createLambdaEvent({ body: undefined }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + const body = await adapter.getRawBody(); + expect(body).toBeNull(); + }); + }); + + describe("getBaseUrl", () => { + // Note: AWS lambda uses stages which are passed in lambda request context + // Contexts are appended to the lambda base URL, like so: / + // In this case we're simulating test stage, which results in /test + // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + + it("should return base url with protocol from x-forwarded-proto", () => { + const event = createLambdaEvent({ + headers: { + host: "example.com", + "x-forwarded-proto": "https", + }, + }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer https when x-forwarded-proto has multiple values", () => { + const event = createLambdaEvent({ + headers: { + host: "example.com", + "x-forwarded-proto": "https,http,wss", + }, + }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer http when x-forwarded-proto has multiple values and https is not present", () => { + const event = createLambdaEvent({ + headers: { + host: "example.com", + "x-forwarded-proto": "wss,http", + }, + }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("http://example.com"); + }); + + it("should use first protocol when http or https is not present in x-forwarded-proto", () => { + const event = createLambdaEvent({ + headers: { + host: "example.com", + "x-forwarded-proto": "wss,ftp", + }, + }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("wss://example.com"); + }); + + it("should use https as default when x-forwarded-proto header is not present", () => { + const event = createLambdaEvent({ + headers: { host: "example.org" }, + }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.org"); + }); + + it("should exclude $default stage in base url when present", () => { + const event = createLambdaEvent({ + headers: { + host: "example.org", + "x-forwarded-proto": "https", + }, + requestContext: { + stage: "$default", + }, + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.org"); + }); + + it("should exclude stage in base url if missing", () => { + const event = createLambdaEvent({ + headers: { + host: "example.org", + "x-forwarded-proto": "https", + }, + requestContext: { + stage: undefined, + }, + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.org"); + }); + + it("should include stage in base url when present", () => { + const event = createLambdaEvent({ + headers: { + host: "example.org", + "x-forwarded-proto": "https", + }, + requestContext: { + stage: "test", + }, + }); + + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.getBaseUrl()).toBe("https://example.org/test"); + }); + }); + + describe("method getter", () => { + it("should return POST method when used in request", () => { + const event = createLambdaEvent({ method: "POST" }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.method).toBe("POST"); + }); + + it("should return GET method when used in request", () => { + const event = createLambdaEvent({ method: "GET" }); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + expect(adapter.method).toBe("GET"); + }); + }); + + describe("send", () => { + it("should return a response with a JSON body and appropriate headers", async () => { + const event = createLambdaEvent(); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + const sampleJson = { foo: "bar" }; + + const response = await adapter.send({ + bodyType: "json", + body: sampleJson, + status: 201, + }); + + expect(response).toEqual({ + statusCode: 201, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sampleJson), + }); + }); + + it("should return a response with plain text body and appropriate headers", async () => { + const event = createLambdaEvent(); + const adapter = new AwsLambdaAdapter(event, mockLambdaContext); + + const response = await adapter.send({ + status: 200, + body: "Some text", + bodyType: "string", + }); + + expect(response).toEqual({ + statusCode: 200, + headers: { + "Content-Type": "text/plain", + }, + body: "Some text", + }); + }); + }); +}); diff --git a/src/handlers/platforms/aws-lambda/platform-adapter.ts b/src/handlers/platforms/aws-lambda/platform-adapter.ts new file mode 100644 index 00000000..606388de --- /dev/null +++ b/src/handlers/platforms/aws-lambda/platform-adapter.ts @@ -0,0 +1,110 @@ +import type { + APIGatewayProxyEventV2, + APIGatewayProxyStructuredResultV2, + Context, +} from "aws-lambda"; + +import { + ActionHandlerResult, + HTTPMethod, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type AwsLambdaHandlerInput = APIGatewayProxyEventV2; +export type AWSLambdaHandler = ( + event: APIGatewayProxyEventV2, + context: Context +) => Promise; + +/** PlatformAdapter for AWS Lambda HTTP events + * + * 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} + * */ +export class AwsLambdaAdapter implements PlatformAdapterInterface { + public request: AwsLambdaHandlerInput; + + constructor(private event: APIGatewayProxyEventV2, private context: Context) { + this.request = event; + } + + getHeader(requestedName: string): string | null { + // TODO: Check if it works correctly with both API gateway and Lambda Function URL + + // Lambda headers are always in lowercase for new deployments using API Gateway, + // they use HTTP/2 which requires them to be lowercase + // + // if there are multiple values they are separated by comma: , + // https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + const name = requestedName.toLocaleLowerCase(); + return this.request.headers[name] || null; + } + + async getBody(): Promise { + if (!this.request.body) { + return null; + } + + return JSON.parse(this.request.body); + } + + async getRawBody(): Promise { + if (!this.request.body) { + return null; + } + + return this.request.body; + } + + // This stage name is used when no stage name is provided in AWS CDK + // this means that stage name is not appended to the lambda URL + private DEFAULT_STAGE_NAME = "$default"; + + getBaseUrl(): string { + const xForwardedProto = this.getHeader("x-forwarded-proto") || "https"; + const host = this.getHeader("host"); + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + + // prefer https, then http over other protocols + const protocol = + protocols.find((el) => el === "https") || + protocols.find((el) => el === "http") || + protocols[0]; + + // API Gateway splits deployment into multiple stages which are + // included in the API url (e.g. /dev or /prod) + // default stage name means that it's not appended to the URL + // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + const { stage } = this.event.requestContext; + + if (stage && stage !== this.DEFAULT_STAGE_NAME) { + return `${protocol}://${host}/${stage}`; + } + + return `${protocol}://${host}`; + } + + get method(): HTTPMethod { + return this.event.requestContext.http.method as HTTPMethod; + } + + async send(result: ActionHandlerResult): Promise { + const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; + + return { + statusCode: result.status, + headers: { + "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", + }, + body, + }; + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 00000000..a00e3fbc --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,20 @@ +import { AsyncWebhookEventType } from "@/types"; + +import { AWSLambdaHandler } from "../platform-adapter"; +import { AwsLambdaWebhookHandler, SaleorWebApiWebhook, 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: AwsLambdaWebhookHandler): AWSLambdaHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.test.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.test.ts new file mode 100644 index 00000000..1949c06a --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.test.ts @@ -0,0 +1,171 @@ +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 { createLambdaEvent, mockLambdaContext } from "../test-utils"; +import { AwsLambdaSyncWebhookHandler, SaleorSyncWebhook } from "./saleor-sync-webhook"; + +describe("AWS Lambda SaleorSyncWebhook", () => { + const mockAPL = new MockAPL(); + + const webhookConfig = { + apl: mockAPL, + webhookPath: "api/webhooks/checkout-calculate-taxes", + event: "CHECKOUT_CALCULATE_TAXES" as const, + query: "subscription { event { ... on CheckoutCalculateTaxes { payload } } }", + name: "Lambda Webhook Test", + isActive: true, + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getWebhookManifest", () => { + it("returns valid URL when baseUrl has Lambda stage", () => { + const webhook = new SaleorSyncWebhook(webhookConfig); + expect(webhook.getWebhookManifest("https://aws-lambda.com/prod").targetUrl).toBe( + "https://aws-lambda.com/prod/api/webhooks/checkout-calculate-taxes" + ); + expect(webhook.getWebhookManifest("https://aws-lambda.com/prod/").targetUrl).toBe( + "https://aws-lambda.com/prod/api/webhooks/checkout-calculate-taxes" + ); + expect(webhook.getWebhookManifest("https://aws-lambda.com/test").targetUrl).toBe( + "https://aws-lambda.com/test/api/webhooks/checkout-calculate-taxes" + ); + }); + + it("returns valid URL when baseURl doesn't have lambda stage ($default stage)", () => { + const webhook = new SaleorSyncWebhook(webhookConfig); + expect(webhook.getWebhookManifest("https://aws-lambda.com/").targetUrl).toBe( + "https://aws-lambda.com/api/webhooks/checkout-calculate-taxes" + ); + }); + + it("returns valid URL if webhook path has forward slash", () => { + const webhook = new SaleorSyncWebhook({ + ...webhookConfig, + webhookPath: `/${webhookConfig.webhookPath}`, + }); + expect(webhook.getWebhookManifest("https://aws-lambda.com/prod").targetUrl).toBe( + "https://aws-lambda.com/prod/api/webhooks/checkout-calculate-taxes" + ); + expect(webhook.getWebhookManifest("https://aws-lambda.com/prod/").targetUrl).toBe( + "https://aws-lambda.com/prod/api/webhooks/checkout-calculate-taxes" + ); + expect(webhook.getWebhookManifest("https://aws-lambda.com/test").targetUrl).toBe( + "https://aws-lambda.com/test/api/webhooks/checkout-calculate-taxes" + ); + expect(webhook.getWebhookManifest("https://aws-lambda.com/").targetUrl).toBe( + "https://aws-lambda.com/api/webhooks/checkout-calculate-taxes" + ); + }); + }); + + describe("createHandler", () => { + it("should validate request and return successful tax calculation", 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: webhookConfig.apl.mockToken, + jwks: webhookConfig.apl.mockJwks, + saleorApiUrl: webhookConfig.apl.workingSaleorApiUrl, + appId: webhookConfig.apl.mockAppId, + }, + }, + }); + + const saleorSyncWebhook = new SaleorSyncWebhook(webhookConfig); + + const handler: AwsLambdaSyncWebhookHandler = vi + .fn() + .mockImplementation(async (_event, _context, ctx) => ({ + statusCode: 200, + body: JSON.stringify( + ctx.buildResponse({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 10.8 }], + shipping_price_gross_amount: 2.16, + shipping_tax_rate: 8, + shipping_price_net_amount: 2, + }) + ), + })); + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + + // Note: Events are not representative, + // we mock resolved value from webhook validator + const event = createLambdaEvent(); + + const response = await wrappedHandler(event, mockLambdaContext); + + expect(response.statusCode).toBe(200); + expect(handler).toBeCalledTimes(1); + expect(JSON.parse(response.body!)).toEqual({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 10.8 }], + shipping_price_gross_amount: 2.16, + shipping_tax_rate: 8, + shipping_price_net_amount: 2, + }); + }); + + it("should return 500 error when validation fails", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); + + const saleorSyncWebhook = new SaleorSyncWebhook(webhookConfig); + + const handler = vi.fn(); + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + + const event = createLambdaEvent(); + + const response = await wrappedHandler(event, mockLambdaContext); + + expect(response.statusCode).toBe(500); + expect(response.body).toContain("Unexpected error while handling request"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should use custom error format when provided", async () => { + const mockFormatErrorResponse = vi.fn().mockResolvedValue({ + body: "Customized error message", + code: 418, + } as FormatWebhookErrorResult); + + const error = new Error("Test error"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error, + }); + + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...webhookConfig, + formatErrorResponse: mockFormatErrorResponse, + }); + + const handler = vi.fn(); + const wrappedHandler = saleorSyncWebhook.createHandler(handler); + + // Note: Events are not representative, + // we mock resolved value from webhook validator + const event = createLambdaEvent(); + const response = await wrappedHandler(event, mockLambdaContext); + + expect(mockFormatErrorResponse).toHaveBeenCalledWith(error, event); + expect(response.statusCode).toBe(418); + expect(response.body).toBe("Customized error message"); + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 00000000..6f4c56ea --- /dev/null +++ b/src/handlers/platforms/aws-lambda/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 { AWSLambdaHandler } from "../platform-adapter"; +import { AwsLambdaWebhookHandler, SaleorWebApiWebhook, WebhookConfig } from "./saleor-webhook"; + +export type AwsLambdaSyncWebhookHandler< + TPayload, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> = AwsLambdaWebhookHandler>; + +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: AwsLambdaSyncWebhookHandler): AWSLambdaHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..0c6d03f2 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,49 @@ +import { APIGatewayProxyStructuredResultV2, Context } from "aws-lambda"; + +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 { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "../platform-adapter"; + +const debug = createDebug("SaleorWebhook"); + +export type WebhookConfig = + GenericWebhookConfig; + +/** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ +export type AwsLambdaWebhookHandler = ( + event: AwsLambdaHandlerInput, + context: Context, + ctx: WebhookContext & TExtras +) => Promise | APIGatewayProxyStructuredResultV2; + +export abstract class SaleorWebApiWebhook< + TPayload = unknown, + TExtras extends Record = {} +> extends GenericSaleorWebhook { + /** + * Wraps provided function, to ensure incoming request comes from registered Saleor instance. + * Also provides additional `context` object containing typed payload and request properties. + */ + createHandler(handlerFn: AwsLambdaWebhookHandler): AWSLambdaHandler { + return async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const prepareRequestResult = await super.prepareRequest(adapter); + + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } + + debug("Incoming request validated. Call handlerFn"); + return handlerFn(event, context, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); + }; + } +} diff --git a/src/handlers/platforms/aws-lambda/test-utils.ts b/src/handlers/platforms/aws-lambda/test-utils.ts new file mode 100644 index 00000000..a05d9158 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/test-utils.ts @@ -0,0 +1,63 @@ +import { APIGatewayProxyEventV2, Context } from "aws-lambda"; +import { vi } from "vitest"; + +export const mockLambdaContext: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: "testFunction", + functionVersion: "1", + invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789012:function:test", + memoryLimitInMB: "128", + awsRequestId: "test-request-id", + logGroupName: "test-log-group", + logStreamName: "test-log-stream", + getRemainingTimeInMillis: () => 10000, + done: vi.fn(), + fail: vi.fn(), + succeed: vi.fn(), +}; + +export function createLambdaEvent( + config: Omit, "requestContext"> & { + requestContext?: Partial; + path?: string; + method?: "POST" | "GET"; + } = {} +): APIGatewayProxyEventV2 { + const { + path = "/some-path", + method = "POST", + requestContext: requestContextOverrides, + ...overrides + } = config ?? {}; + + return { + version: "2.0", + routeKey: `${method} ${path}`, + rawPath: path, + rawQueryString: "", + headers: {}, + body: "", + isBase64Encoded: false, + ...overrides, + requestContext: { + accountId: "123456789012", + apiId: "api-id", + domainName: "example.com", + domainPrefix: "example", + http: { + method, + path, + protocol: "HTTP/1.1", + sourceIp: "192.168.0.1", + userAgent: "vitest-test", + ...requestContextOverrides?.http, + }, + requestId: "test-request-id", + routeKey: `${method} /${path}`, + stage: "$default", + time: "03/Feb/2025:16:00:00 +0000", + timeEpoch: Date.now(), + ...requestContextOverrides, + }, + }; +} diff --git a/src/handlers/shared/generic-saleor-webhook.ts b/src/handlers/shared/generic-saleor-webhook.ts index 63b9b80a..66ab1360 100644 --- a/src/handlers/shared/generic-saleor-webhook.ts +++ b/src/handlers/shared/generic-saleor-webhook.ts @@ -74,8 +74,21 @@ export abstract class GenericSaleorWebhook< this.formatErrorResponse = configuration.formatErrorResponse; } + /** Gets webhook absolute URL based on baseUrl of app + * baseUrl is passed usually from manifest + * baseUrl can include it's own pathname (e.g. http://aws-lambda.com/prod -> has /prod pathname) + * that should be included in full webhook URL, e.g. http://my-webhook.com/prod/api/webhook/order-created */ private getTargetUrl(baseUrl: string) { - return new URL(this.webhookPath, baseUrl).href; + const parsedBaseUrl = new URL(baseUrl); + + // Remove slash `/` at the beginning of webhook path + const normalizedWebhookPath = this.webhookPath.replace(/^\//, ""); + + /** Note: URL removes path from `baseUrl`, so we must add it to webhookPath + * URL.pathname = http://my-fn.com/path -> /path + * Replace double slashes // -> / (either from webhook path or baseUrl) */ + const fullPath = `${parsedBaseUrl.pathname}/${normalizedWebhookPath}`.replace("//", "/"); + return new URL(fullPath, baseUrl).href; } /** diff --git a/tsup.config.ts b/tsup.config.ts index 09c4652a..1192794a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -20,6 +20,7 @@ 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", + "handlers/aws-lambda/index": "src/handlers/platforms/aws-lambda/index.ts", // Virtual export "handlers/next-app-router/index": "src/handlers/platforms/fetch-api/index.ts",