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 Web API platform handlers #403

Merged
merged 2 commits into from
Feb 6, 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/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