Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AWS Lambda handlers #404

Merged
merged 7 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/serious-lamps-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/app-sdk": patch
---

Added AWS Lambda platform handlers
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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<NonNullable<CreateAppRegisterHandlerOptions["onRequestStart"]>>()
.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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<AwsLambdaHandlerInput>;

/**
* 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);
};
114 changes: 114 additions & 0 deletions src/handlers/platforms/aws-lambda/create-manifest-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<CreateManifestHandlerOptions["manifestFactory"]>()
.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: <baseUrl>/<stage>
// In this case we're simulating test stage, which results in <baseUrl>/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<CreateManifestHandlerOptions["manifestFactory"]>()
.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",
});
});
});
Loading