Introduce APL interface

This commit is contained in:
Krzysztof Wolski 2022-08-25 10:44:45 +02:00
parent a59519aede
commit 43f81f1af6
7 changed files with 166 additions and 6 deletions

View file

@ -12,7 +12,7 @@
"ignorePatterns": ["pnpm-lock.yaml"], "ignorePatterns": ["pnpm-lock.yaml"],
"plugins": ["simple-import-sort", "@typescript-eslint"], "plugins": ["simple-import-sort", "@typescript-eslint"],
"rules": { "rules": {
"import/no-extraneous-dependencies": ["error", {"devDependencies": true}], "import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
"quotes": ["error", "double"], "quotes": ["error", "double"],
"react/react-in-jsx-scope": "off", // next does not require react imports "react/react-in-jsx-scope": "off", // next does not require react imports
"import/extensions": "off", // file extension not required when importing "import/extensions": "off", // file extension not required when importing

68
docs/apl.md Normal file
View file

@ -0,0 +1,68 @@
# APL - Auth Persistance Layer
APL is an interface for managing auth data of registered Apps. Implementing it does not restrict you from choosing the right technology for your project (for example Redis, Postgres, S3, etc.), and provides a common set of operations which can be used by your application and functions provided by App SDK.
## Available methods
- `get: (domain: string) => Promise<AuthData | undefined>` - If the entry for given domain exists, returns AuthData (`{ domain: string, token: string }`) object.
- `set: (authData: AuthData) => Promise<void>` - Save auth data.
- `delete: (domain: string) => Promise<void>` - Remove auth data fot the given domain.
- `getAll: () => Promise<AuthData[]>` - Returns all auth data available.
## Example implementation
Let's create an APL, which uses redis for data storage:
```ts
import { createClient } from "redis";
import { APL, AuthData } from "@saleor/app-sdk/types/apl";
const client = createClient();
await client.connect();
const redisAPL: APL = {
get: async (domain: string) => {
const token = await client.get(domain);
if (token) {
return { token, domain };
}
return;
},
set: async (authData: AuthData) => {
await client.set(authData.domain, authData.token);
},
delete: async (domain: string) => {
await client.del(domain);
},
getAll: async () => {
throw new Exception("Not implemented.");
},
};
```
You'll be able to use it directly:
```ts
import { redisAPL } from "./apl";
const createTestData = async () => {
await redisAPL.set({ domain: "example.com ", token: "test-token" });
};
```
And with middleware from the SDK:
```ts
import { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware";
import { redisAPL } from "./apl";
const handler = async (request) => {
return Response.OK({ message: "If you see this, your app is registered!" });
};
// the middleware will reject request if it's domain has not been registered
export default withRegisteredSaleorDomainHeader({ apl: redisAPL })(handler);
```

View file

@ -53,6 +53,11 @@
"import": "./const.mjs", "import": "./const.mjs",
"require": "./const.js" "require": "./const.js"
}, },
"./apl": {
"types": "./apl.d.ts",
"import": "./apl.mjs",
"require": "./apl.js"
},
"./middleware": { "./middleware": {
"types": "./middleware.d.ts", "types": "./middleware.d.ts",
"import": "./middleware.mjs", "import": "./middleware.mjs",

11
src/APL/apl.ts Normal file
View file

@ -0,0 +1,11 @@
export interface AuthData {
domain: string;
token: string;
}
export interface APL {
get: (domain: string) => Promise<AuthData | undefined>;
set: (authData: AuthData) => Promise<void>;
delete: (domain: string) => Promise<void>;
getAll: () => Promise<AuthData[]>;
}

1
src/APL/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./apl";

View file

@ -1,17 +1,19 @@
import { Handler, Request } from "retes"; import { Handler, Request } from "retes";
import { Response } from "retes/types"; import { Response } from "retes/response";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { withBaseURL } from "./middleware"; import { APL } from "./APL";
import { SALEOR_DOMAIN_HEADER } from "./const";
import { withBaseURL, withRegisteredSaleorDomainHeader } from "./middleware";
const getMockEmptyResponse = async () => ({} as Response); const getMockSuccessResponse = async () => Response.OK({});
describe("middleware.test.ts", () => { describe("middleware.test.ts", () => {
describe("withBaseURL", () => { describe("withBaseURL", () => {
let mockHandlerFn: Handler = vi.fn(getMockEmptyResponse); let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse);
beforeEach(() => { beforeEach(() => {
mockHandlerFn = vi.fn(); mockHandlerFn = vi.fn(getMockSuccessResponse);
}); });
it("Adds base URL from request header to context and calls handler", async () => { it("Adds base URL from request header to context and calls handler", async () => {
@ -29,4 +31,60 @@ describe("middleware.test.ts", () => {
expect(mockHandlerFn).toHaveBeenCalledOnce(); expect(mockHandlerFn).toHaveBeenCalledOnce();
}); });
}); });
describe("withRegisteredSaleorDomainHeader", () => {
let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse);
const mockAPL: APL = {
get: async (domain: string) =>
domain === "example.com"
? {
domain: "example.com",
token: "mock-token",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
};
beforeEach(() => {
mockHandlerFn = vi.fn(getMockSuccessResponse);
});
it("Pass request when auth data are available", 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({ apl: mockAPL })(mockHandlerFn)(
mockRequest
);
expect(response.status).toBe(200);
expect(mockHandlerFn).toHaveBeenCalledOnce();
});
it("Reject request when auth data are not available", async () => {
const mockRequest = {
context: {},
headers: {
host: "my-saleor-env.saleor.cloud",
"x-forwarded-proto": "https",
[SALEOR_DOMAIN_HEADER]: "not-registered.example.com",
},
} as unknown as Request;
const response = await withRegisteredSaleorDomainHeader({ apl: mockAPL })(mockHandlerFn)(
mockRequest
);
expect(response.status).eq(403);
expect(mockHandlerFn).toBeCalledTimes(0);
});
});
}); });

View file

@ -3,6 +3,7 @@ import * as jose from "jose";
import type { Middleware, Request } from "retes"; import type { Middleware, Request } from "retes";
import { Response } from "retes/response"; import { Response } from "retes/response";
import { APL } from "./APL";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_SIGNATURE_HEADER } from "./const"; import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_SIGNATURE_HEADER } from "./const";
import { getSaleorHeaders } from "./headers"; import { getSaleorHeaders } from "./headers";
import { getJwksUrl } from "./urls"; import { getJwksUrl } from "./urls";
@ -187,3 +188,19 @@ export const withJWTVerified =
return handler(request); return handler(request);
}; };
export const withRegisteredSaleorDomainHeader =
({ apl }: { apl: APL }): Middleware =>
(handler) =>
async (request) => {
const { domain: saleorDomain } = getSaleorHeaders(request.headers);
const authData = await apl.get(saleorDomain);
if (!authData) {
return Response.Forbidden({
success: false,
message: `Domain ${saleorDomain} not registered.`,
});
}
return handler(request);
};