diff --git a/.changeset/breezy-buses-greet.md b/.changeset/breezy-buses-greet.md new file mode 100644 index 0000000000..8549331ac5 --- /dev/null +++ b/.changeset/breezy-buses-greet.md @@ -0,0 +1,5 @@ +--- +"segment": patch +--- + +Added DynamoDB APL. This APL is using DynamoDB as storage. diff --git a/apps/segment/README.md b/apps/segment/README.md index 5d5513f6a5..c7fa9c354d 100644 --- a/apps/segment/README.md +++ b/apps/segment/README.md @@ -62,3 +62,50 @@ To start the migration run command: ```bash pnpm migrate ``` + +### Setting up DynamoDB + +Segment app uses DynamoDB as it's internal database. + +In order to work properly you need to either set-up local DynamoDB instance or connect to a real DynamoDB on AWS account. + +#### Local DynamoDB + +To use a local DynamoDB instance you can use Docker Compose: + +```bash +docker compose up +``` + +After that a local DynamoDB instance will be spun-up at `http://localhost:8000`. + +To set up tables needed for the app run following command for each table used in app: + +```shell +./scripts/setup-dynamodb.sh +``` + +After setting up database, you must configure following variables: + +```bash +DYNAMODB_MAIN_TABLE_NAME=segment-main-table +AWS_REGION=localhost +AWS_ENDPOINT_URL=http://localhost:8000 +AWS_ACCESS_KEY_ID=fake_id +AWS_SECRET_ACCESS_KEY=fake_key +``` + +Local instance doesn't require providing any authentication details. + +To see data stored by the app you can use [NoSQL Workbench](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.html) app provided by AWS. After installing the app go to Operation builder > Add connection > DynamoDB local and use the default values. + +#### Production DynamoDB + +To configure DynamoDB for production usage, provide credentials in a default AWS SDK format (see [AWS Docs](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html)) + +```bash +DYNAMODB_MAIN_TABLE_NAME=segment-main-table +AWS_REGION=us-east-1 # Region when DynamoDB was deployed +AWS_ACCESS_KEY_ID=AK... +AWS_SECRET_ACCESS_KEY=... +``` diff --git a/apps/segment/docker-compose.yml b/apps/segment/docker-compose.yml new file mode 100644 index 0000000000..815e6fc349 --- /dev/null +++ b/apps/segment/docker-compose.yml @@ -0,0 +1,12 @@ +services: + dynamodb: + command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data" + image: "amazon/dynamodb-local:latest" + ports: + - "8000:8000" + volumes: + - "./docker/dynamodb:/home/dynamodblocal/data" + working_dir: /home/dynamodblocal +volumes: + dynamodb: + driver: local diff --git a/apps/segment/package.json b/apps/segment/package.json index 96edb4a925..d6133f4a23 100644 --- a/apps/segment/package.json +++ b/apps/segment/package.json @@ -16,6 +16,9 @@ "test": "vitest" }, "dependencies": { + "@aws-sdk/client-dynamodb": "3.651.1", + "@aws-sdk/lib-dynamodb": "3.651.1", + "@aws-sdk/util-dynamodb": "3.651.1", "@hookform/resolvers": "^3.3.1", "@opentelemetry/api": "../../node_modules/@opentelemetry/api", "@opentelemetry/api-logs": "../../node_modules/@opentelemetry/api-logs", @@ -50,6 +53,7 @@ "@urql/exchange-auth": "2.1.4", "@vitejs/plugin-react": "4.3.1", "dotenv": "16.3.1", + "dynamodb-toolbox": "1.8.2", "graphql": "16.7.1", "graphql-tag": "2.12.6", "modern-errors": "7.0.1", @@ -78,12 +82,13 @@ "@total-typescript/ts-reset": "0.6.1", "@types/react": "18.2.5", "@types/react-dom": "18.2.5", + "@typescript-eslint/eslint-plugin": "7.15.0", + "@typescript-eslint/parser": "7.15.0", + "aws-sdk-client-mock": "4.0.1", "eslint": "../../node_modules/eslint", "eslint-config-saleor": "workspace:*", "eslint-plugin-neverthrow": "^1.1.4", "eslint-plugin-node": "11.1.0", - "@typescript-eslint/eslint-plugin": "7.15.0", - "@typescript-eslint/parser": "7.15.0", "graphql-config": "5.0.3", "jsdom": "^20.0.3", "node-mocks-http": "^1.12.2", diff --git a/apps/segment/scripts/setup-dynamodb.sh b/apps/segment/scripts/setup-dynamodb.sh new file mode 100755 index 0000000000..c22910f50f --- /dev/null +++ b/apps/segment/scripts/setup-dynamodb.sh @@ -0,0 +1,11 @@ +#!/bin/bash +if ! aws dynamodb describe-table --table-name segment-main-table --endpoint-url http://localhost:8000 --region localhost >/dev/null 2>&1; then + aws dynamodb create-table --table-name segment-main-table \ + --attribute-definitions AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S \ + --key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE \ + --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ + --endpoint-url http://localhost:8000 \ + --region localhost +else + echo "Table segment-main-table already exists - creation is skipped" +fi diff --git a/apps/segment/src/env.ts b/apps/segment/src/env.ts index d1080fb6a0..42df8546c7 100644 --- a/apps/segment/src/env.ts +++ b/apps/segment/src/env.ts @@ -14,7 +14,7 @@ export const env = createEnv({ }, server: { ALLOWED_DOMAIN_PATTERN: z.string().optional(), - APL: z.enum(["saleor-cloud", "file"]).optional().default("file"), + APL: z.enum(["saleor-cloud", "file", "dynamodb"]).optional().default("file"), APP_API_BASE_URL: z.string().optional(), APP_IFRAME_BASE_URL: z.string().optional(), APP_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), @@ -27,6 +27,10 @@ export const env = createEnv({ PORT: z.coerce.number().optional().default(3000), SECRET_KEY: z.string(), VERCEL_URL: z.string().optional(), + DYNAMODB_MAIN_TABLE_NAME: z.string().optional(), + AWS_REGION: z.string().optional(), + AWS_ACCESS_KEY_ID: z.string().optional(), + AWS_SECRET_ACCESS_KEY: z.string().optional(), }, shared: { NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"), @@ -51,6 +55,10 @@ export const env = createEnv({ REST_APL_TOKEN: process.env.REST_APL_TOKEN, SECRET_KEY: process.env.SECRET_KEY, VERCEL_URL: process.env.VERCEL_URL, + DYNAMODB_MAIN_TABLE_NAME: process.env.DYNAMODB_MAIN_TABLE_NAME, + AWS_REGION: process.env.AWS_REGION, + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, }, isServer: typeof window === "undefined" || process.env.NODE_ENV === "test", }); diff --git a/apps/segment/src/lib/__tests__/in-memory-apl-repository.ts b/apps/segment/src/lib/__tests__/in-memory-apl-repository.ts new file mode 100644 index 0000000000..a6dff6bd9a --- /dev/null +++ b/apps/segment/src/lib/__tests__/in-memory-apl-repository.ts @@ -0,0 +1,47 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { err, ok, Result } from "neverthrow"; + +import { BaseError } from "@/errors"; +import { APLRepository } from "@/modules/db/types"; + +export class InMemoryAPLRepository implements APLRepository { + public entries: Record = {}; + + async getEntry(args: { + saleorApiUrl: string; + }): Promise>> { + if (this.entries[args.saleorApiUrl]) { + return ok(this.entries[args.saleorApiUrl]); + } + + return ok(null); + } + + async setEntry(args: { + authData: AuthData; + }): Promise>> { + this.entries[args.authData.saleorApiUrl] = args.authData; + return ok(undefined); + } + + async deleteEntry(args: { + saleorApiUrl: string; + }): Promise>> { + if (this.entries[args.saleorApiUrl]) { + delete this.entries[args.saleorApiUrl]; + return ok(undefined); + } + + return err(new BaseError("Error deleting entry")); + } + + async getAllEntries(): Promise>> { + const values = Object.values(this.entries); + + if (values.length === 0) { + return ok(null); + } + + return ok(values); + } +} diff --git a/apps/segment/src/lib/dyanmodb-apl.test.ts b/apps/segment/src/lib/dyanmodb-apl.test.ts new file mode 100644 index 0000000000..bd661f2283 --- /dev/null +++ b/apps/segment/src/lib/dyanmodb-apl.test.ts @@ -0,0 +1,231 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { err } from "neverthrow"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BaseError } from "@/errors"; + +import { InMemoryAPLRepository } from "./__tests__/in-memory-apl-repository"; +import { DynamoAPL } from "./dynamodb-apl"; + +describe("DynamoAPL", () => { + const mockedAuthData: AuthData = { + saleorApiUrl: "saleorApiUrl", + token: "appToken", + domain: "saleorDomain", + appId: "saleorAppId", + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should get auth data if it exists", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + repository.setEntry({ + authData: mockedAuthData, + }); + + const result = await apl.get("saleorApiUrl"); + + expect(result).toStrictEqual(mockedAuthData); + }); + + it("should return undefined if auth data does not exist", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.get("saleorApiUrl"); + + expect(result).toBeUndefined(); + }); + + it("should throw an error if getting auth data fails", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + vi.spyOn(repository, "getEntry").mockReturnValue( + Promise.resolve(err(new BaseError("Error getting data"))), + ); + + await expect(apl.get("saleorApiUrl")).rejects.toThrowError(DynamoAPL.GetAuthDataError); + }); + + it("should set auth data", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.set(mockedAuthData); + + expect(result).toBeUndefined(); + + const getEntryResult = await repository.getEntry({ + saleorApiUrl: mockedAuthData.saleorApiUrl, + }); + + expect(getEntryResult._unsafeUnwrap()).toStrictEqual(mockedAuthData); + }); + + it("should throw an error if setting auth data fails", async () => { + const repository = new InMemoryAPLRepository(); + + vi.spyOn(repository, "setEntry").mockResolvedValue(err(new BaseError("Error setting data"))); + + const apl = new DynamoAPL({ repository }); + + await expect(apl.set(mockedAuthData)).rejects.toThrowError(DynamoAPL.SetAuthDataError); + }); + + it("should update existing auth data", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + repository.setEntry({ + authData: mockedAuthData, + }); + + apl.set({ + saleorApiUrl: mockedAuthData.saleorApiUrl, + token: "newAppToken", + domain: "newSaleorDomain", + appId: "newSaleorAppId", + }); + + const getEntryResult = await apl.get(mockedAuthData.saleorApiUrl); + + expect(getEntryResult).toStrictEqual({ + saleorApiUrl: mockedAuthData.saleorApiUrl, + domain: "newSaleorDomain", + appId: "newSaleorAppId", + token: "newAppToken", + }); + }); + + it("should delete auth data", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + repository.setEntry({ + authData: mockedAuthData, + }); + + await apl.delete(mockedAuthData.saleorApiUrl); + + const getEntryResult = await apl.get(mockedAuthData.saleorApiUrl); + + expect(getEntryResult).toBeUndefined(); + }); + + it("should throw an error if deleting auth data fails", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + await expect(apl.delete("saleorApiUrl")).rejects.toThrowError(DynamoAPL.DeleteAuthDataError); + }); + + it("should get all auth data", async () => { + const repository = new InMemoryAPLRepository(); + const secondEntry: AuthData = { + saleorApiUrl: "saleorApiUrl2", + token: "appToken2", + domain: "saleorDomain2", + appId: "saleorAppId2", + }; + const apl = new DynamoAPL({ repository }); + + repository.setEntry({ + authData: mockedAuthData, + }); + + repository.setEntry({ + authData: secondEntry, + }); + + const result = await apl.getAll(); + + expect(result).toStrictEqual([mockedAuthData, secondEntry]); + }); + + it("should throw an error if getting all auth data fails", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + vi.spyOn(repository, "getAllEntries").mockResolvedValue( + err(new BaseError("Error getting data")), + ); + + await expect(apl.getAll()).rejects.toThrowError(DynamoAPL.GetAllAuthDataError); + }); + + it("should return ready:true when APL related env variables are set", async () => { + vi.spyOn(await import("@/env"), "env", "get").mockReturnValue({ + DYNAMODB_MAIN_TABLE_NAME: "table_name", + AWS_REGION: "region", + AWS_ACCESS_KEY_ID: "access_key_id", + AWS_SECRET_ACCESS_KEY: "secret_access_key", + APL: "dynamodb", + APP_LOG_LEVEL: "info", + MANIFEST_APP_ID: "", + OTEL_ENABLED: false, + PORT: 0, + SECRET_KEY: "", + NODE_ENV: "test", + ENV: "local", + }); + + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.isReady(); + + expect(result).toStrictEqual({ ready: true }); + }); + + it("should return ready:false when APL related env variables are not set", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.isReady(); + + expect(result).toStrictEqual({ + ready: false, + error: new DynamoAPL.MissingEnvVariablesError("Missing DynamoDB env variables"), + }); + }); + + it("should return configured:true when APL related env variables are set", async () => { + vi.spyOn(await import("@/env"), "env", "get").mockReturnValue({ + DYNAMODB_MAIN_TABLE_NAME: "table_name", + AWS_REGION: "region", + AWS_ACCESS_KEY_ID: "access_key_id", + AWS_SECRET_ACCESS_KEY: "secret_access_key", + APL: "dynamodb", + APP_LOG_LEVEL: "info", + MANIFEST_APP_ID: "", + OTEL_ENABLED: false, + PORT: 0, + SECRET_KEY: "", + NODE_ENV: "test", + ENV: "local", + }); + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.isConfigured(); + + expect(result).toStrictEqual({ configured: true }); + }); + + it("should return configured:false when APL related env variables are not set", async () => { + const repository = new InMemoryAPLRepository(); + const apl = new DynamoAPL({ repository }); + + const result = await apl.isConfigured(); + + expect(result).toStrictEqual({ + configured: false, + error: new DynamoAPL.MissingEnvVariablesError("Missing DynamoDB env variables"), + }); + }); +}); diff --git a/apps/segment/src/lib/dynamodb-apl.ts b/apps/segment/src/lib/dynamodb-apl.ts new file mode 100644 index 0000000000..bc9658e25f --- /dev/null +++ b/apps/segment/src/lib/dynamodb-apl.ts @@ -0,0 +1,135 @@ +import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "@saleor/app-sdk/APL"; +import { getOtelTracer } from "@saleor/apps-otel/src/otel-tracer"; + +import { env } from "@/env"; +import { BaseError } from "@/errors"; +import { APLRepository } from "@/modules/db/types"; + +export class DynamoAPL implements APL { + static GetAuthDataError = BaseError.subclass("GetAuthDataError"); + static SetAuthDataError = BaseError.subclass("SetAuthDataError"); + static DeleteAuthDataError = BaseError.subclass("DeleteAuthDataError"); + static GetAllAuthDataError = BaseError.subclass("GetAllAuthDataError"); + static MissingEnvVariablesError = BaseError.subclass("MissingEnvVariablesError"); + + private tracer = getOtelTracer(); + + constructor(private deps: { repository: APLRepository }) {} + + async get(saleorApiUrl: string): Promise { + return this.tracer.startActiveSpan("DynamoAPL.get", async (span) => { + const getEntryResult = await this.deps.repository.getEntry({ + saleorApiUrl, + }); + + if (getEntryResult.isErr()) { + span.end(); + throw new DynamoAPL.GetAuthDataError("Failed to get APL entry", { + cause: getEntryResult.error, + }); + } + + if (!getEntryResult.value) { + span.end(); + return undefined; + } + + span.end(); + return getEntryResult.value; + }); + } + + async set(authData: AuthData): Promise { + return this.tracer.startActiveSpan("DynamoAPL.set", async (span) => { + const setEntryResult = await this.deps.repository.setEntry({ + authData, + }); + + if (setEntryResult.isErr()) { + span.end(); + throw new DynamoAPL.SetAuthDataError("Failed to set APL entry", { + cause: setEntryResult.error, + }); + } + + span.end(); + return undefined; + }); + } + + async delete(saleorApiUrl: string): Promise { + return this.tracer.startActiveSpan("DynamoAPL.delete", async (span) => { + const deleteEntryResult = await this.deps.repository.deleteEntry({ + saleorApiUrl, + }); + + if (deleteEntryResult.isErr()) { + span.end(); + throw new DynamoAPL.DeleteAuthDataError("Failed to delete APL entry", { + cause: deleteEntryResult.error, + }); + } + + span.end(); + return undefined; + }); + } + + async getAll(): Promise { + return this.tracer.startActiveSpan("DynamoAPL.getAll", async (span) => { + const getAllEntriesResult = await this.deps.repository.getAllEntries(); + + if (getAllEntriesResult.isErr()) { + span.end(); + throw new DynamoAPL.GetAllAuthDataError("Failed to get all APL entries", { + cause: getAllEntriesResult.error, + }); + } + + if (!getAllEntriesResult.value) { + span.end(); + return []; + } + + span.end(); + return getAllEntriesResult.value; + }); + } + + async isReady(): Promise { + const ready = this.envVariablesRequriedByDynamoDBExist(); + + return ready + ? { + ready: true, + } + : { + ready: false, + error: new DynamoAPL.MissingEnvVariablesError("Missing DynamoDB env variables"), + }; + } + + async isConfigured(): Promise { + const configured = this.envVariablesRequriedByDynamoDBExist(); + + return configured + ? { + configured: true, + } + : { + configured: false, + error: new DynamoAPL.MissingEnvVariablesError("Missing DynamoDB env variables"), + }; + } + + private envVariablesRequriedByDynamoDBExist() { + const variables = [ + "DYNAMODB_MAIN_TABLE_NAME", + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + ] as const; + + return variables.every((variable) => !!env[variable]); + } +} diff --git a/apps/segment/src/lib/dynamodb-client.ts b/apps/segment/src/lib/dynamodb-client.ts new file mode 100644 index 0000000000..b5d56ee282 --- /dev/null +++ b/apps/segment/src/lib/dynamodb-client.ts @@ -0,0 +1,12 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + +export const createDynamoDBClient = () => { + const client = new DynamoDBClient(); + + return client; +}; + +export const createDynamoDBDocumentClient = (client: DynamoDBClient) => { + return DynamoDBDocumentClient.from(client); +}; diff --git a/apps/segment/src/logger.ts b/apps/segment/src/logger.ts index 95a2ecb92b..c0b9e35413 100644 --- a/apps/segment/src/logger.ts +++ b/apps/segment/src/logger.ts @@ -1,11 +1,14 @@ import { attachLoggerConsoleTransport, rootLogger } from "@saleor/apps-logger"; +import { createRequire } from "module"; import packageJson from "../package.json"; import { env } from "./env"; rootLogger.settings.maskValuesOfKeys = ["metadata", "username", "password", "apiKey"]; -if (env.NODE_ENV !== "production") { +const require = createRequire(import.meta.url); + +if (env.NODE_ENV === "development") { attachLoggerConsoleTransport(rootLogger); } diff --git a/apps/segment/src/modules/db/segment-apl-mapper.ts b/apps/segment/src/modules/db/segment-apl-mapper.ts new file mode 100644 index 0000000000..9e613aa8a4 --- /dev/null +++ b/apps/segment/src/modules/db/segment-apl-mapper.ts @@ -0,0 +1,28 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { FormattedItem, type PutItemInput } from "dynamodb-toolbox"; + +import { SegmentAPLEntityType, SegmentMainTable } from "@/modules/db/segment-main-table"; + +export class SegmentAPLMapper { + dynamoDBEntityToAuthData(entity: FormattedItem): AuthData { + return { + domain: entity.domain, + token: entity.token, + saleorApiUrl: entity.saleorApiUrl, + appId: entity.appId, + jwks: entity.jwks, + }; + } + + authDataToDynamoPutEntity(authData: AuthData): PutItemInput { + return { + PK: SegmentMainTable.getAPLPrimaryKey({ saleorApiUrl: authData.saleorApiUrl }), + SK: SegmentMainTable.getAPLSortKey(), + domain: authData.domain, + token: authData.token, + saleorApiUrl: authData.saleorApiUrl, + appId: authData.appId, + jwks: authData.jwks, + }; + } +} diff --git a/apps/segment/src/modules/db/segment-apl-repository-factory.ts b/apps/segment/src/modules/db/segment-apl-repository-factory.ts new file mode 100644 index 0000000000..9bb6799fac --- /dev/null +++ b/apps/segment/src/modules/db/segment-apl-repository-factory.ts @@ -0,0 +1,42 @@ +import { env } from "@/env"; +import { BaseError } from "@/errors"; + +import { SegmentAPLRepository } from "./segment-apl-repository"; +import { + documentClient, + SegmentMainTable, + SegmentMainTableEntityFactory, +} from "./segment-main-table"; + +export class SegmentAPLRepositoryFactory { + static RepositoryCreationError = BaseError.subclass("RepositoryCreationError"); + + static create(): SegmentAPLRepository { + if ( + !env.DYNAMODB_MAIN_TABLE_NAME || + !env.AWS_REGION || + !env.AWS_ACCESS_KEY_ID || + !env.AWS_SECRET_ACCESS_KEY + ) { + throw new SegmentAPLRepositoryFactory.RepositoryCreationError( + "DynamoDB APL is not configured - missing env variables.", + ); + } + + try { + // TODO: when we have config in DyanamoDB - move to `segment-main-table.ts` + const table = SegmentMainTable.create({ + tableName: env.DYNAMODB_MAIN_TABLE_NAME, + documentClient, + }); + const segmentAPLEntity = SegmentMainTableEntityFactory.createAPLEntity(table); + + return new SegmentAPLRepository({ segmentAPLEntity }); + } catch (error) { + throw new SegmentAPLRepositoryFactory.RepositoryCreationError( + "Failed to create DynamoDB APL repository", + { cause: error }, + ); + } + } +} diff --git a/apps/segment/src/modules/db/segment-apl-repository.test.ts b/apps/segment/src/modules/db/segment-apl-repository.test.ts new file mode 100644 index 0000000000..335d6f77c4 --- /dev/null +++ b/apps/segment/src/modules/db/segment-apl-repository.test.ts @@ -0,0 +1,221 @@ +import { + DeleteCommand, + DynamoDBDocumentClient, + GetCommand, + PutCommand, + ScanCommand, +} from "@aws-sdk/lib-dynamodb"; +import { AuthData } from "@saleor/app-sdk/APL"; +import { mockClient } from "aws-sdk-client-mock"; +import { SavedItem } from "dynamodb-toolbox"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { SegmentAPLRepository } from "./segment-apl-repository"; +import { SegmentMainTable, SegmentMainTableEntityFactory } from "./segment-main-table"; + +describe("SegmentAPLRepository", () => { + const mockDocumentClient = mockClient(DynamoDBDocumentClient); + + const segmentMainTable = SegmentMainTable.create({ + // @ts-expect-error https://github.com/m-radzikowski/aws-sdk-client-mock/issues/197 + documentClient: mockDocumentClient, + tableName: "segment-test-table", + }); + + const segmentAPLEntity = SegmentMainTableEntityFactory.createAPLEntity(segmentMainTable); + + const mockedAuthData: AuthData = { + appId: "appId", + saleorApiUrl: "saleorApiUrl", + token: "appToken", + }; + + beforeEach(() => { + mockDocumentClient.reset(); + }); + + it("should successfully get AuthData entry from DynamoDB", async () => { + const mockedAPLEntry: SavedItem = { + PK: "saleorApiUrl", + SK: "APL", + token: "appToken", + saleorApiUrl: "saleorApiUrl", + appId: "appId", + _et: "APL", + createdAt: "2023-01-01T00:00:00.000Z", + modifiedAt: "2023-01-01T00:00:00.000Z", + }; + + mockDocumentClient.on(GetCommand, {}).resolvesOnce({ + Item: mockedAPLEntry, + }); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getEntry({ saleorApiUrl: "saleorApiUrl" }); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toStrictEqual({ + appId: "appId", + domain: undefined, + jwks: undefined, + saleorApiUrl: "saleorApiUrl", + token: "appToken", + }); + }); + + it("should handle errors when getting AuthData from DynamoDB", async () => { + mockDocumentClient.on(GetCommand, {}).rejectsOnce("Exception"); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getEntry({ saleorApiUrl: "saleorApiUrl" }); + + expect(result.isErr()).toBe(true); + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(SegmentAPLRepository.ReadEntityError); + }); + + it("should return null if AuthData entry does not exist in DynamoDB", async () => { + mockDocumentClient.on(GetCommand, {}).resolvesOnce({}); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getEntry({ saleorApiUrl: "saleorApiUrl" }); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toBe(null); + }); + + it("should successfully set AuthData entry in DynamoDB", async () => { + mockDocumentClient.on(PutCommand, {}).resolvesOnce({}); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.setEntry({ + authData: mockedAuthData, + }); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toBe(undefined); + }); + + it("should handle errors when setting AuthData entry DynamoDB", async () => { + mockDocumentClient.on(PutCommand, {}).rejectsOnce("Exception"); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.setEntry({ + authData: mockedAuthData, + }); + + expect(result.isErr()).toBe(true); + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(SegmentAPLRepository.WriteEntityError); + }); + + it("should successfully delete AuthData entry from DynamoDB", async () => { + mockDocumentClient.on(DeleteCommand, {}).resolvesOnce({}); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.deleteEntry({ saleorApiUrl: "saleorApiUrl" }); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toBe(undefined); + }); + + it("should handle errors when deleting AuthData entry from DynamoDB", async () => { + mockDocumentClient.on(DeleteCommand, {}).rejectsOnce("Exception"); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.deleteEntry({ saleorApiUrl: "saleorApiUrl" }); + + expect(result.isErr()).toBe(true); + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(SegmentAPLRepository.DeleteEntityError); + }); + + it("should successfully get all AuthData entries from DynamoDB", async () => { + const mockedAPLEntries: SavedItem[] = [ + { + PK: "saleorApiUrl", + SK: "APL", + token: "appToken", + saleorApiUrl: "saleorApiUrl", + appId: "appId", + _et: "APL", + createdAt: "2023-01-01T00:00:00.000Z", + modifiedAt: "2023-01-01T00:00:00.000Z", + }, + { + PK: "additionalSaleorApiUrl", + SK: "APL", + token: "newAppToken", + saleorApiUrl: "additionalSaleorApiUrl", + appId: "newAppId", + _et: "APL", + createdAt: "2024-01-01T00:00:00.000Z", + modifiedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + + mockDocumentClient.on(ScanCommand, {}).resolvesOnce({ + Items: mockedAPLEntries, + }); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getAllEntries(); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toStrictEqual([ + { + appId: "appId", + domain: undefined, + jwks: undefined, + saleorApiUrl: "saleorApiUrl", + token: "appToken", + }, + { + appId: "newAppId", + domain: undefined, + jwks: undefined, + saleorApiUrl: "additionalSaleorApiUrl", + token: "newAppToken", + }, + ]); + }); + + it("should return null if there are no AuthData entries in DynamoDB", async () => { + mockDocumentClient.on(ScanCommand, {}).resolvesOnce({ + Items: [], + }); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getAllEntries(); + + expect(result.isOk()).toBe(true); + + expect(result._unsafeUnwrap()).toBe(null); + }); + + it("should handle error when getting all AuthData entries from DynamoDB", async () => { + mockDocumentClient.on(ScanCommand, {}).rejectsOnce("Exception"); + + const repository = new SegmentAPLRepository({ segmentAPLEntity }); + + const result = await repository.getAllEntries(); + + expect(result.isErr()).toBe(true); + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(SegmentAPLRepository.ScanEntityError); + }); +}); diff --git a/apps/segment/src/modules/db/segment-apl-repository.ts b/apps/segment/src/modules/db/segment-apl-repository.ts new file mode 100644 index 0000000000..def765b114 --- /dev/null +++ b/apps/segment/src/modules/db/segment-apl-repository.ts @@ -0,0 +1,139 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { DeleteItemCommand, GetItemCommand, PutItemCommand, ScanCommand } from "dynamodb-toolbox"; +import { err, ok, ResultAsync } from "neverthrow"; + +import { BaseError } from "@/errors"; +import { createLogger } from "@/logger"; +import { SegmentAPLEntityType, SegmentMainTable } from "@/modules/db/segment-main-table"; + +import { SegmentAPLMapper } from "./segment-apl-mapper"; +import { APLRepository } from "./types"; + +export class SegmentAPLRepository implements APLRepository { + private logger = createLogger("SegmentAPLRepository"); + + private segmentAPLMapper = new SegmentAPLMapper(); + + static ReadEntityError = BaseError.subclass("ReadEntityError"); + static WriteEntityError = BaseError.subclass("WriteEntityError"); + static DeleteEntityError = BaseError.subclass("DeleteEntityError"); + static ScanEntityError = BaseError.subclass("ScanEntityError"); + + constructor( + private deps: { + segmentAPLEntity: SegmentAPLEntityType; + }, + ) {} + + async getEntry(args: { saleorApiUrl: string }) { + const getEntryResult = await ResultAsync.fromPromise( + this.deps.segmentAPLEntity + .build(GetItemCommand) + .key({ + PK: SegmentMainTable.getAPLPrimaryKey({ + saleorApiUrl: args.saleorApiUrl, + }), + SK: SegmentMainTable.getAPLSortKey(), + }) + .send(), + (error) => + new SegmentAPLRepository.ReadEntityError("Failed to read APL entity", { cause: error }), + ); + + if (getEntryResult.isErr()) { + this.logger.error("Error while reading APL entity from DynamoDB", { + error: getEntryResult.error, + }); + + return err(getEntryResult.error); + } + + if (!getEntryResult.value.Item) { + this.logger.warn("APL entry not found", { args }); + + return ok(null); + } + + return ok(this.segmentAPLMapper.dynamoDBEntityToAuthData(getEntryResult.value.Item)); + } + + async setEntry(args: { authData: AuthData }) { + const setEntryResult = await ResultAsync.fromPromise( + this.deps.segmentAPLEntity + .build(PutItemCommand) + .item(this.segmentAPLMapper.authDataToDynamoPutEntity(args.authData)) + .send(), + (error) => + new SegmentAPLRepository.WriteEntityError("Failed to write APL entity", { cause: error }), + ); + + if (setEntryResult.isErr()) { + this.logger.error("Error while putting APL into DynamoDB", { + error: setEntryResult.error, + }); + + return err(setEntryResult.error); + } + + return ok(undefined); + } + + async deleteEntry(args: { saleorApiUrl: string }) { + const deleteEntryResult = await ResultAsync.fromPromise( + this.deps.segmentAPLEntity + .build(DeleteItemCommand) + .key({ + PK: SegmentMainTable.getAPLPrimaryKey({ + saleorApiUrl: args.saleorApiUrl, + }), + SK: SegmentMainTable.getAPLSortKey(), + }) + .send(), + (error) => + new SegmentAPLRepository.DeleteEntityError("Failed to delete APL entity", { + cause: error, + }), + ); + + if (deleteEntryResult.isErr()) { + this.logger.error("Error while deleting entry APL from DynamoDB", { + error: deleteEntryResult.error, + }); + + return err(deleteEntryResult.error); + } + + return ok(undefined); + } + + async getAllEntries() { + const scanEntriesResult = await ResultAsync.fromPromise( + this.deps.segmentAPLEntity.table + .build(ScanCommand) + .entities(this.deps.segmentAPLEntity) + .options({ + // keep all the entries in memory - we should introduce pagination in the future + maxPages: Infinity, + }) + .send(), + (error) => + new SegmentAPLRepository.ScanEntityError("Failed to scan APL entities", { cause: error }), + ); + + if (scanEntriesResult.isErr()) { + this.logger.error("Error while scanning APL entities from DynamoDB", { + error: scanEntriesResult.error, + }); + + return err(scanEntriesResult.error); + } + + const possibleItems = scanEntriesResult.value.Items ?? []; + + if (possibleItems.length > 0) { + return ok(possibleItems.map(this.segmentAPLMapper.dynamoDBEntityToAuthData)); + } + + return ok(null); + } +} diff --git a/apps/segment/src/modules/db/segment-main-table.test.ts b/apps/segment/src/modules/db/segment-main-table.test.ts new file mode 100644 index 0000000000..f852dfff8e --- /dev/null +++ b/apps/segment/src/modules/db/segment-main-table.test.ts @@ -0,0 +1,59 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import { EntityParser } from "dynamodb-toolbox"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SegmentMainTable, SegmentMainTableEntityFactory } from "./segment-main-table"; + +describe("SegmentMainTable", () => { + const mockDate = new Date("2023-01-01T00:00:00Z"); + + beforeAll(() => { + vi.setSystemTime(mockDate); + }); + + afterEach(() => { + vi.resetModules(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + const mockDocumentClient = mockClient(DynamoDBDocumentClient); + + const segmentMainTable = SegmentMainTable.create({ + // @ts-expect-error https://github.com/m-radzikowski/aws-sdk-client-mock/issues/197 + documentClient: mockDocumentClient, + tableName: "segment-test-table", + }); + + beforeEach(() => { + mockDocumentClient.reset(); + }); + + describe("SegmentAPLEntity", () => { + it("should create a new entity in DynamoDB with default fields", () => { + const aplEntity = SegmentMainTableEntityFactory.createAPLEntity(segmentMainTable); + + const parseResult = aplEntity.build(EntityParser).parse({ + PK: "saleorApiUrl", + SK: "APL", + token: "appToken", + saleorApiUrl: "saleorApiUrl", + appId: "appId", + }); + + expect(parseResult.item).toStrictEqual({ + PK: "saleorApiUrl", + SK: "APL", + _et: "APL", + appId: "appId", + saleorApiUrl: "saleorApiUrl", + token: "appToken", + createdAt: "2023-01-01T00:00:00.000Z", + modifiedAt: "2023-01-01T00:00:00.000Z", + }); + }); + }); +}); diff --git a/apps/segment/src/modules/db/segment-main-table.ts b/apps/segment/src/modules/db/segment-main-table.ts new file mode 100644 index 0000000000..924affbf19 --- /dev/null +++ b/apps/segment/src/modules/db/segment-main-table.ts @@ -0,0 +1,80 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { Entity, schema, string, Table } from "dynamodb-toolbox"; + +import { createDynamoDBClient, createDynamoDBDocumentClient } from "@/lib/dynamodb-client"; + +type PartitionKey = { name: "PK"; type: "string" }; +type SortKey = { name: "SK"; type: "string" }; + +/** + * This table is used to store all relevant data for the Segment application meaning: APL, configuration, etc. + */ +export class SegmentMainTable extends Table { + private constructor(args: ConstructorParameters>[number]) { + super(args); + } + + static create({ + documentClient, + tableName, + }: { + documentClient: DynamoDBDocumentClient; + tableName: string; + }): SegmentMainTable { + return new SegmentMainTable({ + documentClient, + name: tableName, + partitionKey: { name: "PK", type: "string" }, + sortKey: { + name: "SK", + type: "string", + }, + }); + } + + static getAPLPrimaryKey({ saleorApiUrl }: { saleorApiUrl: string }) { + return `${saleorApiUrl}` as const; + } + + static getAPLSortKey() { + return `APL` as const; + } +} + +const SegmentConfigTableSchema = { + apl: schema({ + PK: string().key(), + SK: string().key(), + domain: string().optional(), + token: string(), + saleorApiUrl: string(), + appId: string(), + jwks: string().optional(), + }), +}; + +export const client = createDynamoDBClient(); + +export const documentClient = createDynamoDBDocumentClient(client); + +export const SegmentMainTableEntityFactory = { + createAPLEntity: (table: SegmentMainTable) => { + return new Entity({ + table, + name: "APL", + schema: SegmentConfigTableSchema.apl, + timestamps: { + created: { + name: "createdAt", + savedAs: "createdAt", + }, + modified: { + name: "modifiedAt", + savedAs: "modifiedAt", + }, + }, + }); + }, +}; + +export type SegmentAPLEntityType = ReturnType; diff --git a/apps/segment/src/modules/db/types.ts b/apps/segment/src/modules/db/types.ts new file mode 100644 index 0000000000..302c892c76 --- /dev/null +++ b/apps/segment/src/modules/db/types.ts @@ -0,0 +1,15 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { Result } from "neverthrow"; + +import { BaseError } from "@/errors"; + +export interface APLRepository { + getEntry(args: { + saleorApiUrl: string; + }): Promise>>; + setEntry(args: { authData: AuthData }): Promise>>; + deleteEntry(args: { + saleorApiUrl: string; + }): Promise>>; + getAllEntries(): Promise>>; +} diff --git a/apps/segment/src/saleor-app.ts b/apps/segment/src/saleor-app.ts index 30c5c5e0d3..9eb9303230 100644 --- a/apps/segment/src/saleor-app.ts +++ b/apps/segment/src/saleor-app.ts @@ -3,12 +3,20 @@ import { SaleorApp } from "@saleor/app-sdk/saleor-app"; import { env } from "./env"; import { BaseError } from "./errors"; +import { DynamoAPL } from "./lib/dynamodb-apl"; +import { SegmentAPLRepositoryFactory } from "./modules/db/segment-apl-repository-factory"; export let apl: APL; const MisconfiguredSaleorCloudAPLError = BaseError.subclass("MisconfiguredSaleorCloudAPLError"); switch (env.APL) { + case "dynamodb": { + const repository = SegmentAPLRepositoryFactory.create(); + + apl = new DynamoAPL({ repository }); + break; + } case "saleor-cloud": { if (!env.REST_APL_ENDPOINT || !env.REST_APL_TOKEN) { throw new MisconfiguredSaleorCloudAPLError( diff --git a/apps/segment/src/setup-tests.ts b/apps/segment/src/setup-tests.ts index cb0ff5c3b5..d788c3b294 100644 --- a/apps/segment/src/setup-tests.ts +++ b/apps/segment/src/setup-tests.ts @@ -1 +1,5 @@ +import { vi } from "vitest"; + +vi.stubEnv("SECRET_KEY", "test_secret_key"); + export {}; diff --git a/apps/segment/turbo.json b/apps/segment/turbo.json index 11410c5ad2..e2206676d1 100644 --- a/apps/segment/turbo.json +++ b/apps/segment/turbo.json @@ -17,7 +17,11 @@ "MANIFEST_APP_ID", "SENTRY_ORG", "SENTRY_PROJECT", - "SENTRY_AUTH_TOKEN" + "SENTRY_AUTH_TOKEN", + "DYNAMODB_MAIN_TABLE_NAME", + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826560b27b..2dd3235b93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1125,6 +1125,15 @@ importers: apps/segment: dependencies: + '@aws-sdk/client-dynamodb': + specifier: 3.651.1 + version: 3.651.1 + '@aws-sdk/lib-dynamodb': + specifier: 3.651.1 + version: 3.651.1(@aws-sdk/client-dynamodb@3.651.1) + '@aws-sdk/util-dynamodb': + specifier: 3.651.1 + version: 3.651.1(@aws-sdk/client-dynamodb@3.651.1) '@hookform/resolvers': specifier: ^3.3.1 version: 3.3.1(react-hook-form@7.44.3(react@18.2.0)) @@ -1227,6 +1236,9 @@ importers: dotenv: specifier: 16.3.1 version: 16.3.1 + dynamodb-toolbox: + specifier: 1.8.2 + version: 1.8.2(@aws-sdk/client-dynamodb@3.651.1)(@aws-sdk/lib-dynamodb@3.651.1(@aws-sdk/client-dynamodb@3.651.1)) graphql: specifier: 16.7.1 version: 16.7.1 @@ -1312,6 +1324,9 @@ importers: '@typescript-eslint/parser': specifier: 7.15.0 version: 7.15.0(eslint@node_modules+eslint)(typescript@5.5.4) + aws-sdk-client-mock: + specifier: 4.0.1 + version: 4.0.1 eslint: specifier: ../../node_modules/eslint version: link:../../node_modules/eslint