├── test ├── testMessage.json └── achievement-microservices.test.ts ├── .npmignore ├── docs └── AchievementMicroservices.png ├── jest.config.js ├── models ├── progressMessage.ts ├── adminData.ts ├── playerData.ts └── tableDecorator.ts ├── bin └── achievement-microservices.ts ├── CODE_OF_CONDUCT.md ├── lambda ├── outHandler.ts ├── adminHandler.ts └── mainHandler.ts ├── tsconfig.json ├── package.json ├── LICENSE ├── cdk.json ├── .gitignore ├── CONTRIBUTING.md ├── lib └── achievementMicroservices.ts └── README.md /test/testMessage.json: -------------------------------------------------------------------------------- 1 | { "playerId": "player1", "progressId": "p1", "progressIncrement": 1 } 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /docs/AchievementMicroservices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/achievement-microservices/main/docs/AchievementMicroservices.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /models/progressMessage.ts: -------------------------------------------------------------------------------- 1 | import {pk, Table} from "./tableDecorator"; 2 | 3 | @Table("ProgressMessage") 4 | export class ProgressMessage { 5 | @pk 6 | messageId: string; 7 | ttl: number; 8 | } 9 | 10 | -------------------------------------------------------------------------------- /bin/achievement-microservices.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "source-map-support/register"; 3 | import * as cdk from "aws-cdk-lib"; 4 | import { AchievementMicroservices } from "../lib/achievementMicroservices"; 5 | 6 | const app = new cdk.App(); 7 | new AchievementMicroservices(app, "AchievementMicroServices", {}); 8 | -------------------------------------------------------------------------------- /models/adminData.ts: -------------------------------------------------------------------------------- 1 | import { GSI, gsiPk, gsiSk, Table, pk } from "./tableDecorator"; 2 | 3 | @Table("AchievementData") 4 | @GSI("progressIndex") 5 | export class AchievementData { 6 | @pk 7 | achievementId: string; 8 | @gsiPk 9 | requiredProgress: string; 10 | @gsiSk 11 | requiredAmount: number; 12 | } 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /lambda/outHandler.ts: -------------------------------------------------------------------------------- 1 | import { SQSEvent, SQSHandler } from "aws-lambda"; 2 | 3 | interface OutMessage { 4 | playerId: string; 5 | achievementId: string; 6 | } 7 | 8 | export const handler: SQSHandler = async ({ 9 | Records, 10 | }: SQSEvent): Promise => { 11 | for (const { body } of Records) { 12 | const { playerId, achievementId }: OutMessage = JSON.parse(body); 13 | console.log( 14 | `Player ${playerId} achieved an achievement ${achievementId}` 15 | ); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /models/playerData.ts: -------------------------------------------------------------------------------- 1 | import { Table, pk, sk } from "./tableDecorator"; 2 | 3 | @Table("PlayerData") 4 | export class PlayerData { 5 | @pk 6 | playerId: string; 7 | @sk 8 | id: string; 9 | } 10 | 11 | export class PlayerAchievement extends PlayerData { 12 | playerId: string; 13 | id: string; 14 | achievedAt: number; 15 | } 16 | 17 | export class PlayerProgress extends PlayerData { 18 | playerId: string; 19 | id: string; 20 | progress: number; 21 | lastUpdated: number; 22 | } 23 | 24 | export enum Prefix { 25 | achievement = "achievement_", 26 | progress = "progress_", 27 | } 28 | -------------------------------------------------------------------------------- /test/achievement-microservices.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import { AchievementMicroservices } from "../lib/achievementMicroservices"; 3 | import { Template } from "aws-cdk-lib/assertions"; 4 | 5 | test("resource count test", () => { 6 | const app = new cdk.App(); 7 | const stack = new AchievementMicroservices(app, "TestStack"); 8 | const template = Template.fromStack(stack); 9 | 10 | template.resourceCountIs("AWS::Lambda::Function", 3); 11 | template.resourceCountIs("AWS::DynamoDB::Table", 3); 12 | template.resourceCountIs("AWS::SQS::Queue", 2); 13 | template.resourceCountIs("AWS::ApiGateway::Account", 1); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "achievement-microservcies", 3 | "version": "0.1.0", 4 | "bin": { 5 | "achievement-microservices": "bin/achievement-microservices.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/aws-lambda": "^8.10.93", 15 | "@types/jest": "^26.0.10", 16 | "@types/node": "10.17.27", 17 | "aws-cdk": "2.17.0", 18 | "jest": "^26.4.2", 19 | "prettier": "^2.6.2", 20 | "ts-jest": "^26.2.0", 21 | "ts-node": "^9.0.0", 22 | "typescript": "~3.9.7" 23 | }, 24 | "dependencies": { 25 | "aigle": "^1.14.1", 26 | "aws-cdk-lib": "2.186.0", 27 | "aws-sdk": "^2.1106.0", 28 | "constructs": "^10.0.0", 29 | "source-map-support": "^0.5.16" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/achievement-microservices.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 25 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 26 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 27 | "@aws-cdk/core:target-partitions": [ 28 | "aws", 29 | "aws-cn" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /models/tableDecorator.ts: -------------------------------------------------------------------------------- 1 | type Constructor = new (...args: any[]) => T; 2 | type Field = string; 3 | export enum Keys { 4 | PK = "pk", 5 | SK = "sk", 6 | } 7 | 8 | export const tableMap = new Map(); 9 | export const keyMap = new Map>(); 10 | export const gsiKeyMap = new Map>(); 11 | export const gsiIndexMap = new Map(); 12 | 13 | export function Table(tableName: string) { 14 | return (Class: Constructor) => { 15 | tableMap.set(Class, tableName); 16 | }; 17 | } 18 | 19 | export function GSI(indexName: string) { 20 | return (Class: Constructor) => { 21 | gsiIndexMap.set(Class, indexName); 22 | }; 23 | } 24 | 25 | export const pk = addKey(Keys.PK); 26 | export const sk = addKey(Keys.SK); 27 | export const gsiPk = addKey(Keys.PK, true); 28 | export const gsiSk = addKey(Keys.SK, true); 29 | 30 | export function addKey(key: Keys, isGSI = false) { 31 | return (Class: any, field: Field) => { 32 | if (isGSI) { 33 | const map = gsiKeyMap.get(Class.constructor) ?? new Map(); 34 | map.set(key, field); 35 | gsiKeyMap.set(Class.constructor, map); 36 | return; 37 | } 38 | const map = keyMap.get(Class.constructor) ?? new Map(); 39 | map.set(key, field); 40 | keyMap.set(Class.constructor, map); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript cache 45 | *.tsbuildinfo 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Microbundle cache 54 | .rpt2_cache/ 55 | .rts2_cache_cjs/ 56 | .rts2_cache_es/ 57 | .rts2_cache_umd/ 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # Next.js build output 76 | .next 77 | 78 | # Nuxt.js build / generate output 79 | .nuxt 80 | dist 81 | 82 | # Gatsby files 83 | .cache/ 84 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 85 | # https://nextjs.org/blog/next-9-1#public-directory-support 86 | # public 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | # TernJS port file 101 | .tern-port 102 | 103 | #idea 104 | .idea* 105 | 106 | # CDK Context & Staging files 107 | cdk.context.json 108 | .cdk.staging/ 109 | cdk.out/ 110 | *.tabl.json 111 | -------------------------------------------------------------------------------- /lambda/adminHandler.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from "aws-sdk"; 2 | import { AchievementData } from "../models/adminData"; 3 | import { 4 | APIGatewayProxyEvent, 5 | APIGatewayProxyHandler, 6 | APIGatewayProxyResult, 7 | } from "aws-lambda"; 8 | import { DocumentClient } from "aws-sdk/clients/dynamodb"; 9 | import { keyMap, Keys, tableMap } from "../models/tableDecorator"; 10 | import { PlayerData } from "../models/playerData"; 11 | 12 | const db = new AWS.DynamoDB.DocumentClient(); 13 | 14 | export const handler: APIGatewayProxyHandler = async ({ 15 | body, 16 | httpMethod, 17 | queryStringParameters, 18 | }: APIGatewayProxyEvent): Promise => { 19 | console.log(`method = ${httpMethod}`); 20 | 21 | try { 22 | switch (httpMethod) { 23 | case "POST": { 24 | const achievement = JSON.parse(body!) as AchievementData; 25 | await post(achievement); 26 | return { 27 | statusCode: 201, 28 | body: "success!", 29 | }; 30 | } 31 | case "GET": { 32 | const playerData = await get(queryStringParameters!["playerId"]!); 33 | return { 34 | statusCode: 200, 35 | body: JSON.stringify(playerData), 36 | }; 37 | } 38 | default: 39 | return { 40 | statusCode: 501, 41 | body: "not implemented", 42 | }; 43 | } 44 | } catch (error) { 45 | console.log(error); 46 | return { statusCode: 500, body: JSON.stringify({ message: error }) }; 47 | } 48 | }; 49 | 50 | async function post({ 51 | achievementId, 52 | requiredProgress, 53 | requiredAmount, 54 | }: AchievementData) { 55 | const achievementDataParams: DocumentClient.PutItemInput = { 56 | TableName: tableMap.get(AchievementData)!, 57 | Item: { 58 | [keyMap.get(AchievementData)!.get(Keys.PK)!]: achievementId, 59 | requiredProgress, 60 | requiredAmount, 61 | }, 62 | }; 63 | await db.put(achievementDataParams).promise(); 64 | } 65 | 66 | async function get(playerId: string) { 67 | const playerDataParams: DocumentClient.QueryInput = { 68 | TableName: tableMap.get(PlayerData)!, 69 | KeyConditionExpression: "#playerId = :playerId", 70 | ExpressionAttributeNames: { 71 | "#playerId": keyMap.get(PlayerData)!.get(Keys.PK)!, 72 | }, 73 | ExpressionAttributeValues: { 74 | ":playerId": playerId, 75 | }, 76 | }; 77 | const { Items } = await db.query(playerDataParams).promise(); 78 | return Items as PlayerData[]; 79 | } 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /lib/achievementMicroservices.ts: -------------------------------------------------------------------------------- 1 | import { AchievementData } from "../models/AdminData"; 2 | import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb"; 3 | import { Construct } from "constructs"; 4 | import { 5 | gsiIndexMap, 6 | gsiKeyMap, 7 | keyMap, 8 | Keys, 9 | tableMap, 10 | } from "../models/tableDecorator"; 11 | import { LambdaRestApi } from "aws-cdk-lib/aws-apigateway"; 12 | import { 13 | NodejsFunction, 14 | NodejsFunctionProps, 15 | } from "aws-cdk-lib/aws-lambda-nodejs"; 16 | import { PlayerData } from "../models/PlayerData"; 17 | import { PolicyStatement } from "aws-cdk-lib/aws-iam"; 18 | import { ProgressMessage } from "../models/progressMessage"; 19 | import { Queue } from "aws-cdk-lib/aws-sqs"; 20 | import { Runtime } from "aws-cdk-lib/aws-lambda"; 21 | import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources"; 22 | import { Stack, StackProps } from "aws-cdk-lib"; 23 | 24 | export class AchievementMicroservices extends Stack { 25 | constructor(scope: Construct, id: string, props?: StackProps) { 26 | super(scope, id, props); 27 | 28 | const functionProp: NodejsFunctionProps = { 29 | runtime: Runtime.NODEJS_14_X, 30 | memorySize: 1024, 31 | }; 32 | 33 | const mainHandler = new NodejsFunction(this, "mainHandler", { 34 | entry: "lambda/mainHandler.ts", 35 | ...functionProp, 36 | }); 37 | 38 | const adminHandler = new NodejsFunction(this, "adminHandler", { 39 | entry: "lambda/adminHandler.ts", 40 | ...functionProp, 41 | }); 42 | 43 | const outHandler = new NodejsFunction(this, "outHandler", { 44 | entry: "lambda/outHandler.ts", 45 | ...functionProp, 46 | }); 47 | 48 | const achievementDataTable = new Table(this, "AchievementData", { 49 | tableName: tableMap.get(AchievementData)!, 50 | partitionKey: { 51 | name: keyMap.get(AchievementData)!.get(Keys.PK)!, 52 | type: AttributeType.STRING, 53 | }, 54 | billingMode: BillingMode.PAY_PER_REQUEST, 55 | }); 56 | 57 | achievementDataTable.addGlobalSecondaryIndex({ 58 | indexName: gsiIndexMap.get(AchievementData)!, 59 | partitionKey: { 60 | name: gsiKeyMap.get(AchievementData)!.get(Keys.PK)!, 61 | type: AttributeType.STRING, 62 | }, 63 | sortKey: { 64 | name: gsiKeyMap.get(AchievementData)!.get(Keys.SK)!, 65 | type: AttributeType.NUMBER, 66 | }, 67 | }); 68 | 69 | const playerAchievementTable = new Table(this, "PlayerAchievement", { 70 | tableName: tableMap.get(PlayerData)!, 71 | partitionKey: { 72 | name: keyMap.get(PlayerData)!.get(Keys.PK)!, 73 | type: AttributeType.STRING, 74 | }, 75 | sortKey: { 76 | name: keyMap.get(PlayerData)!.get(Keys.SK)!, 77 | type: AttributeType.STRING, 78 | }, 79 | billingMode: BillingMode.PAY_PER_REQUEST, 80 | }); 81 | 82 | const progressMessageTable = new Table(this, "ProgressMessageTable", { 83 | tableName: tableMap.get(ProgressMessage)!, 84 | partitionKey: { 85 | name: keyMap.get(ProgressMessage)!.get(Keys.PK)!, 86 | type: AttributeType.STRING, 87 | }, 88 | timeToLiveAttribute: "ttl", 89 | billingMode: BillingMode.PAY_PER_REQUEST, 90 | }); 91 | 92 | achievementDataTable.grantReadData(mainHandler); 93 | achievementDataTable.grantReadWriteData(adminHandler); 94 | playerAchievementTable.grantReadWriteData(mainHandler); 95 | playerAchievementTable.grantReadData(adminHandler); 96 | progressMessageTable.grantReadWriteData(mainHandler); 97 | 98 | const inQueue = new Queue(this, "InQueue"); 99 | const outQueue = new Queue(this, "OutQueue"); 100 | 101 | mainHandler.addEventSource( 102 | new SqsEventSource(inQueue, { 103 | reportBatchItemFailures: true, 104 | batchSize: 10, 105 | }) 106 | ); 107 | 108 | mainHandler.addEnvironment("OUT_QUEUE_URL", outQueue.queueUrl); 109 | mainHandler.addToRolePolicy( 110 | new PolicyStatement({ 111 | actions: ["sqs:SendMessage"], 112 | resources: [outQueue.queueArn], 113 | }) 114 | ); 115 | 116 | outHandler.addEventSource(new SqsEventSource(outQueue)); 117 | 118 | const adminAPI = new LambdaRestApi(this, "AdminAPI", { 119 | handler: adminHandler, 120 | proxy: false, 121 | }); 122 | 123 | const achievements = adminAPI.root.addResource("achievements"); 124 | achievements.addMethod("POST"); 125 | achievements.addMethod("GET"); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Achievement Microservices Sample 2 | 3 | This CDK project is a sample solution for achievement microservices in games.\ 4 | The sample solution contains AWS Lambda Functions, Amazon DynamoDB Tables, Amazon SQS Queues and Amazon API Gateway. 5 | 6 | ## Key features 7 | 8 | - the solution is constructed with only serverless services 9 | - the solution handles achievement progress additions asynchronously 10 | - a single progress can trigger multiple achievements 11 | - for example, progress with winning matches could trigger achievements for 10 wins, 50 wins and 100 wins. 12 | - achievement requirements can be appended, and new achievements will be unlocked on the next progress event, as long as the new requirements are after the previous requirements, 13 | - for example, if there are 10 wins and 50 wins achievements, then you can add 100 wins but not 30 wins. 14 | - the solution has an SQS queue in front, so multiple backend services can send progress events 15 | - the solution has an SQS queue for outputting achieved events, you can add handlers to send achieved events back to clients 16 | - for example, have an API server with websocket connection and poll events from the queue 17 | - the solution is decoupled from game specific details (achievement descriptions, images, titles, and more), the solution simply handles progresses and achievements with IDs 18 | - the solution handles duplicated messages gracefully using DynamoDB conditional Put 19 | 20 | ## Assumptions 21 | 22 | - another service, such as an application server, has bidirectional connection with clients 23 | - achievement metadata is stored separately with all IDs matched 24 | 25 | ## Architecture 26 | 27 | As Shown in the diagram below, there are three Lambda functions, two SQS queues in front of DynamoDB.\ 28 | `In Queue` handles all input messages from game backend services, and `Out Handler` can be replaced or used to talk back to game clients.\ 29 | There also is an API Gateway for admin purposes, however another API Gateway may be required for requesting current player achievements.\ 30 | ![alt text](./docs/AchievementMicroservices.png) 31 | 32 | ## DynamoDB Tables 33 | 34 | ### Player Table: (1 table, 2 entry types) 35 | 36 | #### Player Achievement Entries 37 | 38 | Each entry represents an achieved achievement by a player with player ID 39 | 40 | | PK: playerId | SK: id | achievedAt | 41 | |-------------------|-----------------------|------------| 42 | | string: player ID | string achievement ID | timestamp | 43 | 44 | #### Player Progress Entries 45 | 46 | Each entry represents current progress for specific progress with progress ID by a player with player ID 47 | 48 | | PK: playerId | SK: id | progress | lastUpdated | 49 | |-------------------|---------------------|--------------------------|-------------| 50 | | string: player ID | string: progress ID | number: current progress | timestamp | 51 | 52 | ### Admin Data Table (1 table, 1 entry type) 53 | 54 | #### Achievement Data Entries 55 | 56 | Each entry represents an achievement and its requirement, a requirement has progress ID and required amount of progress 57 | 58 | 59 | | PK: achievementId | GSI PK: requiredProgress | GSI SK: requiredAmount | 60 | |------------------------|--------------------------|-------------------------| 61 | | string: achievement ID | string: progress ID | number: required amount | 62 | 63 | ### Progress Message Table (1 table, 1 entry type) 64 | 65 | #### Progress Message Entries 66 | 67 | Each entry represents a message from In Queue, a message only stays in this table for 5 minutes, this is used for duplication check 68 | 69 | | PK: messageId | TTL: ttl | 70 | |------------------------|-----------------------| 71 | | string: sqs message ID | number: ttl timestamp | 72 | 73 | ## SQS Message Syntax 74 | 75 | ### In Message 76 | 77 | ``` 78 | { 79 | “playerId”: string, 80 | “progressId”: string, 81 | “progressIncrement”: number, 82 | } 83 | ``` 84 | 85 | ### Out Message 86 | 87 | ``` 88 | { 89 | “playerId”: string, 90 | “achievementId”: string, 91 | } 92 | ``` 93 | 94 | ### Prerequisites 95 | 96 | - An AWS account 97 | - Nodejs LTS installed, such as 14.x 98 | - Install Docker Engine 99 | 100 | ## Usage 101 | 102 | ### Deployment 103 | 104 | To deploy the example stack to your default AWS account/region, under project root folder, run: 105 | 106 | 1. `yarn install` to install all the dependencies 107 | 2. `cdk deploy` to deploy this stack to your default AWS account/region 108 | 109 | ### Setup Achievement Data 110 | Once the deployment is completed, you should be able to locate API Gateway url in `Outputs` tab. Use the url and send `POST` request to insert achievement data. Sample request body is:\ 111 | 112 | ``` 113 | { 114 | "achievementId": "a1", 115 | "requiredProgress": "p1", 116 | "requiredAmount": 3, 117 | } 118 | ``` 119 | 120 | With `curl`:\ 121 | 122 | ``` 123 | $ curl -XPOST https:///prod/achievements \ 124 | -H "Content-Type: application/json" \ 125 | -d '{"achievementId": "a1", "requiredProgress": "p1", "requiredAmount": 3}' 126 | ``` 127 | 128 | ### Send Progress Events 129 | 130 | To test your service, you can start sending messages to `In Queue`. You can use test message located in `test/testMessage.json`. 131 | 132 | To send messages, you can either: 133 | 134 | - use AWS Console, go to Amazon SQS, find a queue with `InQueue` in its name, then go to `Send and receive messages` 135 | - use AWS CLI, for example 136 | - `aws sqs send-message --queue-url --message-body file://test/testMessage.json` 137 | 138 | ## License 139 | 140 | This solution is licensed under the MIT-0 License. See the LICENSE file. 141 | 142 | Also, this application uses below open source project, 143 | 144 | - [aigle](https://www.npmjs.com/package/aigle) 145 | -------------------------------------------------------------------------------- /lambda/mainHandler.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from "aws-sdk"; 2 | import Aigle from "aigle"; 3 | import { AchievementData } from "../models/adminData"; 4 | import { DocumentClient } from "aws-sdk/clients/dynamodb"; 5 | import { 6 | gsiIndexMap, 7 | gsiKeyMap, 8 | keyMap, 9 | Keys, 10 | tableMap, 11 | } from "../models/tableDecorator"; 12 | import { ProgressMessage } from "../models/progressMessage"; 13 | import { PlayerData, PlayerProgress, Prefix } from "../models/playerData"; 14 | import { 15 | SQSBatchItemFailure, 16 | SQSBatchResponse, 17 | SQSEvent, 18 | SQSHandler, 19 | } from "aws-lambda"; 20 | 21 | const outSQS = new AWS.SQS(); 22 | const db = new AWS.DynamoDB.DocumentClient(); 23 | const outQueueUrl = process.env.OUT_QUEUE_URL || ""; 24 | const progressMessageTTL = 5 * 60 * 1000; 25 | 26 | interface InMessage { 27 | playerId: string; 28 | progressId: string; 29 | progressIncrement: number; 30 | } 31 | 32 | interface OutMessage { 33 | playerId: string; 34 | achievementId: string; 35 | } 36 | 37 | export const handler: SQSHandler = async ({ 38 | Records, 39 | }: SQSEvent): Promise => { 40 | const timeStamp = Date.now(); 41 | const batchItemFailures: SQSBatchItemFailure[] = []; 42 | await Aigle.forEach(Records, async ({ body, messageId }) => { 43 | try { 44 | const message: InMessage = JSON.parse(body!); 45 | await updateProgress(messageId, message, timeStamp); 46 | } catch (e) { 47 | batchItemFailures.push({ 48 | itemIdentifier: messageId, 49 | }); 50 | console.log(e); 51 | } 52 | }); 53 | return { batchItemFailures }; 54 | }; 55 | 56 | async function updateProgress( 57 | messageId: string, 58 | { playerId, progressId, progressIncrement }: InMessage, 59 | timeStamp: number 60 | ) { 61 | const playerDataPk = keyMap.get(PlayerData)!.get(Keys.PK)!; 62 | const playerDataSk = keyMap.get(PlayerData)!.get(Keys.SK)!; 63 | const progressMessageTableName = tableMap.get(ProgressMessage)!; 64 | const playerDataTableName = tableMap.get(PlayerData)!; 65 | 66 | const progressMessageCheckParams: DocumentClient.TransactWriteItem = { 67 | Put: { 68 | TableName: progressMessageTableName, 69 | Item: { 70 | [keyMap.get(ProgressMessage)!.get(Keys.PK)!]: messageId, 71 | ttl: Math.floor( 72 | new Date(Date.now() + progressMessageTTL).getTime() / 1000 73 | ), 74 | }, 75 | ConditionExpression: "attribute_not_exists(messageId)", 76 | ReturnValuesOnConditionCheckFailure: "ALL_OLD", 77 | }, 78 | }; 79 | const updateProgressParams: DocumentClient.TransactWriteItem = { 80 | Update: { 81 | TableName: playerDataTableName, 82 | Key: { 83 | [playerDataPk]: playerId, 84 | [playerDataSk]: Prefix.progress + progressId, 85 | }, 86 | UpdateExpression: 87 | "SET #progress = if_not_exists(progress, :zero) + :progressIncrement," + 88 | " #lastUpdated = :lastUpdated", 89 | ExpressionAttributeNames: { 90 | "#progress": "progress", 91 | "#lastUpdated": "lastUpdated", 92 | }, 93 | ExpressionAttributeValues: { 94 | ":progressIncrement": progressIncrement, 95 | ":lastUpdated": timeStamp, 96 | ":zero": 0, 97 | }, 98 | }, 99 | }; 100 | 101 | try { 102 | await db 103 | .transactWrite({ 104 | TransactItems: [progressMessageCheckParams, updateProgressParams], 105 | }) 106 | .promise(); 107 | } catch (e: any) { 108 | if (e.name == "TransactionCanceledException") { 109 | if (e.message.includes("TransactionConflict")) { 110 | console.log( 111 | `transact write failed, transaction has conflicted, 112 | likely due to the same player updating the same progress at the same time` 113 | ); 114 | throw e; 115 | } 116 | console.log( 117 | `transact write failed, a message with the same message id ${messageId} has already been processed` 118 | ); 119 | return; 120 | } 121 | throw e; 122 | } 123 | 124 | const getProgressParams: DocumentClient.GetItemInput = { 125 | TableName: playerDataTableName, 126 | Key: { 127 | [playerDataPk]: playerId, 128 | [playerDataSk]: Prefix.progress + progressId, 129 | }, 130 | }; 131 | 132 | const { Item } = await db.get(getProgressParams).promise(); 133 | const { progress } = Item as PlayerProgress; 134 | 135 | const getAchievementDataPrams: DocumentClient.QueryInput = { 136 | TableName: tableMap.get(AchievementData)!, 137 | IndexName: gsiIndexMap.get(AchievementData)!, 138 | KeyConditionExpression: "#progress = :v_progress", 139 | ExpressionAttributeNames: { 140 | "#progress": gsiKeyMap.get(AchievementData)!.get(Keys.PK)!, 141 | }, 142 | ExpressionAttributeValues: { 143 | ":v_progress": progressId, 144 | }, 145 | }; 146 | 147 | const { Items } = await db.query(getAchievementDataPrams).promise(); 148 | if (!Items) { 149 | console.log("get achievement data returned returned null"); 150 | return; 151 | } 152 | 153 | for (const { achievementId, requiredAmount } of Items as AchievementData[]) { 154 | if (requiredAmount > progress) { 155 | return; 156 | } 157 | 158 | const achievedParams: DocumentClient.PutItemInput = { 159 | TableName: tableMap.get(PlayerData)!, 160 | Item: { 161 | [playerDataPk]: playerId, 162 | [playerDataSk]: Prefix.achievement + achievementId, 163 | achievedAt: timeStamp, 164 | }, 165 | ConditionExpression: `attribute_not_exists(${keyMap 166 | .get(PlayerData)! 167 | .get(Keys.PK)!})`, 168 | }; 169 | 170 | try { 171 | await db.put(achievedParams).promise(); 172 | await outSQS 173 | .sendMessage({ 174 | MessageBody: JSON.stringify({ 175 | playerId, 176 | achievementId, 177 | } as OutMessage), 178 | QueueUrl: outQueueUrl, 179 | }) 180 | .promise(); 181 | } catch (e: any) { 182 | if (e.name == "ConditionalCheckFailedException") { 183 | continue; 184 | } 185 | throw e; 186 | } 187 | } 188 | } 189 | --------------------------------------------------------------------------------