diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57634a2b..f284aef5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,9 +30,25 @@ jobs: - uses: actions/checkout@v3 - run: npm ci - run: npm run test - + + e2e-test: + runs-on: ubuntu-latest + strategy: + matrix: + directory: ["services/api"] + defaults: + run: + working-directory: ${{ matrix.directory }} + steps: + - uses: actions/checkout@v3 + - uses: rrainn/dynamodb-action@v2.0.1 + with: + port: 8001 + - run: npm ci + - run: npm run test:e2e + build-api: - needs: [lint, test] + needs: [lint, test, e2e-test] if: github.ref == 'refs/heads/develop' uses: jbrunton/workflows/.github/workflows/build-image.yml@develop with: diff --git a/client/src/components/messages/MessagesList.tsx b/client/src/components/messages/MessagesList.tsx index 7af746e3..858844f9 100644 --- a/client/src/components/messages/MessagesList.tsx +++ b/client/src/components/messages/MessagesList.tsx @@ -1,21 +1,28 @@ import { List, ListIcon, ListItem } from '@chakra-ui/react' import React from 'react' -import { Message } from '../../data/messages' +import { Message, User } from '../../data/messages' import { UserIcon } from '../icons/User' export type MessagesListProps = { - messages: Message[] + data: { + messages: Message[] + authors: Record + } } -export const MessagesList: React.FC = ({ messages }) => { +export const MessagesList: React.FC = ({ data: { messages, authors } }) => { return ( - {messages.map((message) => ( - - - {message.content} - - ))} + {messages.map((message) => { + const author = authors[message.authorId] + return ( + + + {author?.name ?? 'Anon'}: + {message.content} + + ) + })} ) } diff --git a/client/src/components/messages/MessagesWidget.tsx b/client/src/components/messages/MessagesWidget.tsx index 8daec8dd..215986b1 100644 --- a/client/src/components/messages/MessagesWidget.tsx +++ b/client/src/components/messages/MessagesWidget.tsx @@ -13,7 +13,7 @@ export const MessagesWidget: React.FC = ({ roomId }) => { const accessToken = useAccessToken() const inputRef = useRef(null) const [content, setContent] = useState('') - const { data: messages, isError } = useMessages(roomId, accessToken) + const { data, isError } = useMessages(roomId, accessToken) const { mutate, isLoading } = usePostMessage(roomId, content, accessToken) const [isSending, setIsSending] = useState(false) @@ -37,7 +37,7 @@ export const MessagesWidget: React.FC = ({ roomId }) => { } return (
- {messages && !isError && } + {data && !isError && } { @@ -16,10 +23,13 @@ export const useMessages = (roomId: string, accessToken?: string) => { }, }) if (response.ok) { - const messages = await response.json() - return messages as Message[] + const data = await response.json() + return data as { messages: Message[]; authors: Record } + } + return { + messages: [], + authors: {}, } - return [] } return useQuery({ queryKey: [`messages/${roomId}`], diff --git a/docker-compose.yml b/docker-compose.yml index 4ec928c6..e03d1245 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,13 @@ services: volumes: - "./docker/dynamodb:/home/dynamodblocal/data" working_dir: /home/dynamodblocal + + dynamodb-test: + command: "-jar DynamoDBLocal.jar -inMemory -port 8001" + image: "amazon/dynamodb-local:latest" + container_name: dynamodb-test + ports: + - "8001:8001" + # volumes: + # - "./docker/dynamodb:/home/dynamodblocal/data" + # working_dir: /home/dynamodblocal diff --git a/services/api/package-lock.json b/services/api/package-lock.json index fab8a928..54b14295 100644 --- a/services/api/package-lock.json +++ b/services/api/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/credential-provider-node": "3.238.0", "@aws-sdk/lib-dynamodb": "3.238.0", "@nestjs/common": "^9.0.0", + "@nestjs/config": "2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/mapped-types": "*", "@nestjs/passport": "9.0.0", @@ -45,6 +46,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", + "jest-mock-extended": "3.0.1", "prettier": "^2.3.2", "source-map-support": "^0.5.20", "supertest": "^6.1.3", @@ -2612,6 +2614,30 @@ } } }, + "node_modules/@nestjs/config": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-2.2.0.tgz", + "integrity": "sha512-78Eg6oMbCy3D/YvqeiGBTOWei1Jwi3f2pSIZcZ1QxY67kYsJzTRTkwRT8Iv30DbK0sGKc1mcloDLD5UXgZAZtg==", + "dependencies": { + "dotenv": "16.0.1", + "dotenv-expand": "8.0.3", + "lodash": "4.17.21", + "uuid": "8.3.2" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^6.0.0 || ^7.2.0" + } + }, + "node_modules/@nestjs/config/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/core": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-9.2.1.tgz", @@ -4735,6 +4761,22 @@ "sentence-case": "^1.1.2" } }, + "node_modules/dotenv": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz", + "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/dotenv-expand": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-8.0.3.tgz", + "integrity": "sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==", + "engines": { + "node": ">=12" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -6679,6 +6721,19 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.1.tgz", + "integrity": "sha512-RF4Ow8pXvbRuEcCTj56oYHmig5311BSFvbEGxPNYL51wGKGu93MvVQgx0UpFmjqyBXIcElkZo2Rke88kR1iSKQ==", + "dev": true, + "dependencies": { + "ts-essentials": "^7.0.3" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -9406,6 +9461,15 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=3.7.0" + } + }, "node_modules/ts-jest": { "version": "28.0.8", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.8.tgz", @@ -12115,6 +12179,24 @@ "uuid": "9.0.0" } }, + "@nestjs/config": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-2.2.0.tgz", + "integrity": "sha512-78Eg6oMbCy3D/YvqeiGBTOWei1Jwi3f2pSIZcZ1QxY67kYsJzTRTkwRT8Iv30DbK0sGKc1mcloDLD5UXgZAZtg==", + "requires": { + "dotenv": "16.0.1", + "dotenv-expand": "8.0.3", + "lodash": "4.17.21", + "uuid": "8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "@nestjs/core": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-9.2.1.tgz", @@ -13755,6 +13837,16 @@ "sentence-case": "^1.1.2" } }, + "dotenv": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz", + "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==" + }, + "dotenv-expand": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-8.0.3.tgz", + "integrity": "sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==" + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -15198,6 +15290,15 @@ "@types/node": "*" } }, + "jest-mock-extended": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.1.tgz", + "integrity": "sha512-RF4Ow8pXvbRuEcCTj56oYHmig5311BSFvbEGxPNYL51wGKGu93MvVQgx0UpFmjqyBXIcElkZo2Rke88kR1iSKQ==", + "dev": true, + "requires": { + "ts-essentials": "^7.0.3" + } + }, "jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -17268,6 +17369,13 @@ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true }, + "ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true, + "requires": {} + }, "ts-jest": { "version": "28.0.8", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.8.tgz", diff --git a/services/api/package.json b/services/api/package.json index 90e7c473..954a7d8e 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -15,8 +15,12 @@ "start": "node dist/src/main", "start:dev": "NODE_ENV=development nest start --watch", "start:debug": "NODE_ENV=development nest start --debug --watch", + "db:init": "NODE_ENV=development ts-node ./scripts/db/init.ts", + "db:drop": "NODE_ENV=development ts-node ./scripts/db/drop.ts", + "db:test:drop": "NODE_ENV=test ts-node ./scripts/dropdb.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", + "test:int": "jest --config ./jest.int.config.js", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", @@ -27,6 +31,7 @@ "@aws-sdk/credential-provider-node": "3.238.0", "@aws-sdk/lib-dynamodb": "3.238.0", "@nestjs/common": "^9.0.0", + "@nestjs/config": "2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/mapped-types": "*", "@nestjs/passport": "9.0.0", @@ -59,6 +64,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", + "jest-mock-extended": "3.0.1", "prettier": "^2.3.2", "source-map-support": "^0.5.20", "supertest": "^6.1.3", diff --git a/services/api/scripts/db/drop.ts b/services/api/scripts/db/drop.ts new file mode 100644 index 00000000..95b48369 --- /dev/null +++ b/services/api/scripts/db/drop.ts @@ -0,0 +1,14 @@ +import { DataModule } from '../../src/data/data.module'; +import { DynamoDBAdapter } from '../../src/data/adapters/dynamodb.adapter'; +import { runWithContext } from '../runWithContext'; + +runWithContext({ + rootModule: DataModule, + run: async (app, logger) => { + const db = app.get(DynamoDBAdapter); + const { TableDescription } = await db.destroy(); + logger.log( + `Dropped table ${TableDescription?.TableName} (${TableDescription?.TableArn})`, + ); + }, +}); diff --git a/services/api/scripts/db/init.ts b/services/api/scripts/db/init.ts new file mode 100644 index 00000000..d5d7823b --- /dev/null +++ b/services/api/scripts/db/init.ts @@ -0,0 +1,14 @@ +import { DynamoDBAdapter } from '../../src/data/adapters/dynamodb.adapter'; +import { DataModule } from '../../src/data/data.module'; +import { runWithContext } from '../runWithContext'; + +runWithContext({ + rootModule: DataModule, + run: async (app, logger) => { + const db = app.get(DynamoDBAdapter); + const { TableDescription } = await db.create(); + logger.log( + `Created table ${TableDescription?.TableName} (${TableDescription?.TableArn})`, + ); + }, +}); diff --git a/services/api/scripts/dropdb.ts b/services/api/scripts/dropdb.ts deleted file mode 100644 index aaffa035..00000000 --- a/services/api/scripts/dropdb.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DynamoDBClient, DeleteTableCommand } from '@aws-sdk/client-dynamodb'; -const ddbClient = new DynamoDBClient({ region: 'localhost', endpoint: 'http://localhost:8000' }); - -export const run = async () => { - try { - const data = await ddbClient.send(new DeleteTableCommand({ - TableName: 'Auth0Test' - })); - console.log('Table Delete', data); - return data; - } catch (err) { - console.log('Error', err); - } -}; -run(); diff --git a/services/api/scripts/initdb.ts b/services/api/scripts/initdb.ts deleted file mode 100644 index c6428ea1..00000000 --- a/services/api/scripts/initdb.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { DynamoDBClient, CreateTableCommand } from '@aws-sdk/client-dynamodb'; -const ddbClient = new DynamoDBClient({ region: 'localhost', endpoint: 'http://localhost:8000' }); - -export const params = { - AttributeDefinitions: [ - { - AttributeName: "Id", - AttributeType: "S", - }, - { - AttributeName: "Sort", - AttributeType: "S", - } - ], - KeySchema: [ - { - AttributeName: "Id", //ATTRIBUTE_NAME_1 - KeyType: "HASH", - }, - { - AttributeName: "Sort", //ATTRIBUTE_NAME_2 - KeyType: "RANGE", - }, - ], - ProvisionedThroughput: { - ReadCapacityUnits: 1, - WriteCapacityUnits: 1, - }, - TableName: 'Auth0Test', - StreamSpecification: { - StreamEnabled: false, - }, -}; - -export const run = async () => { - try { - const data = await ddbClient.send(new CreateTableCommand(params)); - console.log('Table Created', data); - return data; - } catch (err) { - console.log('Error', err); - } -}; -run(); diff --git a/services/api/scripts/runWithContext.ts b/services/api/scripts/runWithContext.ts new file mode 100644 index 00000000..ea36d560 --- /dev/null +++ b/services/api/scripts/runWithContext.ts @@ -0,0 +1,22 @@ +import { NestFactory } from '@nestjs/core'; +import { INestApplicationContext, Logger } from '@nestjs/common'; + +const logger = new Logger(); + +export type RunWithContextParams = { + rootModule: any; + run: (app: INestApplicationContext, logger: Logger) => Promise; +}; + +export const runWithContext = (params: RunWithContextParams) => { + const run = async () => { + const { rootModule, run } = params; + try { + const app = await NestFactory.createApplicationContext(rootModule); + await run(app, logger); + } catch (e) { + logger.error(e); + } + }; + run(); +}; diff --git a/services/api/src/auth/auth0/auth0.config.ts b/services/api/src/auth/auth0/auth0.config.ts index a84428c5..b7572f31 100644 --- a/services/api/src/auth/auth0/auth0.config.ts +++ b/services/api/src/auth/auth0/auth0.config.ts @@ -1,9 +1,9 @@ const domain = 'jbrunton.eu.auth0.com'; export const config = { - domain, - audience: 'https://auth0-test-api.jbrunton-aws.com', - issuerUrl: `https://${domain}/`, - clientId: 'Nv4fV7kgzIIQ7LobQgZQpWQ6WOWinFLJ', - clientSecret: process.env.AUTH0_CLIENT_SECRET, + domain, + audience: 'https://auth0-test-api.jbrunton-aws.com', + issuerUrl: `https://${domain}/`, + clientId: 'Nv4fV7kgzIIQ7LobQgZQpWQ6WOWinFLJ', + clientSecret: process.env.AUTH0_CLIENT_SECRET, }; diff --git a/services/api/src/auth/auth0/jwt.strategy.ts b/services/api/src/auth/auth0/jwt.strategy.ts index b31c412d..9c7b3edb 100644 --- a/services/api/src/auth/auth0/jwt.strategy.ts +++ b/services/api/src/auth/auth0/jwt.strategy.ts @@ -4,8 +4,6 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { passportJwtSecret } from 'jwks-rsa'; import { config } from './auth0.config'; - - @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { diff --git a/services/api/src/auth/user-profile/identify.decorator.ts b/services/api/src/auth/user-profile/identify.decorator.ts index 0baca9cb..469727c1 100644 --- a/services/api/src/auth/user-profile/identify.decorator.ts +++ b/services/api/src/auth/user-profile/identify.decorator.ts @@ -1,5 +1,4 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import fetch from 'node-fetch'; import { ExtractJwt } from 'passport-jwt'; import { client } from '../auth0/auth0.client'; import { UserInfo } from './user-info'; @@ -10,7 +9,7 @@ export const Identify = createParamDecorator( async (_data: unknown, ctx: ExecutionContext): Promise => { const request = ctx.switchToHttp().getRequest(); const accessToken = extractAccessToken(request); - + if (!accessToken) return null; return await client.getProfile(accessToken); diff --git a/services/api/src/config/database.config.ts b/services/api/src/config/database.config.ts new file mode 100644 index 00000000..06981059 --- /dev/null +++ b/services/api/src/config/database.config.ts @@ -0,0 +1,41 @@ +import { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'; +import { registerAs } from '@nestjs/config'; +import * as assert from 'assert'; + +export type DatabaseConfig = { + clientConfig: DynamoDBClientConfig; + tableName: string; +}; + +const nodeEnv = process.env.NODE_ENV || 'production'; + +const productionConfig = (): DatabaseConfig => { + const tableName = process.env.DB_TABLE_NAME; + assert(tableName); + + return { + tableName, + clientConfig: { + region: 'us-east-1', + endpoint: 'https://dynamodb.us-east-1.amazonaws.com', + }, + }; +}; + +const localConfig = (): DatabaseConfig => { + const tableName = nodeEnv === 'test' ? 'Auth0Test-Test' : 'Auth0Test-Dev'; + const port = nodeEnv === 'test' ? 8001 : 8000; + return { + tableName, + clientConfig: { + endpoint: `http://localhost:${port}`, + region: 'local', + credentials: { accessKeyId: 'dummyid', secretAccessKey: 'dummysecret' }, + }, + }; +}; + +export default registerAs( + 'database', + nodeEnv === 'production' ? productionConfig : localConfig, +); diff --git a/services/api/src/data/adapters/dynamodb.adapter.e2e-spec.ts b/services/api/src/data/adapters/dynamodb.adapter.e2e-spec.ts new file mode 100644 index 00000000..892783d0 --- /dev/null +++ b/services/api/src/data/adapters/dynamodb.adapter.e2e-spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataModule } from '../data.module'; +import { DynamoDBAdapter } from './dynamodb.adapter'; + +const timeout = 60; + +jest.setTimeout(timeout * 1000); + +describe('DbAdapter', () => { + let db: DynamoDBAdapter; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [DataModule], + }).compile(); + db = moduleFixture.get(DynamoDBAdapter); + await db.create(); + await db.waitForTable(timeout); + }); + + afterAll(async () => { + await db.waitForTable(timeout); + await db.destroy(); + }); + + it('queries items', async () => { + const items = [ + { Id: 'User#1', Sort: 'User' }, + { Id: 'User#1', Sort: 'Room#1' }, + { Id: 'User#2', Sort: 'User' }, + ]; + for (const item of items) { + await db.putItem({ + Item: item, + }); + } + + const result = await db.query({ + KeyConditionExpression: 'Id = :id and begins_with(Sort,:filter)', + ExpressionAttributeValues: { + ':id': 'User#1', + ':filter': 'Room#', + }, + }); + + expect(result.Items?.length).toEqual(1); + }); + + it('batch gets items', async () => { + const items = [ + { Id: 'User#1', Sort: 'User' }, + { Id: 'User#1', Sort: 'Room#1' }, + { Id: 'User#2', Sort: 'User' }, + ]; + for (const item of items) { + await db.putItem({ + Item: item, + }); + } + + const result = await db.batchGet([ + { Id: 'User#1', Sort: 'User' }, + { Id: 'User#2', Sort: 'User' }, + ]); + + expect(result).toEqual([ + { Sort: 'User', Id: 'User#1' }, + { Sort: 'User', Id: 'User#2' }, + ]); + }); +}); diff --git a/services/api/src/data/adapters/dynamodb.adapter.ts b/services/api/src/data/adapters/dynamodb.adapter.ts new file mode 100644 index 00000000..c82eaf6d --- /dev/null +++ b/services/api/src/data/adapters/dynamodb.adapter.ts @@ -0,0 +1,146 @@ +import { + CreateTableCommand, + CreateTableCommandInput, + CreateTableCommandOutput, + DeleteTableCommand, + DynamoDBClient, + waitUntilTableExists, +} from '@aws-sdk/client-dynamodb'; +import { + BatchGetCommand, + DynamoDBDocumentClient, + PutCommand, + PutCommandInput, + PutCommandOutput, + QueryCommand, + QueryCommandInput, + QueryCommandOutput, +} from '@aws-sdk/lib-dynamodb'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import databaseConfig from '../../config/database.config'; +import { NativeAttributeValue } from '@aws-sdk/util-dynamodb/dist-types/models'; +import * as assert from 'assert'; + +const tableParams = { + AttributeDefinitions: [ + { + AttributeName: 'Id', + AttributeType: 'S', + }, + { + AttributeName: 'Sort', + AttributeType: 'S', + }, + ], + KeySchema: [ + { + AttributeName: 'Id', + KeyType: 'HASH', + }, + { + AttributeName: 'Sort', + KeyType: 'RANGE', + }, + ], + ProvisionedThroughput: { + ReadCapacityUnits: 1, + WriteCapacityUnits: 1, + }, + TableName: 'Auth0Test', + StreamSpecification: { + StreamEnabled: false, + }, +}; + +@Injectable() +export class DynamoDBAdapter { + private logger = new Logger(DynamoDBAdapter.name); + private readonly tableName; + private readonly docClient: DynamoDBDocumentClient; + + constructor( + @Inject(databaseConfig.KEY) dbConfig: ConfigType, + ) { + this.logger.log( + `Creating database adapter with config: ${JSON.stringify(dbConfig)}`, + ); + const dbClient = new DynamoDBClient(dbConfig.clientConfig); + this.tableName = dbConfig.tableName; + this.docClient = DynamoDBDocumentClient.from(dbClient); + } + + async putItem( + params: Omit, + ): Promise { + return this.docClient.send( + new PutCommand({ ...params, TableName: this.tableName }), + ); + } + + async query( + params: Omit, + ): Promise { + return this.docClient.send( + new QueryCommand({ ...params, TableName: this.tableName }), + ); + } + + async batchGet>( + keys: Record[], + ): Promise { + if (!keys.length) { + return []; + } + + const params = { + RequestItems: { + [this.tableName]: { + Keys: keys, + }, + }, + }; + + const output = await this.docClient.send(new BatchGetCommand(params)); + assert(output.Responses); + + const response = output.Responses[this.tableName]; + assert(response); + + return response; + } + + async create( + params: Omit = tableParams, + ): Promise { + this.validateSafeEnv(); + return this.docClient.send( + new CreateTableCommand({ ...params, TableName: this.tableName }), + ); + } + + async destroy() { + this.validateSafeEnv(); + return this.docClient.send( + new DeleteTableCommand({ + TableName: this.tableName, + }), + ); + } + + async waitForTable(timeout: number) { + await waitUntilTableExists( + { client: this.docClient, maxWaitTime: timeout, minDelay: 1 }, + { TableName: this.tableName }, + ); + } + + private validateSafeEnv() { + const safeEnvironments = ['development', 'test']; + if (!safeEnvironments.includes(process.env.NODE_ENV || '')) { + throw new Error( + `Expected safe NODE_ENV (${safeEnvironments}), was ${process.env.NODE_ENV}`, + ); + } + } +} diff --git a/services/api/src/data/data.module.ts b/services/api/src/data/data.module.ts new file mode 100644 index 00000000..ccbd7c8e --- /dev/null +++ b/services/api/src/data/data.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DynamoDBAdapter } from './adapters/dynamodb.adapter'; +import { MessagesRepository } from './messages/messages.repository'; +import { UsersRepository } from './users/users.repository'; +import databaseConfig from '../config/database.config'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ConfigModule.forFeature(databaseConfig)], + providers: [DynamoDBAdapter, MessagesRepository, UsersRepository], + exports: [MessagesRepository, UsersRepository], +}) +export class DataModule {} diff --git a/services/api/src/data/messages/messages.repository.spec.ts b/services/api/src/data/messages/messages.repository.spec.ts new file mode 100644 index 00000000..a76aabe5 --- /dev/null +++ b/services/api/src/data/messages/messages.repository.spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mock } from 'jest-mock-extended'; +import { DynamoDBAdapter } from '../adapters/dynamodb.adapter'; +import { MessagesRepository } from './messages.repository'; + +describe('MessagesRepository', () => { + let repository: MessagesRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MessagesRepository, + { + provide: DynamoDBAdapter, + useValue: mock(), + }, + ], + }).compile(); + + repository = module.get(MessagesRepository); + }); + + it('should be defined', () => { + expect(repository).toBeDefined(); + }); +}); diff --git a/services/api/src/data/messages/messages.repository.ts b/services/api/src/data/messages/messages.repository.ts new file mode 100644 index 00000000..4fcdb4a9 --- /dev/null +++ b/services/api/src/data/messages/messages.repository.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { CreateMessageDto } from 'src/messages/dto/create-message.dto'; +import { Message } from 'src/messages/entities/message.entity'; +import { DynamoDBAdapter } from '../adapters/dynamodb.adapter'; + +const newMessageId = (time: number) => { + const rand = crypto.randomBytes(4).toString('hex'); + return `Msg#${time}#${rand}`; +}; + +@Injectable() +export class MessagesRepository { + constructor(private readonly db: DynamoDBAdapter) {} + + async storeMessage( + message: CreateMessageDto, + time: number, + ): Promise { + const Id = `Room#${message.roomId}`; + const messageId = newMessageId(time); + const data = { + ...message, + time, + }; + const params = { + Item: { + Id, + Sort: messageId, + Data: data, + Type: 'Message', + }, + }; + await this.db.putItem(params); + return { + id: messageId, + ...data, + }; + } + + async getMessages(roomId: string): Promise { + const data = await this.db.query({ + KeyConditionExpression: 'Id = :roomId and begins_with(Sort,:filter)', + ExpressionAttributeValues: { + ':roomId': `Room#${roomId}`, + ':filter': 'Msg#', + }, + }); + const messages = data.Items?.map((item) => { + const id = item.Sort; + const data = item.Data; + return { + id, + ...data, + }; + }); + return messages || []; + } +} diff --git a/services/api/src/data/users/users.repository.ts b/services/api/src/data/users/users.repository.ts new file mode 100644 index 00000000..8dd21905 --- /dev/null +++ b/services/api/src/data/users/users.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { omit, pick } from 'rambda'; +import { User } from 'src/messages/entities/user.entity'; +import { DynamoDBAdapter } from '../adapters/dynamodb.adapter'; + +@Injectable() +export class UsersRepository { + constructor(private readonly db: DynamoDBAdapter) {} + + async storeUser(user: User): Promise { + const Id = `User#${user.id}`; + const params = { + Item: { + Id, + Sort: 'User', + Data: pick(['name', 'picture'], user), + Type: 'User', + }, + }; + await this.db.putItem(params); + return { + id: Id, + ...omit(['id'], user), + }; + } + + async getUsers(userIds: string[]): Promise { + const params = userIds.map((userId) => ({ + Id: userId, + Sort: 'User', + })); + const userItems = await this.db.batchGet(params); + return userItems.map((item) => ({ + id: item.Id, + name: item.Data.name, + picture: item.Data.picture, + })); + } +} diff --git a/services/api/src/main.ts b/services/api/src/main.ts index 3ad65413..e5b65d8e 100644 --- a/services/api/src/main.ts +++ b/services/api/src/main.ts @@ -4,7 +4,7 @@ import { AppModule } from './app.module'; const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; -const logger = new Logger("main"); +const logger = new Logger('main'); async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/services/api/src/messages/dto/create-message.dto.ts b/services/api/src/messages/dto/create-message.dto.ts index 16e738b7..178e8426 100644 --- a/services/api/src/messages/dto/create-message.dto.ts +++ b/services/api/src/messages/dto/create-message.dto.ts @@ -1,4 +1,5 @@ export class CreateMessageDto { roomId: string; content: string; + authorId: string; } diff --git a/services/api/src/messages/entities/message.entity.ts b/services/api/src/messages/entities/message.entity.ts index 960268da..8551cecc 100644 --- a/services/api/src/messages/entities/message.entity.ts +++ b/services/api/src/messages/entities/message.entity.ts @@ -2,4 +2,5 @@ export class Message { id: string; content: string; time: number; + authorId: string; } diff --git a/services/api/src/messages/entities/user.entity.ts b/services/api/src/messages/entities/user.entity.ts new file mode 100644 index 00000000..f4de8076 --- /dev/null +++ b/services/api/src/messages/entities/user.entity.ts @@ -0,0 +1,5 @@ +export class User { + id: string; + name: string; + picture: string; +} diff --git a/services/api/src/messages/messages.controller.spec.ts b/services/api/src/messages/messages.controller.e2e-spec.ts similarity index 80% rename from services/api/src/messages/messages.controller.spec.ts rename to services/api/src/messages/messages.controller.e2e-spec.ts index 3ce06276..654835b6 100644 --- a/services/api/src/messages/messages.controller.spec.ts +++ b/services/api/src/messages/messages.controller.e2e-spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { DataModule } from '../data/data.module'; import { MessagesController } from './messages.controller'; import { MessagesService } from './messages.service'; @@ -7,8 +8,9 @@ describe('MessagesController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [DataModule], controllers: [MessagesController], - providers: [MessagesService], + providers: [MessagesService, MessagesController], }).compile(); controller = module.get(MessagesController); diff --git a/services/api/src/messages/messages.controller.ts b/services/api/src/messages/messages.controller.ts index 87e8b727..9105f064 100644 --- a/services/api/src/messages/messages.controller.ts +++ b/services/api/src/messages/messages.controller.ts @@ -2,6 +2,9 @@ import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { MessagesService } from './messages.service'; import { CreateMessageDto } from './dto/create-message.dto'; import { Auth } from '../auth/auth.decorator'; +import { Identify } from '../auth/user-profile/identify.decorator'; +import { UserInfo } from '../auth/user-profile/user-info'; +import { User } from './entities/user.entity'; @Controller('messages') export class MessagesController { @@ -9,8 +12,16 @@ export class MessagesController { @Auth() @Post() - createMessage(@Body() createMessageDto: CreateMessageDto) { - return this.messagesService.create(createMessageDto); + createMessage( + @Body() createMessageDto: CreateMessageDto, + @Identify() profile: UserInfo, + ) { + const user: User = { + id: profile.sub, + name: profile.name || 'anon', + picture: profile.picture || '', + }; + return this.messagesService.create(createMessageDto, user); } @Auth() diff --git a/services/api/src/messages/messages.module.ts b/services/api/src/messages/messages.module.ts index 5a7da452..a932e37d 100644 --- a/services/api/src/messages/messages.module.ts +++ b/services/api/src/messages/messages.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { MessagesService } from './messages.service'; import { MessagesController } from './messages.controller'; +import { DataModule } from '../data/data.module'; @Module({ + imports: [DataModule], controllers: [MessagesController], - providers: [MessagesService] + providers: [MessagesService], }) export class MessagesModule {} diff --git a/services/api/src/messages/messages.service.spec.ts b/services/api/src/messages/messages.service.spec.ts index d928c590..57eefc15 100644 --- a/services/api/src/messages/messages.service.spec.ts +++ b/services/api/src/messages/messages.service.spec.ts @@ -1,12 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { MessagesRepository } from '../data/messages/messages.repository'; +import { UsersRepository } from '../data/users/users.repository'; import { MessagesService } from './messages.service'; +import { mock } from 'jest-mock-extended'; describe('MessagesService', () => { let service: MessagesService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [MessagesService], + providers: [ + MessagesService, + { provide: UsersRepository, useValue: mock() }, + { provide: MessagesRepository, useValue: mock() }, + ], }).compile(); service = module.get(MessagesService); diff --git a/services/api/src/messages/messages.service.ts b/services/api/src/messages/messages.service.ts index 3862b57a..55633c78 100644 --- a/services/api/src/messages/messages.service.ts +++ b/services/api/src/messages/messages.service.ts @@ -1,72 +1,45 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CreateMessageDto } from './dto/create-message.dto'; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; -import * as crypto from "crypto"; -import { pick } from "rambda"; import { Message } from './entities/message.entity'; - -const ddbClientConfig = process.env.NODE_ENV === "development" - ? { endpoint: "http://localhost:8000", region: "local" } - : { region: "us-east-1", endpoint: "https://dynamodb.us-east-1.amazonaws.com" }; - -const ddbClient = new DynamoDBClient(ddbClientConfig); - -const docClient = DynamoDBDocumentClient.from(ddbClient); - -const tableName = process.env.DB_TABLE_NAME || "Auth0Test"; - -const newMessageId = (time: number) => { - const rand = crypto.randomBytes(4).toString("hex"); - return `Msg#${time}#${rand}`; -}; +import { MessagesRepository } from '../data/messages/messages.repository'; +import { User } from './entities/user.entity'; +import { UsersRepository } from '../data/users/users.repository'; @Injectable() export class MessagesService { - private readonly logger = new Logger(MessagesService.name); - - constructor() { - this.logger.log(`Starting MessagesService with config: ${JSON.stringify({...ddbClientConfig, tableName})}`); - } - - async create({ roomId, content }: CreateMessageDto): Promise { + constructor( + private readonly messagesRepo: MessagesRepository, + private readonly usersRepo: UsersRepository, + ) {} + + async create( + { roomId, content }: CreateMessageDto, + user: User, + ): Promise { const time = new Date().getTime(); - const id = newMessageId(time); - const message: Message = { - id, - content, - time, - }; - const params = { - TableName: tableName, - Item: { - Id: `Room#${roomId}`, - Sort: id, - Data: pick(["content", "time"], message), - Type: "Message", + const author = await this.usersRepo.storeUser(user); + return await this.messagesRepo.storeMessage( + { + content, + roomId, + authorId: author.id, }, - }; - await docClient.send(new PutCommand(params)); - return message; + time, + ); } - async findForRoom(roomId: string): Promise { - const data = await docClient.send(new QueryCommand({ - TableName: tableName, - KeyConditionExpression: "Id = :roomId and begins_with(Sort,:filter)", - ExpressionAttributeValues: { - ":roomId": `Room#${roomId}`, - ":filter": "Msg#" - } - })); - const messages = data.Items?.map(item => { - const id = item.Sort; - const data = item.Data; - return { - id, - ...data, - }; - }); - return messages || []; + async findForRoom( + roomId: string, + ): Promise<{ messages: Message[]; authors: object }> { + const messages = await this.messagesRepo.getMessages(roomId); + const authorIds = new Set(messages.map((msg) => msg.authorId)); + const authors = await this.usersRepo.getUsers(Array.from(authorIds)); + const authorsMap = Object.fromEntries( + Array.from(authors.map((author) => [author.id, author])), + ); + return { + messages, + authors: authorsMap, + }; } } diff --git a/services/api/test/app.e2e-spec.ts b/services/api/test/app.e2e-spec.ts index 50cda623..104a3b6c 100644 --- a/services/api/test/app.e2e-spec.ts +++ b/services/api/test/app.e2e-spec.ts @@ -16,9 +16,6 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + return request(app.getHttpServer()).get('/').expect(200).expect('OK'); }); }); diff --git a/services/api/test/jest-e2e.json b/services/api/test/jest-e2e.json index e9d912f3..93400cd3 100644 --- a/services/api/test/jest-e2e.json +++ b/services/api/test/jest-e2e.json @@ -1,6 +1,6 @@ { "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", + "rootDir": "../", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": {