Skip to content

Commit

Permalink
Add Web API platform handlers (#403)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
witoszekdev authored Feb 6, 2025
1 parent 51caa77 commit 4fa8271
Show file tree
Hide file tree
Showing 28 changed files with 1,251 additions and 147 deletions.
5 changes: 5 additions & 0 deletions .changeset/rare-tools-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/app-sdk": minor
---

Added handlers for Web API: Request and Response
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 8 additions & 61 deletions src/handlers/actions/register-action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,59 +66,6 @@ export type HookCallbackErrorParams = {

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

export type AppRegisterHandlerOptions<Request> = 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<string | ((saleorApiUrl: string) => boolean)>;
/**
* Run right after Saleor calls this endpoint
*/
onRequestStart?(
request: Request,
context: {
authToken?: string;
saleorApiUrl?: string;
respondWithError: CallbackErrorHandler;
}
): Promise<void>;
/**
* Run after all security checks
*/
onRequestVerified?(
request: Request,
context: {
authData: AuthData;
respondWithError: CallbackErrorHandler;
}
): Promise<void>;
/**
* 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<void>;
/**
* Run after APL fails to set AuthData
*/
onAplSetFailed?(
request: Request,
context: {
authData: AuthData;
error: unknown;
respondWithError: CallbackErrorHandler;
}
): Promise<void>;
};

export class RegisterActionHandler<I>
implements ActionHandlerInterface<RegisterHandlerResponseBody>
{
Expand All @@ -142,7 +89,7 @@ export class RegisterActionHandler<I>
}

async handleAction(
config: AppRegisterHandlerOptions<I>
config: GenericCreateAppRegisterHandlerOptions<I>
): Promise<ActionHandlerResult<RegisterHandlerResponseBody>> {
debug("Request received");

Expand Down Expand Up @@ -278,7 +225,7 @@ export class RegisterActionHandler<I>
}

private async handleOnRequestStartCallback(
onRequestStart: AppRegisterHandlerOptions<I>["onRequestStart"],
onRequestStart: GenericCreateAppRegisterHandlerOptions<I>["onRequestStart"],
{ authToken, saleorApiUrl }: { authToken: string; saleorApiUrl: string }
) {
if (onRequestStart) {
Expand All @@ -305,7 +252,7 @@ export class RegisterActionHandler<I>
allowedSaleorUrls,
}: {
saleorApiUrl: string;
allowedSaleorUrls: AppRegisterHandlerOptions<I>["allowedSaleorUrls"];
allowedSaleorUrls: GenericCreateAppRegisterHandlerOptions<I>["allowedSaleorUrls"];
}) {
if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) {
debug(
Expand All @@ -326,7 +273,7 @@ export class RegisterActionHandler<I>
return null;
}

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

if (!aplConfigured) {
Expand Down Expand Up @@ -408,7 +355,7 @@ export class RegisterActionHandler<I>
}

private async handleOnRequestVerifiedCallback(
onRequestVerified: AppRegisterHandlerOptions<I>["onRequestVerified"],
onRequestVerified: GenericCreateAppRegisterHandlerOptions<I>["onRequestVerified"],
authData: AuthData
) {
if (onRequestVerified) {
Expand Down Expand Up @@ -436,8 +383,8 @@ export class RegisterActionHandler<I>
authData,
}: {
apl: APL;
onAplSetFailed: AppRegisterHandlerOptions<I>["onAplSetFailed"];
onAuthAplSaved: AppRegisterHandlerOptions<I>["onAuthAplSaved"];
onAplSetFailed: GenericCreateAppRegisterHandlerOptions<I>["onAplSetFailed"];
onAuthAplSaved: GenericCreateAppRegisterHandlerOptions<I>["onAuthAplSaved"];
authData: AuthData;
}) {
try {
Expand Down
184 changes: 184 additions & 0 deletions src/handlers/platforms/fetch-api/create-app-register-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<CreateAppRegisterHandlerOptions["onRequestStart"]>>()
.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();
});
});
});
36 changes: 36 additions & 0 deletions src/handlers/platforms/fetch-api/create-app-register-handler.ts
Original file line number Diff line number Diff line change
@@ -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<WebApiHandlerInput>;

/**
* 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);
};
Loading

0 comments on commit 4fa8271

Please sign in to comment.