Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DynamoDB APL to Segment app #1690

Merged
merged 9 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/segment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=...
```
12 changes: 12 additions & 0 deletions apps/segment/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions apps/segment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"test": "vitest"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "3.651.1",
"@aws-sdk/lib-dynamodb": "3.651.1",
"@aws-sdk/util-dynamodb": "3.651.1",
"dynamodb-toolbox": "1.8.2",
"@hookform/resolvers": "^3.3.1",
"@opentelemetry/api": "../../node_modules/@opentelemetry/api",
"@opentelemetry/api-logs": "../../node_modules/@opentelemetry/api-logs",
Expand Down
11 changes: 11 additions & 0 deletions apps/segment/scripts/setup-dynamodb.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion apps/segment/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
Expand All @@ -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",
});
107 changes: 107 additions & 0 deletions apps/segment/src/lib/dynamodb-apl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "@saleor/app-sdk/APL";

import { BaseError } from "@/errors";
import { SegmentAPLRepository } from "@/modules/db/segment-apl-repository";
import { SegmentAPLEntityType } from "@/modules/db/segment-main-table";

export class DynamoAPL implements APL {
private segmentAPLRepository: SegmentAPLRepository;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick, but the class is APL and app context is Segment, so you "know" the outer context - you dont have to prefix it. Calling variable "repo" or "repository" is verbose enough


static SetAuthDataError = BaseError.subclass("SetAuthDataError");
static DeleteAuthDataError = BaseError.subclass("DeleteAuthDataError");
static MissingEnvVariablesError = BaseError.subclass("MissingEnvVariablesError");

constructor({ segmentAPLEntity }: { segmentAPLEntity: SegmentAPLEntityType }) {
this.segmentAPLRepository = new SegmentAPLRepository({ segmentAPLEntity });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why dont you inject repo?

}

async get(saleorApiUrl: string): Promise<AuthData | undefined> {
const getEntryResult = await this.segmentAPLRepository.getEntry({
saleorApiUrl,
});

if (getEntryResult.isErr()) {
// TODO: should we throw here?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, because APL interface is allowing undefined as a non-existing value

but, we should check:

  1. if its 404-like error, map to underfined
  2. if its connection error/db down etc, we should throw (panic mode)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we have a easy way of knowing if there is connection error - I checked and toolbox doesn't expose that info if it throws error

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think:

  1. Every promise rejection from dynamo is "unhandled"
  2. Resolved promise with Item === null is correct request, but empty database row

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense - I will update implementation to add such logic

return undefined;
}

return getEntryResult.value;
}

async set(authData: AuthData): Promise<void> {
const setEntryResult = await this.segmentAPLRepository.setEntry({
authData,
});

if (setEntryResult.isErr()) {
lkostrowski marked this conversation as resolved.
Show resolved Hide resolved
throw new DynamoAPL.SetAuthDataError("Failed to set APL entry", {
cause: setEntryResult.error,
});
}

return undefined;
}

async delete(saleorApiUrl: string): Promise<void> {
const deleteEntryResult = await this.segmentAPLRepository.deleteEntry({
saleorApiUrl,
});

if (deleteEntryResult.isErr()) {
throw new DynamoAPL.DeleteAuthDataError("Failed to delete APL entry", {
cause: deleteEntryResult.error,
});
}

return undefined;
}

async getAll(): Promise<AuthData[]> {
const getAllEntriesResult = await this.segmentAPLRepository.getAllEntries();

if (getAllEntriesResult.isErr()) {
// TODO: should we throw here?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above- depending on the error type

return [];
}

return getAllEntriesResult.value;
}

async isReady(): Promise<AplReadyResult> {
const ready = this.envVariablesRequriedByDynamoDBExist();

return ready
? {
ready: true,
}
: {
ready: false,
error: new DynamoAPL.MissingEnvVariablesError("Missing DynamoDB env variables"),
};
}

async isConfigured(): Promise<AplConfiguredResult> {
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",
];

// eslint-disable-next-line node/no-process-env
return variables.every((variable) => !!process.env[variable]);
}
}
12 changes: 12 additions & 0 deletions apps/segment/src/lib/dynamodb-client.ts
Original file line number Diff line number Diff line change
@@ -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);
};
3 changes: 3 additions & 0 deletions apps/segment/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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"];

const require = createRequire(import.meta.url);

if (env.NODE_ENV !== "production") {
attachLoggerConsoleTransport(rootLogger);
}
Expand Down
28 changes: 28 additions & 0 deletions apps/segment/src/modules/db/segment-apl-mapper.ts
Original file line number Diff line number Diff line change
@@ -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<SegmentAPLEntityType>): AuthData {
return {
domain: entity.domain,
token: entity.token,
saleorApiUrl: entity.saleorApiUrl,
appId: entity.appId,
jwks: entity.jwks,
};
}

authDataToDynamoPutEntity(authData: AuthData): PutItemInput<SegmentAPLEntityType> {
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,
};
}
}
Loading
Loading