Skip to content

Commit 853abaa

Browse files
authored
Add AWS Lambda handlers (#404)
* Add lambda and web api handlers Add exports Update JSDocs Add tests for register handler Remove `saleorDomain` from type Rename aws-lambda file Throw errors in adapters, instead of resolving to null Add AWS lambda tests Fix lambda types Fix lambda headers Add lambda manifest test, fix headers resolution Fetch API: add tests for protected handler, fix types Add tests for lambda protected handler, fix types Simplify Fetch API adapter logic, add tests for adapter Add lambda tests Refactor sync webhook names, refactor types Add web api sync webhook tests Add async webhook tests Add lambda sync webhook test Fix types Removed unused functions Remove fetch-api * Add changeset * Add missing exports * Fix invalid stage name resolution * Fix base URL parsing when it includes it's own path * Fix comment * Fix type error in tests
1 parent 4fa8271 commit 853abaa

19 files changed

+1225
-1
lines changed

.changeset/serious-lamps-rhyme.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@saleor/app-sdk": patch
3+
---
4+
5+
Added AWS Lambda platform handlers

package.json

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@changesets/cli": "2.27.1",
4848
"@testing-library/dom": "^8.17.1",
4949
"@testing-library/react": "^13.4.0",
50+
"@types/aws-lambda": "^8.10.147",
5051
"@types/debug": "^4.1.7",
5152
"@types/node": "^18.7.15",
5253
"@types/react": "18.0.21",
@@ -155,6 +156,11 @@
155156
"import": "./handlers/fetch-api/index.mjs",
156157
"require": "./handlers/fetch-api/index.js"
157158
},
159+
"./handlers/aws-lambda": {
160+
"types": "./handlers/aws-lambda/index.d.ts",
161+
"import": "./handlers/aws-lambda/index.mjs",
162+
"require": "./handlers/aws-lambda/index.js"
163+
},
158164
"./handlers/shared": {
159165
"types": "./handlers/shared/index.d.ts",
160166
"import": "./handlers/shared/index.mjs",

pnpm-lock.yaml

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type { APIGatewayProxyEventV2 } from "aws-lambda";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { AuthData } from "@/APL";
5+
import { SALEOR_API_URL_HEADER } from "@/const";
6+
import * as fetchRemoteJwksModule from "@/fetch-remote-jwks";
7+
import * as getAppIdModule from "@/get-app-id";
8+
import { MockAPL } from "@/test-utils/mock-apl";
9+
10+
import {
11+
createAppRegisterHandler,
12+
CreateAppRegisterHandlerOptions,
13+
} from "./create-app-register-handler";
14+
import { createLambdaEvent, mockLambdaContext } from "./test-utils";
15+
16+
describe("AWS Lambda createAppRegisterHandler", () => {
17+
const mockJwksValue = "{}";
18+
const mockAppId = "42";
19+
const saleorApiUrl = "https://mock-saleor-domain.saleor.cloud/graphql/";
20+
const authToken = "mock-auth-token";
21+
22+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue(mockJwksValue);
23+
vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue(mockAppId);
24+
25+
let mockApl: MockAPL;
26+
let event: APIGatewayProxyEventV2;
27+
beforeEach(() => {
28+
mockApl = new MockAPL();
29+
event = createLambdaEvent({
30+
body: JSON.stringify({ auth_token: authToken }),
31+
headers: {
32+
"content-type": "application/json",
33+
host: "mock-slaeor-domain.saleor.cloud",
34+
"x-forwarded-proto": "https",
35+
[SALEOR_API_URL_HEADER]: saleorApiUrl,
36+
},
37+
});
38+
});
39+
40+
it("Sets auth data for correct Lambda event", async () => {
41+
const handler = createAppRegisterHandler({ apl: mockApl });
42+
const response = await handler(event, mockLambdaContext);
43+
44+
expect(response.statusCode).toBe(200);
45+
expect(mockApl.set).toHaveBeenCalledWith({
46+
saleorApiUrl,
47+
token: authToken,
48+
appId: mockAppId,
49+
jwks: mockJwksValue,
50+
});
51+
});
52+
53+
it("Returns 403 for prohibited Saleor URLs in Lambda event", async () => {
54+
event.headers[SALEOR_API_URL_HEADER] = "https://wrong-domain.saleor.cloud/graphql/";
55+
56+
const handler = createAppRegisterHandler({
57+
apl: mockApl,
58+
allowedSaleorUrls: [(url) => url === "https://correct-domain.saleor.cloud"],
59+
});
60+
61+
const response = await handler(event, mockLambdaContext);
62+
const body = JSON.parse(response.body!);
63+
64+
expect(response.statusCode).toBe(403);
65+
expect(body.success).toBe(false);
66+
});
67+
68+
it("Handles invalid JSON bodies in Lambda event", async () => {
69+
event.body = "{ ";
70+
const handler = createAppRegisterHandler({ apl: mockApl });
71+
const response = await handler(event, mockLambdaContext);
72+
73+
expect(response.statusCode).toBe(400);
74+
expect(response.body).toBe("Invalid request json.");
75+
});
76+
77+
describe("Lambda callback hooks", () => {
78+
const expectedAuthData: AuthData = {
79+
token: authToken,
80+
saleorApiUrl,
81+
jwks: mockJwksValue,
82+
appId: mockAppId,
83+
};
84+
85+
it("Triggers success callbacks with Lambda event context", async () => {
86+
const mockOnRequestStart = vi.fn();
87+
const mockOnRequestVerified = vi.fn();
88+
const mockOnAuthAplFailed = vi.fn();
89+
const mockOnAuthAplSaved = vi.fn();
90+
91+
const handler = createAppRegisterHandler({
92+
apl: mockApl,
93+
onRequestStart: mockOnRequestStart,
94+
onRequestVerified: mockOnRequestVerified,
95+
onAplSetFailed: mockOnAuthAplFailed,
96+
onAuthAplSaved: mockOnAuthAplSaved,
97+
});
98+
99+
await handler(event, mockLambdaContext);
100+
101+
expect(mockOnRequestStart).toHaveBeenCalledWith(
102+
event,
103+
expect.objectContaining({
104+
authToken,
105+
saleorApiUrl,
106+
})
107+
);
108+
expect(mockOnRequestVerified).toHaveBeenCalledWith(
109+
event,
110+
expect.objectContaining({
111+
authData: expectedAuthData,
112+
})
113+
);
114+
expect(mockOnAuthAplSaved).toHaveBeenCalledWith(
115+
event,
116+
expect.objectContaining({
117+
authData: expectedAuthData,
118+
})
119+
);
120+
expect(mockOnAuthAplFailed).not.toHaveBeenCalled();
121+
});
122+
123+
it("Triggers failure callback when APL save fails", async () => {
124+
const mockOnAuthAplFailed = vi.fn();
125+
const mockOnAuthAplSaved = vi.fn();
126+
127+
mockApl.set.mockRejectedValueOnce(new Error("Save failed"));
128+
129+
const handler = createAppRegisterHandler({
130+
apl: mockApl,
131+
onAplSetFailed: mockOnAuthAplFailed,
132+
onAuthAplSaved: mockOnAuthAplSaved,
133+
});
134+
135+
await handler(event, mockLambdaContext);
136+
137+
expect(mockOnAuthAplFailed).toHaveBeenCalledWith(
138+
event,
139+
expect.objectContaining({
140+
error: expect.any(Error),
141+
authData: expectedAuthData,
142+
})
143+
);
144+
});
145+
146+
it("Allows custom error responses via hooks", async () => {
147+
const mockOnRequestStart = vi
148+
.fn<NonNullable<CreateAppRegisterHandlerOptions["onRequestStart"]>>()
149+
.mockImplementation((_req, context) =>
150+
context.respondWithError({
151+
status: 401,
152+
message: "test message",
153+
})
154+
);
155+
const handler = createAppRegisterHandler({
156+
apl: mockApl,
157+
onRequestStart: mockOnRequestStart,
158+
});
159+
160+
const response = await handler(event, mockLambdaContext);
161+
162+
expect(response.statusCode).toBe(401);
163+
expect(JSON.parse(response.body!)).toStrictEqual({
164+
error: {
165+
code: "REGISTER_HANDLER_HOOK_ERROR",
166+
message: "test message",
167+
},
168+
success: false,
169+
});
170+
expect(mockOnRequestStart).toHaveBeenCalled();
171+
});
172+
});
173+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { RegisterActionHandler } from "@/handlers/actions/register-action-handler";
2+
import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types";
3+
4+
import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter";
5+
6+
export type CreateAppRegisterHandlerOptions =
7+
GenericCreateAppRegisterHandlerOptions<AwsLambdaHandlerInput>;
8+
9+
/**
10+
* Returns API route handler for AWS Lambda HTTP triggered events
11+
* (created by Amazon API Gateway, Lambda Function URL)
12+
* that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2
13+
*
14+
* Handler is for register endpoint that is called by Saleor when installing the app
15+
*
16+
* It verifies the request and stores `app_token` from Saleor
17+
* in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...)
18+
*
19+
* **Recommended path**: `/api/register`
20+
* (configured in manifest handler)
21+
*
22+
* To learn more check Saleor docs
23+
* @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url}
24+
* @see {@link https://www.npmjs.com/package/@types/aws-lambda}
25+
* */
26+
export const createAppRegisterHandler =
27+
(config: CreateAppRegisterHandlerOptions): AWSLambdaHandler =>
28+
async (event, context) => {
29+
const adapter = new AwsLambdaAdapter(event, context);
30+
const useCase = new RegisterActionHandler(adapter);
31+
const result = await useCase.handleAction(config);
32+
return adapter.send(result);
33+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import { SALEOR_SCHEMA_VERSION } from "@/const";
4+
5+
import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler";
6+
import { createLambdaEvent, mockLambdaContext } from "./test-utils";
7+
8+
describe("AWS Lambda createManifestHandler", () => {
9+
it("Creates a handler that responds with manifest, includes a request and baseUrl in factory method", async () => {
10+
// Note: This event uses $default stage which means it's not included in the URL
11+
// More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
12+
// also see platform-adapter
13+
const event = createLambdaEvent({
14+
method: "GET",
15+
path: "/manifest",
16+
headers: {
17+
"content-type": "application/json",
18+
host: "some-app-host.cloud",
19+
"x-forwarded-proto": "https",
20+
[SALEOR_SCHEMA_VERSION]: "3.20",
21+
},
22+
});
23+
const expectedBaseUrl = "https://some-app-host.cloud";
24+
25+
const mockManifestFactory = vi
26+
.fn<CreateManifestHandlerOptions["manifestFactory"]>()
27+
.mockImplementation(({ appBaseUrl }) => ({
28+
name: "Test app",
29+
tokenTargetUrl: `${appBaseUrl}/api/register`,
30+
appUrl: appBaseUrl,
31+
permissions: [],
32+
id: "app-id",
33+
version: "1",
34+
}));
35+
36+
const handler = createManifestHandler({
37+
manifestFactory: mockManifestFactory,
38+
});
39+
40+
const response = await handler(event, mockLambdaContext);
41+
42+
expect(mockManifestFactory).toHaveBeenCalledWith(
43+
expect.objectContaining({
44+
appBaseUrl: expectedBaseUrl,
45+
request: event,
46+
schemaVersion: 3.2,
47+
})
48+
);
49+
expect(response.statusCode).toBe(200);
50+
expect(JSON.parse(response.body!)).toStrictEqual({
51+
appUrl: expectedBaseUrl,
52+
id: "app-id",
53+
name: "Test app",
54+
permissions: [],
55+
tokenTargetUrl: `${expectedBaseUrl}/api/register`,
56+
version: "1",
57+
});
58+
});
59+
60+
it("Works with event that has AWS Lambda stage", async () => {
61+
// Note: AWS lambda uses stages which are passed in lambda request context
62+
// Contexts are appended to the lambda base URL, like so: <baseUrl>/<stage>
63+
// In this case we're simulating test stage, which results in <baseUrl>/test
64+
// More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
65+
const event = createLambdaEvent({
66+
method: "GET",
67+
path: "/manifest",
68+
headers: {
69+
"content-type": "application/json",
70+
host: "some-app-host.cloud",
71+
"x-forwarded-proto": "https",
72+
[SALEOR_SCHEMA_VERSION]: "3.20",
73+
},
74+
requestContext: {
75+
stage: "test",
76+
},
77+
});
78+
const expectedBaseUrl = "https://some-app-host.cloud/test";
79+
80+
const mockManifestFactory = vi
81+
.fn<CreateManifestHandlerOptions["manifestFactory"]>()
82+
.mockImplementation(({ appBaseUrl }) => ({
83+
name: "Test app",
84+
tokenTargetUrl: `${appBaseUrl}/api/register`,
85+
appUrl: appBaseUrl,
86+
permissions: [],
87+
id: "app-id",
88+
version: "1",
89+
}));
90+
91+
const handler = createManifestHandler({
92+
manifestFactory: mockManifestFactory,
93+
});
94+
95+
const response = await handler(event, mockLambdaContext);
96+
97+
expect(mockManifestFactory).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
appBaseUrl: expectedBaseUrl,
100+
request: event,
101+
schemaVersion: 3.2,
102+
})
103+
);
104+
expect(response.statusCode).toBe(200);
105+
expect(JSON.parse(response.body!)).toStrictEqual({
106+
appUrl: expectedBaseUrl,
107+
id: "app-id",
108+
name: "Test app",
109+
permissions: [],
110+
tokenTargetUrl: `${expectedBaseUrl}/api/register`,
111+
version: "1",
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)