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:
Lukasz Ostrowski 2022-10-11 09:40:08 +02:00 committed by GitHub
parent 61f5ab6c3a
commit a839314f08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 258 additions and 33 deletions

4
.husky/pre-push Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm run test:ci

View file

@ -3,9 +3,22 @@ export interface AuthData {
token: string;
}
export type AplReadyResult =
| {
ready: true;
}
| {
ready: false;
error: Error;
};
export interface APL {
get: (domain: string) => Promise<AuthData | undefined>;
set: (authData: AuthData) => Promise<void>;
delete: (domain: string) => Promise<void>;
getAll: () => Promise<AuthData[]>;
/**
* Inform that configuration is finished and correct
*/
isReady: () => Promise<AplReadyResult>;
}

View file

@ -1,6 +1,6 @@
import { promises as fsPromises } from "fs";
import { APL, AuthData } from "./apl";
import { APL, AplReadyResult, AuthData } from "./apl";
import { createAPLDebug } from "./apl-debug";
const debug = createAPLDebug("FileAPL");
@ -98,4 +98,15 @@ export class FileAPL implements APL {
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,
};
}
}

View file

@ -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");
}
});
});
});
});

View file

@ -1,7 +1,8 @@
/* eslint-disable class-methods-use-this */
// eslint-disable-next-line max-classes-per-file
import fetch, { Response } from "node-fetch";
import { APL, AuthData } from "./apl";
import { APL, AplReadyResult, AuthData } from "./apl";
import { createAPLDebug } from "./apl-debug";
const debug = createAPLDebug("VercelAPL");
@ -13,6 +14,16 @@ export const VercelAPLVariables = {
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 token = process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME];
const domain = process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME];
@ -32,9 +43,9 @@ export type VercelAPLConfig = {
/** Vercel APL
*
* 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)
* - changing the environment variables require server restart
*
@ -122,4 +133,24 @@ export class VercelAPL implements APL {
}
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,
};
}
}

View file

@ -11,6 +11,9 @@ describe("create-app-register-handler", () => {
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn().mockImplementation(async () => ({
ready: true,
})),
};
const { res, req } = createMocks({

View file

@ -3,13 +3,11 @@ import { toNextHandler } from "retes/adapter";
import { withMethod } from "retes/middleware";
import { Response } from "retes/response";
import { APL } from "../../APL";
import { SALEOR_DOMAIN_HEADER } from "../../const";
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
import { HasAPL } from "../../saleor-app";
export type CreateAppRegisterHandlerOptions = {
apl: APL;
};
export type CreateAppRegisterHandlerOptions = HasAPL;
/**
* 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 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 {
await apl.set({ domain: saleorDomain, token: authToken });
} catch {

View file

@ -1,5 +1,6 @@
export * from "./const";
export * from "./headers";
export * from "./infer-webhooks";
export * from "./saleor-app";
export * from "./types";
export * from "./urls";

View file

@ -3,6 +3,7 @@ export * from "./with-auth-token-required";
export * from "./with-base-url";
export * from "./with-jwt-verified";
export * from "./with-registered-saleor-domain-header";
export * from "./with-saleor-app";
export * from "./with-saleor-domain-present";
export * from "./with-saleor-event-match";
export * from "./with-webhook-signature-verified";

View file

@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { APL } from "../APL";
import { SALEOR_DOMAIN_HEADER } from "../const";
import { SaleorApp } from "../saleor-app";
import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header";
import { withSaleorApp } from "./with-saleor-app";
const getMockSuccessResponse = async () => Response.OK({});
@ -39,7 +41,11 @@ describe("middleware", () => {
},
} 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
);
@ -57,11 +63,30 @@ describe("middleware", () => {
},
} 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
);
expect(response.status).eq(403);
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);
});
});
});

View file

@ -1,16 +1,13 @@
import { Middleware } from "retes";
import { Response } from "retes/response";
import { APL } from "../APL";
import { getSaleorHeaders } from "../headers";
import { createMiddlewareDebug } from "./middleware-debug";
import { getSaleorAppFromRequest } from "./with-saleor-app";
const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader");
export const withRegisteredSaleorDomainHeader =
({ apl }: { apl: APL }): Middleware =>
(handler) =>
async (request) => {
export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => {
const { domain: saleorDomain } = getSaleorHeaders(request.headers);
if (!saleorDomain) {
@ -22,7 +19,20 @@ export const withRegisteredSaleorDomainHeader =
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) {
debug("Auth was not found in APL, will respond with Forbidden status");
@ -34,4 +44,4 @@ export const withRegisteredSaleorDomainHeader =
}
return handler(request);
};
};

View 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);
});
});
});

View 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
View 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
View 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();
}
}