Skip to content

Commit

Permalink
Add central SaleorApp instance (#71)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
lkostrowski authored Oct 11, 2022
1 parent 61f5ab6 commit a839314
Show file tree
Hide file tree
Showing 15 changed files with 258 additions and 33 deletions.
4 changes: 4 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm run test:ci
13 changes: 13 additions & 0 deletions src/APL/apl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
13 changes: 12 additions & 1 deletion src/APL/file-apl.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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,
};
}
}
16 changes: 16 additions & 0 deletions src/APL/vercel-apl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});
});
});
});
37 changes: 34 additions & 3 deletions src/APL/vercel-apl.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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];
Expand All @@ -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
*
Expand Down Expand Up @@ -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,
};
}
}
3 changes: 3 additions & 0 deletions src/handlers/next/create-app-register-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
23 changes: 19 additions & 4 deletions src/handlers/next/create-app-register-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./const";
export * from "./headers";
export * from "./infer-webhooks";
export * from "./saleor-app";
export * from "./types";
export * from "./urls";
1 change: 1 addition & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
29 changes: 27 additions & 2 deletions src/middleware/with-registered-saleor-domain-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});

Expand Down Expand Up @@ -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
);

Expand All @@ -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);
});
});
});
56 changes: 33 additions & 23 deletions src/middleware/with-registered-saleor-domain-header.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,47 @@
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) => {
const { domain: saleorDomain } = getSaleorHeaders(request.headers);
export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => {
const { domain: saleorDomain } = getSaleorHeaders(request.headers);

if (!saleorDomain) {
return Response.BadRequest({
success: false,
message: "Domain header missing.",
});
}
if (!saleorDomain) {
return Response.BadRequest({
success: false,
message: "Domain header missing.",
});
}

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 (!authData) {
debug("Auth was not found in APL, will respond with Forbidden status");
if (!saleorApp) {
console.error(
"SaleorApp not found in request context. Ensure your API handler is wrapped with withSaleorApp middleware"
);

return Response.Forbidden({
success: false,
message: `Domain ${saleorDomain} not registered.`,
});
}
return Response.InternalServerError({
success: false,
message: "SaleorApp is misconfigured",
});
}

return handler(request);
};
const authData = await saleorApp?.apl.get(saleorDomain);

if (!authData) {
debug("Auth was not found in APL, will respond with Forbidden status");

return Response.Forbidden({
success: false,
message: `Domain ${saleorDomain} not registered.`,
});
}

return handler(request);
};
27 changes: 27 additions & 0 deletions src/middleware/with-saleor-app.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions src/middleware/with-saleor-app.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit a839314

Please sign in to comment.