diff --git a/backend/ops/src/stacks/backend-stack.ts b/backend/ops/src/stacks/backend-stack.ts index 2b20c10..3e2a095 100644 --- a/backend/ops/src/stacks/backend-stack.ts +++ b/backend/ops/src/stacks/backend-stack.ts @@ -1,37 +1,41 @@ import * as cdk from "aws-cdk-lib" import * as path from "path" import { Construct } from "constructs" +import { aws_apigateway as apigateway, Duration } from "aws-cdk-lib" import { aws_dynamodb as dynamodb } from "aws-cdk-lib" -import { aws_apigateway as apigateway } from "aws-cdk-lib" -import { aws_lambda as lambda } from "aws-cdk-lib" import { aws_iam as iam } from "aws-cdk-lib" +import { aws_lambda as lambda } from "aws-cdk-lib" import { aws_secretsmanager as secretsmanager } from "aws-cdk-lib" import { aws_ssm as ssm } from "aws-cdk-lib" +import { time } from "console" export class BackendStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props) - //Best Practices: - //https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/estimate-the-cost-of-a-dynamodb-table-for-on-demand-capacity.html?did=pg_card&trk=pg_card#estimate-the-cost-of-a-dynamodb-table-for-on-demand-capacity-best-practices - new dynamodb.Table(this, "gameDatabase", { - partitionKey: { name: "playerId", type: dynamodb.AttributeType.STRING }, - sortKey: { name: "formationId", type: dynamodb.AttributeType.STRING }, + const playerHandsTable = new dynamodb.Table(this, "playerHandsTable", { + partitionKey: { name: "version", type: dynamodb.AttributeType.STRING }, + sortKey: { name: "playerId", type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PROVISIONED, pointInTimeRecovery: false, - removalPolicy: cdk.RemovalPolicy.DESTROY, - tableName: "gameDatabase", + removalPolicy: cdk.RemovalPolicy.DESTROY, //Swap this to `retain` when we got something that is shaping up nicely + tableName: "playerHands", }) //The Lambda to handle requests coming through the API Gateway //Lambda would be written in golang - const lambdafn = new lambda.Function(this, "legalBrawlApiHandler", { + const lambdaFn = new lambda.Function(this, "legalBrawlApiHandler", { architecture: lambda.Architecture.ARM_64, runtime: lambda.Runtime.PROVIDED_AL2, handler: "bootstrap", code: lambda.Code.fromAsset(path.join(__dirname, "../../../src/dist")), + timeout: Duration.seconds(20), environment: { LEGAL_BRAWL_SECRET_NAME: "legalBrawl/prod/api/v1/", + PLAYER_HAND_TABLE_NAME: playerHandsTable.tableName, + //The card balancing version. This should later be controlled via some flag, release tag + //or higher-level environment var during release + PLAYER_HAND_VERSION: "1.0", }, }) @@ -45,23 +49,58 @@ export class BackendStack extends cdk.Stack { this, "/legalBrawl/secret/arn/suffix" ) - lambdafn.addToRolePolicy( + lambdaFn.addToRolePolicy( new iam.PolicyStatement({ actions: [ "secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ], + resources: [ + `${apiToken.secretArn}-${SecretArnSuffix}`, + playerHandsTable.tableArn, ], - resources: [`${apiToken.secretArn}-${SecretArnSuffix}`], }) ) const api = new apigateway.LambdaRestApi(this, "legalBrawlApi", { - handler: lambdafn, + handler: lambdaFn, //Setting proxy to `true` will forward *all* requests to Lambda //TODO: Figure out what resources, and what methods we are looking to implement proxy: false, }) + const apiIntegration = new apigateway.LambdaIntegration(lambdaFn) + + //playerHand scheme + const playerHandModel = api.addModel("playerHand", { + contentType: `application/json`, + modelName: "playerHandv1", + schema: { + schema: apigateway.JsonSchemaVersion.DRAFT4, + title: "PlayerHand", + type: apigateway.JsonSchemaType.OBJECT, + required: ["handInfo"], + properties: { + handInfo: { + type: apigateway.JsonSchemaType.OBJECT, + properties: { + playerName: { type: apigateway.JsonSchemaType.STRING }, + playerId: { type: apigateway.JsonSchemaType.STRING }, + version: { type: apigateway.JsonSchemaType.STRING }, + cards: { + type: apigateway.JsonSchemaType.ARRAY, + items: { type: apigateway.JsonSchemaType.INTEGER }, + }, + }, + }, + }, + }, + }) + new ssm.StringParameter(this, "paramLegalBrawlApiUrl", { parameterName: "/legalBrawl/api/url", stringValue: api.url, @@ -70,10 +109,64 @@ export class BackendStack extends cdk.Stack { //b.s. example of API paths const v1 = api.root.addResource("v1") const formation = v1.addResource("formationId") - const formationPostMethod = formation.addMethod("POST", undefined, { + const playerHand = v1.addResource("playerHand") + const formationPostMethod = formation.addMethod("POST", apiIntegration, { apiKeyRequired: false, }) //Set apiKeyRequired to `true` when ready to start locking down the API + //Need to create individual validator objects, refer to: https://github.com/aws/aws-cdk/issues/7613 + const playerHandPostValidator = new apigateway.RequestValidator( + this, + "playerHandPostValidator", + { + restApi: api, + requestValidatorName: "playerHandPostValidator", + validateRequestBody: true, + validateRequestParameters: false, + } + ) + const playerHandGetValidator = new apigateway.RequestValidator( + this, + "playerHandGetValidator", + { + restApi: api, + requestValidatorName: "playerHandGetValidator", + validateRequestParameters: true, + } + ) + const playerHandPutValidator = new apigateway.RequestValidator( + this, + "playerHandPutValidator", + { + restApi: api, + requestValidatorName: "playerHandPutValidator", + validateRequestBody: true, + validateRequestParameters: false, + } + ) + + const playerHandPost = playerHand.addMethod("POST", apiIntegration, { + apiKeyRequired: true, + requestModels: { + "application/json": playerHandModel, + }, + requestValidator: playerHandPostValidator, + }) + const playerHandGet = playerHand.addMethod("GET", apiIntegration, { + apiKeyRequired: true, + requestParameters: { + "method.request.querystring.playerId": true, + }, + requestValidator: playerHandGetValidator, + }) + const playerHandPut = playerHand.addMethod("PUT", apiIntegration, { + apiKeyRequired: true, + requestModels: { + "application/json": playerHandModel, + }, + requestValidator: playerHandPutValidator, + }) + const plan = api.addUsagePlan("legalBrawlUsagePlan", { throttle: { rateLimit: 10, @@ -91,6 +184,27 @@ export class BackendStack extends cdk.Stack { burstLimit: 2, }, }, + { + method: playerHandPost, + throttle: { + rateLimit: 10, + burstLimit: 2, + }, + }, + { + method: playerHandGet, + throttle: { + rateLimit: 10, + burstLimit: 2, + }, + }, + { + method: playerHandPut, + throttle: { + rateLimit: 10, + burstLimit: 2, + }, + }, ], }) diff --git a/backend/src/aws/apigateway.go b/backend/src/aws/apigateway.go new file mode 100644 index 0000000..821241b --- /dev/null +++ b/backend/src/aws/apigateway.go @@ -0,0 +1,337 @@ +package lbapiaws + +import ( + "context" + "encoding/json" + "fmt" + lbapiconfig "legalbrawlapi/config" + "legalbrawlapi/secret" + "log" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-secretsmanager-caching-go/secretcache" + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" +) + +var ( + secretCache, _ = secretcache.New() + env = lbapiconfig.New() +) + +// Parse the body of request (in JSON) into usable structs +func parseBody(b string) (*body, error) { + var body body + err := json.Unmarshal([]byte(b), &body) + if err != nil { + return nil, err + } + + return &body, nil +} + +func parseParameters(p map[string]string) (playerHandCompositeKey, error) { + input := make(map[string]interface{}) + + for k, v := range p { + input[k] = v + } + + var result playerHandCompositeKey + err := mapstructure.Decode(input, &result) + if err != nil { + return playerHandCompositeKey{}, fmt.Errorf("unable to decode get request parameters: %v", err) + } + + result.Version = env.PlayerHandVersion //Adds the balance version to the struct. Not supplied in GET request + + return result, nil +} + +func validateBody(b body) error { + _, err := uuid.Parse(b.HandInfo.PlayerId) + if err != nil { + return fmt.Errorf("playerId is not a valid UUID format: %v", err) + } + + err = validateSubmittedVersion(b.HandInfo.Version, env.PlayerHandVersion) + if err != nil { + return fmt.Errorf("submitted version did not pass validation: %v", err) + } + + return nil +} + +func validateParameters(pg playerHandCompositeKey) error { + err := validatePlayerId(pg.PlayerId) + if err != nil { + return fmt.Errorf("playerId is not a valid UUID format: %v", err) + } + return nil +} + +func validatePlayerId(pId string) error { + _, err := uuid.Parse(pId) + if err != nil { + return fmt.Errorf("playerId is not a valid UUID format: %v", err) + } + return nil +} + +// Checks if submitted version matches current balance version +func validateSubmittedVersion(sv string, av string) error { + if sv != av { + return fmt.Errorf("submitted version (%v) in request, does not match actual balance version %v", sv, av) + } + return nil +} + +func HandleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var response events.APIGatewayProxyResponse + + fmt.Printf("Processing request data for request %s.\n", request.RequestContext.RequestID) + + apiSecret := secret.RetrieveSecrets(ctx, secretCache, env.LegalBrawlSecretName) + secret.ScrubRequest(&request, apiSecret) + fmt.Printf("Body size = %d.\n", len(request.Body)) + + fmt.Println("Headers:") + for key, value := range request.Headers { + fmt.Printf(" %s: %s\n", key, value) + } + + switch request.Path { + + case "/v1/formationId": + response = events.APIGatewayProxyResponse{ + Body: "Hello, World!", + StatusCode: 200, + } + + case "/v1/playerHand": + switch request.HTTPMethod { + + case "POST": + parsedBody, err := parseBody(request.Body) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error parsing request", + StatusCode: 500, + }, + fmt.Errorf("request body failed parsing: %v", err) + } + + err = validateBody(*parsedBody) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "parameters in request failed validation", + StatusCode: 500, + }, fmt.Errorf("body contents failed validation: %v", err) + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error", + StatusCode: 500, + }, fmt.Errorf("error loading sdk config: %v", err) + } + + dynamoHandler := dDBHandler{ + DynamoDbClient: dynamodb.NewFromConfig(cfg), + TableName: env.PlayerHandTableName, + } + + playerHandParams := playerHandCompositeKey{ + PlayerId: parsedBody.HandInfo.PlayerId, + Version: env.PlayerHandVersion, + } + playerExist, _, err := dynamoHandler.checkIfPlayerHandExists(playerHandParams) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "error checking for duplicate player", + StatusCode: 500, + }, fmt.Errorf("failed to check for duplicate playerId %v in playerHand table: %v", parsedBody.HandInfo.PlayerId, err) + } + if playerExist { //Player exists, use PUT request to update player instead + log.Printf("specified playerId %v already exists", parsedBody.HandInfo.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: "playerId already exists", + StatusCode: 400, + } + } else { //Add player hand to the DB + err = dDBHandler.addHand(dynamoHandler, parsedBody.HandInfo) + if err != nil { + log.Fatalf("error adding playerId %v to playerHand table: %v", parsedBody.HandInfo.PlayerId, err) + response = events.APIGatewayProxyResponse{ + Body: "error adding item", + StatusCode: 500, + } + } + log.Printf("playerId %v added to playerHand table", parsedBody.HandInfo.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: "submitted player info successfully added", + StatusCode: 200, + } + } + + case "GET": + parsedParameters, err := parseParameters(request.QueryStringParameters) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error parsing request", + StatusCode: 500, + }, + fmt.Errorf("unable to parse parameters %v", err) + } + + err = validateParameters(parsedParameters) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "parameters in request failed validation", + StatusCode: 500, + }, + fmt.Errorf("parameters failed validation: %v", err) + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error", + StatusCode: 500, + }, fmt.Errorf("error loading sdk config: %v", err) + } + + dynamoHandler := dDBHandler{ + DynamoDbClient: dynamodb.NewFromConfig(cfg), + TableName: env.PlayerHandTableName, + } + + playerExists, _, err := dynamoHandler.checkIfPlayerHandExists(parsedParameters) + if err != nil { + log.Fatalf("failed to determine if player exists on playerHand table: %v", err) + } + if playerExists { + hands, err := dynamoHandler.queryHands(env.PlayerHandVersion) + if err != nil { + log.Fatalf("error querying playerHands table: %v", err) + } + + selectedHand := dynamoHandler.chooseHand(hands) + + selectedHandJson, err := json.Marshal(selectedHand) + if err != nil { + log.Fatalf("error marshalling selectedHand to json: %v", err) + response = events.APIGatewayProxyResponse{ + Body: "internal error", + StatusCode: 500, + } + } + + log.Printf("matchmaking: requesting playerId %v is matched up again playerId %v", parsedParameters.PlayerId, selectedHand.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: fmt.Sprintf("%v", string(selectedHandJson)), + StatusCode: 200, + } + } else { + return events.APIGatewayProxyResponse{ + Body: "playerId does not exist", + StatusCode: 404, + }, + fmt.Errorf("playderId %v does not exist", parsedParameters.PlayerId) + } + + case "PUT": + parsedBody, err := parseBody(request.Body) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error parsing request", + StatusCode: 500, + }, + fmt.Errorf("error: %v", err) + } + + err = validateBody(*parsedBody) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "parameters in request failed validation", + StatusCode: 500, + }, fmt.Errorf("body contents failed validation: %v", err) + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "internal error", + StatusCode: 500, + }, fmt.Errorf("error loading sdk config: %v", err) + } + + dynamoHandler := dDBHandler{ + DynamoDbClient: dynamodb.NewFromConfig(cfg), + TableName: env.PlayerHandTableName, + } + + playerHandParams := playerHandCompositeKey{ + PlayerId: parsedBody.HandInfo.PlayerId, + Version: env.PlayerHandVersion, + } + + playerExist, playerEntry, err := dynamoHandler.checkIfPlayerHandExists(playerHandParams) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "error checking for duplicate player", + StatusCode: 500, + }, fmt.Errorf("failed to check for duplicate player in playerHand table: %v", err) + } + if playerExist { //Check PUT request + isDupe, err := dynamoHandler.checkForDuplicate(parsedBody.HandInfo, playerEntry) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "error checking for duplicate player", + StatusCode: 500, + }, fmt.Errorf("failed to check for duplicate player: %v", err) + } + if isDupe { //The card configuration needs to be different + log.Printf("specified cards for playerId %v already exists", parsedBody.HandInfo.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: "specified card configuration for playerId already exists", + StatusCode: 403, + } + } else { + err = dynamoHandler.updatePlayerHand(parsedBody.HandInfo) + if err != nil { + return events.APIGatewayProxyResponse{ + Body: "error updating playerHand", + StatusCode: 500, + }, fmt.Errorf("failed to update card configuration for playerId %v: %v", parsedBody.HandInfo.PlayerId, err) + } else { + log.Printf("updated playerHand for playerId %v", parsedBody.HandInfo.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: "card configuration for playerId successfully updated", + StatusCode: 200, + } + } + + } + } else { //Player doesn't exist - the request should be a POST request instead + log.Printf("playerId %v does not exist", parsedBody.HandInfo.PlayerId) + response = events.APIGatewayProxyResponse{ + Body: "playerId does not exist", + StatusCode: 404, + } + } + + default: + return events.APIGatewayProxyResponse{ + Body: "invalid method returned", + StatusCode: 501, + }, + fmt.Errorf("invalid method used: %v", request.HTTPMethod) + } + } + + return response, nil +} diff --git a/backend/src/aws/dynamodb.go b/backend/src/aws/dynamodb.go new file mode 100644 index 0000000..fec78f2 --- /dev/null +++ b/backend/src/aws/dynamodb.go @@ -0,0 +1,298 @@ +package lbapiaws + +import ( + "context" + "fmt" + "log" + "math/rand" + "time" + + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/aws-sdk-go/aws" + "github.com/google/go-cmp/cmp" +) + +var ( + timeoutWindow = 3 * time.Second +) + +// Adds the player's submitted hand from the client to the Database. +// +// BUG: Need to check that types match, and contents are not null before marshalling. +func (ddbh dDBHandler) doAddHand(h handInfo) error { + item, err := attributevalue.MarshalMap(h) + if err != nil { + log.Panicf("unable to marshal submitted hand: %v", err) + } + + _, err = ddbh.DynamoDbClient.PutItem(context.TODO(), &dynamodb.PutItemInput{ + TableName: aws.String(ddbh.TableName), + Item: item, + }) + if err != nil { + log.Printf("couldn't add item to table: %v\n", err) + } + + return err +} + +func (ddbh dDBHandler) addHand(h handInfo) error { + err := make(chan error, 1) + + go func() { + err <- ddbh.doAddHand(h) + }() + select { + case <-time.After(timeoutWindow): + return fmt.Errorf("timeout - could not add to playerHands table in allotted window") + + case err := <-err: + if err != nil { + return fmt.Errorf("addHand execution failed. error: %v", err) + } + return nil + } +} + +func (ddbh dDBHandler) doQueryHands(version string) queryHandsResult { + var availableHands []handInfo + var response *dynamodb.QueryOutput + + keyEx := expression.Key("version").Equal(expression.Value(version)) + expr, err := expression.NewBuilder().WithKeyCondition(keyEx).Build() + if err != nil { + return queryHandsResult{ + nil, + fmt.Errorf("could not build expression to query available hands. error: %v", err), + } + } else { + response, err = ddbh.DynamoDbClient.Query(context.TODO(), &dynamodb.QueryInput{ + TableName: aws.String(ddbh.TableName), + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + KeyConditionExpression: expr.KeyCondition(), + }) + if err != nil { + return queryHandsResult{ + nil, + fmt.Errorf("could not query for playerHands in v%v. error: %v", version, err), + } + } else { + err = attributevalue.UnmarshalListOfMaps(response.Items, &availableHands) + if err != nil { + return queryHandsResult{ + availableHands, + fmt.Errorf("couldn't unmarshal query response. error: %v", err), + } + } + } + } + + return queryHandsResult{availableHands, nil} +} + +// Queries *all* entries in the database by version number. +// +// NOTE: This operation is gonna be expensive for a Lambda later on, so this result will eventually +// need to be cached later. +func (ddbh dDBHandler) queryHands(version string) ([]handInfo, error) { + result := make(chan queryHandsResult, 1) + + go func() { + result <- ddbh.doQueryHands(version) + }() + select { + case <-time.After(timeoutWindow): + return nil, fmt.Errorf("timeout - could not query for playerHands in allotted window") + + case result := <-result: + return result.HandInfoSlice, nil + } + +} + +// Creates the composite key for the playerHand dynamodb Table +// +// Use this function if you need to specifically target a player in the database +func (h handInfo) GetKey() (map[string]types.AttributeValue, error) { + version, err := attributevalue.Marshal(h.Version) + if err != nil { + return nil, fmt.Errorf("unable to marshal attribute 'version' with value %v, error: %v", h.Version, err) + } + playerId, err := attributevalue.Marshal(h.PlayerId) + if err != nil { + return nil, fmt.Errorf("unable to marshal attribute 'playerId' with value %v, error: %v", h.PlayerId, err) + } + return map[string]types.AttributeValue{"version": version, "playerId": playerId}, nil +} + +// Selects a random entry in []handInfo +// +// It ain't matchmaking, but it's honest work +func (ddbh dDBHandler) chooseHand(h []handInfo) handInfo { + rand.Seed(time.Now().Unix()) + selectedHand := h[rand.Intn(len(h))] + return selectedHand +} + +// Checks if the player by specified playerId and version exists. If it does, return true, else +// return false. The intention for this is to determine if player has hand has previously been +// recorded on the Database. +// +// Unlike `checkForDuplicate`, this is only a partial check to verify if the player making a request +// is in the DB. It does not check if every attribute is a match. +// TODO: `checkForDuplicate` and `checkIfPlayerHandExists` could be made as a single method achieving +// the same thing +func (ddbh dDBHandler) checkIfPlayerHandExists(p playerHandCompositeKey) (bool, map[string]types.AttributeValue, error) { + result := make(chan checkPlayerHandExistsResult, 1) + + go func() { + result <- ddbh.doCheckIfPlayerHandExist(p) + }() + select { + case <-time.After(timeoutWindow): + return false, nil, fmt.Errorf("timeout - could not return existing player in allotted window") + + case result := <-result: + return result.PlayerExists, result.PlayerItem, nil + } +} + +func (ddbh dDBHandler) doCheckIfPlayerHandExist(p playerHandCompositeKey) checkPlayerHandExistsResult { + playerInRequest := handInfo{ + PlayerId: p.PlayerId, + Version: p.Version, //Incoming request should always be the *latest* playerHand version when checking + } + + var result handInfo + + dbKey, err := playerInRequest.GetKey() + if err != nil { + return checkPlayerHandExistsResult{ + PlayerExists: false, + PlayerItem: nil, + Error: fmt.Errorf("failed to generate dbkey: %v", err), + } + } + + response, err := ddbh.DynamoDbClient.GetItem(context.TODO(), &dynamodb.GetItemInput{ + Key: dbKey, + TableName: aws.String(ddbh.TableName), + }) + if err != nil { + return checkPlayerHandExistsResult{ + PlayerExists: false, + PlayerItem: nil, + Error: fmt.Errorf("failed to query player: %v", err), + } + } else { + err = attributevalue.UnmarshalMap(response.Item, &result) + if err != nil { + return checkPlayerHandExistsResult{ + PlayerExists: false, + PlayerItem: nil, + Error: fmt.Errorf("failed to unmarshal response: %v", err), + } + } + } + + // using cmp.Equal checks for *all* values if they match. We can use the unmarshalled result + // and use that to produce a partial struct like `playerInRequest` for comparison purposes only + playerInDB := handInfo{ + PlayerId: result.PlayerId, + Version: result.Version, + } + + playerExists := cmp.Equal(playerInRequest, playerInDB) + if playerExists { + return checkPlayerHandExistsResult{ + PlayerExists: true, + PlayerItem: response.Item, + Error: nil, + } + } else { + return checkPlayerHandExistsResult{ + PlayerExists: false, + PlayerItem: nil, + Error: nil, + } + } +} + +// Checks if the submitted playerHand item already exists. This is done by checking if the submitted +// hand is a 1:1 match with a pre-existing record. If it is, return true, else return false. +// The intention here is that it can be used to determine whether a POST request needs to in fact be +// a PUT request, and if an isolated PUT request isn't necessary. +// +// This method should be used for `PUT` requests, where a check needs to be made for every single +// attribute for a given item. +func (ddbh dDBHandler) checkForDuplicate(h handInfo, item map[string]types.AttributeValue) (bool, error) { + var dbHand handInfo + + err := attributevalue.UnmarshalMap(item, &dbHand) + if err != nil { + return false, fmt.Errorf("failed to unmarshal response: %v", err) + } + + isDupe := cmp.Equal(h, dbHand) + if isDupe { + return true, nil + } else { + return false, nil + } +} + +func (ddbh dDBHandler) updatePlayerHand(h handInfo) error { + err := make(chan error, 1) + + go func() { + err <- ddbh.doUpdatePlayerHand(h) + }() + select { + case <-time.After(timeoutWindow): + return fmt.Errorf("timeout - could not update playerHand in allotted window") + + case err := <-err: + if err != nil { + return fmt.Errorf("updatePlayerHand execution failed. error: %v", err) + } + return nil + } +} + +func (ddbh dDBHandler) doUpdatePlayerHand(h handInfo) error { + + var attributeMap map[string]map[string]interface{} + + dbKey, err := h.GetKey() + if err != nil { + return fmt.Errorf("failed to generate dbkey: %v", err) + } + + update := expression.Set(expression.Name("cards"), expression.Value(h.Cards)) + expr, err := expression.NewBuilder().WithUpdate(update).Build() + if err != nil { + return fmt.Errorf("could not build expression for updating playerHand (%v) record. error: %v", h.PlayerId, err) + } + + response, err := ddbh.DynamoDbClient.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{ + Key: dbKey, + TableName: &env.PlayerHandTableName, + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + UpdateExpression: expr.Update(), + }) + if err != nil { + return fmt.Errorf("unable to update entry for playerId %v: %v", h.PlayerId, err) + } else { + err = attributevalue.UnmarshalMap(response.Attributes, &attributeMap) + if err != nil { + return fmt.Errorf("failed to unmarshal response: %v", err) + } + + return nil + } +} diff --git a/backend/src/aws/types.go b/backend/src/aws/types.go new file mode 100644 index 0000000..aebe935 --- /dev/null +++ b/backend/src/aws/types.go @@ -0,0 +1,40 @@ +package lbapiaws + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type body struct { + HandInfo handInfo `json:"handinfo"` +} + +// `Version` refers to the balance version of cards. Functions concerning live service should be +// using the latest version defined in the `PlayerHandVersion` env var +type handInfo struct { + PlayerName string `json:"playerName" dynamodbav:"playerName"` + PlayerId string `json:"playerId" dynamodbav:"playerId"` + Version string `json:"version" dynamodbav:"version"` + Cards []int16 `json:"cards" dynamodbav:"cards"` +} + +type dDBHandler struct { + DynamoDbClient *dynamodb.Client + TableName string +} + +type queryHandsResult struct { + HandInfoSlice []handInfo + Error error +} + +type checkPlayerHandExistsResult struct { + PlayerExists bool + PlayerItem map[string]types.AttributeValue + Error error +} + +type playerHandCompositeKey struct { + PlayerId string + Version string +} diff --git a/backend/src/config/config.go b/backend/src/config/config.go index 5b3d06c..7211922 100644 --- a/backend/src/config/config.go +++ b/backend/src/config/config.go @@ -6,6 +6,8 @@ import ( type EnvConfig struct { LegalBrawlSecretName string `split_words:"true" required:"true"` + PlayerHandTableName string `split_words:"true" required:"true" default:"playerHands"` + PlayerHandVersion string `split_words:"true" required:"true"` } func New() EnvConfig { diff --git a/backend/src/go.mod b/backend/src/go.mod index c28ec6e..64def05 100644 --- a/backend/src/go.mod +++ b/backend/src/go.mod @@ -5,15 +5,25 @@ go 1.19 require ( github.com/aws/aws-lambda-go v1.34.1 github.com/aws/aws-sdk-go v1.44.130 - github.com/aws/aws-sdk-go-v2/config v1.17.10 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.4 + github.com/aws/aws-sdk-go-v2/config v1.18.0 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.3 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.17.4 + github.com/google/go-cmp v0.5.9 + github.com/google/uuid v1.3.0 + github.com/mitchellh/mapstructure v1.5.0 ) -require github.com/jmespath/go-jmespath v0.4.0 // indirect +require ( + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.19 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect +) require ( github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.12.23 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.29 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect @@ -21,7 +31,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.17.2 // indirect github.com/aws/aws-secretsmanager-caching-go v1.1.0 github.com/aws/smithy-go v1.13.4 // indirect github.com/kelseyhightower/envconfig v1.4.0 diff --git a/backend/src/go.sum b/backend/src/go.sum index 2d1d129..1839580 100644 --- a/backend/src/go.sum +++ b/backend/src/go.sum @@ -5,10 +5,14 @@ github.com/aws/aws-sdk-go v1.44.130 h1:a/qwOxmYJF47xTZvTjECSJXnfRbjegb3YxvCXfETt github.com/aws/aws-sdk-go v1.44.130/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v1.17.1 h1:02c72fDJr87N8RAC2s3Qu0YuvMRZKNZJ9F+lAehCazk= github.com/aws/aws-sdk-go-v2 v1.17.1/go.mod h1:JLnGeGONAyi2lWXI1p0PCIOIy333JMVK1U7Hf0aRFLw= -github.com/aws/aws-sdk-go-v2/config v1.17.10 h1:zBy5QQ/mkvHElM1rygHPAzuH+sl8nsdSaxSWj0+rpdE= -github.com/aws/aws-sdk-go-v2/config v1.17.10/go.mod h1:/4np+UiJJKpWHN7Q+LZvqXYgyjgeXm5+lLfDI6TPZao= -github.com/aws/aws-sdk-go-v2/credentials v1.12.23 h1:LctvcJMIb8pxvk5hQhChpCu0WlU6oKQmcYb1HA4IZSA= -github.com/aws/aws-sdk-go-v2/credentials v1.12.23/go.mod h1:0awX9iRr/+UO7OwRQFpV1hNtXxOVuehpjVEzrIAYNcA= +github.com/aws/aws-sdk-go-v2/config v1.18.0 h1:ULASZmfhKR/QE9UeZ7mzYjUzsnIydy/K1YMT6uH1KC0= +github.com/aws/aws-sdk-go-v2/config v1.18.0/go.mod h1:H13DRX9Nv5tAcQvPABrE3dm5XnLp1RC7fVSM3OWiLvA= +github.com/aws/aws-sdk-go-v2/credentials v1.13.0 h1:W5f73j1qurASap+jdScUo4aGzSXxaC7wq1i7CiwhvU8= +github.com/aws/aws-sdk-go-v2/credentials v1.13.0/go.mod h1:prZpUfBu1KZLBLVX482Sq4DpDXGugAre08TPEc21GUg= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.3 h1:Xucaa/2h9Ws+QRi99QRVOlhD1g6B87X14ERZ8BdgjiI= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.3/go.mod h1:Gyso9fSiCpGSh47v2g4pstu0hDV/a80dra1teDpjzzk= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.29 h1:oOSTVYhgJMFwgOU068Tat+r/v24LtXLf4aBw00LPg7E= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.29/go.mod h1:ZDZgMDszSr9RPTrQG+vvIXgKvFfIthj0Ngr+0lXk/DU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 h1:E3PXZSI3F2bzyj6XxUXdTIfvp425HHhwKsFvmzBwHgs= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19/go.mod h1:VihW95zQpeKQWVPGkwT+2+WJNQV8UXFfMTWdU6VErL8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 h1:nBO/RFxeq/IS5G9Of+ZrgucRciie2qpLy++3UGZ+q2E= @@ -17,30 +21,42 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 h1:oRHDrwCTVT8ZXi4sr9 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19/go.mod h1:6Q0546uHDp421okhmmGfbxzq2hBqbXFNpi4k+Q1JnQA= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 h1:Mza+vlnZr+fPKFKRq/lKGVvM6B/8ZZmNdEopOwSQLms= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26/go.mod h1:Y2OJ+P+MC1u1VKnavT+PshiEuGPyh/7DqxoDNij4/bg= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.17.4 h1:mN72saOOYAq2qBczDTi2LznXFf98lvimpSethXyVnOQ= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.17.4/go.mod h1:BiglbKCG56L8tmMnUEyEQo422BO9xnNR8vVHnOsByf8= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.23 h1:PsiC3+l7FNXDSWNrprDfVoRNNEHNzyju1ruECjRyvuU= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.23/go.mod h1:5lIdkQbMmEblCTEAyFAsLduBtMPD9Bqt9fwPjBK1KWU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 h1:dpiPHgmFstgkLG07KaYAewvuptq5kvo52xn7tVSrtrQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10/go.mod h1:9cBNUHI2aW4ho0A5T87O294iPDuuUOSIEDjnd1Lq/z0= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.19 h1:V03dAtcAN4Qtly7H3/0B6m3t/cyl4FgyKFqK738fyJw= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.19/go.mod h1:2WpVWFC5n4DYhjNXzObtge8xfgId9UP6GWca46KJFLo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 h1:GE25AWCdNUPh9AOJzI9KIJnja7IwUc1WyUqz/JTyJ/I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19/go.mod h1:02CP6iuYP+IVnBX5HULVdSAku/85eHB2Y9EsFhrkEwU= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.4 h1:Hx79EGrkKNJya2iz2U5A7nyr7DjOu/TGTRefThfBZ1w= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.4/go.mod h1:k6CPuxyzO247nYEM1baEwHH1kRtosRCvgahAepaaShw= github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 h1:GFZitO48N/7EsFDt8fMa5iYdmWqkUDDB3Eje6z3kbG0= github.com/aws/aws-sdk-go-v2/service/sso v1.11.25/go.mod h1:IARHuzTXmj1C0KS35vboR0FeJ89OkEy1M9mWbK2ifCI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 h1:jcw6kKZrtNfBPJkaHrscDOZoe5gvi9wjudnxvozYFJo= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8/go.mod h1:er2JHN+kBY6FcMfcBBKNGCT3CarImmdFzishsqBmSRI= -github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 h1:KRAix/KHvjGODaHAMXnxRk9t0D+4IJVUuS/uwXxngXk= -github.com/aws/aws-sdk-go-v2/service/sts v1.17.1/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.2 h1:tpwEMRdMf2UsplengAOnmSIRdvAxf75oUFR+blBr92I= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.2/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4= github.com/aws/aws-secretsmanager-caching-go v1.1.0 h1:vcV94XGJ9KouXKYBTMqgrBw96Tae8JKLmoUZ5SbaXNo= github.com/aws/aws-secretsmanager-caching-go v1.1.0/go.mod h1:wahQpJP1dZKMqjGFAjGCqilHkTlN0zReGWocPLbXmxg= github.com/aws/smithy-go v1.13.4 h1:/RN2z1txIJWeXeOkzX+Hk/4Uuvv7dWtCjbmVJcrskyk= github.com/aws/smithy-go v1.13.4/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -53,5 +69,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/src/main.go b/backend/src/main.go index 7715d0b..97d5c8e 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -1,38 +1,11 @@ package main import ( - "context" - "fmt" - "legalbrawlapi/config" - "legalbrawlapi/secret" + lbapiaws "legalbrawlapi/aws" - "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" - "github.com/aws/aws-secretsmanager-caching-go/secretcache" ) -var ( - secretCache, _ = secretcache.New() - env = config.New() -) - -func HandleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - fmt.Printf("Processing request data for request %s.\n", request.RequestContext.RequestID) - - apiSecret := secret.RetrieveSecrets(ctx, secretCache, env.LegalBrawlSecretName) - secret.ScrubRequest(&request, apiSecret) - fmt.Printf("Body size = %d.\n", len(request.Body)) - - fmt.Println("Headers:") - for key, value := range request.Headers { - fmt.Printf(" %s: %s\n", key, value) - } - - content := "Hello, World!\n" - - return events.APIGatewayProxyResponse{Body: content, StatusCode: 200}, nil -} - func main() { - lambda.Start(HandleRequest) + lambda.Start(lbapiaws.HandleRequest) }