diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4ed9ba52 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +go.sum linguist-generated=true \ No newline at end of file diff --git a/.github/workflows/go_tests.yaml b/.github/workflows/go_tests.yaml index 22b714dd..11b66a8b 100644 --- a/.github/workflows/go_tests.yaml +++ b/.github/workflows/go_tests.yaml @@ -25,6 +25,8 @@ jobs: go-version: 1.14.x - name: Checkout code uses: actions/checkout@v2 + - name: Setup local DynamoDB + run: docker run -p 18079:8000 amazon/dynamodb-local -jar DynamoDBLocal.jar -sharedDb -inMemory & - name: Start MongoDB uses: supercharge/mongodb-github-action@1.3.0 with: @@ -32,6 +34,12 @@ jobs: mongodb-replica-set: testReplSet - name: get go-acc run: go get -u github.com/ory/go-acc + - name: Copy over the aws creds/config + run: cp -r ./server/persistence/dynamo/.dockerComposeAWS $HOME/.aws + - name: custom health check of the dynamo container badpokerface + run: curl http://127.0.0.1:18079 || (sleep 5s && curl http://127.0.0.1:18079 || (sleep 5s && curl http://127.0.0.1:18079 || (sleep 5s && curl http://127.0.0.1:18079))) + - name: Create dynamoDB table + run: aws dynamodb create-table --endpoint-url http://127.0.0.1:18079 --billing-mode PAY_PER_REQUEST --region us-west-2 --table-name cribbage --attribute-definitions AttributeName=DDBid,AttributeType=S AttributeName=spec,AttributeType=S --key-schema AttributeName=DDBid,KeyType=HASH AttributeName=spec,KeyType=Range - name: Run Golang Tests run: go-acc -o coverage.txt ./... - name: Upload coverage to Codecov diff --git a/docker-compose.yml b/docker-compose.yml index 93488159..80ae9db5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: AWS_ACCESS_KEY_ID: 'DUMMYIDEXAMPLE' AWS_SECRET_ACCESS_KEY: 'DUMMYEXAMPLEKEY' command: - dynamodb describe-limits --endpoint-url http://dynamodb-local:8000 --region us-west-2 + dynamodb create-table --endpoint-url http://dynamodb-local:8000 --billing-mode PAY_PER_REQUEST --region us-west-2 --table-name cribbage --attribute-definitions AttributeName=DDBid,AttributeType=S AttributeName=spec,AttributeType=S --key-schema AttributeName=DDBid,KeyType=HASH AttributeName=spec,KeyType=Range mysql-database: image: mysql:8 restart: always @@ -36,8 +36,13 @@ services: condition: service_started mysql-database: condition: service_healthy + volumes: + - ./server/persistence/dynamo/.dockerComposeAWS:/home/.aws environment: deploy: dockercompose + CRIBBAGE_DB: dynamodb + CRIBBAGE_DBURI: http://dynamodb-local:8000 + HOME: "/home" # for aws config CRIBBAGE_DSN_HOST: mysql-database CRIBBAGE_DSN_USER: gandalf CRIBBAGE_DSN_PASSWORD: flyyoufools diff --git a/go.mod b/go.mod index f3b10808..70ec97ad 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.14 require ( github.com/AlecAivazis/survey/v2 v2.0.4 github.com/apex/gateway v1.1.2 + github.com/aws/aws-sdk-go-v2 v1.9.1-0.20210902223845-0023eb21e8b4 + github.com/aws/aws-sdk-go-v2/config v1.8.0 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.5.0 github.com/gin-gonic/contrib v0.0.0-20191209060500-d6e26eeaa607 github.com/gin-gonic/gin v1.6.3 github.com/glacjay/goini v0.0.0-20161120062552-fd3024d87ee2 // indirect github.com/go-sql-driver/mysql v1.5.0 - github.com/google/go-cmp v0.4.1 // indirect github.com/google/uuid v1.1.1 github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de // indirect github.com/rakyll/globalconf v0.0.0-20180912185831-87f8127c421f diff --git a/go.sum b/go.sum index 94cb4fc4..28d6d6c2 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,33 @@ github.com/apex/gateway v1.1.2 h1:OWyLov8eaau8YhkYKkRuOAYqiUhpBJalBR1o+3FzX+8= github.com/apex/gateway v1.1.2/go.mod h1:AMTkVbz5u5Hvd6QOGhhg0JUrNgCcLVu3XNJOGntdoB4= github.com/aws/aws-lambda-go v1.17.0 h1:Ogihmi8BnpmCNktKAGpNwSiILNNING1MiosnKUfU8m0= github.com/aws/aws-lambda-go v1.17.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= +github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= +github.com/aws/aws-sdk-go-v2 v1.9.1-0.20210902223845-0023eb21e8b4 h1:dgRrK9tpIrrYRmVnXCz0ueUQX2LmPopfmV+gASSX68k= +github.com/aws/aws-sdk-go-v2 v1.9.1-0.20210902223845-0023eb21e8b4/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= +github.com/aws/aws-sdk-go-v2/config v1.8.0 h1:O8EMFBOl6tue5gdJJV6U3Ikyl3lqgx6WrulCYrcy2SQ= +github.com/aws/aws-sdk-go-v2/config v1.8.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= +github.com/aws/aws-sdk-go-v2/credentials v1.4.0 h1:kmvesfjY861FzlCU9mvAfe01D9aeXcG2ZuC+k9F2YLM= +github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0 h1:OxTAgH8Y4BXHD6PGCJ8DHx2kaZPCQfSTqmDsdRZFezE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0/go.mod h1:CpNzHK9VEFUCknu50kkB8z58AH2B5DvPP7ea1LHve/Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.0.4 h1:IM9b6hlCcVFJFydPoyphs/t7YrHfqKy7T4/7AG5Eprs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.0.4/go.mod h1:W5gGbtNXFpF9/ssYZTaItzG/B+j0bjTnwStiCP2AtWU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2 h1:d95cddM3yTm4qffj3P6EnP+TzX1SSkWaQypXSgT/hpA= +github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2/go.mod h1:BQV0agm+JEhqR+2RT5e1XTFIDcAAV0eW6z2trp+iduw= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.5.0 h1:SGwKUQaJudQQZE72dDQlL2FGuHNAEK1CyqKLTjh6mqE= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.5.0/go.mod h1:XY5YhCS9SLul3JSQ08XG/nfxXxrkh6RR21XPq/J//NY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.3.0 h1:gceOysEWNNwLd6cki65IMBZ4WAM0MwgBQq2n7kejoT8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.3.0/go.mod h1:v8ygadNyATSm6elwJ/4gzJwcFhri9RqS8skgHKiwXPU= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.1.0 h1:QCPbsMPMcM4iGbui5SH6O4uxvZffPoBJ4CIGX7dU0l4= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.1.0/go.mod h1:enkU5tq2HoXY+ZMiQprgF3Q83T3PbO77E83yXXzRZWE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0 h1:VNJ5NLBteVXEwE2F1zEXVmyIH58mZ6kIQGJoC7C+vkg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0/go.mod h1:R1KK+vY8AfalhG1AOu5e35pOD2SdoPKQCFLTvnxiohk= +github.com/aws/aws-sdk-go-v2/service/sso v1.4.0 h1:sHXMIKYS6YiLPzmKSvDpPmOpJDHxmAUgbiF49YNVztg= +github.com/aws/aws-sdk-go-v2/service/sso v1.4.0/go.mod h1:+1fpWnL96DL23aXPpMGbsmKe8jLTEfbjuQoA4WS1VaA= +github.com/aws/aws-sdk-go-v2/service/sts v1.7.0 h1:1at4e5P+lvHNl2nUktdM2/v+rpICg/QSEr9TO/uW9vU= +github.com/aws/aws-sdk-go-v2/service/sts v1.7.0/go.mod h1:0qcSMCyASQPN2sk/1KQLQ2Fh6yq8wm0HSDAimPhzCoM= +github.com/aws/smithy-go v1.8.0 h1:AEwwwXQZtUwP5Mz506FeXXrKBe0jA8gVM+1gEcSRooc= +github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 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= @@ -60,8 +87,9 @@ github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaW github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -71,6 +99,10 @@ github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORR github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +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/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= diff --git a/server/dbutils.go b/server/dbutils.go index 57365ef5..cb143806 100644 --- a/server/dbutils.go +++ b/server/dbutils.go @@ -3,6 +3,7 @@ package server import ( "context" "log" + "time" "github.com/joshprzybyszewski/cribbage/model" "github.com/joshprzybyszewski/cribbage/server/interaction" @@ -38,6 +39,10 @@ func handleAction(_ context.Context, db persistence.DB, action model.PlayerActio if err != nil { return err } + + // Now that the server is handling the action, let's set the timestamp to now. + action.SetTimeStamp(time.Now()) + err = play.HandleAction(&g, action, pAPIs) if err != nil { return err diff --git a/server/interaction/npc.go b/server/interaction/npc.go index e2fd8e32..9ac3d1a1 100644 --- a/server/interaction/npc.go +++ b/server/interaction/npc.go @@ -99,6 +99,9 @@ func (npc *NPCPlayer) buildAction(b model.Blocker, g model.Game) (model.PlayerAc ID: npc.ID(), Overcomes: b, } + // the NPC is building the action _now_ + pa.SetTimeStamp(time.Now()) + myHand := g.Hands[npc.ID()] switch b { case model.DealCards: diff --git a/server/persistence/dynamo/.dockerComposeAWS/config b/server/persistence/dynamo/.dockerComposeAWS/config new file mode 100644 index 00000000..dc9be8e6 --- /dev/null +++ b/server/persistence/dynamo/.dockerComposeAWS/config @@ -0,0 +1,2 @@ +[default] +region=us-west-2 diff --git a/server/persistence/dynamo/.dockerComposeAWS/credentials b/server/persistence/dynamo/.dockerComposeAWS/credentials new file mode 100644 index 00000000..4af8b7d8 --- /dev/null +++ b/server/persistence/dynamo/.dockerComposeAWS/credentials @@ -0,0 +1,3 @@ +[default] +aws_access_key_id=DUMMYIDEXAMPLE +aws_secret_access_key=DUMMYEXAMPLEKEY \ No newline at end of file diff --git a/server/persistence/dynamo/dynamo_service_game.go b/server/persistence/dynamo/dynamo_service_game.go new file mode 100644 index 00000000..510b578e --- /dev/null +++ b/server/persistence/dynamo/dynamo_service_game.go @@ -0,0 +1,269 @@ +package dynamo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + "github.com/joshprzybyszewski/cribbage/jsonutils" + "github.com/joshprzybyszewski/cribbage/model" + "github.com/joshprzybyszewski/cribbage/server/persistence" +) + +const ( + gameBytesAttributeName = `gameBytes` +) + +var _ persistence.GameService = (*gameService)(nil) + +type gameService struct { + ctx context.Context + + svc *dynamodb.Client +} + +func newGameService( + ctx context.Context, + svc *dynamodb.Client, +) persistence.GameService { + return &gameService{ + ctx: ctx, + svc: svc, + } +} + +func (gs *gameService) Get(id model.GameID) (model.Game, error) { + return gs.getGame(id, getGameOptions{ + latest: true, + }) +} + +func (gs *gameService) GetAt(id model.GameID, numActions uint) (model.Game, error) { + return gs.getGame(id, getGameOptions{ + actionIndex: numActions, + }) +} + +type getGameOptions struct { + latest bool + actionIndex uint +} + +func (gs *gameService) getGame( + id model.GameID, + opts getGameOptions, +) (model.Game, error) { + pkName := `:gID` + pk := strconv.Itoa(int(id)) + skName := `:sk` + sk := gs.getSpecForAllGameActions() + if !opts.latest { + sk = gs.getSpecForGameActionIndex(opts.actionIndex) + } + hp := hasPrefix{ + pkName: pkName, + skName: skName, + } + + sif := false + + qi := &dynamodb.QueryInput{ + ScanIndexForward: &sif, + TableName: aws.String(dbName), + KeyConditionExpression: hp.conditionExpression(), + ExpressionAttributeValues: map[string]types.AttributeValue{ + pkName: &types.AttributeValueMemberS{ + Value: pk, + }, + skName: &types.AttributeValueMemberS{ + Value: sk, + }, + }, + } + qo, err := gs.svc.Query(gs.ctx, qi) + if err != nil { + return model.Game{}, err + } + if len(qo.Items) == 0 { + return model.Game{}, persistence.ErrGameNotFound + } + + item := qo.Items[0] + if !opts.latest { + // make sure that the index we got back matches the one we requested + spec, ok := item[sortKey].(*types.AttributeValueMemberS) + if !ok { + return model.Game{}, persistence.ErrGameActionDecode + } + i, err := gs.getGameActionIndexFromSpec(spec.Value) + if err != nil { + return model.Game{}, err + } + if i != int(opts.actionIndex) { + return model.Game{}, errors.New(`retrieved unexpected game action index`) + } + } + + gb, ok := item[gs.getSerGameKey()].(*types.AttributeValueMemberB) + if !ok { + return model.Game{}, persistence.ErrGameActionDecode + } + return jsonutils.UnmarshalGame(gb.Value) +} + +func (gs *gameService) UpdatePlayerColor(gID model.GameID, pID model.PlayerID, color model.PlayerColor) error { + g, err := gs.Get(gID) + if err != nil { + return err + } + + if c, ok := g.PlayerColors[pID]; ok { + if c != color { + return errors.New(`mismatched game-player color`) + } + + // the Game already knows this player's color; nothing to do + return nil + } + + if g.PlayerColors == nil { + g.PlayerColors = make(map[model.PlayerID]model.PlayerColor, 1) + } + g.PlayerColors[pID] = color + + return gs.writeGame(writeGameOptions{ + game: g, + actionIndex: uint(len(g.Actions)), + overwrite: true, + }) +} + +func (gs *gameService) Begin(g model.Game) error { + return gs.writeGame(writeGameOptions{ + game: g, + actionIndex: 0, + }) +} + +func (gs *gameService) Save(g model.Game) error { + err := persistence.ValidateLatestActionBelongs(g) + if err != nil { + return err + } + + // validate that the actions on this game are known by the previous game. + sg, err := gs.getGame(g.ID, getGameOptions{ + latest: true, + }) + if err != nil { + return err + } + if len(sg.Actions)+1 != len(g.Actions) { + // The new game state can only have one additional action + return persistence.ErrGameActionsOutOfOrder + } + for i := range sg.Actions { + if !actionsAreEqual(sg.Actions[i], g.Actions[i]) { + return persistence.ErrGameActionsOutOfOrder + } + } + + return gs.writeGame(writeGameOptions{ + game: g, + actionIndex: uint(len(sg.Actions) + 1), + }) +} + +func actionsAreEqual(a, b model.PlayerAction) bool { + return a.GameID == b.GameID && + a.ID == b.ID && + a.Overcomes == b.Overcomes && + a.TimestampStr == b.TimestampStr +} + +type writeGameOptions struct { + game model.Game + actionIndex uint + overwrite bool +} + +// writeGame will write the given game and action +// This method assumes you've already done game state validation. +func (gs *gameService) writeGame(opts writeGameOptions) error { + obj, err := json.Marshal(opts.game) + if err != nil { + return err + } + + pii := &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: strconv.Itoa(int(opts.game.ID)), + }, + sortKey: &types.AttributeValueMemberS{ + Value: gs.getSpecForGameActionIndex(opts.actionIndex), + }, + gs.getSerGameKey(): &types.AttributeValueMemberB{ + Value: obj, + }, + }, + } + + if opts.overwrite { + // we want to find out if we overwrote items, so specify ReturnValues + pii.ReturnValues = types.ReturnValueAllOld + } else { + // Use a conditional expression to only write items if this + // tuple doesn't already exist. + // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html + // and https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html + pii.ConditionExpression = notExists{}.conditionExpression() + } + + pio, err := gs.svc.PutItem(gs.ctx, pii) + if err != nil { + if isConditionalError(err) { + return persistence.ErrGameActionsOutOfOrder + } + return err + } + + if opts.overwrite { + // We need to check that we actually overwrote an element + if _, ok := pio.Attributes[gs.getSerGameKey()]; !ok { + // oh no! We wanted to overwrite a game, but we didn't! + return persistence.ErrGameActionsOutOfOrder + } + } + + return nil +} + +func (gs *gameService) getSerGameKey() string { + return gameBytesAttributeName +} + +func (gs *gameService) getSpecForAllGameActions() string { + return getSortKeyPrefix(gs) + `@` +} + +func (gs *gameService) getSpecForGameActionIndex(i uint) string { + // Since we print out leading zeros to six places, we could + // have issues if our cribbage games ever take more than + // 999,999 actions. This is an arbitrary limit and one that + // we're unlikely to ever encounter. + return gs.getSpecForAllGameActions() + fmt.Sprintf(`%06d`, i) +} + +func (gs *gameService) getGameActionIndexFromSpec(s string) (int, error) { + s = strings.TrimPrefix(s, gs.getSpecForAllGameActions()) + return strconv.Atoi(s) +} diff --git a/server/persistence/dynamo/dynamo_service_interaction.go b/server/persistence/dynamo/dynamo_service_interaction.go new file mode 100644 index 00000000..7f723fe9 --- /dev/null +++ b/server/persistence/dynamo/dynamo_service_interaction.go @@ -0,0 +1,260 @@ +package dynamo + +import ( + "context" + "errors" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + "github.com/joshprzybyszewski/cribbage/model" + "github.com/joshprzybyszewski/cribbage/server/interaction" + "github.com/joshprzybyszewski/cribbage/server/persistence" +) + +const ( + interactionInfoAttributeName = `info` + interactionPreferAttributeName = `prefer` +) + +var _ persistence.InteractionService = (*interactionService)(nil) + +type interactionService struct { + ctx context.Context + + svc *dynamodb.Client +} + +func newInteractionService( + ctx context.Context, + svc *dynamodb.Client, +) persistence.InteractionService { + return &interactionService{ + ctx: ctx, + svc: svc, + } +} + +func (is *interactionService) Get( + id model.PlayerID, +) (interaction.PlayerMeans, error) { + pkName := `:ipID` + pk := string(id) + skName := `:sk` + sk := getSortKeyPrefix(is) + hp := hasPrefix{ + pkName: pkName, + skName: skName, + } + + createQuery := newQueryInputFactory(getQueryInputParams( + pk, pkName, sk, skName, hp.conditionExpression(), + )) + items, err := fullQuery(is.ctx, is.svc, createQuery) + if err != nil { + return interaction.PlayerMeans{}, err + } + + return is.buildPlayerMeansFromItems(id, items) +} + +func (is *interactionService) buildPlayerMeansFromItems( + id model.PlayerID, + items []map[string]types.AttributeValue, +) (interaction.PlayerMeans, error) { + + pm := interaction.PlayerMeans{ + PlayerID: id, + PreferredMode: interaction.Unknown, + } + + for _, item := range items { + if preferAV, ok := item[is.getPreferKey()]; ok { + if pm.PreferredMode != interaction.Unknown { + return interaction.PlayerMeans{}, errors.New(`preferred mode already set`) + } + + preferAVN, ok := preferAV.(*types.AttributeValueMemberN) + if !ok { + return interaction.PlayerMeans{}, errors.New(`wrong prefer attribute type`) + } + preferMode, err := strconv.Atoi(preferAVN.Value) + if err != nil { + return interaction.PlayerMeans{}, err + } + + pm.PreferredMode = interaction.Mode(preferMode) + continue + } + + mode, err := is.getInteractionMode( + item[sortKey], + ) + if err != nil { + return interaction.PlayerMeans{}, err + } + + m := interaction.Means{ + Mode: mode, + } + + serInfo, err := is.getSerInfo( + item[is.getInfoKey()], + ) + if err != nil { + return interaction.PlayerMeans{}, err + } + err = m.AddSerializedInfo(serInfo) + if err != nil { + return interaction.PlayerMeans{}, err + } + + pm.Interactions = append(pm.Interactions, m) + + } + + return pm, nil +} + +func (is *interactionService) getInteractionMode( + specAV types.AttributeValue, +) (interaction.Mode, error) { + specAVS, ok := specAV.(*types.AttributeValueMemberS) + if !ok { + return interaction.Unknown, errors.New(`wrong spec`) + } + + mode, err := is.getInteractionMeansModeFromSpec(specAVS.Value) + if err != nil { + return interaction.Unknown, err + } + return mode, nil +} + +func (is *interactionService) getSerInfo( + infoAV types.AttributeValue, +) ([]byte, error) { + infoAVB, ok := infoAV.(*types.AttributeValueMemberB) + if !ok { + return nil, errors.New(`wrong info type`) + } + + return infoAVB.Value, nil +} + +func (is *interactionService) Create(pm interaction.PlayerMeans) error { + return is.write(writePlayerMeansOptions{ + pm: pm, + isCreation: true, + }) +} + +func (is *interactionService) Update(pm interaction.PlayerMeans) error { + return is.write(writePlayerMeansOptions{ + pm: pm, + }) +} + +type writePlayerMeansOptions struct { + pm interaction.PlayerMeans + isCreation bool +} + +func (is *interactionService) write(opts writePlayerMeansOptions) error { + data := map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: string(opts.pm.PlayerID), + }, + sortKey: &types.AttributeValueMemberS{ + Value: getSortKeyPrefix(is), + }, + is.getPreferKey(): &types.AttributeValueMemberN{ + Value: strconv.Itoa(int(opts.pm.PreferredMode)), + }, + } + + pii := &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: data, + } + + if opts.isCreation { + // Use a conditional expression to only write items if this + // tuple doesn't already exist. + // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html + // and https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html + pii.ConditionExpression = notExists{}.conditionExpression() + } + + _, err := is.svc.PutItem(is.ctx, pii) + if err != nil { + if isConditionalError(err) { + return persistence.ErrInteractionAlreadyExists + } + return err + } + + for _, m := range opts.pm.Interactions { + pii, err = is.getPutItemInputForMeans(opts.pm.PlayerID, m) + if err != nil { + return err + } + _, err = is.svc.PutItem(is.ctx, pii) + if err != nil { + return err + } + } + + return nil +} + +func (is *interactionService) getPutItemInputForMeans( + playerID model.PlayerID, + m interaction.Means, +) (*dynamodb.PutItemInput, error) { + info, err := m.GetSerializedInfo() + if err != nil { + return nil, err + } + + return &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: string(playerID), + }, + sortKey: &types.AttributeValueMemberS{ + Value: is.getSpecForInteractionMeans(m), + }, + is.getInfoKey(): &types.AttributeValueMemberB{ + Value: info, + }, + }, + }, nil +} + +func (is *interactionService) getSpecForInteractionMeans( + m interaction.Means, +) string { + return getSortKeyPrefix(is) + `|` + strconv.Itoa(int(m.Mode)) +} + +func (is *interactionService) getInteractionMeansModeFromSpec(s string) (interaction.Mode, error) { + s = strings.TrimPrefix(s, getSortKeyPrefix(is)+`|`) + i, err := strconv.Atoi(s) + if err != nil { + return interaction.Unknown, err + } + return interaction.Mode(i), nil +} + +func (is *interactionService) getInfoKey() string { + return interactionInfoAttributeName +} + +func (is *interactionService) getPreferKey() string { + return interactionPreferAttributeName +} diff --git a/server/persistence/dynamo/dynamo_service_player.go b/server/persistence/dynamo/dynamo_service_player.go new file mode 100644 index 00000000..7e01c616 --- /dev/null +++ b/server/persistence/dynamo/dynamo_service_player.go @@ -0,0 +1,262 @@ +package dynamo + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + "github.com/joshprzybyszewski/cribbage/model" + "github.com/joshprzybyszewski/cribbage/server/persistence" +) + +const ( + playerColorAttributeName = `color` + playerNameAttributeName = `name` +) + +var _ persistence.PlayerService = (*playerService)(nil) + +type playerService struct { + ctx context.Context + + svc *dynamodb.Client +} + +func newPlayerService( + ctx context.Context, + svc *dynamodb.Client, +) persistence.PlayerService { + return &playerService{ + ctx: ctx, + svc: svc, + } +} + +func (ps *playerService) getGameSortKey() string { + return getSortKeyPrefix(ps) + `Game` +} + +func (ps *playerService) Get(id model.PlayerID) (model.Player, error) { + pkName := `:pID` + pk := string(id) + skName := `:sk` + sk := getSortKeyPrefix(ps) + hp := hasPrefix{ + pkName: pkName, + skName: skName, + } + + createQuery := newQueryInputFactory(getQueryInputParams( + pk, pkName, sk, skName, hp.conditionExpression(), + )) + items, err := fullQuery(ps.ctx, ps.svc, createQuery) + if err != nil { + return model.Player{}, err + } + + return ps.buildPlayerFromItems(id, items) +} + +func (ps *playerService) buildPlayerFromItems( + id model.PlayerID, + items []map[string]types.AttributeValue, +) (model.Player, error) { + + p := model.Player{ + ID: id, + Games: map[model.GameID]model.PlayerColor{}, + } + + for _, item := range items { + if colorAV, ok := item[ps.getColorKey()]; ok { + gID, color, err := ps.getGameIDAndColor(item[sortKey], colorAV) + if err != nil { + return model.Player{}, err + } + + p.Games[gID] = color + continue + } + + name, ok := ps.getPlayerName(item[sortKey], item[ps.getNameKey()]) + if !ok { + return model.Player{}, errors.New(`got unexpected payload`) + } else if p.Name != `` { + return model.Player{}, errors.New(`found two names`) + } + + p.Name = name + } + + if p.Name == `` { + // This player _must_ have had at least a name stored, otherwise + // we done messed up + return model.Player{}, persistence.ErrPlayerNotFound + } + + return p, nil +} + +func (ps *playerService) getSpecForPlayerGameColor( + gID model.GameID, +) string { + return fmt.Sprintf(`%s%d`, ps.getGameSortKey(), gID) +} + +func (ps *playerService) getPlayerGameColorFromSpec( + spec string, +) (model.GameID, error) { + if len(spec) <= len(ps.getGameSortKey()) { + return 0, errors.New(`too short`) + } + + gID, err := strconv.Atoi(spec[len(ps.getGameSortKey()):]) + if err != nil { + return 0, err + } + + return model.GameID(gID), nil +} + +func (ps *playerService) getGameIDAndColor( + specAV, colorAV types.AttributeValue, +) (model.GameID, model.PlayerColor, error) { + specAVS, ok := specAV.(*types.AttributeValueMemberS) + if !ok { + return 0, 0, errors.New(`spec wrong type`) + } + + gID, err := ps.getPlayerGameColorFromSpec(specAVS.Value) + if err != nil { + return 0, 0, err + } + + colorAVS, ok := colorAV.(*types.AttributeValueMemberS) + if !ok { + return 0, 0, errors.New(`color wrong type`) + } + pc := model.NewPlayerColorFromString(colorAVS.Value) + + return gID, pc, nil +} + +func (ps *playerService) getPlayerName( + specAV, nameAV types.AttributeValue, +) (string, bool) { + specAVS, ok := specAV.(*types.AttributeValueMemberS) + if !ok { + return ``, false + } + + spec := specAVS.Value + if spec != getSortKeyPrefix(ps) { + return ``, false + } + + nameAVS, ok := nameAV.(*types.AttributeValueMemberS) + if !ok { + return ``, false + } + return nameAVS.Value, true +} + +func (ps *playerService) Create(p model.Player) error { + data := map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: string(p.ID), + }, + sortKey: &types.AttributeValueMemberS{ + Value: getSortKeyPrefix(ps), + }, + ps.getNameKey(): &types.AttributeValueMemberS{ + Value: p.Name, + }, + } + + // Use a conditional expression to only write items if this + // tuple doesn't already exist. + // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html + // and https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html + _, err := ps.svc.PutItem(ps.ctx, &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: data, + ConditionExpression: notExists{}.conditionExpression(), + }) + if err != nil { + if isConditionalError(err) { + return persistence.ErrPlayerAlreadyExists + } + return err + } + + return nil +} + +func (ps *playerService) BeginGame(gID model.GameID, players []model.Player) error { + for _, p := range players { + err := ps.setPlayerGameColor(p.ID, gID, model.UnsetColor) + if err != nil { + return err + } + } + + return nil +} + +func (ps *playerService) UpdateGameColor( + pID model.PlayerID, + gID model.GameID, + color model.PlayerColor, +) error { + + p, err := ps.Get(pID) + if err != nil { + return err + } + + if c, ok := p.Games[gID]; ok && c != model.UnsetColor { + if c != color { + return errors.New(`mismatched player-games color`) + } + + // Nothing to do; the player already knows its color + return nil + } + + return ps.setPlayerGameColor(pID, gID, color) +} + +func (ps *playerService) setPlayerGameColor( + pID model.PlayerID, + gID model.GameID, + color model.PlayerColor, +) error { + _, err := ps.svc.PutItem(ps.ctx, &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: string(pID), + }, + sortKey: &types.AttributeValueMemberS{ + Value: ps.getSpecForPlayerGameColor(gID), + }, + ps.getColorKey(): &types.AttributeValueMemberS{ + Value: color.String(), + }, + }, + }) + return err +} + +func (ps *playerService) getColorKey() string { + return playerColorAttributeName +} + +func (ps *playerService) getNameKey() string { + return playerNameAttributeName +} diff --git a/server/persistence/dynamo/dynamo_utils.go b/server/persistence/dynamo/dynamo_utils.go new file mode 100644 index 00000000..9a7d8c5d --- /dev/null +++ b/server/persistence/dynamo/dynamo_utils.go @@ -0,0 +1,51 @@ +package dynamo + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +func fullQuery( + ctx context.Context, + svc dynamodb.QueryAPIClient, + createQuery func() *dynamodb.QueryInput, +) ([]map[string]types.AttributeValue, error) { + qi := createQuery() + + var items []map[string]types.AttributeValue + for { + qo, err := svc.Query(ctx, qi) + if err != nil { + return nil, err + } + items = append(items, qo.Items...) + + if len(qo.LastEvaluatedKey) == 0 { + break + } + + qi = createQuery() + qi.ExclusiveStartKey = qo.LastEvaluatedKey + } + + return items, nil +} + +func isConditionalError(err error) bool { + if err == nil { + return false + } + + switch err.(type) { + case *types.ConditionalCheckFailedException: + return true + } + + return strings.Contains( + err.Error(), + (*types.ConditionalCheckFailedException)(nil).ErrorCode(), + ) +} diff --git a/server/persistence/dynamo/dynamo_utils_test.go b/server/persistence/dynamo/dynamo_utils_test.go new file mode 100644 index 00000000..ca6780b9 --- /dev/null +++ b/server/persistence/dynamo/dynamo_utils_test.go @@ -0,0 +1,91 @@ +package dynamo + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/joshprzybyszewski/cribbage/utils/rand" +) + +func TestIsConditionalError(t *testing.T) { + assert.False(t, isConditionalError(nil)) + assert.False(t, isConditionalError(errors.New(`josh is cool`))) + assert.True(t, isConditionalError(&types.ConditionalCheckFailedException{})) + assert.True(t, isConditionalError(errors.New(`operation error DynamoDB: PutItem, https response error StatusCode: 400, RequestID: 68dd7a61-641c-4541-ac28-2de7556b8528, ConditionalCheckFailedException: `))) +} + +func TestFullQuery(t *testing.T) { + if testing.Short() { + t.Skip() + } + ctx := context.Background() + svc := getDynamoService(ctx, `http://localhost:18079`) + + hugeItemID := `hugeID` + rand.String(50) + sk := `prefix` + hugePayload := rand.String(390 * 1024) + + createData := func(i int) map[string]types.AttributeValue { + payload := strconv.Itoa(i) + `suffix` + hugePayload + return map[string]types.AttributeValue{ + partitionKey: &types.AttributeValueMemberS{ + Value: hugeItemID, + }, + sortKey: &types.AttributeValueMemberS{ + Value: sk + strconv.Itoa(i), + }, + `dummyData`: &types.AttributeValueMemberS{ + Value: payload, + }, + } + } + + numGen := 5 + + var err error + for i := 0; i < numGen; i++ { + data := createData(i) + + _, err = svc.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(dbName), + Item: data, + }) + require.NoError(t, err) + } + + pkName := `:pID` + skName := `:sk` + hp := hasPrefix{ + pkName: pkName, + skName: skName, + } + + createQuery := func() *dynamodb.QueryInput { + return &dynamodb.QueryInput{ + TableName: aws.String(dbName), + KeyConditionExpression: hp.conditionExpression(), + ExpressionAttributeValues: map[string]types.AttributeValue{ + pkName: &types.AttributeValueMemberS{ + Value: hugeItemID, + }, + skName: &types.AttributeValueMemberS{ + Value: sk, + }, + }, + } + } + items, err := fullQuery(ctx, svc, createQuery) + require.NoError(t, err) + + if len(items) != numGen { + t.Errorf("did not have %d items, but had %d", numGen, len(items)) + } +} diff --git a/server/persistence/dynamo/dynamodb.go b/server/persistence/dynamo/dynamodb.go new file mode 100644 index 00000000..40476659 --- /dev/null +++ b/server/persistence/dynamo/dynamodb.go @@ -0,0 +1,122 @@ +package dynamo + +import ( + "context" + "fmt" + "log" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + + "github.com/joshprzybyszewski/cribbage/server/persistence" +) + +var _ persistence.DBFactory = dynamoFactory{} + +type dynamoFactory struct { + endpoint string +} + +func NewFactory(endpoint string) (persistence.DBFactory, error) { + return dynamoFactory{ + endpoint: endpoint, + }, nil +} + +func (df dynamoFactory) New(ctx context.Context) (persistence.DB, error) { + svc := getDynamoService(ctx, df.endpoint) + + dto, err := svc.DescribeTable(ctx, &dynamodb.DescribeTableInput{ + TableName: aws.String(dbName), + }) + if err != nil || dto == nil { + return nil, fmt.Errorf("DescribeTable ERROR: %v", err) + } + + gs := newGameService(ctx, svc) + ps := newPlayerService(ctx, svc) + is := newInteractionService(ctx, svc) + + sw := persistence.NewServicesWrapper( + gs, + ps, + is, + ) + + dw := dynamoWrapper{ + ServicesWrapper: sw, + ctx: ctx, + } + + return &dw, nil +} + +func getDynamoService( + ctx context.Context, + endpoint string, +) *dynamodb.Client { + // Initialize a session that the SDK will use to load + // credentials from the shared credentials file ~/.aws/credentials + // and region from the shared configuration file ~/.aws/config. + cfg, err := config.LoadDefaultConfig( + ctx, + ) + if err != nil { + log.Fatalf("unable to load SDK config, %v", err) + } + + opts := make([]func(o *dynamodb.Options), 0, 2) + if len(endpoint) > 0 { + // there should _only_ be an endpoint specified in local dev. Otherwise, + // the magic aws config is supposed to figure it out. + opts = append(opts, + func(o *dynamodb.Options) { + o.EndpointOptions = dynamodb.EndpointResolverOptions{ + DisableHTTPS: true, + } + }, + func(o *dynamodb.Options) { + o.EndpointResolver = dynamodb.EndpointResolverFromURL(endpoint) + }, + ) + } + + // Using the Config value, create the DynamoDB client + return dynamodb.NewFromConfig( + cfg, + opts..., + ) +} + +func (df dynamoFactory) Close() error { + return nil +} + +var _ persistence.DB = (*dynamoWrapper)(nil) + +type dynamoWrapper struct { + persistence.ServicesWrapper + + ctx context.Context +} + +func (dw *dynamoWrapper) Close() error { + // TODO I don't think there's anything to do? + return nil +} + +func (dw *dynamoWrapper) Start() error { + // TODO figure out transactionality in dynamodb + return nil +} + +func (dw *dynamoWrapper) Commit() error { + // TODO figure out transactionality in dynamodb + return nil +} + +func (dw *dynamoWrapper) Rollback() error { + // TODO figure out transactionality in dynamodb + return nil +} diff --git a/server/persistence/dynamo/qa/main.go b/server/persistence/dynamo/qa/main.go new file mode 100644 index 00000000..4888918a --- /dev/null +++ b/server/persistence/dynamo/qa/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/joshprzybyszewski/cribbage/model" + "github.com/joshprzybyszewski/cribbage/server/persistence/dynamo" +) + +func main() { + os.Setenv(`AWS_ACCESS_KEY_ID`, `DUMMYIDEXAMPLE`) + os.Setenv(`AWS_SECRET_ACCESS_KEY`, `DUMMYEXAMPLEKEY`) + + dbf, err := dynamo.NewFactory(`http://localhost:18079`) + if err != nil { + log.Fatalf("dynamo.NewFactory err: %+v", err) + } + fmt.Printf("dbf, err := %+v\n", dbf) + + ctx, toFn := context.WithTimeout(context.Background(), 10*time.Second) + defer toFn() + dw, err := dbf.New(ctx) + if err != nil { + log.Fatalf("dbf.New(context.Background()) err: %+v", err) + } + + pID := model.PlayerID(`joshysquashy3`) + p := model.Player{ + ID: pID, + Name: `jesus is king`, + Games: map[model.GameID]model.PlayerColor{}, + } + fmt.Printf("calling dw.CreatePlayer(%+v)\n", p) + err = dw.CreatePlayer(p) + if err != nil { + log.Printf("dw.CreatePlayer err: %+v", err) + } + + fmt.Printf("calling dw.GetPlayer(%+v)\n", pID) + p2, err := dw.GetPlayer(pID) + if err != nil { + log.Fatalf("dw.GetPlayer err: %+v", err) + } + fmt.Printf("player p2 := %+v\n", p2) + + g := model.Game{ + ID: 4, + } + + fmt.Printf("calling dw.CreateGame(%+v)\n", g) + err = dw.CreateGame(g) + if err != nil { + log.Fatalf("dw.CreateGame err: %+v", err) + } + + fmt.Printf("calling dw.CreateGame(%+v)\n", g) + gg, err := dw.GetGame(g.ID) + if err != nil { + log.Fatalf("dw.GetGame err: %+v", err) + } + fmt.Printf("called dw.GetGame(%+v)\n", gg) + +} diff --git a/server/persistence/dynamo/utils.go b/server/persistence/dynamo/utils.go new file mode 100644 index 00000000..cd927bde --- /dev/null +++ b/server/persistence/dynamo/utils.go @@ -0,0 +1,114 @@ +package dynamo + +import ( + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +const ( + dbName = `cribbage` + partitionKey = `DDBid` + sortKey = `spec` +) + +func getSortKeyPrefix(service interface{}) string { + // all of these need to be different, because they are the + // start of the sort key. we are partitioning our dynamo table usage + // such that each service has the same prefix:# + switch service.(type) { + case *gameService: + return `game` + case *interactionService: + return `interaction` + case *playerService: + return `player` + } + + return `garbage` +} + +type hasPrefix struct { + pkName string + skName string +} + +func (hp hasPrefix) conditionExpression() *string { + var sb strings.Builder + + sb.WriteString(partitionKey) + sb.WriteString(`=`) + sb.WriteString(hp.pkName) + + sb.WriteString(` and `) + + sb.WriteString(`begins_with(`) + sb.WriteString(sortKey) + sb.WriteString(`,`) + sb.WriteString(hp.skName) + sb.WriteString(`)`) + + return aws.String(sb.String()) +} + +type notExists struct{} + +func (notExists) conditionExpression() *string { + var sb strings.Builder + + sb.WriteString(`attribute_not_exists(`) + sb.WriteString(partitionKey) + sb.WriteString(`)`) + + sb.WriteString(` and `) + + sb.WriteString(`attribute_not_exists(`) + sb.WriteString(sortKey) + sb.WriteString(`)`) + + return aws.String(sb.String()) +} + +type queryInputParams struct { + partitionKey string + partitionKeyName string + sortKey string + sortKeyName string + + keyConditionExpression *string +} + +func getQueryInputParams( + pk, pkName, + sk, skName string, + cond *string, +) queryInputParams { + return queryInputParams{ + partitionKey: pk, + partitionKeyName: pkName, + sortKey: sk, + sortKeyName: skName, + keyConditionExpression: cond, + } +} + +func newQueryInputFactory( + params queryInputParams, +) func() *dynamodb.QueryInput { + return func() *dynamodb.QueryInput { + return &dynamodb.QueryInput{ + TableName: aws.String(dbName), + KeyConditionExpression: params.keyConditionExpression, + ExpressionAttributeValues: map[string]types.AttributeValue{ + params.partitionKeyName: &types.AttributeValueMemberS{ + Value: params.partitionKey, + }, + params.sortKeyName: &types.AttributeValueMemberS{ + Value: params.sortKey, + }, + }, + } + } +} diff --git a/server/persistence/dynamo/utils_test.go b/server/persistence/dynamo/utils_test.go new file mode 100644 index 00000000..8cc72cf9 --- /dev/null +++ b/server/persistence/dynamo/utils_test.go @@ -0,0 +1,95 @@ +package dynamo + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/joshprzybyszewski/cribbage/model" +) + +func TestGetSortKeyPrefix(t *testing.T) { + testCases := []struct { + service interface{} + expPrefix string + }{{ + service: (*gameService)(nil), + expPrefix: `game`, + }, { + service: (*interactionService)(nil), + expPrefix: `interaction`, + }, { + service: (*playerService)(nil), + expPrefix: `player`, + }, { + service: (*model.Game)(nil), + expPrefix: `garbage`, + }} + + for _, tc := range testCases { + assert.Equal(t, tc.expPrefix, getSortKeyPrefix(tc.service)) + } +} + +func TestGetConditionExpression(t *testing.T) { + testCases := []struct { + get interface{ conditionExpression() *string } + exp *string + }{{ + get: hasPrefix{ + pkName: `:pkAttrName`, + skName: `:skAttrName`, + }, + exp: aws.String(`DDBid=:pkAttrName and begins_with(spec,:skAttrName)`), + }, { + get: notExists{}, + exp: aws.String(`attribute_not_exists(DDBid) and attribute_not_exists(spec)`), + }} + + for _, tc := range testCases { + act := tc.get.conditionExpression() + assert.Equal(t, tc.exp, act) + } +} + +func TestNewQueryInputFactory(t *testing.T) { + testCases := []struct { + pk, pkName string + sk, skName string + cond *string + exp *dynamodb.QueryInput + }{{ + pk: `is_cool`, + pkName: `:josh`, + sk: `so_cool`, + skName: `:jp`, + cond: aws.String(`attribute_not_exists(:josh) and attribute_not_exists(:jp)`), + exp: &dynamodb.QueryInput{ + TableName: aws.String(dbName), + KeyConditionExpression: aws.String(`attribute_not_exists(:josh) and attribute_not_exists(:jp)`), + ExpressionAttributeValues: map[string]types.AttributeValue{ + `:josh`: &types.AttributeValueMemberS{ + Value: `is_cool`, + }, + `:jp`: &types.AttributeValueMemberS{ + Value: `so_cool`, + }, + }, + }, + }} + + for _, tc := range testCases { + createQuery := newQueryInputFactory(getQueryInputParams( + tc.pk, tc.pkName, + tc.sk, tc.skName, + tc.cond, + )) + require.NotNil(t, createQuery) + actQuery := createQuery() + assert.Equal(t, tc.exp, actQuery) + } +} diff --git a/server/persistence/errors.go b/server/persistence/errors.go index c2b700bf..4e65c882 100644 --- a/server/persistence/errors.go +++ b/server/persistence/errors.go @@ -14,9 +14,11 @@ var ( ErrGameNotFound error = errors.New(`game not found`) ErrGameInitialSave error = errors.New(`game must be saved with no actions`) ErrGameActionsOutOfOrder error = errors.New(`game actions out of order`) + ErrGameActionDecode error = errors.New(`game actions get decode`) ErrGameActionWrongGame error = errors.New(`game action for wrong game`) ErrGameActionWrongPlayer error = errors.New(`game action found for wrong player`) ErrInteractionNotFound error = errors.New(`interaction not found`) ErrInteractionAlreadyExists error = errors.New(`interaction already exists`) + ErrInteractionUnexpected error = errors.New(`unexpected interaction`) ) diff --git a/server/persistence/persistence_test.go b/server/persistence/persistence_test.go index d19ae041..8f1198ae 100644 --- a/server/persistence/persistence_test.go +++ b/server/persistence/persistence_test.go @@ -11,6 +11,7 @@ import ( "github.com/joshprzybyszewski/cribbage/model" "github.com/joshprzybyszewski/cribbage/server/interaction" "github.com/joshprzybyszewski/cribbage/server/persistence" + "github.com/joshprzybyszewski/cribbage/server/persistence/dynamo" "github.com/joshprzybyszewski/cribbage/server/persistence/memory" "github.com/joshprzybyszewski/cribbage/server/persistence/mongodb" "github.com/joshprzybyszewski/cribbage/server/persistence/mysql" @@ -26,6 +27,7 @@ const ( memoryDB dbName = `memoryDB` mongoDB dbName = `mongoDB` mysqlDB dbName = `mysqlDB` + dynamoDB dbName = `dynamoDB` ) var ( @@ -86,6 +88,27 @@ func persistenceGameCopy(dst *model.Game, src model.Game) { func checkPersistedGame(t *testing.T, name dbName, db persistence.DB, expGame model.Game) { actGame, err := db.GetGame(expGame.ID) require.NoError(t, err, `expected to find game with id "%d"`, expGame.ID) + checkPersistedGameCompare(t, name, expGame, actGame) +} + +func checkPersistedGameAt( + t *testing.T, + name dbName, + db persistence.DB, + expGame model.Game, + numPrevActions uint, +) { + if name == memoryDB { + t.Logf("memory doesn't keep track of history") + return + } + + actGame, err := db.GetGameAction(expGame.ID, numPrevActions) + require.NoError(t, err, `expected to find game with id "%d"`, expGame.ID) + checkPersistedGameCompare(t, name, expGame, actGame) +} + +func checkPersistedGameCompare(t *testing.T, name dbName, expGame, actGame model.Game) { if len(expGame.Crib) == 0 { assert.Empty(t, actGame.Crib) expGame.Crib = nil @@ -179,6 +202,12 @@ func TestDB(t *testing.T) { require.NoError(t, err) dbfs[mysqlDB] = mySQLDB + + // We assume you have dynamodb stood up locally when running without -short + dynamodb, err := dynamo.NewFactory(`http://localhost:18079`) + require.NoError(t, err) + + dbfs[dynamoDB] = dynamodb } for dbName, dbf := range dbfs { @@ -191,17 +220,20 @@ func TestDB(t *testing.T) { } func testCreatePlayersWithSimilarNames(t *testing.T, name dbName, db persistence.DB) { + suffix := rand.String(50) + p1Str := `alice` + suffix + p2Str := `Alice` + suffix p1 := model.Player{ - ID: model.PlayerID(`alice`), - Name: `alice`, + ID: model.PlayerID(p1Str), + Name: p1Str, Games: map[model.GameID]model.PlayerColor{}, } assert.NoError(t, db.CreatePlayer(p1)) p2 := model.Player{ - ID: model.PlayerID(`Alice`), - Name: `Alice`, + ID: model.PlayerID(p2Str), + Name: p2Str, Games: map[model.GameID]model.PlayerColor{}, } @@ -364,12 +396,12 @@ func testSaveGameMultipleTimes(t *testing.T, name dbName, db persistence.DB) { require.Error(t, err) assert.EqualError(t, err, persistence.ErrGameNotFound.Error()) - var gCopy model.Game - persistenceGameCopy(&gCopy, g) + var g0, g1, g2, g3 model.Game + persistenceGameCopy(&g0, g) require.NoError(t, db.CreateGame(g)) - checkPersistedGame(t, name, db, gCopy) + checkPersistedGame(t, name, db, g0) require.NoError(t, play.HandleAction(&g, model.PlayerAction{ ID: alice.ID, @@ -378,10 +410,10 @@ func testSaveGameMultipleTimes(t *testing.T, name dbName, db persistence.DB) { Action: model.DealAction{NumShuffles: 10}, TimestampStr: time.Now().Format(time.RFC3339), }, abAPIs)) - persistenceGameCopy(&gCopy, g) + persistenceGameCopy(&g1, g) require.NoError(t, db.SaveGame(g)) - checkPersistedGame(t, name, db, gCopy) + checkPersistedGame(t, name, db, g1) require.NoError(t, play.HandleAction(&g, model.PlayerAction{ ID: alice.ID, @@ -390,10 +422,10 @@ func testSaveGameMultipleTimes(t *testing.T, name dbName, db persistence.DB) { Action: model.BuildCribAction{Cards: []model.Card{g.Hands[alice.ID][0], g.Hands[alice.ID][1]}}, TimestampStr: time.Now().Format(time.RFC3339), }, abAPIs)) - persistenceGameCopy(&gCopy, g) + persistenceGameCopy(&g2, g) require.NoError(t, db.SaveGame(g)) - checkPersistedGame(t, name, db, gCopy) + checkPersistedGame(t, name, db, g2) require.NoError(t, play.HandleAction(&g, model.PlayerAction{ ID: bob.ID, @@ -402,10 +434,15 @@ func testSaveGameMultipleTimes(t *testing.T, name dbName, db persistence.DB) { Action: model.BuildCribAction{Cards: []model.Card{g.Hands[bob.ID][0], g.Hands[bob.ID][1]}}, TimestampStr: time.Now().Format(time.RFC3339), }, abAPIs)) - persistenceGameCopy(&gCopy, g) + persistenceGameCopy(&g3, g) require.NoError(t, db.SaveGame(g)) - checkPersistedGame(t, name, db, gCopy) + checkPersistedGame(t, name, db, g3) + + checkPersistedGameAt(t, name, db, g0, 0) + checkPersistedGameAt(t, name, db, g1, 1) + checkPersistedGameAt(t, name, db, g2, 2) + checkPersistedGameAt(t, name, db, g3, 3) } func testSaveInteraction(t *testing.T, name dbName, db persistence.DB) { @@ -618,6 +655,8 @@ func TestTransactionality(t *testing.T) { require.NoError(t, err) dbfs[mysqlDB] = mySQLDB + + /* TODO we'll address transactionality of dynamodb in a followup */ } txTests := map[string]txTest{ diff --git a/server/setup.go b/server/setup.go index 68fea315..bf3188d7 100644 --- a/server/setup.go +++ b/server/setup.go @@ -9,6 +9,7 @@ import ( "github.com/joshprzybyszewski/cribbage/model" "github.com/joshprzybyszewski/cribbage/server/interaction" "github.com/joshprzybyszewski/cribbage/server/persistence" + "github.com/joshprzybyszewski/cribbage/server/persistence/dynamo" "github.com/joshprzybyszewski/cribbage/server/persistence/memory" "github.com/joshprzybyszewski/cribbage/server/persistence/mongodb" "github.com/joshprzybyszewski/cribbage/server/persistence/mysql" @@ -66,6 +67,9 @@ func getDBFactory(ctx context.Context, cfg factoryConfig) (persistence.DBFactory case `mongo`: log.Println("Creating mongodb factory") return mongodb.NewFactory(*dbURI) + case `dynamodb`: + log.Println("Creating dynamodb factory") + return dynamo.NewFactory(*dbURI) case `mysql`: cfg := mysql.Config{ DSNUser: *dsnUser, @@ -92,7 +96,11 @@ func getDBFactory(ctx context.Context, cfg factoryConfig) (persistence.DBFactory return memory.NewFactory(), nil } - return nil, fmt.Errorf(`db "%s" not supported. Currently supported: "mongo", "mysql", and "memory"`, *database) + return nil, fmt.Errorf( + `db %q not supported.`+ + `Currently supported:`+ + `"dynamodb", "mongo", "mysql", and "memory"`, + *database) } func seedNPCs(ctx context.Context, dbFactory persistence.DBFactory) error {