Skip to content

Commit 4fa8271

Browse files
authored
Add Web API platform handlers (#403)
* Add lambda and web api handlers Add exports Update JSDocs Add tests for register handler Remove `saleorDomain` from type Rename aws-lambda file Throw errors in adapters, instead of resolving to null Add AWS lambda tests Fix lambda types Fix lambda headers Add lambda manifest test, fix headers resolution Fetch API: add tests for protected handler, fix types Add tests for lambda protected handler, fix types Simplify Fetch API adapter logic, add tests for adapter Add lambda tests Refactor sync webhook names, refactor types Add web api sync webhook tests Add async webhook tests Add lambda sync webhook test Fix types Remove aws-lambda handlers (separate PR) Remove lambda Removed unused functions * Add changeset
1 parent 51caa77 commit 4fa8271

28 files changed

+1251
-147
lines changed

.changeset/rare-tools-change.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@saleor/app-sdk": minor
3+
---
4+
5+
Added handlers for Web API: Request and Response

package.json

+10
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@
145145
"import": "./handlers/next/index.mjs",
146146
"require": "./handlers/next/index.js"
147147
},
148+
"./handlers/fetch-api": {
149+
"types": "./handlers/fetch-api/index.d.ts",
150+
"import": "./handlers/fetch-api/index.mjs",
151+
"require": "./handlers/fetch-api/index.js"
152+
},
153+
"./handlers/next-app-router": {
154+
"types": "./handlers/fetch-api/index.d.ts",
155+
"import": "./handlers/fetch-api/index.mjs",
156+
"require": "./handlers/fetch-api/index.js"
157+
},
148158
"./handlers/shared": {
149159
"types": "./handlers/shared/index.d.ts",
150160
"import": "./handlers/shared/index.mjs",

src/handlers/actions/register-action-handler.ts

+8-61
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { SALEOR_API_URL_HEADER } from "@/const";
44
import { createDebug } from "@/debug";
55
import { fetchRemoteJwks } from "@/fetch-remote-jwks";
66
import { getAppId } from "@/get-app-id";
7-
import { HasAPL } from "@/saleor-app";
87

8+
import { GenericCreateAppRegisterHandlerOptions } from "../shared";
99
import {
1010
ActionHandlerInterface,
1111
ActionHandlerResult,
@@ -66,59 +66,6 @@ export type HookCallbackErrorParams = {
6666

6767
export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never;
6868

69-
export type AppRegisterHandlerOptions<Request> = HasAPL & {
70-
/**
71-
* Protect app from being registered in Saleor other than specific.
72-
* By default, allow everything.
73-
*
74-
* Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/)
75-
* or a function that receives a full Saleor API URL ad returns true/false.
76-
*/
77-
allowedSaleorUrls?: Array<string | ((saleorApiUrl: string) => boolean)>;
78-
/**
79-
* Run right after Saleor calls this endpoint
80-
*/
81-
onRequestStart?(
82-
request: Request,
83-
context: {
84-
authToken?: string;
85-
saleorApiUrl?: string;
86-
respondWithError: CallbackErrorHandler;
87-
}
88-
): Promise<void>;
89-
/**
90-
* Run after all security checks
91-
*/
92-
onRequestVerified?(
93-
request: Request,
94-
context: {
95-
authData: AuthData;
96-
respondWithError: CallbackErrorHandler;
97-
}
98-
): Promise<void>;
99-
/**
100-
* Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error
101-
*/
102-
onAuthAplSaved?(
103-
request: Request,
104-
context: {
105-
authData: AuthData;
106-
respondWithError: CallbackErrorHandler;
107-
}
108-
): Promise<void>;
109-
/**
110-
* Run after APL fails to set AuthData
111-
*/
112-
onAplSetFailed?(
113-
request: Request,
114-
context: {
115-
authData: AuthData;
116-
error: unknown;
117-
respondWithError: CallbackErrorHandler;
118-
}
119-
): Promise<void>;
120-
};
121-
12269
export class RegisterActionHandler<I>
12370
implements ActionHandlerInterface<RegisterHandlerResponseBody>
12471
{
@@ -142,7 +89,7 @@ export class RegisterActionHandler<I>
14289
}
14390

14491
async handleAction(
145-
config: AppRegisterHandlerOptions<I>
92+
config: GenericCreateAppRegisterHandlerOptions<I>
14693
): Promise<ActionHandlerResult<RegisterHandlerResponseBody>> {
14794
debug("Request received");
14895

@@ -278,7 +225,7 @@ export class RegisterActionHandler<I>
278225
}
279226

280227
private async handleOnRequestStartCallback(
281-
onRequestStart: AppRegisterHandlerOptions<I>["onRequestStart"],
228+
onRequestStart: GenericCreateAppRegisterHandlerOptions<I>["onRequestStart"],
282229
{ authToken, saleorApiUrl }: { authToken: string; saleorApiUrl: string }
283230
) {
284231
if (onRequestStart) {
@@ -305,7 +252,7 @@ export class RegisterActionHandler<I>
305252
allowedSaleorUrls,
306253
}: {
307254
saleorApiUrl: string;
308-
allowedSaleorUrls: AppRegisterHandlerOptions<I>["allowedSaleorUrls"];
255+
allowedSaleorUrls: GenericCreateAppRegisterHandlerOptions<I>["allowedSaleorUrls"];
309256
}) {
310257
if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) {
311258
debug(
@@ -326,7 +273,7 @@ export class RegisterActionHandler<I>
326273
return null;
327274
}
328275

329-
private async checkAplIsConfigured(apl: AppRegisterHandlerOptions<I>["apl"]) {
276+
private async checkAplIsConfigured(apl: GenericCreateAppRegisterHandlerOptions<I>["apl"]) {
330277
const { configured: aplConfigured } = await apl.isConfigured();
331278

332279
if (!aplConfigured) {
@@ -408,7 +355,7 @@ export class RegisterActionHandler<I>
408355
}
409356

410357
private async handleOnRequestVerifiedCallback(
411-
onRequestVerified: AppRegisterHandlerOptions<I>["onRequestVerified"],
358+
onRequestVerified: GenericCreateAppRegisterHandlerOptions<I>["onRequestVerified"],
412359
authData: AuthData
413360
) {
414361
if (onRequestVerified) {
@@ -436,8 +383,8 @@ export class RegisterActionHandler<I>
436383
authData,
437384
}: {
438385
apl: APL;
439-
onAplSetFailed: AppRegisterHandlerOptions<I>["onAplSetFailed"];
440-
onAuthAplSaved: AppRegisterHandlerOptions<I>["onAuthAplSaved"];
386+
onAplSetFailed: GenericCreateAppRegisterHandlerOptions<I>["onAplSetFailed"];
387+
onAuthAplSaved: GenericCreateAppRegisterHandlerOptions<I>["onAuthAplSaved"];
441388
authData: AuthData;
442389
}) {
443390
try {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { AuthData } from "@/APL";
4+
import { SALEOR_API_URL_HEADER } from "@/const";
5+
import * as fetchRemoteJwksModule from "@/fetch-remote-jwks";
6+
import * as getAppIdModule from "@/get-app-id";
7+
import { MockAPL } from "@/test-utils/mock-apl";
8+
9+
import {
10+
createAppRegisterHandler,
11+
CreateAppRegisterHandlerOptions,
12+
} from "./create-app-register-handler";
13+
14+
describe("Fetch API createAppRegisterHandler", () => {
15+
const mockJwksValue = "{}";
16+
const mockAppId = "42";
17+
const saleorApiUrl = "https://mock-saleor-domain.saleor.cloud/graphql/";
18+
const authToken = "mock-auth-token";
19+
20+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue(mockJwksValue);
21+
vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue(mockAppId);
22+
let mockApl: MockAPL;
23+
let request: Request;
24+
25+
beforeEach(() => {
26+
mockApl = new MockAPL();
27+
request = new Request("https://example.com", {
28+
method: "POST",
29+
headers: {
30+
"Content-Type": "application/json",
31+
Host: "mock-slaeor-domain.saleor.cloud",
32+
"X-Forwarded-Proto": "https",
33+
[SALEOR_API_URL_HEADER]: saleorApiUrl,
34+
},
35+
body: JSON.stringify({ auth_token: authToken }),
36+
});
37+
});
38+
39+
it("Sets auth data for correct request", async () => {
40+
const handler = createAppRegisterHandler({ apl: mockApl });
41+
const response = await handler(request);
42+
43+
expect(response.status).toBe(200);
44+
expect(mockApl.set).toHaveBeenCalledWith({
45+
saleorApiUrl,
46+
token: authToken,
47+
appId: mockAppId,
48+
jwks: mockJwksValue,
49+
});
50+
});
51+
52+
it("Returns 403 for prohibited Saleor URLs", async () => {
53+
request.headers.set(SALEOR_API_URL_HEADER, "https://wrong-domain.saleor.cloud/graphql/");
54+
const handler = createAppRegisterHandler({
55+
apl: mockApl,
56+
allowedSaleorUrls: [saleorApiUrl],
57+
});
58+
59+
const response = await handler(request);
60+
const data = await response.json();
61+
62+
expect(response.status).toBe(403);
63+
expect(data.success).toBe(false);
64+
});
65+
66+
it("Handles invalid JSON bodies", async () => {
67+
const brokenRequest = new Request("https://example.com", {
68+
method: "POST",
69+
headers: {
70+
"Content-Type": "application/json",
71+
Host: "mock-slaeor-domain.saleor.cloud",
72+
"X-Forwarded-Proto": "https",
73+
[SALEOR_API_URL_HEADER]: saleorApiUrl,
74+
},
75+
body: "{ ",
76+
});
77+
const handler = createAppRegisterHandler({
78+
apl: mockApl,
79+
allowedSaleorUrls: [saleorApiUrl],
80+
});
81+
82+
const response = await handler(brokenRequest);
83+
84+
expect(response.status).toBe(400);
85+
await expect(response.text()).resolves.toBe("Invalid request json.");
86+
});
87+
88+
describe("Callback hooks", () => {
89+
const expectedAuthData: AuthData = {
90+
token: authToken,
91+
saleorApiUrl,
92+
jwks: mockJwksValue,
93+
appId: mockAppId,
94+
};
95+
96+
it("Triggers success callbacks when APL save succeeds", async () => {
97+
const mockOnRequestStart = vi.fn();
98+
const mockOnRequestVerified = vi.fn();
99+
const mockOnAuthAplFailed = vi.fn();
100+
const mockOnAuthAplSaved = vi.fn();
101+
102+
const handler = createAppRegisterHandler({
103+
apl: mockApl,
104+
onRequestStart: mockOnRequestStart,
105+
onRequestVerified: mockOnRequestVerified,
106+
onAplSetFailed: mockOnAuthAplFailed,
107+
onAuthAplSaved: mockOnAuthAplSaved,
108+
});
109+
110+
await handler(request);
111+
112+
expect(mockOnRequestStart).toHaveBeenCalledWith(
113+
request,
114+
expect.objectContaining({
115+
authToken,
116+
saleorApiUrl,
117+
})
118+
);
119+
expect(mockOnRequestVerified).toHaveBeenCalledWith(
120+
request,
121+
expect.objectContaining({
122+
authData: expectedAuthData,
123+
})
124+
);
125+
expect(mockOnAuthAplSaved).toHaveBeenCalledWith(
126+
request,
127+
expect.objectContaining({
128+
authData: expectedAuthData,
129+
})
130+
);
131+
expect(mockOnAuthAplFailed).not.toHaveBeenCalled();
132+
});
133+
134+
it("Triggers failure callback when APL save fails", async () => {
135+
const mockOnAuthAplFailed = vi.fn();
136+
const mockOnAuthAplSaved = vi.fn();
137+
138+
mockApl.set.mockRejectedValueOnce(new Error("Save failed"));
139+
140+
const handler = createAppRegisterHandler({
141+
apl: mockApl,
142+
onAplSetFailed: mockOnAuthAplFailed,
143+
onAuthAplSaved: mockOnAuthAplSaved,
144+
});
145+
146+
await handler(request);
147+
148+
expect(mockOnAuthAplFailed).toHaveBeenCalledWith(
149+
request,
150+
expect.objectContaining({
151+
error: expect.any(Error),
152+
authData: expectedAuthData,
153+
})
154+
);
155+
});
156+
157+
it("Allows custom error responses via hooks", async () => {
158+
const mockOnRequestStart = vi
159+
.fn<NonNullable<CreateAppRegisterHandlerOptions["onRequestStart"]>>()
160+
.mockImplementation((_req, context) =>
161+
context.respondWithError({
162+
status: 401,
163+
message: "test message",
164+
})
165+
);
166+
const handler = createAppRegisterHandler({
167+
apl: mockApl,
168+
onRequestStart: mockOnRequestStart,
169+
});
170+
171+
const response = await handler(request);
172+
173+
expect(response.status).toBe(401);
174+
await expect(response.json()).resolves.toStrictEqual({
175+
error: {
176+
code: "REGISTER_HANDLER_HOOK_ERROR",
177+
message: "test message",
178+
},
179+
success: false,
180+
});
181+
expect(mockOnRequestStart).toHaveBeenCalled();
182+
});
183+
});
184+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { RegisterActionHandler } from "@/handlers/actions/register-action-handler";
2+
import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types";
3+
4+
import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter";
5+
6+
export type CreateAppRegisterHandlerOptions =
7+
GenericCreateAppRegisterHandlerOptions<WebApiHandlerInput>;
8+
9+
/**
10+
* Returns API route handler for Web API compatible request handlers
11+
* (examples: Next.js app router, hono, deno, etc.)
12+
* that use signature: (req: Request) => Response
13+
* where Request and Response are Fetch API objects
14+
*
15+
* Handler is for register endpoint that is called by Saleor when installing the app
16+
*
17+
* It verifies the request and stores `app_token` from Saleor
18+
* in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...)
19+
*
20+
* **Recommended path**: `/api/register`
21+
* (configured in manifest handler)
22+
*
23+
* To learn more check Saleor docs
24+
* @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url}
25+
*
26+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response}
27+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request}
28+
* */
29+
export const createAppRegisterHandler =
30+
(config: CreateAppRegisterHandlerOptions): WebApiHandler =>
31+
async (req) => {
32+
const adapter = new WebApiAdapter(req);
33+
const useCase = new RegisterActionHandler(adapter);
34+
const result = await useCase.handleAction(config);
35+
return adapter.send(result);
36+
};

0 commit comments

Comments
 (0)