diff --git a/.github/workflows/build_and_deploy_lambda.yml b/.github/workflows/build_and_deploy_lambda.yml index 4cf763f..c67bb57 100644 --- a/.github/workflows/build_and_deploy_lambda.yml +++ b/.github/workflows/build_and_deploy_lambda.yml @@ -35,38 +35,64 @@ jobs: permissions: id-token: write contents: read - steps: - uses: FranzDiebold/github-env-vars-action@v2.1.0 + + - name: Checkout + uses: actions/checkout@v2 + + - name: Generate hash + id: generate-hash + run: | + ls -lha + echo "::set-output name=hash::$(find src/* package.json -type f -exec sha256sum {} \; | sha256sum)" + + - name: Cache lambda artifact + id: cache + uses: actions/cache@v2 + with: + path: ${{ inputs.working-directory}}/lambda-artifact.zip + key: ${{ inputs.artifact-name }}-lambda-${{ steps.generate-hash.outputs.hash }} + restore-keys: | + ${{ inputs.artifact-name }}-lambda- - name: Setup Node.js + if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-node@v2 with: node-version: '16' - - name: Checkout - uses: actions/checkout@v2 + - name: Cache Node.js modules + uses: actions/cache@v2 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' run: npm install + - name: Build Artifact + if: steps.cache.outputs.cache-hit != 'true' + run: | + npm run build + zip -r ./lambda-artifact.zip ./prod ./package.json + - name: Test + if: steps.cache.outputs.cache-hit != 'true' run: npm test - name: Configure AWS Credentials + if: steps.cache.outputs.cache-hit != 'true' uses: aws-actions/configure-aws-credentials@v3 with: role-to-assume: arn:aws:iam::816923827429:role/ClashBotGitHubUser aws-region: ${{ inputs.region }} - name: Archive and Publish + if: steps.cache.outputs.cache-hit != 'true' run: | - zip -r ./${{ inputs.artifact-name}}.zip ./prod ./node_modules ./package.json - aws s3 cp ./event-publisher.zip s3://${{ inputs.s3-bucket-name }}/artifacts/$(echo ${{ inputs.environment-name }} | awk '{print tolower($0)}')/${{ github.run_number }}/${{ inputs.artifact-name}}.zip - - - name: Upload Artifacts - uses: actions/upload-artifact@v2 - with: - name: event-publisher-${{ inputs.environment-name }}-${{ github.run_number }} - path: ${{ inputs.working-directory}}/${{ inputs.artifact-name }}.zip - if-no-files-found: error + aws s3 cp ./lambda-artifact.zip s3://${{ inputs.s3-bucket-name }}/artifacts/${{ inputs.artifact-name}}/$(echo ${{ inputs.environment-name }} | awk '{print tolower($0)}')/artifact-${{ github.run_number }}.zip \ No newline at end of file diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 92de6d2..408230b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,87 +9,11 @@ on: - '**/.gitignore' jobs: - eventPublisher: - name: Event Publisher Lambda - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./functions/clash-bot/event-publisher - - steps: - - uses: FranzDiebold/github-env-vars-action@v2.1.0 - - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: '16' - - - name: Checkout - uses: actions/checkout@v2 - - - name: Install depedencies - run: npm install - - - name: Test - run: npm test - - - name: Build - run: npm run build - - - name: Archive - run: | - tar -czf ./event-publisher-${{ github.run_number }}.tar.gz ./prod ./node_modules ./package.json - ls -lha - - - name: Upload Artifacts - uses: actions/upload-artifact@v2 - with: - name: event-publisher-${{ github.run_number }} - path: ./functions/clash-bot/event-publisher/event-publisher-${{ github.run_number }}.tar.gz - if-no-files-found: error - - eventHandler: - name: Event Handler Lambda - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./functions/clash-bot/event-handler - - steps: - - uses: FranzDiebold/github-env-vars-action@v2.1.0 - - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: '16' - - - name: Checkout - uses: actions/checkout@v2 - - - name: Install dependencies - run: npm install - - - name: Test - run: npm test - - - name: Build - run: npm run build - - - name: Archive - run: | - tar -czf ./event-handler-${{ github.run_number }}.tar.gz ./prod ./node_modules ./package.json - ls -lha - - - name: Upload Artifacts - uses: actions/upload-artifact@v2 - with: - name: event-handler-${{ github.run_number }} - path: ./functions/clash-bot/event-handler/event-handler-${{ github.run_number }}.tar.gz - if-no-files-found: error - terraformPreReqs: - name: 'Terraform Plan Prereqs' + name: Prerequisites Apply runs-on: ubuntu-latest + environment: + name: Development defaults: run: working-directory: ./terraform/prereqs @@ -127,34 +51,34 @@ jobs: id: validate run: terraform validate -no-color - - name: Terraform Plan - id: plan + - name: Apply + id: apply if: github.event_name == 'pull_request' env: TF_VAR_region: us-east-1 - TF_VAR_environment: development - TF_VAR_s3_bucket_name: ${{ env.S3_BUCKET_NAME }} - run: terraform plan -no-color -input=false + TF_VAR_environment: ${{ vars.ENVIRONMENT }} + TF_VAR_s3_bucket_name: ${{ vars.S3_BUCKET_NAME }} + run: terraform apply -no-color -input=false - name: Update Pull Request uses: actions/github-script@v6.1.0 if: github.event_name == 'pull_request' env: - PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" + APPLY: "terraform\n${{ steps.apply.outputs.stdout }}" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const output = `Clash Bot Workflow Prerequisites - plan + const output = `Clash Bot Workflow Prerequisites - Apply #### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` - #### Terraform Prerequisite Plan 📖\`${{ steps.plan.outcome }}\` + #### Prerequisite Apply 📖\`${{ steps.apply.outcome }}\` #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` -
Show Plan +
Show Apply #### Prerequisites \`\`\`\n - ${process.env.PLAN} + ${process.env.APPLY} \`\`\`
@@ -168,20 +92,103 @@ jobs: body: output }) + eventPublisher: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/event-publisher + artifact-name: event-publisher + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + eventHandler: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/event-handler + artifact-name: event-handler + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + eventNotifier: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/event-notifier + artifact-name: event-notifier + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + createTeam: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/team/create + artifact-name: create-team + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + retrieveTeams: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/team/retrieve + artifact-name: retrieve-teams + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + isTournamentEligible: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/team/is-tournament-eligible + artifact-name: is-tournament-eligible + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + + websocketPublisher: + uses: ./.github/workflows/build_and_deploy_lambda.yml + with: + working-directory: functions/clash-bot/websocket-publisher + artifact-name: websocket-publisher + s3-bucket-name: ${{ vars.S3_BUCKET_NAME }} + environment-name: Development + region: us-east-1 + needs: + - terraformPreReqs + terraformWorkflow: - name: 'Terraform Plan Workflow' + name: Workflow Apply runs-on: ubuntu-latest + environment: + name: Development defaults: run: working-directory: ./terraform/workflow - needs: - - terraformPreReqs - - eventPublisher - - eventHandler permissions: id-token: write contents: read pull-requests: write + needs: + - eventHandler + - eventPublisher + - eventNotifier + - createTeam + - retrieveTeams + - isTournamentEligible + - websocketPublisher steps: - uses: FranzDiebold/github-env-vars-action@v2.1.0 @@ -189,24 +196,40 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Extract Event Publisher Artifact - uses: actions/download-artifact@v2 - with: - name: event-publisher-${{ github.run_number }} - path: ./terraform/prereqs - - - name: Extract Event Handler Artifact - uses: actions/download-artifact@v2 - with: - name: event-handler-${{ github.run_number }} - path: ./terraform/prereqs - - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v3 with: role-to-assume: arn:aws:iam::816923827429:role/ClashBotGitHubUser aws-region: us-east-1 + - name: Find latest Event Handler artifact + id: eventHandlerArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} event-handler development + + - name: Find latest Event Publisher artifact + id: eventPublisherArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} event-publisher development + + - name: Find latest Create Team artifact + id: createTeamArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} create-team development + + - name: Find latest Retrieve Teams artifact + id: retrieveTeamsArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} retrieve-teams development + + - name: Find latest Is Tournament Eligible artifact + id: isTournamentEligibleArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} is-tournament-eligible development + + - name: Find latest Event Notifier artifact + id: eventNotifierArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} event-notifier development + + - name: Find latest Websocket Publisher artifact + id: webSocketPublisherArtifact + run: ../../scripts/find_latest_artifact.sh ${{ vars.S3_BUCKET_NAME }} websocket-publisher development + - name: Setup Terraform uses: hashicorp/setup-terraform@v1 with: @@ -218,43 +241,60 @@ jobs: - name: Terraform Init id: init - run: terraform init + run: terraform init -backend-config=backend-configs/${{ vars.ENVIRONMENT }}.remote.tfbackend - name: Terraform Validate id: validate run: terraform validate -no-color - - name: Terraform Plan - id: plan - if: github.event_name == 'pull_request' + - name: Apply + id: apply env: TF_VAR_region: us-east-1 - TF_VAR_environment: development - TF_VAR_s3_bucket_name: ${{ env.S3_BUCKET_NAME }} - TF_VAR_event_publisher_artifact_path: event-publisher-${{ github.run_number }}.tar.gz - TF_VAR_event_handler_artifact_path: event-handler-${{ github.run_number }}.tar.gz + TF_VAR_environment: ${{ vars.ENVIRONMENT }} + TF_VAR_s3_bucket_name: ${{ vars.S3_BUCKET_NAME }} + TF_VAR_event_publisher_artifact_path: ${{ steps.eventPublisherArtifact.outputs.artifact-path }} + TF_VAR_event_handler_artifact_path: ${{ steps.eventHandlerArtifact.outputs.artifact-path }} + TF_VAR_event_notifier_artifact_path: ${{ steps.eventNotifierArtifact.outputs.artifact-path }} + TF_VAR_create_team_artifact_path: ${{ steps.createTeamArtifact.outputs.artifact-path }} + TF_VAR_retrieve_teams_artifact_path: ${{ steps.retrieveTeamsArtifact.outputs.artifact-path }} + TF_VAR_tournament_eligibility_lambda_artifact_path: ${{ steps.isTournamentEligibleArtifact.outputs.artifact-path }} + TF_VAR_websocket_publisher_artifact_path: ${{ steps.webSocketPublisherArtifact.outputs.artifact-path }} TF_VAR_sqs_batch_size: "1" - run: terraform plan -no-color -input=false + run: terraform apply -no-color -input=false --auto-approve - name: Update Pull Request uses: actions/github-script@v6.1.0 if: github.event_name == 'pull_request' env: - PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" + APPLY: "terraform\n${{ steps.apply.outputs.stdout }}" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const output = `Clash Bot Workflow - plan + const output = `# Clash Bot Workflow PR Details + + ## Lambda Function Versions used + + | Lambda Function | Type | Artifact Version | + | --------------- | ---- | ---------------- | + | Event Publisher | Foundation | ${{ steps.eventPublisherArtifact.outputs.version }} | + | Event Handler | Foundation | ${{ steps.eventHandlerArtifact.outputs.version }} | + | Event Notifier | Foundation | ${{ steps.eventNotifierArtifact.outputs.version }} | + | Websocket Publisher | Foundation | ${{ steps.webSocketPublisherArtifact.outputs.version }} | + | Create Team | Team | ${{ steps.createTeamArtifact.outputs.version }} | + | Retrieve Teams | Team | ${{ steps.retrieveTeamsArtifact.outputs.version }} | + | Is Tournament Eligible | Team | ${{ steps.isTournamentEligibleArtifact.outputs.version }} | + #### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` - #### Terraform Workflow Plan 📖\`${{ steps.plan.outcome }}\` + #### Workflow Apply 📖\`${{ steps.apply.outcome }}\` #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` -
Show Plan +
Show Run - #### Prerequisites + #### Workflow Apply \`\`\`\n - ${process.env.PLAN} + ${process.env.APPLY} \`\`\`
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9178f19 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Relase Clash Bot Workflow + +on: + push: + tags: + - 'v*.*.*' + paths-ignore: + - '**/README.md' + - '**/.gitignore' + +jobs: + eventPublisherDeploy: + name: Event Publisher Lambda Deploy + runs-on: ubuntu-latest + environment: + name: Production + defaults: + run: + working-directory: ./functions/clash-bot/event-publisher + permissions: + id-token: write + contents: read + + steps: + - uses: FranzDiebold/github-env-vars-action@v2.1.0 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '16' + + - name: Checkout + uses: actions/checkout@v2 + + - name: Install depedencies + run: npm install + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: arn:aws:iam::816923827429:role/ClashBotGitHubUser + aws-region: us-east-1 + + - name: Archive and Publish + run: | + zip -r ./event-publisher.zip ./prod ./node_modules ./package.json + aws s3 cp ./event-publisher.zip s3://${{ vars.S3_BUCKET_NAME }}/artifacts/${{ vars.ENVIRONMENT }}/${{ github.run_number }}/event-publisher.zip + + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: event-publisher-${{ vars.ENVIRONMENT}}-${{ github.run_number }} + path: ./functions/clash-bot/event-publisher/event-publisher.zip + if-no-files-found: error + + eventHandlerDeploy: + name: Event Handler Lambda Deploy + runs-on: ubuntu-latest + environment: + name: Production + defaults: + run: + working-directory: ./functions/clash-bot/event-handler + permissions: + id-token: write + contents: read + + steps: + - uses: FranzDiebold/github-env-vars-action@v2.1.0 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '16' + + - name: Checkout + uses: actions/checkout@v2 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: arn:aws:iam::816923827429:role/ClashBotGitHubUser + aws-region: us-east-1 + + - name: Archive and Publish + run: | + zip -r ./event-handler.zip ./prod ./node_modules ./package.json + aws s3 cp ./event-handler.zip s3://${{ vars.S3_BUCKET_NAME }}/artifacts/${{ vars.ENVIRONMENT }}/${{ github.run_number }}/event-handler.zip + + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: event-handler-${{ vars.ENVIRONMENT}}-${{ github.run_number }} + path: ./functions/clash-bot/event-handler/event-handler.zip + if-no-files-found: error + + terraformWorkflow: + name: Workflow Apply + runs-on: ubuntu-latest + environment: + name: Development + defaults: + run: + working-directory: ./terraform/workflow + permissions: + id-token: write + contents: read + pull-requests: write + needs: + - eventPublisherDeploy + - eventHandlerDeploy + + steps: + - uses: FranzDiebold/github-env-vars-action@v2.1.0 + + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: arn:aws:iam::816923827429:role/ClashBotGitHubUser + aws-region: us-east-1 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v1 + with: + cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} + + - name: Terraform Init + id: init + run: terraform init -backend-config=/backend-configs/${{ vars.ENVIRONMENT }}.remote.tfbackend + + - name: Apply + id: apply + env: + TF_VAR_region: us-east-1 + TF_VAR_environment: ${{ vars.ENVIRONMENT }} + TF_VAR_s3_bucket_name: ${{ vars.S3_BUCKET_NAME }} + TF_VAR_event_publisher_artifact_path: artifacts/${{ vars.ENVIRONMENT }}/${{ github.run_number }}/event-publisher.zip + TF_VAR_event_handler_artifact_path: artifacts/${{ vars.ENVIRONMENT }}/${{ github.run_number }}/event-handler.zip + TF_VAR_sqs_batch_size: "1" + run: terraform apply -no-color -input=false --auto-approve diff --git a/functions/clash-bot-layer/package.json b/functions/clash-bot-layer/package.json new file mode 100644 index 0000000..d7cc48e --- /dev/null +++ b/functions/clash-bot-layer/package.json @@ -0,0 +1,41 @@ +{ + "name": "clash-bot-layer", + "version": "1.0.0", + "description": "Used for packaging shared dependencies", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Daniel Poss", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.529.1", + "@aws-sdk/client-dynamodb": "^3.529.1", + "@aws-sdk/client-s3": "^3.529.1", + "@aws-sdk/client-sfn": "^3.529.1", + "@aws-sdk/client-sqs": "^3.529.1", + "@aws-sdk/types": "^3.523.0", + "@aws-sdk/util-dynamodb": "^3.529.1", + "@openapitools/openapi-generator-cli": "^2.12.0", + "@types/aws-lambda": "^8.10.136", + "@types/jest": "^29.5.12", + "@types/pino": "^7.0.5", + "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "aws-sdk-client-mock": "^3.0.1", + "aws-sdk-client-mock-jest": "^3.0.1", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "gulp": "^4.0.2", + "gulp-typescript": "^6.0.0-alpha.1", + "jest": "^29.7.0", + "pino": "^8.19.0", + "pino-pretty": "^10.3.1", + "ts-jest": "^29.1.2", + "ts-mockito": "^2.6.1", + "typescript": "^5.4.2", + "uuid": "^9.0.1" + } +} diff --git a/functions/clash-bot/event-handler/package.json b/functions/clash-bot/event-handler/package.json index 585231b..4ac045c 100644 --- a/functions/clash-bot/event-handler/package.json +++ b/functions/clash-bot/event-handler/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "jest", - "build": "tsc" + "build": "tsc", + "postbuild": "cp package.json prod/package.json" }, "keywords": [ "clash-bot", @@ -34,4 +35,4 @@ "pino": "^8.15.0", "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main" } -} +} \ No newline at end of file diff --git a/functions/clash-bot/event-handler/src/handler.ts b/functions/clash-bot/event-handler/src/handler.ts index 8e6b05f..de123f6 100644 --- a/functions/clash-bot/event-handler/src/handler.ts +++ b/functions/clash-bot/event-handler/src/handler.ts @@ -9,7 +9,7 @@ export const handler: SQSHandler = async (event: SQSEvent) => { const eventMap = setupSFMap(); logger.info(`Recieved ${event.Records.length} events...`); - event.Records.forEach(record => { + let promises = event.Records.map(record => { logger.info(`Processing event ${record.messageId}...`); const client = new SFNClient({}); @@ -17,17 +17,32 @@ export const handler: SQSHandler = async (event: SQSEvent) => { const sfArn = eventMap.get(parsedEvent.event); if (!sfArn) { - logger.error(`Unmapped event found event=${parsedEvent.event}`); - throw new Error('Failed to map event.'); + logger.error(`Unmapped event found event=${parsedEvent.event}!`); + return null; } - client.send(new StartExecutionCommand({ + logger.info(`Setting up payload to trigger SFN ${sfArn}...`); + + logger.info(`Triggering SFN ${sfArn}...`) + + return client.send(new StartExecutionCommand({ stateMachineArn: sfArn, name: `${parsedEvent.event}-${record.messageId}`, - input: JSON.stringify(parsedEvent.payload), + input: JSON.stringify({ + requestId: parsedEvent.uuid, + payload: parsedEvent.payload + }), traceHeader: record.messageId })); }); + + promises = promises.filter(p => p !== null); + + logger.info("Waiting for all promises to resolve..."); + + await Promise.all(promises); + + logger.info("All promises resolved."); }; function setupSFMap(): Map { diff --git a/functions/clash-bot/event-handler/tests/handler.test.ts b/functions/clash-bot/event-handler/tests/handler.test.ts index 0bb17e2..0436322 100644 --- a/functions/clash-bot/event-handler/tests/handler.test.ts +++ b/functions/clash-bot/event-handler/tests/handler.test.ts @@ -83,7 +83,10 @@ describe('Should invoke a AWS Step function based on the event.', () => { await expect(sfnMock).toHaveReceivedCommandWith(StartExecutionCommand, { stateMachineArn: eventEntry.arn, name: `${eventEntry.event}-${sqsEvent.Records[0].messageId}`, - input: JSON.stringify(event.payload), + input: JSON.stringify({ + requestId: event.uuid, + payload: event.payload + }), traceHeader: sqsEvent.Records[0].messageId }); }); @@ -149,13 +152,19 @@ describe('Should invoke a AWS Step function based on the event.', () => { await expect(sfnMock).toHaveReceivedCommandWith(StartExecutionCommand, { stateMachineArn: 'arn:aws:states:us-east-1:123456789012:stateMachine:HelloWorld-StateMachine', name: `${createTeamEvent.event}-${sqsEvent.Records[0].messageId}`, - input: JSON.stringify(createTeamEvent.payload), + input: JSON.stringify({ + requestId: createTeamEvent.uuid, + payload: createTeamEvent.payload + }), traceHeader: sqsEvent.Records[0].messageId }); await expect(sfnMock).toHaveReceivedCommandWith(StartExecutionCommand, { stateMachineArn: 'arn:aws:states:us-east-1:123456789012:stateMachine:HelloWorld-StateMachine-2', name: `${updateTeamEvent.event}-${sqsEvent.Records[1].messageId}`, - input: JSON.stringify(updateTeamEvent.payload), + input: JSON.stringify({ + requestId: updateTeamEvent.uuid, + payload: updateTeamEvent.payload + }), traceHeader: sqsEvent.Records[1].messageId }); }); diff --git a/functions/clash-bot/event-notifier/jest.config.js b/functions/clash-bot/event-notifier/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/functions/clash-bot/event-notifier/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/functions/clash-bot/event-notifier/package.json b/functions/clash-bot/event-notifier/package.json new file mode 100644 index 0000000..99e33a7 --- /dev/null +++ b/functions/clash-bot/event-notifier/package.json @@ -0,0 +1,36 @@ +{ + "name": "event-notifier", + "version": "1.0.0", + "description": "A lambda that that notifies users of events related to their requests", + "main": "index.js", + "scripts": { + "test": "jest", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.398.0", + "@aws-sdk/util-dynamodb": "^3.451.0", + "dotenv": "^16.3.1", + "pino": "^8.15.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main" + } +} diff --git a/functions/clash-bot/event-notifier/src/handler.ts b/functions/clash-bot/event-notifier/src/handler.ts new file mode 100644 index 0000000..36ab5e6 --- /dev/null +++ b/functions/clash-bot/event-notifier/src/handler.ts @@ -0,0 +1,149 @@ +import { DeleteItemCommand, DeleteItemCommandInput, DynamoDBClient, GetItemCommand, GetItemCommandInput, PutItemCommand, PutItemCommandInput, UpdateItemCommand, UpdateItemCommandInput } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { APIGatewayProxyResultV2, APIGatewayProxyWebsocketEventV2, APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda'; +import pino from "pino"; + +export const handler: APIGatewayProxyWebsocketHandlerV2 = async (event: APIGatewayProxyWebsocketEventV2): Promise> => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + logger.info({ + event + }, "Received event..."); + + const { + requestContext: { connectionId, routeKey }, + } = event; + + logger.info(routeKey, "Received route key..."); + + const client = new DynamoDBClient({}); + + if ('$connect' === routeKey) { + logger.info("Received connect event..."); + return { + statusCode: 200, + body: JSON.stringify({ message: 'Connected' }), + }; + } else if ('$disconnect' === routeKey) { + logger.info("Received disconnect event..."); + const getCommand: GetItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId } + } + }; + + const subscriberTopics = await client.send(new GetItemCommand(getCommand)); + + logger.info({ subscriberTopics }, "Subscriber topics."); + + const subscriberToTopics = unmarshall(subscriberTopics.Item!); + + if (subscriberToTopics.topics === undefined || subscriberToTopics.topics.length === 0) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Did not disconnect successfully' }), + }; + } else { + logger.info({ subscriberToTopics }, "Subscriber to topics.",); + + const deleteSubscriberToTopics: DeleteItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId } + } + }; + + const updateCommandPromises = [...subscriberToTopics.topics].map((topic: string) => { + return client.send(new UpdateItemCommand({ + TableName: process.env.TOPIC_TO_SUBSCRIBER_TABLE_NAME, + Key: { + "topic": { S: topic } + }, + ExpressionAttributeNames: { + "#S": "subscribers" + }, + ExpressionAttributeValues: { + ":val": { SS: [event.requestContext.connectionId] } + }, + UpdateExpression: "DELETE #S :val", + })); + }); + try { + await Promise.all([ + ...updateCommandPromises, + client.send(new DeleteItemCommand(deleteSubscriberToTopics)) + ]); + } catch (error) { + logger.error({ error }, "Error deleting subscriber to topics."); + return { + statusCode: 400, + body: JSON.stringify({ message: 'Did not disconnect successfully' }), + }; + } + return { + statusCode: 200, + body: JSON.stringify({ message: 'Disconnected' }), + }; + } + } else if ('subscribe' === routeKey) { + logger.info("Received message event..."); + let topic = ""; + if (event.body !== undefined) { + topic = JSON.parse(event.body).topic; + } else { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Missing topic' }), + }; + } + const updateTopicToSubscribers: UpdateItemCommandInput = { + TableName: process.env.TOPIC_TO_SUBSCRIBER_TABLE_NAME, + Key: { + "topic": { S: topic }, + }, + ExpressionAttributeNames: { + "#S": "subscribers" + }, + ExpressionAttributeValues: { + ":val": { SS: [event.requestContext.connectionId] } + }, + UpdateExpression: "ADD #S :val" + }; + const updateSubscriberToTopics: UpdateItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId }, + }, + ExpressionAttributeNames: { + "#S": "topics" + }, + ExpressionAttributeValues: { + ":val": { SS: [topic] } + }, + UpdateExpression: "ADD #S :val" + }; + try { + await Promise.all([ + client.send(new UpdateItemCommand(updateTopicToSubscribers)), + client.send(new UpdateItemCommand(updateSubscriberToTopics)) + ]); + } catch (error) { + logger.error({ error }, "Error updating topic to subscribers."); + return { + statusCode: 400, + body: JSON.stringify({ message: 'Did not subscribe successfully' }), + }; + } + return { + statusCode: 200, + body: JSON.stringify({ message: `Subcribed to '${topic}'` }), + }; + } + + // Default response + return { + statusCode: 200, + body: JSON.stringify({ message: 'Success' }), + }; +}; diff --git a/functions/clash-bot/event-notifier/tests/event-notifier.test.ts b/functions/clash-bot/event-notifier/tests/event-notifier.test.ts new file mode 100644 index 0000000..8c679b2 --- /dev/null +++ b/functions/clash-bot/event-notifier/tests/event-notifier.test.ts @@ -0,0 +1,218 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, APIGatewayProxyResultV2, APIGatewayProxyWebsocketEventV2 } from 'aws-lambda'; +import { handler } from '../src/handler'; +import { DeleteItemCommand, DeleteItemCommandInput, DynamoDBClient, GetItemCommand, GetItemCommandInput, PutItemCommand, PutItemCommandInput, UpdateItemCommand, UpdateItemCommandInput } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { before } from 'node:test'; + +describe('Handle Websocket connection requests', () => { + + beforeEach(() => { + process.env.TOPIC_TO_SUBSCRIBER_TABLE_NAME = "mockTopicToSubscriberTableName"; + process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME = "mockSubscriberToTopicTableName"; + }); + + test('If a websocket connection reqeust is recieved, it should post the details to DynamoDb to save the state.', async () => { + const event = createMockConnectEvent(); + const context = setupContext(); + + const result = await handler(event, context, () => { }); + expect(result).toBeDefined(); + expect((result as any).statusCode).toBe(200); + expect((result as any).body).toBe('{\"message\":\"Connected\"}'); + }); + + test('If a websocket disconnect reqeust is recieved, it should post the details to DynamoDb to save the state.', async () => { + const event = createMockDisconnectEvent(); + const context = setupContext(); + const topic = "mockTopic"; + + const client = mockClient(DynamoDBClient); + + client.on(DeleteItemCommand) + .resolves({}); + client.on(GetItemCommand) + .resolves({ + Item: { + "topics": { + SS: [topic] + } + } + }); + client.on(UpdateItemCommand) + .resolves({}); + + const getCommand: GetItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId } + } + }; + + const expectedDeleteCommandForSubscriberToTopic: DeleteItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId } + } + }; + + const expectedDeleteCommandForTopicToSubscriber: UpdateItemCommandInput = { + TableName: process.env.TOPIC_TO_SUBSCRIBER_TABLE_NAME, + Key: { + "topic": { S: topic } + }, + ExpressionAttributeNames: { + "#S": "subscribers" + }, + ExpressionAttributeValues: { + ":val": { SS: [event.requestContext.connectionId] } + }, + UpdateExpression: "DELETE #S :val", + }; + + + const result = await handler(event, context, () => { }); + expect(result).toBeDefined(); + expect((result as any).statusCode).toBe(200); + expect((result as any).body).toBe('{\"message\":\"Disconnected\"}'); + expect(client).toHaveReceivedCommandWith(GetItemCommand, getCommand); + expect(client).toHaveReceivedCommandWith(UpdateItemCommand, expectedDeleteCommandForTopicToSubscriber); + expect(client).toHaveReceivedCommandWith(DeleteItemCommand, expectedDeleteCommandForSubscriberToTopic); + }); + + test('If a websocket subscribe request is recieved, it should post the details to DynamoDb to save the state.', async () => { + const event = createMockMessageEvent(); + const context = setupContext(); + const topic = JSON.parse(event.body ?? "{}").topic; + + const client = mockClient(DynamoDBClient); + + client.on(UpdateItemCommand) + .resolves({}); + + const expectedUpdateItemInput: UpdateItemCommandInput = { + TableName: process.env.TOPIC_TO_SUBSCRIBER_TABLE_NAME, + Key: { + "topic": { S: topic }, + }, + ExpressionAttributeNames: { + "#S": "subscribers" + }, + ExpressionAttributeValues: { + ":val": { SS: [event.requestContext.connectionId] } + }, + UpdateExpression: "ADD #S :val" + }; + + const expectedUpdateItemInputSubscriberToTopics: UpdateItemCommandInput = { + TableName: process.env.SUBSCRIBER_TO_TOPIC_TABLE_NAME, + Key: { + "subscriber": { S: event.requestContext.connectionId }, + }, + ExpressionAttributeNames: { + "#S": "topics" + }, + ExpressionAttributeValues: { + ":val": { SS: [topic] } + }, + UpdateExpression: "ADD #S :val" + }; + + const result = await handler(event, context, () => { }); + expect(result).toBeDefined(); + expect((result as any).statusCode).toBe(200); + expect((result as any).body).toBe(`{\"message\":\"Subcribed to '${topic}'\"}`); + expect(client).toHaveReceivedCommandWith(UpdateItemCommand, expectedUpdateItemInput); + expect(client).toHaveReceivedCommandWith(UpdateItemCommand, expectedUpdateItemInputSubscriberToTopics); + }); + +}); + +const setupContext = () => { + return { + callbackWaitsForEmptyEventLoop: false, + functionName: '', + functionVersion: '', + invokedFunctionArn: '', + memoryLimitInMB: '', + awsRequestId: '', + logGroupName: '', + logStreamName: '', + getRemainingTimeInMillis: function (): number { + throw new Error('Function not implemented.'); + }, + done: function (error?: Error | undefined, result?: any): void { + throw new Error('Function not implemented.'); + }, + fail: function (error: string | Error): void { + throw new Error('Function not implemented.'); + }, + succeed: function (messageOrObject: any): void { + throw new Error('Function not implemented.'); + } + }; +} + +const createMockConnectEvent = (): APIGatewayProxyWebsocketEventV2 => { + return { + requestContext: { + apiId: 'sampleApiId', + domainName: 'sampleDomainName', + requestId: 'sampleRequestId', + routeKey: '$connect', + stage: 'dev', + messageId: 'sampleMessageId', + eventType: 'CONNECT', + extendedRequestId: 'sampleExtendedRequestId', + requestTime: 'sampleRequestTime', + messageDirection: 'IN', + connectedAt: Date.now(), + requestTimeEpoch: Date.now(), + connectionId: 'sampleConnectionId' + }, + isBase64Encoded: false + }; +}; + +const createMockDisconnectEvent = (): APIGatewayProxyWebsocketEventV2 => { + return { + requestContext: { + apiId: 'sampleApiId', + domainName: 'sampleDomainName', + requestId: 'sampleRequestId', + routeKey: '$disconnect', + stage: 'dev', + messageId: 'sampleMessageId', + eventType: 'CONNECT', + extendedRequestId: 'sampleExtendedRequestId', + requestTime: 'sampleRequestTime', + messageDirection: 'IN', + connectedAt: Date.now(), + requestTimeEpoch: Date.now(), + connectionId: 'sampleConnectionId' + }, + isBase64Encoded: false + }; +}; + +const createMockMessageEvent = (): APIGatewayProxyWebsocketEventV2 => { + return { + requestContext: { + routeKey: "subscribe", + messageId: "OqOHdcL-oAMCKcw=", + eventType: "MESSAGE", + extendedRequestId: "OqOHdHnOoAMFozw=", + requestTime: "19/Nov/2023:19:09:22 +0000", + messageDirection: "IN", + stage: "events-development", + connectedAt: 1700420961168, + requestTimeEpoch: 1700420962611, + requestId: "OqOHdHnOoAMFozw=", + domainName: "k10wm04op6.execute-api.us-east-1.amazonaws.com", + connectionId: "OqOHOcLhIAMCKcw=", + apiId: "k10wm04op6" + }, + body: "{\"action\":\"subscribe\",\"topic\":\"mocktopic\"}", + isBase64Encoded: false + }; +}; \ No newline at end of file diff --git a/functions/clash-bot/event-notifier/tsconfig.json b/functions/clash-bot/event-notifier/tsconfig.json new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/functions/clash-bot/event-notifier/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/functions/clash-bot/event-publisher/package.json b/functions/clash-bot/event-publisher/package.json index 475559b..409b63e 100644 --- a/functions/clash-bot/event-publisher/package.json +++ b/functions/clash-bot/event-publisher/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "jest", - "build": "tsc" + "build": "tsc", + "postbuild": "cp package.json prod/package.json" }, "keywords": [ "clash-bot" diff --git a/functions/clash-bot/event-publisher/src/handler.ts b/functions/clash-bot/event-publisher/src/handler.ts index b06bc64..8494146 100644 --- a/functions/clash-bot/event-publisher/src/handler.ts +++ b/functions/clash-bot/event-publisher/src/handler.ts @@ -15,6 +15,16 @@ export const handler: APIGatewayProxyHandler = async (event: APIGatewayProxyEven try { const sqsClient = new SQSClient({}); const mappedEventType = eventMap.get(event.requestContext.path); + if (!event.body || !JSON.parse(event.body).uuid) { + logger.error(`Missing uuid in body for request url=${event.requestContext.path}...`); + return { + statusCode: 400, + body: JSON.stringify({ + error: "Missing request id." + }) + }; + } + const requestIdFromUser = JSON.parse(event.body).uuid; if (!mappedEventType) { logger.error(`Unmapped event found url=${event.requestContext.path}`); return { @@ -28,13 +38,15 @@ export const handler: APIGatewayProxyHandler = async (event: APIGatewayProxyEven logger.info(`Event Type for url => ${mappedEventType}...`) const eventToBeSent: EventPayload = { payload: JSON.parse(event.body!), - uuid: event.requestContext.requestId, + uuid: requestIdFromUser, event: mappedEventType, url: event.requestContext.path, }; const input: SendMessageCommandInput = { QueueUrl: process.env.QUEUE_URL, - MessageBody: JSON.stringify(eventToBeSent) + MessageBody: JSON.stringify(eventToBeSent), + MessageGroupId: 'event', + MessageDeduplicationId: requestIdFromUser }; const message = new SendMessageCommand(input); const response = await sqsClient @@ -43,10 +55,11 @@ export const handler: APIGatewayProxyHandler = async (event: APIGatewayProxyEven return { statusCode: 200, body: JSON.stringify({ - requestId: event.requestContext.requestId + requestId: requestIdFromUser }) }; } catch (error) { + logger.error(error, "Failed to publish event."); return { statusCode: 500, body: JSON.stringify({ diff --git a/functions/clash-bot/event-publisher/tests/event-publisher.test.ts b/functions/clash-bot/event-publisher/tests/event-publisher.test.ts index 02480af..7960e35 100644 --- a/functions/clash-bot/event-publisher/tests/event-publisher.test.ts +++ b/functions/clash-bot/event-publisher/tests/event-publisher.test.ts @@ -17,7 +17,9 @@ describe('Publish an event to SQS with an event type.', () => { snsMock .on(SendMessageCommand) .resolvesOnce({}); + const requestIdFromUser = "1234"; const bodyOfRequest = { + uuid: requestIdFromUser, details: {} }; const eventTwo: APIGatewayProxyEvent = createEvent( @@ -27,14 +29,58 @@ describe('Publish an event to SQS with an event type.', () => { ); const expectedEventToBeSent: EventPayload = { payload: bodyOfRequest, - uuid: eventTwo.requestContext.requestId, + uuid: requestIdFromUser, event: EVENT_TYPE.CREATE_TEAM, url: eventTwo.requestContext.path, }; await handler(eventTwo, setupContext(), {} as any); await expect(snsMock).toHaveReceivedCommandWith(SendMessageCommand, { QueueUrl: process.env.QUEUE_URL, - MessageBody: JSON.stringify(expectedEventToBeSent) + MessageBody: JSON.stringify(expectedEventToBeSent), + MessageGroupId: 'event', + MessageDeduplicationId: requestIdFromUser + }); + }); + + describe("Missing uuid", () => { + + test('Error - If the request does not have a uuid, then return a bad request response.', async () => { + const snsMock = mockClient(SQSClient) + snsMock + .on(SendMessageCommand) + .resolvesOnce({}); + const bodyOfRequest = { + details: { + somebody: "somebody" + } + }; + const eventTwo: APIGatewayProxyEvent = createEvent( + "/api/v2/teams", + "POST", + bodyOfRequest + ); + const response = await handler(eventTwo, setupContext(), {} as any); + expect((response as APIGatewayProxyResult).statusCode).toBe(400); + }); + + test('Error - If the request does not have a uuid as it is an empty string, then return a bad request response.', async () => { + const snsMock = mockClient(SQSClient) + snsMock + .on(SendMessageCommand) + .resolvesOnce({}); + const bodyOfRequest = { + uuid: "", + details: { + somebody: "somebody" + } + }; + const eventTwo: APIGatewayProxyEvent = createEvent( + "/api/v2/teams", + "POST", + bodyOfRequest + ); + const response = await handler(eventTwo, setupContext(), {} as any); + expect((response as APIGatewayProxyResult).statusCode).toBe(400); }); }); @@ -61,6 +107,7 @@ describe('Publish an event to SQS with an event type.', () => { .on(SendMessageCommand) .rejects({}); const bodyOfRequest = { + uuid: "1234", details: {} }; const eventTwo: APIGatewayProxyEvent = createEvent( diff --git a/functions/clash-bot/team/create/jest.config.js b/functions/clash-bot/team/create/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/functions/clash-bot/team/create/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/functions/clash-bot/team/create/package.json b/functions/clash-bot/team/create/package.json new file mode 100644 index 0000000..28f88e5 --- /dev/null +++ b/functions/clash-bot/team/create/package.json @@ -0,0 +1,37 @@ +{ + "name": "clash-bot-create-team", + "version": "1.0.0", + "description": "A typescript/nodejs lambda function to create a team in Clash Bot", + "main": "index.js", + "scripts": { + "test": "jest", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "@types/uuid": "^9.0.7", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.405.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main", + "dotenv": "^16.3.1", + "pino": "^8.15.0", + "uuid": "^9.0.1" + } +} diff --git a/functions/clash-bot/team/create/src/handler.ts b/functions/clash-bot/team/create/src/handler.ts new file mode 100644 index 0000000..720c17f --- /dev/null +++ b/functions/clash-bot/team/create/src/handler.ts @@ -0,0 +1,48 @@ +import { Handler } from 'aws-lambda'; +import pino, { P } from "pino"; +import { DynamoDBClient, PutItemCommand, PutItemCommandInput } from '@aws-sdk/client-dynamodb'; +import { v4 as uuidv4 } from 'uuid'; +import { Team, TeamFromJSON } from 'clash-bot-shared'; + +export const handler: Handler = async (event, context) => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + + logger.info({ eventRecieved: event }, 'Recieved event...'); + logger.info({ contextRecieved: context } , 'Recieved context...'); + + const dynamoDbClient = new DynamoDBClient({}); + + const teamEvent = event as Team; + + if (teamEvent.playerDetails === undefined) { + return { + status: 'Failed', + details: 'Player details are required to create a team.', + originalRecord: event + }; + } else { + // Define the item to add to the table + const item: PutItemCommandInput = { + TableName: process.env.TABLE_NAME, + Item: { + "type": { S: "Team" }, + "id": { S: uuidv4() }, + "playerDetails": { S: JSON.stringify(teamEvent.playerDetails) ?? '' }, + "serverId": { S: teamEvent.serverId ?? ''}, + "tournament": { S: JSON.stringify(teamEvent.tournament) ?? '' }, + "lastUpdatedAt": { S: new Date().toISOString() } + } + }; + + // Create a new PutItemCommand + const command = new PutItemCommand(item); + await dynamoDbClient.send(command); + + return { + status: 'Done', + updatedRecord: event, + originalRecord: event + }; + } +}; \ No newline at end of file diff --git a/functions/clash-bot/team/create/tests/handler.test.ts b/functions/clash-bot/team/create/tests/handler.test.ts new file mode 100644 index 0000000..0885377 --- /dev/null +++ b/functions/clash-bot/team/create/tests/handler.test.ts @@ -0,0 +1,76 @@ +import { handler } from '../src/handler'; +import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { v4 as uuidv4 } from 'uuid'; +import { Team, TeamFromJSON } from 'clash-bot-shared'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; + +describe('handler', () => { + it('should return a status of "Done"', async () => { + // Mock the event and context objects + const event: Team = { + playerDetails: { + top: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: 'tournamentName', + tournamentDay: '1' + }, + }; + const context = { /* mock context object */ }; + + const dynamodbMock = mockClient(DynamoDBClient) + dynamodbMock + .on(PutItemCommand) + .resolvesOnce({}); + + // Call the handler function + const result = await handler(event, context as any, {} as any); + + // Assert the result + expect(result).toEqual({ + status: 'Done', + updatedRecord: event, + originalRecord: event + }); + + // Assert that the DynamoDBClient and PutItemCommand were called with the correct arguments + expect(dynamodbMock).toHaveReceivedCommandWith(PutItemCommand, { + TableName: process.env.TABLE_NAME, + Item: { + "type": { S: "Team" }, + "id": { S: expect.any(String) }, + "playerDetails": { S: JSON.stringify(event.playerDetails) }, + "serverId": { S: expect.any(String) }, + "tournament": { S: JSON.stringify(event.tournament) }, + "lastUpdatedAt": { S: expect.any(String) } + } + }); + }); + + it('Should return a failed status if the event does not include player details', async () => { + // Mock the event and context objects + const event: Team = { + serverId: 'serverId', + tournament: { + tournamentName: 'tournamentName', + tournamentDay: '1' + }, + }; + const context = { /* mock context object */ }; + + // Call the handler function + const result = await handler(event, context as any, {} as any); + + // Assert the result + expect(result).toEqual({ + status: 'Failed', + details: 'Player details are required to create a team.', + originalRecord: event + }); + }); +}); \ No newline at end of file diff --git a/functions/clash-bot/team/create/tsconfig.json b/functions/clash-bot/team/create/tsconfig.json new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/functions/clash-bot/team/create/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/functions/clash-bot/team/is-tournament-eligible/jest.config.js b/functions/clash-bot/team/is-tournament-eligible/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/functions/clash-bot/team/is-tournament-eligible/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/functions/clash-bot/team/is-tournament-eligible/package.json b/functions/clash-bot/team/is-tournament-eligible/package.json new file mode 100644 index 0000000..3e913b4 --- /dev/null +++ b/functions/clash-bot/team/is-tournament-eligible/package.json @@ -0,0 +1,36 @@ +{ + "name": "clash-bot-is-tournament-eligible", + "version": "1.0.0", + "description": "Checks if a player is eligible to participate in a tournament", + "main": "index.js", + "scripts": { + "test": "jest", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.405.0", + "@aws-sdk/util-dynamodb": "^3.451.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main", + "dotenv": "^16.3.1", + "pino": "^8.15.0" + } +} diff --git a/functions/clash-bot/team/is-tournament-eligible/src/handler.ts b/functions/clash-bot/team/is-tournament-eligible/src/handler.ts new file mode 100644 index 0000000..a7e3b6a --- /dev/null +++ b/functions/clash-bot/team/is-tournament-eligible/src/handler.ts @@ -0,0 +1,105 @@ +import { Handler } from 'aws-lambda'; +import pino from "pino"; +import { DynamoDBClient, QueryCommand, QueryCommandInput } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { TABLE_TYPES } from 'clash-bot-shared'; + +export const handler: Handler = async (event, context) => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + + logger.info({ eventRecieved: event }, 'Recieved event...'); + logger.info({ contextRecieved: context } , 'Recieved context...'); + + if (event.tournament === undefined && event.tournamentDay === undefined) { + logger.info('No tournament or tournament day provided.'); + return { + isEligible: false + }; + } + + const dynamoDBClient = new DynamoDBClient({}); + + let queryConditions: QueryCommandInput = { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: '#type = :type', + ExpressionAttributeNames: { + "#type": "type" + }, + ExpressionAttributeValues: { + ":type": { + S: TABLE_TYPES.TOURNAMENT + }, + } + }; + + if (event.tournament !== undefined && event.tournamentDay !== undefined) { + logger.info({ tournament: event.tournament, tournamentDay: event.tournamentDay }, 'Tournament and tournament day provided...'); + queryConditions = { + ...queryConditions, + FilterExpression: '#tournament = :tournament AND #tournamentDay = :tournamentDay', + ExpressionAttributeNames: { + ...queryConditions.ExpressionAttributeNames, + "#tournament": "tournament", + "#tournamentDay": "tournamentDay" + }, + ExpressionAttributeValues: { + ...queryConditions.ExpressionAttributeValues, + ":tournament": { + S: event.tournament + }, + ":tournamentDay": { + S: event.tournamentDay + } + } + }; + } else if (event.tournament !== undefined) { + logger.info({ tournament: event.tournament }, 'Tournament provided...') + queryConditions = { + ...queryConditions, + FilterExpression: '#tournament = :tournament', + ExpressionAttributeNames: { + ...queryConditions.ExpressionAttributeNames, + "#tournament": "tournament" + }, + ExpressionAttributeValues: { + ...queryConditions.ExpressionAttributeValues, + ":tournament": { + S: event.tournament + } + } + }; + } + + const queryCommand = new QueryCommand(queryConditions); + + logger.info("Sending query to DynamoDb..."); + const queryCommandOutput = await dynamoDBClient.send(queryCommand); + logger.info({ queryCommandOutput }, "Recieved query response from DynamoDb."); + + if (queryCommandOutput.Items === undefined || queryCommandOutput.Items.length === 0) { + logger.info("No tournaments found."); + return { + isEligible: false + }; + } else { + logger.info({ tournaments: queryCommandOutput.Items }, "Tournaments found."); + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + const tournaments = queryCommandOutput.Items + .map(item => unmarshall(item)) + .filter(tournament => { + const dayOfDateOfTournament = new Date(tournament.date); + dayOfDateOfTournament.setHours(0, 0, 0, 0); + const tournamentIsEligible = dayOfDateOfTournament >= currentDate; + logger.info({ ...tournament, tournamentIsEligible, currentDate }, 'Tournament eligibility...'); + return tournamentIsEligible; + }); + + logger.info({ tournaments }, 'Eligible tournaments found.'); + + return { + isEligible: tournaments.length > 0 + }; + } +}; \ No newline at end of file diff --git a/functions/clash-bot/team/is-tournament-eligible/tests/handler.test.ts b/functions/clash-bot/team/is-tournament-eligible/tests/handler.test.ts new file mode 100644 index 0000000..28eb9a1 --- /dev/null +++ b/functions/clash-bot/team/is-tournament-eligible/tests/handler.test.ts @@ -0,0 +1,143 @@ +import { handler } from '../src/handler'; +import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { marshall } from '@aws-sdk/util-dynamodb'; +import { TABLE_TYPES } from 'clash-bot-shared'; + +describe('handler', () => { + test('If the tournament is after the current date, then respond with true', async () => { + const tournamentName = 'Clash Tournament'; + const date = new Date(); + date.setHours(1, 0, 0, 0); + const mock = mockClient(DynamoDBClient); + const mockQueryCommand = jest.fn(); + mockQueryCommand.mockResolvedValue({ + Items: [ + marshall({ + type: TABLE_TYPES.TOURNAMENT, + tournament: tournamentName, + date: date.toISOString() + }) + ] + }); + mock.on(QueryCommand).resolves(mockQueryCommand()); + + const result = await handler({ + tournament: tournamentName + }, {} as any, {} as any); + + expect(result).toEqual({ + isEligible: true + }); + expect(mock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: '#type = :type', + FilterExpression: '#tournament = :tournament', + ExpressionAttributeNames: { + '#type': 'type', + '#tournament': 'tournament' + }, + ExpressionAttributeValues: { + ':type': { S: TABLE_TYPES.TOURNAMENT, }, + ':tournament': { S: tournamentName } + } + }); + }); + + test('If the tournament and tournament day are passed and is after the current date, then respond with true', async () => { + const tournamentName = 'Clash Tournament'; + const tournamentDay = '1'; + const date = new Date(); + date.setHours(1, 0, 0, 0); + const mock = mockClient(DynamoDBClient); + const mockQueryCommand = jest.fn(); + mockQueryCommand.mockResolvedValue({ + Items: [ + marshall({ + type: TABLE_TYPES.TOURNAMENT, + tournament: tournamentName, + tournamentDay, + date: date.toISOString() + }) + ] + }); + mock.on(QueryCommand).resolves(mockQueryCommand()); + + const result = await handler({ + tournament: tournamentName, + tournamentDay + }, {} as any, {} as any); + + expect(result).toEqual({ + isEligible: true + }); + expect(mock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: '#type = :type', + FilterExpression: '#tournament = :tournament AND #tournamentDay = :tournamentDay', + ExpressionAttributeNames: { + '#type': 'type', + '#tournament': 'tournament', + '#tournamentDay': 'tournamentDay' + }, + ExpressionAttributeValues: { + ':type': { S: TABLE_TYPES.TOURNAMENT }, + ':tournament': { S: tournamentName }, + ':tournamentDay': { S: tournamentDay } + } + }); + }); + + test('If multiple days are returned, check both days', async () => { + const tournamentName = 'Clash Tournament'; + const tournamentDay = '1'; + const date = new Date(); + date.setHours(1, 0, 0, 0); + const priorDate = new Date(); + priorDate.setHours(-1, 0, 0, 0); + const mock = mockClient(DynamoDBClient); + const mockQueryCommand = jest.fn(); + mockQueryCommand.mockResolvedValue({ + Items: [ + marshall({ + type: TABLE_TYPES.TOURNAMENT, + tournament: tournamentName, + tournamentDay, + date: priorDate.toISOString() + }), + marshall({ + type: TABLE_TYPES.TOURNAMENT, + tournament: tournamentName, + tournamentDay: '2', + date: date.toISOString() + }) + ] + }); + mock.on(QueryCommand).resolves(mockQueryCommand()); + + const result = await handler({ + tournament: tournamentName, + tournamentDay + }, {} as any, {} as any); + + expect(result).toEqual({ + isEligible: true + }); + expect(mock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: '#type = :type', + FilterExpression: '#tournament = :tournament AND #tournamentDay = :tournamentDay', + ExpressionAttributeNames: { + '#type': 'type', + '#tournament': 'tournament', + '#tournamentDay': 'tournamentDay' + }, + ExpressionAttributeValues: { + ':type': { S: TABLE_TYPES.TOURNAMENT }, + ':tournament': { S: tournamentName }, + ':tournamentDay': { S: tournamentDay } + } + }); + }); +}); \ No newline at end of file diff --git a/functions/clash-bot/team/is-tournament-eligible/tsconfig.json b/functions/clash-bot/team/is-tournament-eligible/tsconfig.json new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/functions/clash-bot/team/is-tournament-eligible/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/functions/clash-bot/team/retrieve/ClashBotWorkflow.code-workspace b/functions/clash-bot/team/retrieve/ClashBotWorkflow.code-workspace new file mode 100644 index 0000000..3a098c5 --- /dev/null +++ b/functions/clash-bot/team/retrieve/ClashBotWorkflow.code-workspace @@ -0,0 +1,44 @@ +{ + "folders": [ + { + "path": "../../../.." + }, + { + "name": "clash-bot-shared", + "path": "../../clash-bot-shared" + }, + { + "name": "event-publisher", + "path": "../../event-publisher" + }, + { + "name": "create", + "path": "../create" + }, + { + "name": "retrieve", + "path": "." + }, + { + "name": "can-create-team", + "path": "../can-create-team" + }, + { + "name": "event-handler", + "path": "../../event-handler" + }, + { + "name": "event-notifier", + "path": "../../event-notifier" + }, + { + "name": "websocket-publisher", + "path": "../../websocket-publisher" + } + ], + "settings": { + "jest.disabledWorkspaceFolders": [ + "can-create-team" + ] + } +} \ No newline at end of file diff --git a/functions/clash-bot/team/retrieve/jest.config.js b/functions/clash-bot/team/retrieve/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/functions/clash-bot/team/retrieve/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/functions/clash-bot/team/retrieve/package.json b/functions/clash-bot/team/retrieve/package.json new file mode 100644 index 0000000..a73ae68 --- /dev/null +++ b/functions/clash-bot/team/retrieve/package.json @@ -0,0 +1,38 @@ +{ + "name": "clash-bot-retrieve-teams", + "version": "1.0.0", + "description": "A Lambda function to retrieve teams from DynamoDB for Clash Bot", + "main": "index.js", + "scripts": { + "test": "jest", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "@types/uuid": "^9.0.7", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.405.0", + "@aws-sdk/util-dynamodb": "^3.451.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main", + "dotenv": "^16.3.1", + "pino": "^8.15.0", + "uuid": "^9.0.1" + } +} diff --git a/functions/clash-bot/team/retrieve/src/handler.ts b/functions/clash-bot/team/retrieve/src/handler.ts new file mode 100644 index 0000000..c4e60d4 --- /dev/null +++ b/functions/clash-bot/team/retrieve/src/handler.ts @@ -0,0 +1,97 @@ +import { Handler } from 'aws-lambda'; +import pino, { P } from "pino"; +import { DynamoDBClient, QueryCommand, QueryCommandInput } from '@aws-sdk/client-dynamodb'; +import { v4 as uuidv4 } from 'uuid'; +import { Team, TeamFromJSON } from 'clash-bot-shared'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; + +export const handler: Handler = async (event, context) => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + + logger.info({ eventRecieved: event }, 'Recieved event...'); + logger.info({ contextRecieved: context } , 'Recieved context...'); + + let queryConditions: QueryCommandInput = { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: 'type = :type', + ExpressionAttributeNames: { + "#type": "type" + }, + ExpressionAttributeValues: { + ":type": { + S: "Team" + }, + } + }; + + if (event.tournamentName && event.tournamentDay && event.serverId) { + logger.info("Filtering by tournament name '%s', tournament day '%s', and server id '%s'...", event.tournamentName, event.tournamentDay, event.serverId); + queryConditions = { + ...queryConditions, + FilterExpression: '#tournament.#tournamentName = :tournamentName AND #tournament.#tournamentDay = :tournamentDay AND #serverId = :serverId', + ExpressionAttributeNames: { + ...queryConditions.ExpressionAttributeNames, + "#tournament": "tournament", + "#tournamentName": "tournamentName", + "#tournamentDay": "tournamentDay", + "#serverId": "serverId" + }, + ExpressionAttributeValues: { + ...queryConditions.ExpressionAttributeValues, + ":tournamentName": { S: event.tournamentName }, + ":tournamentDay": { S: event.tournamentDay }, + ":serverId": { S: event.serverId } + } + } + } else if (event.tournamentName && event.tournamentDay) { + logger.info("Filtering by tournament name '%s' and tournament day '%s'...", event.tournamentName, event.tournamentDay); + queryConditions = { + ...queryConditions, + FilterExpression: '#tournament.#tournamentName = :tournamentName AND #tournament.#tournamentDay = :tournamentDay', + ExpressionAttributeNames: { + ...queryConditions.ExpressionAttributeNames, + "#tournament": "tournament", + "#tournamentName": "tournamentName", + "#tournamentDay": "tournamentDay" + }, + ExpressionAttributeValues: { + ...queryConditions.ExpressionAttributeValues, + ":tournamentName": { S: event.tournamentName }, + ":tournamentDay": { S: event.tournamentDay } + } + } + } else if (event.tournamentName) { + logger.info("Filtering by tournament name '%s'...", event.tournamentName); + queryConditions = { + ...queryConditions, + FilterExpression: '#tournament.#tournamentName = :tournamentName', + ExpressionAttributeNames: { + ...queryConditions.ExpressionAttributeNames, + "#tournament": "tournament", + "#tournamentName": "tournamentName" + }, + ExpressionAttributeValues: { + ...queryConditions.ExpressionAttributeValues, + ":tournamentName": { S: event.tournamentName } + } + } + } + + const dynamoDbClient = new DynamoDBClient({}); + const query = new QueryCommand(queryConditions); + + const results = await dynamoDbClient.send(query); + const items = results.Items ?? []; + + logger.info("Returned %d records from DynamoDb...", items.length); + logger.debug({ items }, "Retrieved items from DynamoDB..."); + + return items.map((item) => { + const unmarshalledItem = unmarshall(item); + return { + ...unmarshalledItem, + lastUpdatedAt: new Date(unmarshalledItem.lastUpdatedAt) + }; + }); +}; \ No newline at end of file diff --git a/functions/clash-bot/team/retrieve/tests/handler.test.ts b/functions/clash-bot/team/retrieve/tests/handler.test.ts new file mode 100644 index 0000000..ae044e1 --- /dev/null +++ b/functions/clash-bot/team/retrieve/tests/handler.test.ts @@ -0,0 +1,304 @@ +import { handler } from '../src/handler'; +import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { Team, TeamFromJSON } from 'clash-bot-shared'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { marshall } from '@aws-sdk/util-dynamodb'; + +describe('handler', () => { + test('If filtering ctiteria is not passed, should return a list of clash bot teams', async () => { + + const listOfExcpectedTeams: Team[] = [ + { + id: '1', + name: 'team1', + playerDetails: { + top: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: 'tournamentName', + tournamentDay: '1' + }, + lastUpdatedAt: new Date() + }, + { + id: '2', + name: 'team2', + playerDetails: { + bot: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: 'tournamentName', + tournamentDay: '1' + }, + lastUpdatedAt: new Date() + } + ]; + + const dynamodbMock = mockClient(DynamoDBClient); + + const items = listOfExcpectedTeams.map((team) => { + let marshalledTeam = { + ...team, + lastUpdatedAt: team.lastUpdatedAt?.toISOString() + } + return marshall(marshalledTeam); + }); + + dynamodbMock.on(QueryCommand).resolvesOnce({ + Items: items + }); + const results = await handler({}, {} as any, {} as any); + + expect(dynamodbMock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: 'type = :type', + ExpressionAttributeNames: { + "#type": "type" + }, + ExpressionAttributeValues: { + ":type": { + S: "Team" + } + } + }); + expect(results).toEqual(listOfExcpectedTeams); + }); + + test('If filtering ctiteria for tournament is passed, should return a list of clash bot teams for a tournament', async () => { + const tournamentName = 'tournamentName'; + const listOfExcpectedTeams: Team[] = [ + { + id: '1', + name: 'team1', + playerDetails: { + top: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: tournamentName, + tournamentDay: '1' + }, + lastUpdatedAt: new Date() + }, + { + id: '2', + name: 'team2', + playerDetails: { + bot: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: tournamentName, + tournamentDay: '1' + }, + lastUpdatedAt: new Date() + } + ]; + + const dynamodbMock = mockClient(DynamoDBClient); + + const items = listOfExcpectedTeams.map((team) => { + let marshalledTeam = { + ...team, + lastUpdatedAt: team.lastUpdatedAt?.toISOString() + } + return marshall(marshalledTeam); + }); + + dynamodbMock.on(QueryCommand).resolvesOnce({ + Items: items + }); + const results = await handler({ + tournamentName + }, {} as any, {} as any); + + expect(dynamodbMock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: 'type = :type', + FilterExpression: '#tournament.#tournamentName = :tournamentName', + ExpressionAttributeNames: { + "#type": "type", + "#tournament": "tournament", + "#tournamentName": "tournamentName" + }, + ExpressionAttributeValues: { + ":type": { + S: "Team" + }, + ":tournamentName": { S: tournamentName } + } + }); + expect(results).toEqual(listOfExcpectedTeams); + }); + + test('If filtering ctiteria for tournament and tournament day are passed, should return a list of clash bot teams for a tournament', async () => { + const tournamentName = 'tournamentName'; + const tournamentDay = '1'; + const listOfExcpectedTeams: Team[] = [ + { + id: '1', + name: 'team1', + playerDetails: { + top: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: tournamentName, + tournamentDay: tournamentDay + }, + lastUpdatedAt: new Date() + }, + { + id: '2', + name: 'team2', + playerDetails: { + bot: { + discordId: '1', + name: 'someUser' + } + }, + serverId: 'serverId', + tournament: { + tournamentName: tournamentName, + tournamentDay: tournamentDay + }, + lastUpdatedAt: new Date() + } + ]; + + const dynamodbMock = mockClient(DynamoDBClient); + + const items = listOfExcpectedTeams.map((team) => { + let marshalledTeam = { + ...team, + lastUpdatedAt: team.lastUpdatedAt?.toISOString() + } + return marshall(marshalledTeam); + }); + + dynamodbMock.on(QueryCommand).resolvesOnce({ + Items: items + }); + const results = await handler({ + tournamentName, + tournamentDay + }, {} as any, {} as any); + + expect(dynamodbMock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: 'type = :type', + FilterExpression: '#tournament.#tournamentName = :tournamentName AND #tournament.#tournamentDay = :tournamentDay', + ExpressionAttributeNames: { + "#type": "type", + "#tournament": "tournament", + "#tournamentName": "tournamentName", + "#tournamentDay": "tournamentDay" + }, + ExpressionAttributeValues: { + ":type": { + S: "Team" + }, + ":tournamentName": { S: tournamentName }, + ":tournamentDay": { S: tournamentDay } + }}); + expect(results).toEqual(listOfExcpectedTeams); + }); + + test('If filtering ctiteria for tournament, tournament day, and server id are passed, should return a list of clash bot teams for a tournament, tournament id and server id', async () => { + const tournamentName = 'tournamentName'; + const tournamentDay = '1'; + const serverId = 'serverId'; + const listOfExcpectedTeams: Team[] = [ + { + id: '1', + name: 'team1', + playerDetails: { + top: { + discordId: '1', + name: 'someUser' + } + }, + serverId: serverId, + tournament: { + tournamentName: tournamentName, + tournamentDay: tournamentDay + }, + lastUpdatedAt: new Date() + }, + { + id: '2', + name: 'team2', + playerDetails: { + bot: { + discordId: '1', + name: 'someUser' + } + }, + serverId: serverId, + tournament: { + tournamentName: tournamentName, + tournamentDay: tournamentDay + }, + lastUpdatedAt: new Date() + } + ]; + + const dynamodbMock = mockClient(DynamoDBClient); + + const items = listOfExcpectedTeams.map((team) => { + let marshalledTeam = { + ...team, + lastUpdatedAt: team.lastUpdatedAt?.toISOString() + } + return marshall(marshalledTeam); + }); + + dynamodbMock.on(QueryCommand).resolvesOnce({ + Items: items + }); + const results = await handler({ + tournamentName, + tournamentDay, + serverId + }, {} as any, {} as any); + expect(dynamodbMock).toHaveReceivedCommandWith(QueryCommand, { + TableName: process.env.TABLE_NAME, + KeyConditionExpression: 'type = :type', + FilterExpression: '#tournament.#tournamentName = :tournamentName AND #tournament.#tournamentDay = :tournamentDay AND #serverId = :serverId', + ExpressionAttributeNames: { + "#type": "type", + "#tournament": "tournament", + "#tournamentName": "tournamentName", + "#tournamentDay": "tournamentDay", + "#serverId": "serverId" + }, + ExpressionAttributeValues: { + ":type": { + S: "Team" + }, + ":tournamentName": { S: tournamentName }, + ":tournamentDay": { S: tournamentDay }, + ":serverId": { S: serverId } + } + }); + expect(results).toEqual(listOfExcpectedTeams); + }); +}); \ No newline at end of file diff --git a/functions/clash-bot/team/retrieve/tsconfig.json b/functions/clash-bot/team/retrieve/tsconfig.json new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/functions/clash-bot/team/retrieve/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/functions/clash-bot/websocket-publisher/.eslintrc.js b/functions/clash-bot/websocket-publisher/.eslintrc.js new file mode 100644 index 0000000..7d3cf15 --- /dev/null +++ b/functions/clash-bot/websocket-publisher/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + parser: '@typescript-eslint/parser', // Specifies the ESLint parser + extends: [ + 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin + ], + parserOptions: { + ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features + sourceType: 'module', // Allows for the use of imports + }, + rules: { + // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs + // e.g. "@typescript-eslint/explicit-function-return-type": "off", + }, + }; \ No newline at end of file diff --git a/functions/clash-bot/websocket-publisher/jest.config.js b/functions/clash-bot/websocket-publisher/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/functions/clash-bot/websocket-publisher/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/functions/clash-bot/websocket-publisher/package.json b/functions/clash-bot/websocket-publisher/package.json new file mode 100644 index 0000000..66b33b9 --- /dev/null +++ b/functions/clash-bot/websocket-publisher/package.json @@ -0,0 +1,41 @@ +{ + "name": "clash-bot-websocket-publisher", + "version": "1.0.0", + "description": "A auto generated lambda for Clash Bot", + "main": "index.js", + "scripts": { + "test": "jest", + "prebuild": "eslint . --ext .ts", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "eslint": "^8.54.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.454.0", + "@aws-sdk/client-dynamodb": "^3.398.0", + "@aws-sdk/util-dynamodb": "^3.451.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main", + "dotenv": "^16.3.1", + "pino": "^8.15.0" + } +} diff --git a/functions/clash-bot/websocket-publisher/src/handler.ts b/functions/clash-bot/websocket-publisher/src/handler.ts new file mode 100644 index 0000000..5de9ad9 --- /dev/null +++ b/functions/clash-bot/websocket-publisher/src/handler.ts @@ -0,0 +1,80 @@ +import { Handler } from 'aws-lambda'; +import pino from "pino"; + +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi'; +import { SUBSCRIPTION_TYPE, WebsocketEvent } from 'clash-bot-shared'; +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; + +export const handler: Handler = async (event: WebsocketEvent) => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + logger.info({ eventRecieved: event }, 'Recieved event...'); + try { + + let topic = ""; + + if (event.requestId) { + topic = event.requestId; + } else { + throw new Error("Missing requestId"); + } + + logger.info({ topic }, 'Retrieving subscriptions to topic...'); + + const dynamoDbClient = new DynamoDBClient({}); + + const topics = [topic, SUBSCRIPTION_TYPE.WATCH_ALL]; + logger.info({ topics }, 'Retrieving subscriptions to topics...') + const getTopicCommands = topics.map((topic) => new GetItemCommand({ + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topic } + } + })); + + const results = await Promise.all(getTopicCommands.map((command) => dynamoDbClient.send(command))); + + const subscribers: string[] = []; + + if (results.length > 0) { + for (const getItemOutput of results) { + if (getItemOutput.Item !== undefined && getItemOutput.Item.subscribers !== undefined) { + subscribers.push(...unmarshall(getItemOutput.Item).subscribers); + } + } + } else { + logger.info({ topic }, 'No subscribers found...'); + return { + posts: [], + topic, + payload: event.payload + } + } + + logger.info({ subscribers }, 'Subscribers found...'); + logger.info({ event }, 'Sending payload...'); + + const responses = await Promise.all(subscribers.map((subscriber) => { + const requestParams = { + ConnectionId: subscriber, + Data: JSON.stringify(event.payload), + }; + const client = new ApiGatewayManagementApiClient({ endpoint: process.env.WEBSOCKET_API_ENDPOINT }) + + return client.send(new PostToConnectionCommand(requestParams)); + })); + + return { + posts: responses, + topic, + payload: event.payload + }; + } catch (error) { + logger.error(error, "Failed."); + return { + error: 'Failed to publish to websocket', + stack: error + }; + } +}; \ No newline at end of file diff --git a/functions/clash-bot/websocket-publisher/tests/handler.test.ts b/functions/clash-bot/websocket-publisher/tests/handler.test.ts new file mode 100644 index 0000000..8b00d82 --- /dev/null +++ b/functions/clash-bot/websocket-publisher/tests/handler.test.ts @@ -0,0 +1,217 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { handler } from '../src/handler'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi'; +import { WebsocketEvent, SUBSCRIPTION_TYPE } from 'clash-bot-shared'; +import { Context } from 'aws-lambda'; + +describe('Websocket Publisher', () => { + + test('Should query from Dynamo Db the list of connections associated to a specific topic based on the message request id and the all topic and post to the aws ws api', async () => { + const topicKey = 'topic'; + const event = { + requestId: topicKey, + payload: { + message: 'message' + } + }; + const context = {}; + const connectionIdForAll = 'connectionIdForAll'; + + const dynamoClientMock = mockClient(DynamoDBClient); + const apiGatewayMock = mockClient(ApiGatewayManagementApiClient); + + dynamoClientMock.on(GetItemCommand) + .resolves({ + Item: { + topic: { + S: topicKey + }, + subscribers: { + SS: [ + 'connectionId' + ] + } + } + }); + dynamoClientMock.on(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: SUBSCRIPTION_TYPE.WATCH_ALL } + } + }).resolves({ + Item: { + topic: { + S: topicKey + }, + subscribers: { + SS: [ + connectionIdForAll + ] + } + } + }); + apiGatewayMock.on(PostToConnectionCommand) + .resolves({}); + + const result = await handler(event as WebsocketEvent, context as Context, () => { }); + expect(result).toBeDefined(); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topicKey } + } + }); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: SUBSCRIPTION_TYPE.WATCH_ALL } + } + }); + expect(apiGatewayMock).toHaveReceivedCommandWith(PostToConnectionCommand, { + ConnectionId: 'connectionId', + Data: JSON.stringify(event.payload) + }); + expect(apiGatewayMock).toHaveReceivedCommandWith(PostToConnectionCommand, { + ConnectionId: 'connectionIdForAll', + Data: JSON.stringify(event.payload) + }); + }); + + test('No topics subscribers', async () => { + const topicKey = 'topic'; + const event = { + requestId: topicKey, + payload: { + message: 'message' + } + }; + const context = {}; + + const dynamoClientMock = mockClient(DynamoDBClient); + const apiGatewayMock = mockClient(ApiGatewayManagementApiClient); + + dynamoClientMock.on(GetItemCommand) + .resolves({ + Item: {} + }); + apiGatewayMock.on(PostToConnectionCommand) + .resolves({}); + + const result = await handler(event as WebsocketEvent, context as Context, () => { }); + expect(result).toBeDefined(); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topicKey } + } + }); + expect(apiGatewayMock).not.toHaveReceivedCommand(PostToConnectionCommand); + }); + + test('No topics subscribers', async () => { + const topicKey = 'topic'; + const event = { + requestId: topicKey, + payload: { + message: 'message' + } + }; + const context = {}; + + const dynamoClientMock = mockClient(DynamoDBClient); + const apiGatewayMock = mockClient(ApiGatewayManagementApiClient); + + dynamoClientMock.on(GetItemCommand) + .resolves({}); + apiGatewayMock.on(PostToConnectionCommand) + .resolves({}); + + const result = await handler(event as WebsocketEvent, context as Context, () => { }); + expect(result).toEqual({ + posts: [], + topic: topicKey, + payload: event.payload + }); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topicKey } + } + }); + expect(apiGatewayMock).not.toHaveReceivedCommand(PostToConnectionCommand); + }); + + test('No topics subscribers for WATCH_ALL', async () => { + const topicKey = 'topic'; + const connectionIdForAll = '1234'; + const event = { + requestId: topicKey, + payload: { + message: 'message' + } + }; + const context = {}; + + const dynamoClientMock = mockClient(DynamoDBClient); + const apiGatewayMock = mockClient(ApiGatewayManagementApiClient); + + dynamoClientMock.on(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: SUBSCRIPTION_TYPE.WATCH_ALL } + } + }).resolves({ + Item: {} + }); + + dynamoClientMock.on(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topicKey } + } + }).resolves({ + Item: { + topic: { + S: topicKey + }, + subscribers: { + SS: [ + connectionIdForAll + ] + } + } + }); + apiGatewayMock.on(PostToConnectionCommand) + .resolves({}); + + const result = await handler(event as WebsocketEvent, context as Context, () => { }); + expect(result).toBeDefined(); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: topicKey } + } + }); + expect(dynamoClientMock).toHaveReceivedCommandWith(GetItemCommand, { + TableName: process.env.TOPIC_TO_SUBSCRIBERS_TABLE_NAME, + Key: { + "topic": { S: SUBSCRIPTION_TYPE.WATCH_ALL } + } + }); + expect(apiGatewayMock).toHaveReceivedCommand(PostToConnectionCommand); + }); + + test('Error Case - Missing requestId', async () => { + const event = {}; + const context = {}; + + const results = await handler(event as WebsocketEvent, context as Context, () => { }); + expect(results).toEqual({ + error: "Failed to publish to websocket", + stack: new Error("Missing requestId") + }); + }); + +}); \ No newline at end of file diff --git a/functions/clash-bot/websocket-publisher/tsconfig.json b/functions/clash-bot/websocket-publisher/tsconfig.json new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/functions/clash-bot/websocket-publisher/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/lambda-generator/generator.py b/lambda-generator/generator.py new file mode 100644 index 0000000..3a5c0c4 --- /dev/null +++ b/lambda-generator/generator.py @@ -0,0 +1,43 @@ +import argparse +import os +from jinja2 import Environment, FileSystemLoader +import re + +def generate_file(name, items): + env = Environment(loader=FileSystemLoader('.')) + env.filters['to_kebab_case'] = to_kebab_case + + for template_name in env.list_templates(extensions=["j2"]): + template = env.get_template(template_name) + + output = template.render( + name=name, + items=items, + ) + + # Remove the .j2 extension from the template name for the output file + output_file_name = os.path.splitext(template_name)[0] + + print(f'Writing {output_file_name}...') + output_dir = os.path.join('../functions/clash-bot', name, os.path.dirname(output_file_name)) + os.makedirs(output_dir, exist_ok=True) + + with open(os.path.join(output_dir, os.path.basename(output_file_name)), 'w') as f: + + f.write(output) + +def to_kebab_case(s): + s = re.sub(r'(.)([A-Z][a-z]+)', r'\1-\2', s) + return re.sub(r'([a-z0-9])([A-Z])', r'\1-\2', s).lower() + +def main(): + parser = argparse.ArgumentParser(description='Generate a Terraform file for a Lambda function.') + parser.add_argument('--name', required=True, help='The name of the Lambda function.') + parser.add_argument('--items', nargs='+', help='A list of items.') + + args = parser.parse_args() + + generate_file(args.name, args.items) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lambda-generator/templates/lambda/jest.config.js.j2 b/lambda-generator/templates/lambda/jest.config.js.j2 new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/lambda-generator/templates/lambda/jest.config.js.j2 @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/lambda-generator/templates/lambda/package.json.j2 b/lambda-generator/templates/lambda/package.json.j2 new file mode 100644 index 0000000..2abe661 --- /dev/null +++ b/lambda-generator/templates/lambda/package.json.j2 @@ -0,0 +1,36 @@ +{ + "name": "clash-bot-{{ name|to_kebab_case }}", + "version": "1.0.0", + "description": "A auto generated lambda for Clash Bot", + "main": "index.js", + "scripts": { + "test": "jest", + "build": "tsc", + "postbuild": "cp package.json prod/package.json" + }, + "keywords": [ + "clash-bot" + ], + "author": "Poss111", + "license": "ISC", + "devDependencies": { + "@aws-sdk/types": "^3.398.0", + "@types/aws-lambda": "^8.10.119", + "@types/jest": "^29.5.4", + "@types/pino": "^7.0.5", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", + "jest": "^29.6.4", + "pino-pretty": "^10.2.0", + "ts-jest": "^29.1.1", + "ts-mockito": "^2.6.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.398.0", + "@aws-sdk/util-dynamodb": "^3.451.0", + "dotenv": "^16.3.1", + "pino": "^8.15.0", + "clash-bot-shared": "github:Poss111/ClashBotWorkflowShared#main" + } +} diff --git a/lambda-generator/templates/lambda/src/handler.ts.j2 b/lambda-generator/templates/lambda/src/handler.ts.j2 new file mode 100644 index 0000000..a1e7b5f --- /dev/null +++ b/lambda-generator/templates/lambda/src/handler.ts.j2 @@ -0,0 +1,27 @@ +import { APIGatewayProxyEvent, APIGatewayProxyHandler } from 'aws-lambda'; +import pino from "pino"; + +import { APIGatewayProxyResult } from 'aws-lambda'; + +export const handler: APIGatewayProxyHandler = async (event: APIGatewayProxyEvent): Promise => { + const level = process.env.LOGGER_LEVEL === undefined ? "info" : process.env.LOGGER_LEVEL; + const logger = pino({ level }); + logger.info({ eventRecieved: event }, 'Recieved event...'); + try { + return { + statusCode: 200, + body: JSON.stringify({ + requestId: "" + }) + }; + } catch (error) { + logger.error(error, "Failed."); + return { + statusCode: 500, + body: JSON.stringify({ + requestId: event.requestContext.requestId, + error: "Failed" + }) + }; + } +}; \ No newline at end of file diff --git a/lambda-generator/templates/lambda/tests/handler.test.ts.j2 b/lambda-generator/templates/lambda/tests/handler.test.ts.j2 new file mode 100644 index 0000000..c7f2455 --- /dev/null +++ b/lambda-generator/templates/lambda/tests/handler.test.ts.j2 @@ -0,0 +1,24 @@ +import { handler } from '../src/handler'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { before } from 'node:test'; + +describe('Placeholder', () => { + + test('Happy Path', async () => { + const event = {}; + const context = {}; + + const result = await handler(event as any, context as any, () => { }); + expect(result).toBeDefined(); + }); + + test('Error Case', async () => { + const event = {}; + const context = {}; + + const result = await handler(event as any, context as any, () => { }); + expect(result).toBeDefined(); + }); + +}); \ No newline at end of file diff --git a/lambda-generator/templates/lambda/tsconfig.json.j2 b/lambda-generator/templates/lambda/tsconfig.json.j2 new file mode 100644 index 0000000..62859a2 --- /dev/null +++ b/lambda-generator/templates/lambda/tsconfig.json.j2 @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", + "strict": true, + "preserveConstEnums": true, + "noEmit": false, + "sourceMap": false, + "module":"commonjs", + "moduleResolution":"node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "rootDir": "./src", + "outDir": "prod", + "baseUrl": ".", + "paths": { + "clash-bot-shared": [ "node_modules/clash-bot-shared/src"], + "clash-bot-shared/*": [ "node_modules/clash-bot-shared/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"], + } \ No newline at end of file diff --git a/lambda-generator/templates/terraform/lambda.tf.j2 b/lambda-generator/templates/terraform/lambda.tf.j2 new file mode 100644 index 0000000..f500ce7 --- /dev/null +++ b/lambda-generator/templates/terraform/lambda.tf.j2 @@ -0,0 +1,19 @@ +module "tournament_eligibility_lambda" { + source = "./modules/lambda" + + prefix = "{{ name }}" + s3_bucket_name = var.s3_bucket_name + environment = var.environment + + artifact_path = var.{{ name }}_artifact_path + + environment_variables = { + {% for item in items %} + {{ item }} = "placeholder" + {% endfor %} + } + + iam_policy_json = templatefile( + "${path.module}/policies/{{ name }}-lambda-policy.json", + ) +} \ No newline at end of file diff --git a/sample-data/retrieve-active-clash-tournaments.json b/sample-data/retrieve-active-clash-tournaments.json new file mode 100644 index 0000000..2405014 --- /dev/null +++ b/sample-data/retrieve-active-clash-tournaments.json @@ -0,0 +1,30 @@ +[ + { + "id": 135101, + "themeI d": 4, + "nameKey": "aram2022", + "nameKeySecondary": "day_4", + "schedule": [ + { + "id": 127581, + "registrationTime": 1702253700000, + "startTime": 1702263600000, + "cancelled": false + } + ] + }, + { + "id": 135081, + "themeId": 4, + "nameKey": "aram2022", + "nameKeySecondary": "day_3", + "schedule": [ + { + "id": 127561, + "registrationTime": 1702167300000, + "startTime": 1702177200000, + "cancelled": false + } + ] + } +] \ No newline at end of file diff --git a/scripts/find_latest_artifact.sh b/scripts/find_latest_artifact.sh new file mode 100755 index 0000000..3d50020 --- /dev/null +++ b/scripts/find_latest_artifact.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +BUCKET_NAME=${1} +ARTIFACT_NAME=${2} +ENVIRONMENT=${3} + +# List objects in the S3 bucket path +OBJECTS=$(aws s3 ls s3://$BUCKET_NAME/artifacts/$ARTIFACT_NAME/$ENVIRONMENT --recursive | awk '{print $4}') + +# Extract numbers from the object names and find the maximum number artifact +ARTIFACT_PATH="" +VERSION=0 +for object in $OBJECTS; do + NUMBER=$(basename $object | grep -o -E '[0-9]+') + if [[ "$NUMBER" -gt "$MAX_NUMBER" ]]; then + ARTIFACT_PATH=$object + VERSION=$NUMBER + fi +done + +# Check if ARTIFACT_PATH is empty +if [[ -z "$ARTIFACT_PATH" ]]; then + echo "Error: No artifact path found" >&2 + exit 1 +fi + +echo "PATH: $ARTIFACT_PATH" +echo "ARTIFACT_VERSION: $VERSION" + +# Set ARTIFACT_PATH as an output +echo "::set-output name=artifact-path::$ARTIFACT_PATH" +echo "::set-output name=version::$VERSION" \ No newline at end of file diff --git a/scripts/list-dependencies.sh b/scripts/list-dependencies.sh new file mode 100755 index 0000000..f275371 --- /dev/null +++ b/scripts/list-dependencies.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Directory to search +DIR="../functions/clash-bot" + +# Find all package.json files in the directory +FILES=$(find $DIR -name 'node_modules' -prune -o -name 'package.json' -print) + +# Initialize an empty string to hold all dependencies +ALL_DEPENDENCIES="" + +# Loop over each file +for FILE in $FILES +do + # Extract dependencies and devDependencies + DEPENDENCIES=$(jq -r '.dependencies | to_entries[] | .key + "@" + .value' $FILE) + + # Add the dependencies from this file to the list of all dependencies + ALL_DEPENDENCIES="$ALL_DEPENDENCIES $DEPENDENCIES" +done + +# Remove duplicate dependencies +ALL_DEPENDENCIES=$(echo $ALL_DEPENDENCIES | tr ' ' '\n' | sort -u | tr '\n' ' ') + +# Generate the npm install command +echo "npm install --save-dev $ALL_DEPENDENCIES" \ No newline at end of file diff --git a/scripts/package-and-upload-lambda-layer.sh b/scripts/package-and-upload-lambda-layer.sh new file mode 100755 index 0000000..a00d7a4 --- /dev/null +++ b/scripts/package-and-upload-lambda-layer.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Directory to zip +DIR="../functions/clash-bot-layer/node_modules" + +# S3 bucket name +BUCKET_NAME="$1" + +# Zip file name +ZIP_FILE="lambda_layer.zip" + +# S3 key +S3_KEY="layers/$ZIP_FILE" + +# Create a temporary directory +TEMP_DIR=$(mktemp -d -t nodejs) + +# Copy the subdirectory to the temporary directory +cp -r $DIR $TEMP_DIR/ + +# Zip the directory +zip -r $ZIP_FILE $TEMP_DIR/* + +# Upload the zip file to S3 +aws s3 cp $ZIP_FILE s3://$BUCKET_NAME/$S3_KEY --profile Master + +# Remove the zip file and the temporary directory +rm $ZIP_FILE +rm -r $TEMP_DIR \ No newline at end of file diff --git a/scripts/setup-data.sh b/scripts/setup-data.sh new file mode 100755 index 0000000..76de2f6 --- /dev/null +++ b/scripts/setup-data.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +PROFILE_NAME="Master" +REGION="us-east-1" +TABLE_NAME="clash-bot-workflow-development" + +for row in $(cat ./setup-data/tournaments.json | jq -r '.[] | @base64'); do + ITEM=$(echo ${row} | base64 --decode | jq -r '{type: .type, id: .id, tournament: .tournament, date: .date, tournamentDay: .tournamentDay} | map_values({S: .})') + echo ${ITEM} + aws dynamodb put-item --table-name ${TABLE_NAME} --item "${ITEM}" --profile ${PROFILE_NAME} --region ${REGION} +done \ No newline at end of file diff --git a/scripts/setup-data/tournaments.json b/scripts/setup-data/tournaments.json new file mode 100644 index 0000000..26bfd2c --- /dev/null +++ b/scripts/setup-data/tournaments.json @@ -0,0 +1,16 @@ +[ + { + "type": "TOURNAMENT", + "id": "aram2022#day_3#135081", + "tournament": "aram2022", + "date": "2022-03-01T00:00:00Z", + "tournamentDay": "day_3" + }, + { + "type": "TOURNAMENT", + "id": "aram2022#day_4#135101", + "tournament": "aram2022", + "date": "2022-03-08T00:00:00Z", + "tournamentDay": "day_4" + } + ] \ No newline at end of file diff --git a/terraform/prereqs/main.tf b/terraform/prereqs/main.tf index 5d24bfe..d80b8b4 100644 --- a/terraform/prereqs/main.tf +++ b/terraform/prereqs/main.tf @@ -29,4 +29,12 @@ module "lambda_bucket" { ] } POLICY -} \ No newline at end of file +} + +resource "aws_lambda_layer_version" "lambda_layer" { + s3_bucket = module.lambda_bucket.s3_bucket_id + s3_key = "layers/lambda_layer_2.zip" + layer_name = "clash-bot-workflow-layer" + + compatible_runtimes = ["nodejs16.x"] +} diff --git a/terraform/workflow/main.tf b/terraform/workflow/api-gateway.tf similarity index 59% rename from terraform/workflow/main.tf rename to terraform/workflow/api-gateway.tf index 41b0c8e..2862a3f 100644 --- a/terraform/workflow/main.tf +++ b/terraform/workflow/api-gateway.tf @@ -1,33 +1,3 @@ -provider "aws" { - region = var.region - - default_tags { - tags = { - Application = "ClashBot" - Environment = var.environment - } - } -} - -terraform { - backend "remote" { - organization = "ClashBot" - - workspaces { - name = "ClashBotWorkflow" - } - } -} - -data "aws_acm_certificate" "issued" { - domain = "clash-bot.ninja" - statuses = ["ISSUED"] -} - -resource "aws_cloudwatch_log_group" "api_gateway_default_log_group" { - name = "api_gateway_default_log_group" -} - module "api_gateway" { source = "terraform-aws-modules/apigateway-v2/aws" @@ -51,31 +21,23 @@ module "api_gateway" { # Routes and integrations integrations = { - "$default" = { - lambda_arn = aws_lambda_function.event_publisher_lambda.arn, - integration_type = "AWS_PROXY" + "POST /api/v2/teams" = { + lambda_arn = module.event_publisher_lambda.arn, + integration_type = "AWS_PROXY", } } -} -module "dynamodb_table" { - source = "terraform-aws-modules/dynamodb-table/aws" + default_route_settings = { + throttling_burst_limit = 5 + throttling_rate_limit = 5.0 + } +} - name = "clash-bot-workflow-${var.environment}" - hash_key = "type" - range_key = "id" - billing_mode = "PROVISIONED" - write_capacity = 5 - read_capacity = 1 +data "aws_acm_certificate" "issued" { + domain = "clash-bot.ninja" + statuses = ["ISSUED"] +} - attributes = [ - { - name = "type" - type = "S" - }, - { - name = "id" - type = "S" - } - ] +resource "aws_cloudwatch_log_group" "api_gateway_default_log_group" { + name = "api_gateway_default_log_group-${var.environment}" } diff --git a/terraform/workflow/backend-configs/development.remote.tfbackend b/terraform/workflow/backend-configs/development.remote.tfbackend new file mode 100644 index 0000000..89c447b --- /dev/null +++ b/terraform/workflow/backend-configs/development.remote.tfbackend @@ -0,0 +1,4 @@ +# config.remote.tfbackend +workspaces { name = "ClashBotWorkflow" } +hostname = "app.terraform.io" +organization = "ClashBot" \ No newline at end of file diff --git a/terraform/workflow/backend-configs/production.remote.tfbackend b/terraform/workflow/backend-configs/production.remote.tfbackend new file mode 100644 index 0000000..b031dce --- /dev/null +++ b/terraform/workflow/backend-configs/production.remote.tfbackend @@ -0,0 +1,4 @@ +# config.remote.tfbackend +workspaces { name = "ClashBotWorkflowProduction" } +hostname = "app.terraform.io" +organization = "ClashBot" diff --git a/terraform/workflow/dynamodb.tf b/terraform/workflow/dynamodb.tf new file mode 100644 index 0000000..dc837aa --- /dev/null +++ b/terraform/workflow/dynamodb.tf @@ -0,0 +1,49 @@ +module "dynamodb_table" { + source = "terraform-aws-modules/dynamodb-table/aws" + + name = "clash-bot-workflow-${var.environment}" + hash_key = "type" + range_key = "id" + billing_mode = "PAY_PER_REQUEST" + + attributes = [ + { + name = "type" + type = "S" + }, + { + name = "id" + type = "S" + } + ] +} + +module "events_table" { + source = "terraform-aws-modules/dynamodb-table/aws" + + name = "clash-bot-topics-${var.environment}" + hash_key = "topic" + billing_mode = "PAY_PER_REQUEST" + + attributes = [ + { + name = "topic" + type = "S" + } + ] +} + +module "subscriber_table" { + source = "terraform-aws-modules/dynamodb-table/aws" + + name = "clash-bot-subscriber-${var.environment}" + hash_key = "subscriber" + billing_mode = "PAY_PER_REQUEST" + + attributes = [ + { + name = "subscriber" + type = "S" + } + ] +} \ No newline at end of file diff --git a/terraform/workflow/event-handler-lambda.tf b/terraform/workflow/event-handler-lambda.tf deleted file mode 100644 index 3c09786..0000000 --- a/terraform/workflow/event-handler-lambda.tf +++ /dev/null @@ -1,76 +0,0 @@ -resource "aws_lambda_function" "event_handler_lambda" { - function_name = "clash-bot-event-handler" - handler = "prod/handler.handler" - runtime = "nodejs16.x" - role = aws_iam_role.lambda_handler_exec.arn - - s3_bucket = var.s3_bucket_name - s3_key = var.event_handler_artifact_path - - environment { - variables = { - CREATE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn - UPDATE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn - DELETE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn - CREATE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn - UPDATE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn - DELETE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn - } - } -} - -resource "aws_lambda_event_source_mapping" "sqs_trigger" { - event_source_arn = module.clash_bot_event_sqs.queue_arn - function_name = aws_lambda_function.event_handler_lambda.function_name - batch_size = var.sqs_batch_size -} - -resource "aws_iam_role" "lambda_handler_exec" { - name = "clash_bot_lambda_event_handler_exec_role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" - } - } - ] - }) -} - -resource "aws_iam_role_policy_attachment" "lambda_handler_exec_policy" { - role = aws_iam_role.lambda_handler_exec.name - policy_arn = aws_iam_policy.event_handler_policy.arn -} - -data "aws_iam_policy_document" "event_handler_policy_document" { - statement { - effect = "Allow" - actions = [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ] - resources = ["*"] - } - - statement { - effect = "Allow" - actions = [ - "sqs:DeleteMessage", - "sqs:GetQueueAttributes", - "sqs:ReceiveMessage", - ] - resources = ["*"] - } -} - -resource "aws_iam_policy" "event_handler_policy" { - name = "ClashBotWorkflowEventHandlerPolicy" - description = "Allows the event handler lambda to interact with SQS and CloudWatch Logs" - policy = data.aws_iam_policy_document.event_handler_policy_document.json -} \ No newline at end of file diff --git a/terraform/workflow/event-publisher-lambda.tf b/terraform/workflow/event-publisher-lambda.tf deleted file mode 100644 index 8958943..0000000 --- a/terraform/workflow/event-publisher-lambda.tf +++ /dev/null @@ -1,76 +0,0 @@ -resource "aws_lambda_function" "event_publisher_lambda" { - function_name = "clash-bot-event-publisher" - handler = "prod/handler.handler" - runtime = "nodejs16.x" - role = aws_iam_role.lambda_publisher_exec.arn - - s3_bucket = var.s3_bucket_name - s3_key = var.event_publisher_artifact_path - - environment { - variables = { - QUEUE_URL = module.clash_bot_event_sqs.queue_arn - } - } -} - -resource "aws_iam_role" "lambda_publisher_exec" { - name = "clash_bot_lambda_event_publisher_exec_role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" - } - } - ] - }) -} - -resource "aws_lambda_permission" "apigw" { - statement_id = "AllowExecutionFromAPIGateway" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.event_publisher_lambda.function_name - principal = "apigateway.amazonaws.com" - - # The /*/* portion grants access from any method on any resource - # within the API Gateway "REST API". - source_arn = "${module.api_gateway.apigatewayv2_api_execution_arn}/*/$default" -} - -resource "aws_iam_role_policy_attachment" "lambda_publisher_exec_policy" { - role = aws_iam_role.lambda_publisher_exec.name - policy_arn = aws_iam_policy.event_publisher_policy.arn -} - -data "aws_iam_policy_document" "event_publisher_policy_document" { - statement { - effect = "Allow" - actions = [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ] - resources = ["*"] - } - - statement { - effect = "Allow" - actions = [ - "sqs:DeleteMessage", - "sqs:GetQueueAttributes", - "sqs:ReceiveMessage", - ] - resources = ["*"] - } -} - -resource "aws_iam_policy" "event_publisher_policy" { - name = "ClashBotEventPublisherPolicy" - description = "Allows the event publisher lambda to publish events to the event queue and log events to CloudWatch" - policy = data.aws_iam_policy_document.event_publisher_policy_document.json -} \ No newline at end of file diff --git a/terraform/workflow/event-setup-lambdas.tf b/terraform/workflow/event-setup-lambdas.tf new file mode 100644 index 0000000..6c7e549 --- /dev/null +++ b/terraform/workflow/event-setup-lambdas.tf @@ -0,0 +1,82 @@ +module "event_handler_lambda" { + source = "./modules/lambda" + + prefix = "event-handler" + s3_bucket_name = var.s3_bucket_name + environment = var.environment + + lambda_layer_arn = data.aws_lambda_layer_version.lambda_layer.arn + + artifact_path = var.event_handler_artifact_path + + environment_variables = { + CREATE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn + UPDATE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn + DELETE_TEAM_SF_ARN = module.create_team_step_function.state_machine_arn + CREATE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn + UPDATE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn + DELETE_TENTATIVE_QUEUE_SF_ARN = module.create_team_step_function.state_machine_arn + } + + iam_policy_json = templatefile( + "${path.module}/policies/event-handler-lambda-policy.json", + { + CREATE_TEAM_STATE_MACHINE_ARN = module.create_team_step_function.state_machine_arn + SQS_ARN = module.clash_bot_event_sqs.queue_arn + } + ) +} + +resource "aws_lambda_event_source_mapping" "event_handler_sqs_trigger" { + event_source_arn = module.clash_bot_event_sqs.queue_arn + function_name = module.event_handler_lambda.name + batch_size = 1 +} + +module "event_publisher_lambda" { + source = "./modules/lambda" + + prefix = "event-publisher" + s3_bucket_name = var.s3_bucket_name + environment = var.environment + + lambda_layer_arn = data.aws_lambda_layer_version.lambda_layer.arn + + artifact_path = var.event_publisher_artifact_path + + environment_variables = { + QUEUE_URL = module.clash_bot_event_sqs.queue_url + } + + iam_policy_json = templatefile( + "${path.module}/policies/event-publisher-lambda-policy.json", + { + SQS_ARN = module.clash_bot_event_sqs.queue_arn + } + ) +} + +module "event_notifier_lambda" { + source = "./modules/lambda" + + prefix = "event-notifier" + s3_bucket_name = var.s3_bucket_name + environment = var.environment + + lambda_layer_arn = data.aws_lambda_layer_version.lambda_layer.arn + + artifact_path = var.event_notifier_artifact_path + + environment_variables = { + TOPIC_TO_SUBSCRIBER_TABLE_NAME = module.events_table.dynamodb_table_id, + SUBSCRIBER_TO_TOPIC_TABLE_NAME = module.subscriber_table.dynamodb_table_id + } + + iam_policy_json = templatefile( + "${path.module}/policies/event-notifier-lambda-policy.json", + { + DYNAMODB_ARN = module.events_table.dynamodb_table_arn, + DYNAMODB_TWO_ARN = module.subscriber_table.dynamodb_table_arn, + } + ) +} \ No newline at end of file diff --git a/terraform/workflow/modules/lambda/lambda.tf b/terraform/workflow/modules/lambda/lambda.tf new file mode 100644 index 0000000..126d512 --- /dev/null +++ b/terraform/workflow/modules/lambda/lambda.tf @@ -0,0 +1,45 @@ +resource "aws_lambda_function" "lambda" { + function_name = "${var.prefix}-${lower(var.environment)}" + handler = "prod/handler.handler" + runtime = "nodejs16.x" + role = aws_iam_role.lambda_exec_role.arn + + s3_bucket = var.s3_bucket_name + s3_key = var.artifact_path + + layers = [ + var.lambda_layer_arn + ] + + environment { + variables = var.environment_variables + } +} + +resource "aws_iam_role" "lambda_exec_role" { + name = "${replace(var.prefix, "-", "_")}_exec_role-${lower(var.environment)}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_exec_policy" { + role = aws_iam_role.lambda_exec_role.name + policy_arn = aws_iam_policy.policy.arn +} + +resource "aws_iam_policy" "policy" { + name = "${var.prefix}-Policy-${lower(var.environment)}" + description = "Policy for the ${var.prefix} lambda function." + policy = var.iam_policy_json +} diff --git a/terraform/workflow/modules/lambda/outputs.tf b/terraform/workflow/modules/lambda/outputs.tf new file mode 100644 index 0000000..4e252c8 --- /dev/null +++ b/terraform/workflow/modules/lambda/outputs.tf @@ -0,0 +1,14 @@ +output "arn" { + value = aws_lambda_function.lambda.arn + description = "The ARN for the lambda function." +} + +output "name" { + value = aws_lambda_function.lambda.function_name + description = "The name for the lambda function." +} + +output "invoke_arn" { + value = aws_lambda_function.lambda.invoke_arn + description = "The invoke ARN for the lambda function." +} \ No newline at end of file diff --git a/terraform/workflow/modules/lambda/variables.tf b/terraform/workflow/modules/lambda/variables.tf new file mode 100644 index 0000000..d08103d --- /dev/null +++ b/terraform/workflow/modules/lambda/variables.tf @@ -0,0 +1,34 @@ +variable "prefix" { + type = string + description = "The prefix to use for all resources." +} + +variable "environment" { + type = string + description = "The environment to use." +} + +variable "s3_bucket_name" { + type = string + description = "The s3 bucket that stores the lambda function code." +} + +variable "artifact_path" { + type = string + description = "Path to the artifact for the lambda function." +} + +variable "environment_variables" { + type = map(string) + description = "Environment variables to set for the lambda function." +} + +variable "iam_policy_json" { + type = string + description = "The json for the iam policy." +} + +variable "lambda_layer_arn" { + type = string + description = "The arn of the lambda layer." +} diff --git a/terraform/workflow/openapi-spec/clashbot-service.yml b/terraform/workflow/openapi-spec/clashbot-service.yml deleted file mode 100644 index c63dad0..0000000 --- a/terraform/workflow/openapi-spec/clashbot-service.yml +++ /dev/null @@ -1,1361 +0,0 @@ -openapi: '3.0.1' -info: - title: Clash Bot Service - description: | - # Welcome to Clash Bot! - - Where all of your League of Legends Clash scheduling needs are met! - - ## Purpose - - Clash Bot Webapp Service to support League of Legends Clash tournament scheduling with Discord. - - ## Disclaimer - - Clash-Bot is not endorsed by Riot Games and does not reflect the views or opinions of Riot Games or - anyone officially involved in producing or managing League of Legends. League of Legends and Riot Games - are trademarks or registered trademarks of Riot Games, Inc. League of Legends © Riot Games, Inc. - contact: - name: ClashBot-API-Support - email: rixxroid@gmail.com - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - version: 2.0.0 -servers: - - url: http://localhost:{port}/{basePath} - description: The local API server. - variables: - port: - default: '8080' - basePath: - default: api/v2 -tags: - - name: Champions - description: Dealing with User's League of Legends champions choices. - - name: Server - description: A Discord server that is leveraging the Clash Bot. - - name: Subscription - description: Subscriptions to certain notifications that Clash Bot produces. - - name: Team - description: A Clash Bot team for a League of Legends Clash tournament. - - name: Tentative - description: A tentative queue for upcoming League of Legends Clash tournaments. - - name: Tournament - description: A League of Legends Clash Tournament. - - name: Maintenance - description: Endpoints related to maintenance of the Service. - - name: User - description: A Discord user that is leveraging the Clash Bot. -components: - schemas: - Server: - description: A Discord Server - type: object - properties: - id: - description: The unique identifier for a Discord Server. - type: string - name: - description: The Discord Server name associated to the id. - type: string - Event: - description: A websocket event to be published. - type: object - required: - - id - - teamEvent - - serverId - - causedBy - properties: - id: - description: The unique identifier for the event. - type: string - teamEvent: - $ref: '#/components/schemas/TeamEvent' - summary: - description: A message to describe the event. - type: string - serverId: - description: The Discord server id attached to the event. - type: string - causedBy: - description: Who the event was caused by. - type: string - TeamEvent: - type: object - required: - - eventType - properties: - team: - $ref: '#/components/schemas/Team' - tentative: - $ref: '#/components/schemas/Tentative' - eventType: - $ref: '#/components/schemas/EventType' - EventType: - description: The type of event. - type: string - enum: - - CREATED - - JOINED - - REMOVED - - UPDATED - - DELETED - Servers: - description: A list of the Player's selected Server - properties: - servers: - type: array - items: - $ref: '#/components/schemas/Server' - Team: - description: A League of Legends Clash Team - type: object - properties: - id: - description: Unique identifier for a Team. - type: string - name: - description: The name of the Team. - type: string - playerDetails: - description: The available positions a Player can be assigned to for a Team. - type: object - properties: - Top: - $ref: '#/components/schemas/TeamPlayer' - Mid: - $ref: '#/components/schemas/TeamPlayer' - Jg: - $ref: '#/components/schemas/TeamPlayer' - Bot: - $ref: '#/components/schemas/TeamPlayer' - Supp: - $ref: '#/components/schemas/TeamPlayer' - serverId: - description: The Discord server id that the Team belongs to. - type: string - tournament: - $ref: '#/components/schemas/BaseTournament' - lastUpdatedAt: - description: The timestamp that the object was updated at - type: string - format: date-time - TeamRequired: - allOf: - - $ref: '#/components/schemas/Team' - - type: object - required: - - serverId - - tournament - - playerDetails - TeamUpdate: - description: Allowed properties to be updated on a Clash Team - required: - - teamName - properties: - teamName: - type: string - description: The name of the team - Teams: - description: A list of League of Legend's Clash Teams - properties: - count: - type: integer - teams: - type: array - items: - $ref: '#/components/schemas/Team' - BaseTournament: - description: The base necessary Tournament details - type: object - properties: - tournamentName: - description: The name of the Tournament. - type: string - tournamentDay: - description: The day number of the Tournament. [1-4] - example: 1 - type: string - DetailedTournament: - description: A League of Legends Clash Tournament - type: object - properties: - tournamentName: - description: The name of the Tournament. - type: string - tournamentDay: - description: The day number of the Tournament. [1-4] - example: 1 - type: string - startTime: - description: When the Tournament starts. - type: string - format: date-time - registrationTime: - description: When you can register for the Tournament. - type: string - format: date-time - Tournaments: - description: A list of Tournaments - type: object - properties: - count: - type: integer - tournaments: - type: array - items: - $ref: '#/components/schemas/DetailedTournament' - Tentative: - description: A queue for Players unsure if they will play Clash for a given Tournament. - type: object - properties: - id: - description: Unique identifier for the Tentative Queue. - type: string - serverId: - description: The default Discord Server id for the player to use. - type: string - tournamentDetails: - $ref: '#/components/schemas/BaseTournament' - tentativePlayers: - items: - $ref: '#/components/schemas/TentativePlayer' - lastUpdatedAt: - description: The timestamp that the object was updated at - type: string - format: date-time - TentativeRequired: - allOf: - - $ref: '#/components/schemas/Tentative' - - type: object - required: - - serverId - - tournamentDetails - - tentativePlayers - Tentatives: - description: A list of queues for Players unsure if they will play Clash for a given Tournament. - type: object - properties: - count: - type: integer - queues: - type: array - items: - $ref: '#/components/schemas/Tentative' - Player: - description: A Clash Bot Player - type: object - properties: - discordId: - description: Discord Id for the Player - type: string - name: - description: The Players discord name - type: string - role: - $ref: '#/components/schemas/Role' - champions: - description: A list of the Users preferred champions. - type: array - items: - $ref: '#/components/schemas/Champion' - subscriptions: - type: array - items: - $ref: '#/components/schemas/Subscription' - serverId: - description: The Discord Server id that the User is defaulted to. - type: string - selectedServers: - description: The list of available Discord Servers for the player to filter by. - type: array - items: - type: string - TeamPlayer: - description: A Player record with a subset of Player information for usage with Teams. - type: object - properties: - discordId: - description: Discord Id for the Player - type: string - name: - description: The Players discord name - type: string - champions: - description: A list of the Users preferred champions. - type: array - items: - $ref: '#/components/schemas/Champion' - TentativePlayer: - description: A Player record with a subset of Player information for usage with Tentative queues. - type: object - properties: - discordId: - description: Discord Id for the Player - type: string - name: - description: The Players discord name - type: string - champions: - description: A list of the Users preferred champions. - type: array - items: - $ref: '#/components/schemas/Champion' - role: - $ref: '#/components/schemas/Role' - Subscription: - description: A map of subscriptions a player has for Clash Bot - type: object - properties: - key: - $ref: '#/components/schemas/SubscriptionType' - isOn: - type: boolean - Champion: - description: A record listing details on a League of Legends champion - type: object - properties: - name: - type: string - Champions: - description: A list of League of Legends champions and their details - properties: - champions: - type: array - items: - $ref: '#/components/schemas/Champion' - Role: - description: A League of Legends role. - type: string - enum: - - TOP - - MID - - JG - - BOT - - SUPP - SubscriptionType: - description: The type of User subscription. - type: string - enum: - - DISCORD_MONDAY_NOTIFICATION - Error: - description: The base error object. - type: object - properties: - code: - type: integer - format: int32 - message: - type: string - PositionDetails: - description: Details necessary to update a User's position on a Team with. - type: object - properties: - champions: - $ref: "#/components/schemas/Champions" - role: - $ref: "#/components/schemas/Role" - TeamTournamentDetails: - description: Details necessary to update a User's position on a Team based on fluid details. - type: object - properties: - discordId: - description: The user's Discord Id. - type: string - tournamentName: - description: The Clash Tournament's name. - tournamentDay: - description: The Clash Tournament's day. - role: - $ref: "#/components/schemas/Role" - ArchiveMetadata: - description: Metadata regarding the archive process. - type: object - properties: - teamsMoved: - description: The number of Teams moved into the archive table. - type: integer - tentativeQueuesMoved: - description: The number of Tentative Queues moved into the archive table. - type: integer - totalTime: - description: The total time the process took in milliseconds - type: string - parameters: - AuditHeaderParam: - name: x-caused-by - in: header - schema: - type: string - default: Not Found - required: true - InactiveQueryParam: - name: archived - in: query - description: Will retrieve records that are from past Tournaments - schema: - type: boolean - default: false - DiscordIdPathParam: - name: discordId - in: path - description: The Discord id of the Player - required: true - schema: - type: string - DiscordIdQueryParam: - name: discordId - in: query - description: The Discord id of the Player - schema: - type: string - ServerIdPathParam: - name: serverId - in: path - description: The Discord id of the Discord Server - required: true - schema: - type: string - ServerIdQueryParam: - name: serverId - in: query - description: The Discord id of the Discord Server - schema: - type: string - SubscriptionPathParam: - name: subscription - description: The subscription type. - in: path - required: true - schema: - $ref: '#/components/schemas/SubscriptionType' - TeamIdPathParam: - name: teamId - in: path - description: The unique identifier of the Clash Bot Team - required: true - schema: - type: string - TentativeQueueIdPathParam: - name: tentativeId - in: path - description: The unique identifier of the Clash Bot Tentative queue - required: true - schema: - type: string - TournamentNamePathParam: - name: tournamentName - in: path - description: The LoL Clash Tournament name - required: true - schema: - type: string - TournamentNameQueryParam: - name: tournamentName - in: query - description: The LoL Clash Tournament name - schema: - type: string - TournamentDayPathParam: - name: tournamentDay - in: path - description: The LoL Clash Tournament day - required: true - schema: - type: string - TournamentDayQueryParam: - name: tournamentDay - in: query - description: The LoL Clash Tournament day - schema: - type: string - requestBodies: - PostTeamRequestBody: - description: Details to create a Clash Bot Team with. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TeamRequired' - examples: - baseCreateTeam: - $ref: '#/components/examples/TeamCreateExample' - PatchTeamRequestBody: - description: Details to update a Clash Bot Team's metadata with. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TeamUpdate' - examples: - baseCreateTeam: - $ref: '#/components/examples/TeamMetadataExample' - PatchAssignUserToTeamRequestBody: - description: Details to assign a user to a Team with. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PositionDetails' - examples: - assignTop: - $ref: '#/components/examples/AssignUserToTeamAsTopExample' - assignJg: - $ref: '#/components/examples/AssignUserToTeamAsJgExample' - assignMid: - $ref: '#/components/examples/AssignUserToTeamAsMidExample' - assignBot: - $ref: '#/components/examples/AssignUserToTeamAsBotExample' - assignSupp: - $ref: '#/components/examples/AssignUserToTeamAsSuppExample' - PostTentativeQueueRequestBody: - description: Details to create a Clash Bot Tentative Queue with. - content: - application/json: - schema: - $ref: '#/components/schemas/TentativeRequired' - examples: - baseCreateTeam: - $ref: '#/components/examples/TentativeQueueCreateExample' - CreatePlayerBody: - description: All necessary parameters to create a new Player - content: - application/json: - schema: - type: object - required: - - discordId - - name - - serverId - properties: - discordId: - description: The Discord id of the player - type: string - name: - description: The Clash Bot User's name - type: string - serverId: - description: The Discord Server that the player is using by default. - type: string - selectedGuilds: - description: The list of available Discord Servers for the player to filter by. - type: array - items: - type: string - UpdatePlayerBody: - description: All necessary parameters to update an existing Player - content: - application/json: - schema: - type: object - required: - - serverId - properties: - serverId: - description: The Discord Server that the player is using by default. - type: string - ChampionRequestBody: - description: A list of champions. - content: - application/json: - schema: - $ref: '#/components/schemas/Champions' - ServerRequestBody: - description: A list of Discord Servers. - content: - application/json: - schema: - $ref: '#/components/schemas/Servers' - - responses: - ArchiveResponse: - description: The metadata regarding the archive process. - content: - application/json: - schema: - $ref: "#/components/schemas/ArchiveMetadata" - TentativeResponse: - description: A Tentative queue. - content: - application/json: - schema: - $ref: "#/components/schemas/Tentative" - TentativeListResponse: - description: The retrieved list of Tentative queues for a Discord Server. - content: - application/json: - schema: - $ref: "#/components/schemas/Tentatives" - TeamResponse: - description: A Clash Bot Team. - content: - application/json: - schema: - $ref: "#/components/schemas/Team" - examples: - success: - $ref: '#/components/examples/TeamExample' - TeamListResponse: - description: The retrieved list of Teams for a Discord Server. - content: - application/json: - schema: - $ref: "#/components/schemas/Teams" - TeamInteractionResponse: - description: The updated team details. - content: - application/json: - schema: - $ref: "#/components/schemas/Team" - ChampionListResponse: - description: List of champions for a Player - content: - application/json: - schema: - $ref: "#/components/schemas/Champions" - SelectedServersResponse: - description: List of selected Servers for a Player - content: - application/json: - schema: - $ref: "#/components/schemas/Servers" - NotFound: - description: Unable to find requested resource - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - NoneFound: - description: No results found matching the criteria. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - BadInput: - description: Input given is invalid. - ClashBotException: - description: Default error. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - examples: - TeamCreateExample: - description: | - The minimum required payload to create a Team with. If you do not provide a name, it will be created for you. - summary: A base Team post body. - value: - { - "playerDetails": { - "Top": { - "discordId": "1", - } - }, - "serverId": 123, - "tournament": { - "tournamentName": "awesome_sauce", - "tournamentDay": "1" - } - } - AssignUserToTeamAsTopExample: - description: | - The details to assign a Player to a Clash Bot Team as Top. - summary: Assign Player to a Team as Top. - value: - { - "role": "TOP" - } - AssignUserToTeamAsJgExample: - description: | - The details to assign a Player to a Clash Bot Team as Jungle. - summary: Assign Player to a Team as Jungle. - value: - { - "role": "JG" - } - AssignUserToTeamAsMidExample: - description: | - The details to assign a Player to a Clash Bot Team as Mid. - summary: Assign Player to a Team as Mid. - value: - { - "role": "MID" - } - AssignUserToTeamAsBotExample: - description: | - The details to assign a Player to a Clash Bot Team as Bot. - summary: Assign Player to a Team as Bot. - value: - { - "role": "BOT" - } - AssignUserToTeamAsSuppExample: - description: | - The details to assign a Player to a Clash Bot Team as Support. - summary: Assign Player to a Team as Support. - value: - { - "role": "SUPP" - } - TeamMetadataExample: - description: | - An example of a payload to update the Clash Bot Team's metadata. - summary: A base Team patch body. - value: - { - "teamName": "Some new name", - } - TentativeQueueCreateExample: - description: | - The minimum required payload to create a Tentative Queue with. - summary: A base Tentative queue post body. - value: - { - "serverId": 1234, - "tournamentDetails": { - "tournamentName": "awesome_sauce", - "tournamentDay": "1" - }, - "tentativePlayers": [ - { - "discordId": "1", - "role": "Top" - } - ] - } - TeamExample: - description: | - A Team object. - summary: A Team - value: - { - "id": "123abcde", - "name": "A simple team", - "playerDetails": { - "Top": { - "discordId": "1234", - "name": "Player 1", - "champions": [ - { - "name": "Aatrox" - } - ] - }, - "Mid": { - "discordId": "1235", - "name": "Player 2", - "champions": [ - { - "name": "Anivia" - } - ] - } - }, - "serverId": 12345, - "tournament": { - "tournamentName": "awesome_sauce", - "tournamentDay": "1" - } - } - TentativeExample: - description: | - A Tentative queue object. - summary: A Tentative queue - value: - { - "id": "1234asdf", - "serverId": 1234, - "tournamentDetails": { - "tournamentName": "awesome_sauce", - "tournamentDay": "1" - }, - "tentativePlayers": [ - { - "discordId": "1", - "name": "Player One", - "champions": [ - { - "name": "Azir" - } - ], - "role": "Mid" - } - ] - } -paths: - /teams: - summary: Used to interact with Teams that are active - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - get: - description: Returns a list of Teams. - tags: - - Team - parameters: - - $ref: '#/components/parameters/InactiveQueryParam' - - $ref: '#/components/parameters/DiscordIdQueryParam' - - $ref: '#/components/parameters/ServerIdQueryParam' - - $ref: '#/components/parameters/TournamentNameQueryParam' - - $ref: '#/components/parameters/TournamentDayQueryParam' - operationId: retrieveTeams - responses: - 200: - $ref: '#/components/responses/TeamListResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - post: - description: Creates a Team with the defined details - tags: - - Team - operationId: createTeam - requestBody: - $ref: '#/components/requestBodies/PostTeamRequestBody' - responses: - 200: - $ref: '#/components/responses/TeamResponse' - 400: - $ref: '#/components/responses/BadInput' - 500: - $ref: '#/components/responses/ClashBotException' - /teams/{teamId}: - summary: To interact with a created Team. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/TeamIdPathParam' - get: - description: Returns a single Clash Bot Team based on the id provided. - operationId: retrieveTeamBasedOnId - tags: - - Team - responses: - 200: - $ref: '#/components/responses/TeamResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - patch: - description: Updates an existing Clash Bot Team's metadata. - operationId: updateTeam - tags: - - Team - requestBody: - $ref: '#/components/requestBodies/PatchTeamRequestBody' - responses: - 200: - $ref: '#/components/responses/TeamResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - /teams/{teamId}/users/{discordId}: - summary: To interact with a Team on the behalf of a Player. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/TeamIdPathParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - patch: - description: Assign's a User to the specified Team based on the role provided. - operationId: assignUserToTeam - tags: - - Team - requestBody: - $ref: '#/components/requestBodies/PatchAssignUserToTeamRequestBody' - responses: - 200: - $ref: '#/components/responses/TeamInteractionResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - delete: - description: Removes a User from the specified Team. - operationId: removeUserFromTeam - tags: - - Team - responses: - 200: - $ref: '#/components/responses/TeamInteractionResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - /tentatives: - summary: Interacts with Tentative queues. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - get: - description: Retrieves a list of Tentative queues. - operationId: retrieveTentativeQueues - parameters: - - $ref: '#/components/parameters/InactiveQueryParam' - - $ref: '#/components/parameters/DiscordIdQueryParam' - - $ref: '#/components/parameters/ServerIdQueryParam' - - $ref: '#/components/parameters/TournamentNameQueryParam' - - $ref: '#/components/parameters/TournamentDayQueryParam' - tags: - - Tentative - responses: - 200: - $ref: '#/components/responses/TentativeListResponse' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/schemas/Error' - post: - description: Creates a Tentative queue. - operationId: createTentativeQueue - requestBody: - $ref: '#/components/requestBodies/PostTentativeQueueRequestBody' - tags: - - Tentative - responses: - 200: - $ref: '#/components/responses/TentativeResponse' - 204: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/schemas/Error' - /tentatives/{tentativeId}: - summary: Interacts with a Tentative queue. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/TentativeQueueIdPathParam' - get: - description: Retrieves a Tentative queues. - operationId: retrieveTentativeQueue - tags: - - Tentative - responses: - 200: - $ref: '#/components/responses/TentativeResponse' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/schemas/Error' - /tentatives/{tentativeId}/users/{discordId}: - summary: Interacts with a specific Tentative queue. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/TentativeQueueIdPathParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - patch: - description: Updates an existing Tentative queue. - operationId: assignUserToATentativeQueue - tags: - - Tentative - responses: - 200: - $ref: '#/components/responses/TentativeResponse' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/schemas/Error' - delete: - description: Removes a User from the specified Tentative Queue. - operationId: removeUserFromTentativeQueue - tags: - - Tentative - responses: - 200: - $ref: '#/components/responses/TentativeResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NoneFound' - 500: - $ref: '#/components/responses/ClashBotException' - /tournaments: - summary: APIs to interact with Clash Tournaments. - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - get: - operationId: getTournaments - parameters: - - name: tournament - description: The Tournament name to filter by. - in: query - style: form - required: false - schema: - type: string - - name: day - description: The tournament day to filter by. - in: query - style: form - required: false - schema: - type: string - - name: upcomingOnly - description: Whether to return only upcoming tournaments or not? - in: query - style: form - required: false - schema: - type: boolean - tags: - - Tournament - responses: - 200: - description: return a tournament or Tournaments - content: - application/json: - schema: - $ref: '#/components/schemas/Tournaments' - 400: - description: If no Tournaments can be found with a name. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - post: - description: To create a Tournament - operationId: createTournament - tags: - - Tournament - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DetailedTournament' - responses: - 200: - description: Create Tournament record - content: - application/json: - schema: - $ref: '#/components/schemas/DetailedTournament' - /tournaments/riot: - summary: Used to interact with Riot's Clash Tournaments api for League of Legends - patch: - description: Will retrieve Clash Tournaments from Riot's League of Legends API - operationId: retrieveRiotClashTournaments - tags: - - Tournament - responses: - 200: - description: Successfully retrieved and persisted Clash Tournaments - content: - application/json: - schema: - $ref: '#/components/schemas/Tournaments' - /users: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - get: - description: Retrieve a Clash Bot Player Details - operationId: getUser - parameters: - - $ref: '#/components/parameters/DiscordIdQueryParam' - tags: - - User - responses: - 200: - description: The Clash Bot Player details. - content: - application/json: - schema: - $ref: '#/components/schemas/Player' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - post: - description: Create a new Clash Bot Player. - operationId: createUser - tags: - - User - requestBody: - $ref: '#/components/requestBodies/CreatePlayerBody' - responses: - 200: - description: Created a new Clash Bot Player - content: - application/json: - schema: - $ref: '#/components/schemas/Player' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - /users/{discordId}: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - patch: - description: Update an existing Clash Bot Player. - operationId: updateUser - tags: - - User - requestBody: - $ref: '#/components/requestBodies/UpdatePlayerBody' - responses: - 200: - description: Updated an existing Player - content: - application/json: - schema: - $ref: '#/components/schemas/Player' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - /users/{discordId}/subscriptions/{subscription}: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - - $ref: '#/components/parameters/SubscriptionPathParam' - get: - description: Retrieve details on a user's subscription. - operationId: isUserSubscribed - tags: - - Subscription - responses: - 200: - description: The User's subscription details. - content: - application/json: - schema: - $ref: '#/components/schemas/Subscription' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - post: - description: Subscribes the User to the specified subscription. - operationId: subscribeUser - tags: - - Subscription - responses: - 200: - description: The User's subscription details after a successful subscription. - content: - application/json: - schema: - $ref: '#/components/schemas/Subscription' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - delete: - description: Unsubscribes the User from the specified subscription. - operationId: unsubscribeUser - tags: - - Subscription - responses: - 200: - description: The User's subscription details after they have successfully unsubscribed. - content: - application/json: - schema: - $ref: '#/components/schemas/Subscription' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 500: - $ref: '#/components/responses/ClashBotException' - default: - $ref: "#/components/responses/ClashBotException" - /users/{discordId}/champions: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - get: - description: Returns a list of preferred champions that the User has. - operationId: retrieveUsersPreferredChampions - tags: - - Champions - responses: - 200: - $ref: '#/components/responses/ChampionListResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - post: - description: Updates the users preferred champions with an entirely new list. Cannot be greater than a length of 5. - operationId: createListOfPreferredChampionsForUser - tags: - - Champions - requestBody: - $ref: '#/components/requestBodies/ChampionRequestBody' - responses: - 200: - $ref: '#/components/responses/ChampionListResponse' - 204: - $ref: '#/components/responses/NoneFound' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - patch: - description: Adds the requested champion to the users preferred champions. Cannot be greater than a length of 5. - operationId: addToPreferredChampionsForUser - tags: - - Champions - requestBody: - $ref: '#/components/requestBodies/ChampionRequestBody' - responses: - 200: - $ref: '#/components/responses/ChampionListResponse' - 204: - $ref: '#/components/responses/NoneFound' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - delete: - description: Removes the requested champion to the users preferred champions. - operationId: removePreferredChampionForUser - tags: - - Champions - parameters: - - name: champions - description: The list of champion's names to remove from the user's list of champions - in: query - required: true - schema: - type: array - items: - type: string - responses: - 200: - $ref: '#/components/responses/ChampionListResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - /users/{discordId}/servers: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - - $ref: '#/components/parameters/DiscordIdPathParam' - get: - description: Returns a list of selected servers that the User has. - operationId: retrieveUsersSelectedServers - tags: - - User - responses: - 200: - $ref: '#/components/responses/SelectedServersResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - post: - description: Updates the users selected servers with an entirely new list. Cannot be greater than a length of 5. - operationId: createUsersSelectedServers - tags: - - User - requestBody: - $ref: '#/components/requestBodies/ServerRequestBody' - responses: - 200: - $ref: '#/components/responses/SelectedServersResponse' - 204: - $ref: '#/components/responses/NoneFound' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - patch: - description: Adds the selected server to the users selected servers. Cannot be greater than a length of 5. - operationId: addUsersSelectedServers - tags: - - User - requestBody: - $ref: '#/components/requestBodies/ServerRequestBody' - responses: - 200: - $ref: '#/components/responses/SelectedServersResponse' - 204: - $ref: '#/components/responses/NoneFound' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - delete: - description: Removes the selected server to the users selected servers. - operationId: removeUsersSelectedServers - tags: - - User - parameters: - - name: champions - description: The list of selected servers to remove from the user's list of champions - in: query - required: true - schema: - type: array - items: - type: string - responses: - 200: - $ref: '#/components/responses/SelectedServersResponse' - 400: - $ref: '#/components/responses/BadInput' - 404: - $ref: '#/components/responses/NotFound' - 5XX: - $ref: '#/components/schemas/Error' - /archive: - parameters: - - $ref: '#/components/parameters/AuditHeaderParam' - post: - operationId: archiveTeamsAndTentativeQueues - description: | - Will move all Teams and Tentative Queues that are now inactive into an archive table. - This will help keep current operations clean and quick. - tags: - - Maintenance - responses: - 200: - $ref: '#/components/responses/ArchiveResponse' - 5XX: - $ref: '#/components/schemas/Error' - diff --git a/terraform/workflow/output.tf b/terraform/workflow/output.tf index e69de29..8999a40 100644 --- a/terraform/workflow/output.tf +++ b/terraform/workflow/output.tf @@ -0,0 +1,39 @@ +output "api-gateway-endpoint" { + value = module.api_gateway.apigatewayv2_api_api_endpoint + description = "The endpoint for the API Gateway." +} + +output "event-publisher-lambda-arn" { + value = module.event_publisher_lambda.arn + description = "The ARN for the event publisher lambda function." +} + +output "event-handler-lambda-arn" { + value = module.event_handler_lambda.arn + description = "The ARN for the event handler lambda function." +} + +output "create-team-lambda-arn" { + value = module.create_team_lambda.arn + description = "The ARN for the create team lambda function." +} + +output "retrieve-team-lambda-arn" { + value = module.retrieve_team_lambda.arn + description = "The ARN for the retrieve team lambda function." +} + +output "tournament-eligibility-lambda-arn" { + value = module.tournament_eligibility_lambda.arn + description = "The ARN for the tournament eligibility lambda function." +} + +output "event-sqs" { + value = module.clash_bot_event_sqs.queue_arn + description = "The ARN for the event SQS queue." +} + +output "step-function-arn" { + value = module.create_team_step_function.state_machine_arn + description = "The ARN for the step function." +} \ No newline at end of file diff --git a/terraform/workflow/policies/create-team-lambda-policy.json b/terraform/workflow/policies/create-team-lambda-policy.json new file mode 100644 index 0000000..6d4cc8e --- /dev/null +++ b/terraform/workflow/policies/create-team-lambda-policy.json @@ -0,0 +1,27 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:BatchGetItem" + ], + "Effect": "Allow", + "Resource": "${DYNAMODB_ARN}" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/event-handler-lambda-policy.json b/terraform/workflow/policies/event-handler-lambda-policy.json new file mode 100644 index 0000000..a9e46b5 --- /dev/null +++ b/terraform/workflow/policies/event-handler-lambda-policy.json @@ -0,0 +1,28 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage" + ], + "Effect": "Allow", + "Resource": "${SQS_ARN}" + }, + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": "${CREATE_TEAM_STATE_MACHINE_ARN}" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/event-notifier-lambda-policy.json b/terraform/workflow/policies/event-notifier-lambda-policy.json new file mode 100644 index 0000000..d7b5e9c --- /dev/null +++ b/terraform/workflow/policies/event-notifier-lambda-policy.json @@ -0,0 +1,30 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:BatchGetItem" + ], + "Effect": "Allow", + "Resource": [ + "${DYNAMODB_ARN}", + "${DYNAMODB_TWO_ARN}" + ] + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/event-publisher-lambda-policy.json b/terraform/workflow/policies/event-publisher-lambda-policy.json new file mode 100644 index 0000000..9ce6671 --- /dev/null +++ b/terraform/workflow/policies/event-publisher-lambda-policy.json @@ -0,0 +1,19 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "sqs:SendMessage", + "Effect": "Allow", + "Resource": "${SQS_ARN}" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/retrieve-teams-lambda-policy.json b/terraform/workflow/policies/retrieve-teams-lambda-policy.json new file mode 100644 index 0000000..6d4cc8e --- /dev/null +++ b/terraform/workflow/policies/retrieve-teams-lambda-policy.json @@ -0,0 +1,27 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:BatchGetItem" + ], + "Effect": "Allow", + "Resource": "${DYNAMODB_ARN}" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/tournament-eligibility-lambda-policy.json b/terraform/workflow/policies/tournament-eligibility-lambda-policy.json new file mode 100644 index 0000000..6d4cc8e --- /dev/null +++ b/terraform/workflow/policies/tournament-eligibility-lambda-policy.json @@ -0,0 +1,27 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:BatchGetItem" + ], + "Effect": "Allow", + "Resource": "${DYNAMODB_ARN}" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/policies/websocket-publisher-lambda-policy.json b/terraform/workflow/policies/websocket-publisher-lambda-policy.json new file mode 100644 index 0000000..4ac7ef3 --- /dev/null +++ b/terraform/workflow/policies/websocket-publisher-lambda-policy.json @@ -0,0 +1,35 @@ +{ + "Statement": [ + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:BatchGetItem" + ], + "Effect": "Allow", + "Resource": "${DYNAMODB_ARN}" + }, + { + "Action": [ + "execute-api:Invoke", + "execute-api:ManageConnections" + ], + "Effect": "Allow", + "Resource": "${WS_GATEWAY_ARN}/POST/@connections/*" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/workflow/providers.tf b/terraform/workflow/providers.tf new file mode 100644 index 0000000..471fa3d --- /dev/null +++ b/terraform/workflow/providers.tf @@ -0,0 +1,20 @@ +provider "aws" { + region = var.region + + default_tags { + tags = { + Application = "ClashBot-Workflow" + Environment = var.environment + } + } +} + +terraform { + required_version = ">= 0.12.0" + backend "remote" {} + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} \ No newline at end of file diff --git a/terraform/workflow/step-functions.lambda.tf b/terraform/workflow/step-functions.lambda.tf index db54a87..7174f34 100644 --- a/terraform/workflow/step-functions.lambda.tf +++ b/terraform/workflow/step-functions.lambda.tf @@ -1,31 +1,161 @@ +data "aws_lambda_layer_version" "lambda_layer" { + layer_name = "clash-bot-workflow-layer" +} + module "create_team_step_function" { source = "terraform-aws-modules/step-functions/aws" - name = "retrieve-teams-${var.environment}" - definition = <