Add central SaleorApp instance (#71)
* Add SaleorApp class * Add middleware and tests * Move APL validation to APL * Fix test * Add prepush hook * Add better error for missing vercel envs * Add test
This commit is contained in:
parent
61f5ab6c3a
commit
a839314f08
15 changed files with 258 additions and 33 deletions
4
.husky/pre-push
Executable file
4
.husky/pre-push
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm run test:ci
|
|
@ -3,9 +3,22 @@ export interface AuthData {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AplReadyResult =
|
||||||
|
| {
|
||||||
|
ready: true;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ready: false;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
|
||||||
export interface APL {
|
export interface APL {
|
||||||
get: (domain: string) => Promise<AuthData | undefined>;
|
get: (domain: string) => Promise<AuthData | undefined>;
|
||||||
set: (authData: AuthData) => Promise<void>;
|
set: (authData: AuthData) => Promise<void>;
|
||||||
delete: (domain: string) => Promise<void>;
|
delete: (domain: string) => Promise<void>;
|
||||||
getAll: () => Promise<AuthData[]>;
|
getAll: () => Promise<AuthData[]>;
|
||||||
|
/**
|
||||||
|
* Inform that configuration is finished and correct
|
||||||
|
*/
|
||||||
|
isReady: () => Promise<AplReadyResult>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { promises as fsPromises } from "fs";
|
import { promises as fsPromises } from "fs";
|
||||||
|
|
||||||
import { APL, AuthData } from "./apl";
|
import { APL, AplReadyResult, AuthData } from "./apl";
|
||||||
import { createAPLDebug } from "./apl-debug";
|
import { createAPLDebug } from "./apl-debug";
|
||||||
|
|
||||||
const debug = createAPLDebug("FileAPL");
|
const debug = createAPLDebug("FileAPL");
|
||||||
|
@ -98,4 +98,15 @@ export class FileAPL implements APL {
|
||||||
|
|
||||||
return [authData];
|
return [authData];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async isReady(): Promise<AplReadyResult> {
|
||||||
|
/**
|
||||||
|
* Assume FileAPL is just ready to use.
|
||||||
|
* Consider checking if directory is writable
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,5 +158,21 @@ describe("APL", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isReady", () => {
|
||||||
|
it("Returns error with message mentioning missing env variables", async () => {
|
||||||
|
const apl = new VercelAPL(aplConfig);
|
||||||
|
|
||||||
|
const result = await apl.isReady();
|
||||||
|
|
||||||
|
if (!result.ready) {
|
||||||
|
expect(result.error.message).toEqual(
|
||||||
|
"Env variables: \"SALEOR_AUTH_TOKEN\", \"SALEOR_DOMAIN\", \"SALEOR_REGISTER_APP_URL\", \"SALEOR_DEPLOYMENT_TOKEN\" not found or is empty. Ensure env variables exist"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("This should not happen");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
import fetch, { Response } from "node-fetch";
|
import fetch, { Response } from "node-fetch";
|
||||||
|
|
||||||
import { APL, AuthData } from "./apl";
|
import { APL, AplReadyResult, AuthData } from "./apl";
|
||||||
import { createAPLDebug } from "./apl-debug";
|
import { createAPLDebug } from "./apl-debug";
|
||||||
|
|
||||||
const debug = createAPLDebug("VercelAPL");
|
const debug = createAPLDebug("VercelAPL");
|
||||||
|
@ -13,6 +14,16 @@ export const VercelAPLVariables = {
|
||||||
SALEOR_DEPLOYMENT_TOKEN: "SALEOR_DEPLOYMENT_TOKEN",
|
SALEOR_DEPLOYMENT_TOKEN: "SALEOR_DEPLOYMENT_TOKEN",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class VercelAplMisconfiguredError extends Error {
|
||||||
|
constructor(public missingEnvVars: string[]) {
|
||||||
|
super(
|
||||||
|
`Env variables: ${missingEnvVars
|
||||||
|
.map((v) => `"${v}"`)
|
||||||
|
.join(", ")} not found or is empty. Ensure env variables exist`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getEnvAuth = (): AuthData | undefined => {
|
const getEnvAuth = (): AuthData | undefined => {
|
||||||
const token = process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME];
|
const token = process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME];
|
||||||
const domain = process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME];
|
const domain = process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME];
|
||||||
|
@ -32,9 +43,9 @@ export type VercelAPLConfig = {
|
||||||
/** Vercel APL
|
/** Vercel APL
|
||||||
*
|
*
|
||||||
* Use environment variables for auth data storage. To update data on existing deployment,
|
* Use environment variables for auth data storage. To update data on existing deployment,
|
||||||
* theres Saleor microservice which update new values with the Vercel API and restarts the instance.
|
* there's Saleor microservice which update new values with the Vercel API and restarts the instance.
|
||||||
*
|
*
|
||||||
* This APL should be used for single tenant purposes due to it's limitations:
|
* This APL should be used for single tenant purposes due to its limitations:
|
||||||
* - only stores single auth data entry (setting up a new one will overwrite previous values)
|
* - only stores single auth data entry (setting up a new one will overwrite previous values)
|
||||||
* - changing the environment variables require server restart
|
* - changing the environment variables require server restart
|
||||||
*
|
*
|
||||||
|
@ -122,4 +133,24 @@ export class VercelAPL implements APL {
|
||||||
}
|
}
|
||||||
return [authData];
|
return [authData];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async isReady(): Promise<AplReadyResult> {
|
||||||
|
const invalidEnvKeys = Object.values(VercelAPLVariables).filter((key) => {
|
||||||
|
const envValue = process.env[key];
|
||||||
|
|
||||||
|
return !envValue || envValue.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidEnvKeys.length > 0) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
error: new VercelAplMisconfiguredError(invalidEnvKeys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,9 @@ describe("create-app-register-handler", () => {
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
getAll: vi.fn(),
|
getAll: vi.fn(),
|
||||||
|
isReady: vi.fn().mockImplementation(async () => ({
|
||||||
|
ready: true,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { res, req } = createMocks({
|
const { res, req } = createMocks({
|
||||||
|
|
|
@ -3,13 +3,11 @@ import { toNextHandler } from "retes/adapter";
|
||||||
import { withMethod } from "retes/middleware";
|
import { withMethod } from "retes/middleware";
|
||||||
import { Response } from "retes/response";
|
import { Response } from "retes/response";
|
||||||
|
|
||||||
import { APL } from "../../APL";
|
|
||||||
import { SALEOR_DOMAIN_HEADER } from "../../const";
|
import { SALEOR_DOMAIN_HEADER } from "../../const";
|
||||||
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
|
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
|
||||||
|
import { HasAPL } from "../../saleor-app";
|
||||||
|
|
||||||
export type CreateAppRegisterHandlerOptions = {
|
export type CreateAppRegisterHandlerOptions = HasAPL;
|
||||||
apl: APL;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates API handler for Next.js. Creates handler called by Saleor that registers app.
|
* Creates API handler for Next.js. Creates handler called by Saleor that registers app.
|
||||||
|
@ -21,6 +19,23 @@ export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOption
|
||||||
const authToken = request.params.auth_token;
|
const authToken = request.params.auth_token;
|
||||||
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string;
|
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string;
|
||||||
|
|
||||||
|
const { ready: aplReady } = await apl.isReady();
|
||||||
|
|
||||||
|
if (!aplReady) {
|
||||||
|
return new Response(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "APL_NOT_READY",
|
||||||
|
message: "App is not ready yet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apl.set({ domain: saleorDomain, token: authToken });
|
await apl.set({ domain: saleorDomain, token: authToken });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export * from "./const";
|
export * from "./const";
|
||||||
export * from "./headers";
|
export * from "./headers";
|
||||||
export * from "./infer-webhooks";
|
export * from "./infer-webhooks";
|
||||||
|
export * from "./saleor-app";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./urls";
|
export * from "./urls";
|
||||||
|
|
|
@ -3,6 +3,7 @@ export * from "./with-auth-token-required";
|
||||||
export * from "./with-base-url";
|
export * from "./with-base-url";
|
||||||
export * from "./with-jwt-verified";
|
export * from "./with-jwt-verified";
|
||||||
export * from "./with-registered-saleor-domain-header";
|
export * from "./with-registered-saleor-domain-header";
|
||||||
|
export * from "./with-saleor-app";
|
||||||
export * from "./with-saleor-domain-present";
|
export * from "./with-saleor-domain-present";
|
||||||
export * from "./with-saleor-event-match";
|
export * from "./with-saleor-event-match";
|
||||||
export * from "./with-webhook-signature-verified";
|
export * from "./with-webhook-signature-verified";
|
||||||
|
|
|
@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { APL } from "../APL";
|
import { APL } from "../APL";
|
||||||
import { SALEOR_DOMAIN_HEADER } from "../const";
|
import { SALEOR_DOMAIN_HEADER } from "../const";
|
||||||
|
import { SaleorApp } from "../saleor-app";
|
||||||
import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header";
|
import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header";
|
||||||
|
import { withSaleorApp } from "./with-saleor-app";
|
||||||
|
|
||||||
const getMockSuccessResponse = async () => Response.OK({});
|
const getMockSuccessResponse = async () => Response.OK({});
|
||||||
|
|
||||||
|
@ -39,7 +41,11 @@ describe("middleware", () => {
|
||||||
},
|
},
|
||||||
} as unknown as Request;
|
} as unknown as Request;
|
||||||
|
|
||||||
const response = await withRegisteredSaleorDomainHeader({ apl: mockAPL })(mockHandlerFn)(
|
const app = new SaleorApp({
|
||||||
|
apl: mockAPL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await withSaleorApp(app)(withRegisteredSaleorDomainHeader(mockHandlerFn))(
|
||||||
mockRequest
|
mockRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -57,11 +63,30 @@ describe("middleware", () => {
|
||||||
},
|
},
|
||||||
} as unknown as Request;
|
} as unknown as Request;
|
||||||
|
|
||||||
const response = await withRegisteredSaleorDomainHeader({ apl: mockAPL })(mockHandlerFn)(
|
const app = new SaleorApp({
|
||||||
|
apl: mockAPL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await withSaleorApp(app)(withRegisteredSaleorDomainHeader(mockHandlerFn))(
|
||||||
mockRequest
|
mockRequest
|
||||||
);
|
);
|
||||||
expect(response.status).eq(403);
|
expect(response.status).eq(403);
|
||||||
expect(mockHandlerFn).toBeCalledTimes(0);
|
expect(mockHandlerFn).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Throws if SaleorApp not found in context", async () => {
|
||||||
|
const mockRequest = {
|
||||||
|
context: {},
|
||||||
|
headers: {
|
||||||
|
host: "my-saleor-env.saleor.cloud",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
[SALEOR_DOMAIN_HEADER]: "example.com",
|
||||||
|
},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const response = await withRegisteredSaleorDomainHeader(mockHandlerFn)(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).eq(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
import { Middleware } from "retes";
|
import { Middleware } from "retes";
|
||||||
import { Response } from "retes/response";
|
import { Response } from "retes/response";
|
||||||
|
|
||||||
import { APL } from "../APL";
|
|
||||||
import { getSaleorHeaders } from "../headers";
|
import { getSaleorHeaders } from "../headers";
|
||||||
import { createMiddlewareDebug } from "./middleware-debug";
|
import { createMiddlewareDebug } from "./middleware-debug";
|
||||||
|
import { getSaleorAppFromRequest } from "./with-saleor-app";
|
||||||
|
|
||||||
const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader");
|
const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader");
|
||||||
|
|
||||||
export const withRegisteredSaleorDomainHeader =
|
export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => {
|
||||||
({ apl }: { apl: APL }): Middleware =>
|
|
||||||
(handler) =>
|
|
||||||
async (request) => {
|
|
||||||
const { domain: saleorDomain } = getSaleorHeaders(request.headers);
|
const { domain: saleorDomain } = getSaleorHeaders(request.headers);
|
||||||
|
|
||||||
if (!saleorDomain) {
|
if (!saleorDomain) {
|
||||||
|
@ -22,7 +19,20 @@ export const withRegisteredSaleorDomainHeader =
|
||||||
|
|
||||||
debug("Middleware called with domain: \"%s\"", saleorDomain);
|
debug("Middleware called with domain: \"%s\"", saleorDomain);
|
||||||
|
|
||||||
const authData = await apl.get(saleorDomain);
|
const saleorApp = getSaleorAppFromRequest(request);
|
||||||
|
|
||||||
|
if (!saleorApp) {
|
||||||
|
console.error(
|
||||||
|
"SaleorApp not found in request context. Ensure your API handler is wrapped with withSaleorApp middleware"
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.InternalServerError({
|
||||||
|
success: false,
|
||||||
|
message: "SaleorApp is misconfigured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = await saleorApp?.apl.get(saleorDomain);
|
||||||
|
|
||||||
if (!authData) {
|
if (!authData) {
|
||||||
debug("Auth was not found in APL, will respond with Forbidden status");
|
debug("Auth was not found in APL, will respond with Forbidden status");
|
||||||
|
|
27
src/middleware/with-saleor-app.test.ts
Normal file
27
src/middleware/with-saleor-app.test.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Request } from "retes";
|
||||||
|
import { Response } from "retes/response";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { FileAPL } from "../APL";
|
||||||
|
import { SALEOR_DOMAIN_HEADER } from "../const";
|
||||||
|
import { SaleorApp } from "../saleor-app";
|
||||||
|
import { withSaleorApp } from "./with-saleor-app";
|
||||||
|
|
||||||
|
describe("middleware", () => {
|
||||||
|
describe("withSaleorApp", () => {
|
||||||
|
it("Adds SaleorApp instance to request context", async () => {
|
||||||
|
const mockRequest = {
|
||||||
|
context: {},
|
||||||
|
headers: {
|
||||||
|
[SALEOR_DOMAIN_HEADER]: "example.com",
|
||||||
|
},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
await withSaleorApp(new SaleorApp({ apl: new FileAPL() }))((request) => {
|
||||||
|
expect(request.context.saleorApp).toBeDefined();
|
||||||
|
|
||||||
|
return Response.OK("");
|
||||||
|
})(mockRequest);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
21
src/middleware/with-saleor-app.ts
Normal file
21
src/middleware/with-saleor-app.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Middleware, Request } from "retes";
|
||||||
|
|
||||||
|
import { SaleorApp } from "../saleor-app";
|
||||||
|
import { createMiddlewareDebug } from "./middleware-debug";
|
||||||
|
|
||||||
|
const debug = createMiddlewareDebug("withSaleorApp");
|
||||||
|
|
||||||
|
export const withSaleorApp =
|
||||||
|
(saleorApp: SaleorApp): Middleware =>
|
||||||
|
(handler) =>
|
||||||
|
async (request) => {
|
||||||
|
debug("Middleware called");
|
||||||
|
|
||||||
|
request.context ??= {};
|
||||||
|
request.context.saleorApp = saleorApp;
|
||||||
|
|
||||||
|
return handler(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSaleorAppFromRequest = (request: Request): SaleorApp | undefined =>
|
||||||
|
request.context?.saleorApp;
|
22
src/saleor-app.test.ts
Normal file
22
src/saleor-app.test.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { FileAPL } from "./APL";
|
||||||
|
import { SaleorApp } from "./saleor-app";
|
||||||
|
|
||||||
|
describe("SaleorApp", () => {
|
||||||
|
const initialEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...initialEnv };
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Constructs", () => {
|
||||||
|
const instance = new SaleorApp({
|
||||||
|
apl: new FileAPL(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(instance).toBeDefined();
|
||||||
|
expect(instance.apl).toBeInstanceOf(FileAPL);
|
||||||
|
});
|
||||||
|
});
|
25
src/saleor-app.ts
Normal file
25
src/saleor-app.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { APL, AplReadyResult } from "./APL";
|
||||||
|
|
||||||
|
export interface HasAPL {
|
||||||
|
apl: APL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleorAppParams {
|
||||||
|
apl: APL;
|
||||||
|
requiredEnvVars?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaleorApp implements HasAPL {
|
||||||
|
readonly apl: APL;
|
||||||
|
|
||||||
|
readonly requiredEnvVars: string[];
|
||||||
|
|
||||||
|
constructor(options: SaleorAppParams) {
|
||||||
|
this.apl = options.apl;
|
||||||
|
this.requiredEnvVars = options.requiredEnvVars ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady(): Promise<AplReadyResult> {
|
||||||
|
return this.apl.isReady();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue